dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x

This commit is contained in:
hermes committed 2026-10-01 19:21:19 +00:00
commit e6dcef9bf5
302 files changed
+75178

No files matched your search

@@ -0,0 +1,229 @@
import { spawn } from 'node:child_process'
import { cp, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { RuntimeSlots } from '../../patch-manager/index.mjs'
import { PlatformPaths } from '../../../../platform/lib/paths.mjs'
import { LocalApiClient } from './client.mjs'
import { ManagedDeploymentBuilder } from './managed-store.mjs'
import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs'
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolve(Buffer.concat(stdout))
: reject(new Error(Buffer.concat(stderr).toString('utf8'))))
})
}
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
function matchesRecoveryJournal(record, deployment) {
if (record?.id === deployment.runtime) return true
if (record === null || record === undefined) return false
if (!Array.isArray(record.receiptTokens) || !Array.isArray(deployment.receiptTokens)) return false
const sameReceipts = [...record.receiptTokens].sort().join('\0')
=== [...deployment.receiptTokens].sort().join('\0')
return record.dshVersion === deployment.dsh
&& record.environmentVersion === deployment.environment
&& record.snapshotId === deployment.dataSnapshot
&& sameReceipts
}
export class PlatformActivator {
constructor({
dataRoot,
runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
bootstrap,
stage0,
builder,
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
this.dataRoot = dataRoot
this.paths = new PlatformPaths(dataRoot, runRoot)
this.bootstrap = bootstrap ?? new LocalApiClient(this.paths.bootstrapSocket)
this.stage0 = stage0 ?? new LocalApiClient(this.paths.trustSocket)
this.builder = builder ?? new ManagedDeploymentBuilder({ paths: this.paths })
this.sleep = sleep
this.runtimeSlots = new RuntimeSlots(this.paths.runtimesRoot)
this.environmentSlots = new RuntimeSlots(this.paths.environmentsRoot)
this.systemPluginSlots = new RuntimeSlots(this.paths.systemPluginsRoot)
this.experimentalCandidates = new Map()
}
prepareManaged(prepared, options) {
return this.builder.buildStable(prepared, options)
}
async pristine(version, receipt) {
const root = join(this.paths.pristineRoot, version)
if (!await exists(root)) {
const staging = `${root}.${randomUUID()}.tmp`
await mkdir(staging, { recursive: true })
try {
const entries = (await run('tar', ['-tzf', receipt.path])).toString('utf8').split('\n').filter(Boolean)
if (!entries.includes('package/package.json')) throw new Error('official DSH has no package metadata')
if (entries.some(entry => entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..'))) {
throw new Error('official DSH archive contains an unsafe path')
}
await run('tar', ['-xzf', receipt.path, '-C', staging])
await rename(staging, root)
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
const packageMetadata = JSON.parse(await readFile(join(root, 'package', 'package.json'), 'utf8'))
if (packageMetadata.name !== '@deepseek-ai/dsh' || packageMetadata.version !== version) {
throw new Error('official DSH package metadata differs from its requested version')
}
return join(root, 'package')
}
async stageEnvironment(prepared) {
const version = prepared.environment.manifest.version
const versions = join(this.paths.environmentsRoot, 'versions')
const destination = join(versions, version)
if (await exists(destination)) return version
const staging = join(versions, `.${version}.${randomUUID()}.tmp`)
await mkdir(join(staging, 'artifacts'), { recursive: true })
try {
await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json'))
for (const descriptor of prepared.environment.manifest.artifacts) {
await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id))
}
await rename(staging, destination)
return version
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async activate(prepared, { onProgress = async () => {}, onSwitching = async () => {} } = {}) {
const desiredBootstrap = prepared.stable.desired.bootstrap.version
const artifacts = prepared.bootstrap.manifest.artifacts
if (artifacts.length !== 1) throw new Error('Bootstrap manifest must contain exactly one package')
const receipt = prepared.receipts.get(artifacts[0].id)
if (receipt === undefined) throw new Error('Bootstrap package receipt is required')
const staged = await this.stage0.stageBootstrap(receipt.token, desiredBootstrap)
// Older Stage-0 responses have only status=switching. Preserve that handoff
// contract while allowing content-identical targets to continue in place.
if (staged.restartRequired === true || staged.status === 'switching') await new Promise(() => {})
const managed = await this.prepareManaged(prepared, { onProgress })
await onSwitching()
await this.bootstrap.request('POST', '/v1/deployments/activate', { record: managed.record })
return managed.record
}
async rollback(recordId) {
return this.bootstrap.request('POST', '/v1/deployments/rollback', recordId === undefined ? {} : { recordId })
}
rollbackDeployments() {
return this.bootstrap.request('GET', '/v1/deployments/rollback-plan')
}
async currentDeployment({ required = true } = {}) {
let record
for (let attempt = 0; attempt < 100; attempt += 1) {
({ record } = await this.bootstrap.request('GET', '/v1/deployments/current'))
if (record !== null || !required) break
const status = await this.bootstrap.request('GET', '/v1/status')
if (status.startupComplete === true) break
await this.sleep(100)
}
if (record === null) {
if (required) throw new Error('current Deployment is required')
return null
}
return Object.freeze({
authority: record.authority,
targetSequence: record.targetSequence,
dsh: record.dshVersion,
runtime: record.id,
environment: record.environmentVersion,
dataSnapshot: record.snapshotId,
receiptTokens: Object.freeze([...record.receiptTokens]),
})
}
async prepareExperimental(prepared, { targetSequence }) {
const { record: current } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (current === null) throw new Error('current Deployment is required')
const receiptTokens = await this.experimentalActivationTokens(prepared.receiptTokens)
const built = await this.builder.buildExperimental(prepared, current, receiptTokens, { targetSequence })
this.experimentalCandidates.set(built.record.id, built.record)
return Object.freeze({
runtimeId: built.record.id,
environmentVersion: built.record.environmentVersion,
dshVersion: built.record.dshVersion,
})
}
suspendDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/suspend') }
resumeDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/resume') }
health() { return this.bootstrap.request('GET', '/v1/health') }
async experimentalActivationTokens(experimentalTokens) {
const active = (await this.stage0.activeReceipts()).receipts
return Object.freeze([
...active.filter(receipt => receipt.authorityType === 'stable').map(receipt => receipt.token),
...experimentalTokens,
])
}
async switchExperimental(runtimeId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
return this.bootstrap.request('POST', '/v1/deployments/candidate', { record })
}
async bindExperimentalSnapshot(runtimeId, snapshotId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
const content = { ...record, snapshotId }
delete content.id
const bound = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) })
this.experimentalCandidates.delete(runtimeId)
this.experimentalCandidates.set(bound.id, bound)
return bound.id
}
async commitExperimental(runtimeId) {
const result = await this.bootstrap.request('POST', '/v1/deployments/candidate/commit', { recordId: runtimeId })
this.experimentalCandidates.delete(runtimeId)
return result
}
async restoreDeployment(deployment, { resume = true } = {}) {
await this.bootstrap.request('POST', '/v1/deployments/candidate/cancel')
const { record } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (!matchesRecoveryJournal(record, deployment)) {
const { previous } = await this.rollbackDeployments()
if (previous === null) throw new Error('previous Deployment is unavailable')
if (!matchesRecoveryJournal(previous, deployment)) {
throw new Error('previous Deployment differs from the recovery journal')
}
await this.bootstrap.request('POST', '/v1/deployments/rollback', { recordId: previous.id })
}
if (resume) await this.resumeDsh()
}
async cleanup() {
const platform = await this.bootstrap.request('POST', '/v1/platform/gc')
return Object.freeze({
platform,
bootstrap: await this.stage0.collectBootstrap(),
trustedObjects: (await this.stage0.collectGarbage()).removed,
})
}
}
@@ -0,0 +1,170 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export const AUTOMATIC_CHECK_INTERVALS = Object.freeze([3_600, 10_800, 21_600, 43_200, 86_400])
const DEFAULT_STATE = Object.freeze({
schema: 1,
automaticCheck: Object.freeze({
enabled: true,
intervalSeconds: 21_600,
notificationsEnabled: true,
}),
latestAutomatic: Object.freeze({ stable: null, upstream: null }),
})
function exactKeys(value, expected, label) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
const keys = Object.keys(value).sort()
const wanted = [...expected].sort()
if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index])) {
throw new Error(`${label} fields are invalid`)
}
}
function parseConfig(value) {
exactKeys(value, ['enabled', 'intervalSeconds', 'notificationsEnabled'], 'automatic check config')
if (typeof value.enabled !== 'boolean') throw new Error('automatic check enabled must be boolean')
if (!AUTOMATIC_CHECK_INTERVALS.includes(value.intervalSeconds)) throw new Error('automatic check interval is invalid')
if (typeof value.notificationsEnabled !== 'boolean') throw new Error('update notifications enabled must be boolean')
return Object.freeze({ ...value })
}
function parseStable(value) {
if (value === null) return null
exactKeys(value, ['checkedAt', 'dsh', 'environment', 'targetSequence'], 'Stable notification')
if (!Number.isSafeInteger(value.targetSequence) || value.targetSequence < 1) throw new Error('Stable notification sequence is invalid')
compareDshVersions(value.dsh, value.dsh)
if (typeof value.environment !== 'string' || value.environment.length === 0) throw new Error('Stable notification Environment is invalid')
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Stable notification timestamp is invalid')
return Object.freeze({ ...value })
}
function parseUpstream(value) {
if (value === null) return null
exactKeys(value, ['checkedAt', 'version'], 'Upstream notification')
compareDshVersions(value.version, value.version)
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Upstream notification timestamp is invalid')
return Object.freeze({ ...value })
}
function parseState(value) {
exactKeys(value, ['automaticCheck', 'latestAutomatic', 'schema'], 'automatic check state')
if (value.schema !== 1) throw new Error('automatic check state schema must be 1')
exactKeys(value.latestAutomatic, ['stable', 'upstream'], 'automatic notification state')
return Object.freeze({
schema: 1,
automaticCheck: parseConfig(value.automaticCheck),
latestAutomatic: Object.freeze({
stable: parseStable(value.latestAutomatic.stable),
upstream: parseUpstream(value.latestAutomatic.upstream),
}),
})
}
function isHeld(version, environment, holds) {
return holds.some(hold => hold.dshVersion === version
&& (hold.type !== 'combination' || hold.environmentVersion === environment))
}
function stableSatisfied(candidate, current) {
return current.environment === candidate.environment && compareDshVersions(current.dsh, candidate.dsh) >= 0
}
export class AutomaticCheckStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.pending = Promise.resolve()
}
async read() {
try {
return parseState(JSON.parse(await readFile(this.path, 'utf8')))
} catch (error) {
if (error?.code === 'ENOENT') return DEFAULT_STATE
throw error
}
}
async replace(update) {
const operation = this.pending.then(async () => {
const next = parseState(await update(await this.read()))
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
this.pending = operation.catch(() => {})
return operation
}
configure(value) {
const config = parseConfig(value)
return this.replace(previous => ({
...previous,
automaticCheck: config,
latestAutomatic: !config.enabled || !config.notificationsEnabled
? { stable: null, upstream: null }
: previous.latestAutomatic,
}))
}
record({ channel, current, target, upstream, stableAvailable, holds = [] }) {
return this.replace(previous => {
if (!previous.automaticCheck.enabled || !previous.automaticCheck.notificationsEnabled) return previous
const checkedAt = this.now().toISOString()
let stable = previous.latestAutomatic.stable
let latestUpstream = previous.latestAutomatic.upstream
if (stable !== null && stableSatisfied(stable, current)) stable = null
if (
stableAvailable === true
&& (stable === null || target.targetSequence > stable.targetSequence)
) {
stable = {
targetSequence: target.targetSequence,
dsh: target.desired.dsh.version,
environment: target.desired.environment.version,
checkedAt,
}
}
if (channel !== 'experimental') {
latestUpstream = null
} else {
if (latestUpstream !== null && compareDshVersions(current.dsh, latestUpstream.version) >= 0) latestUpstream = null
if (
upstream !== null
&& compareDshVersions(upstream.version, current.dsh) > 0
&& !isHeld(upstream.version, target.desired.environment.version, holds)
&& (latestUpstream === null || compareDshVersions(upstream.version, latestUpstream.version) > 0)
) latestUpstream = { version: upstream.version, checkedAt }
}
return { ...previous, latestAutomatic: { stable, upstream: latestUpstream } }
})
}
clearSatisfied(current) {
return this.replace(previous => ({
...previous,
latestAutomatic: {
stable: previous.latestAutomatic.stable !== null
&& stableSatisfied(previous.latestAutomatic.stable, current)
? null
: previous.latestAutomatic.stable,
upstream: previous.latestAutomatic.upstream !== null
&& compareDshVersions(current.dsh, previous.latestAutomatic.upstream.version) >= 0
? null
: previous.latestAutomatic.upstream,
},
}))
}
clearUpstream() {
return this.replace(previous => ({
...previous,
latestAutomatic: { ...previous.latestAutomatic, upstream: null },
}))
}
}
@@ -0,0 +1,169 @@
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
import { isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
const DEFAULT_STATE = Object.freeze({
schema: 1,
updateChannel: 'stable',
holds: Object.freeze([]),
experimentalBlocked: null,
})
function text(value, label) {
if (typeof value !== 'string' || value === '' || value.length > 512 || value.includes('\0')) {
throw new TrustError(`${label} is invalid`)
}
return value
}
function parseHold(value) {
const hold = plainObject(value, 'update hold')
const keys = Object.keys(hold).sort()
const expected = ['createdAt', 'dshVersion', 'environmentVersion', 'id', 'reason', 'type']
if (JSON.stringify(keys) !== JSON.stringify(expected)) throw new TrustError('update hold has unexpected fields')
if (!['version', 'combination'].includes(hold.type)) throw new TrustError('update hold type is invalid')
if (hold.type === 'version' && hold.environmentVersion !== null) throw new TrustError('version hold cannot name an Environment')
if (hold.type === 'combination' && typeof hold.environmentVersion !== 'string') {
throw new TrustError('combination hold requires an Environment')
}
isoTimestamp(hold.createdAt, 'update hold creation time')
return Object.freeze({
id: text(hold.id, 'update hold ID'),
type: hold.type,
dshVersion: text(hold.dshVersion, 'update hold DSH version'),
environmentVersion: hold.environmentVersion === null ? null : text(hold.environmentVersion, 'update hold Environment'),
reason: text(hold.reason, 'update hold reason'),
createdAt: hold.createdAt,
})
}
function parseState(value) {
const state = plainObject(value, 'channel state')
const keys = Object.keys(state).sort()
if (JSON.stringify(keys) !== JSON.stringify(['experimentalBlocked', 'holds', 'schema', 'updateChannel'])) {
throw new TrustError('channel state has unexpected fields')
}
if (state.schema !== 1) throw new TrustError('channel state schema must be 1')
if (!['stable', 'experimental'].includes(state.updateChannel)) throw new TrustError('update channel is invalid')
if (!Array.isArray(state.holds)) throw new TrustError('channel holds must be an array')
const holds = state.holds.map(parseHold)
if (new Set(holds.map(hold => hold.id)).size !== holds.length) throw new TrustError('update hold IDs must be unique')
const blocked = state.experimentalBlocked === null ? null : parseHold(state.experimentalBlocked)
if (blocked !== null && blocked.type !== 'combination') throw new TrustError('Experimental block must identify a combination')
return Object.freeze({ schema: 1, updateChannel: state.updateChannel, holds: Object.freeze(holds), experimentalBlocked: blocked })
}
export class ChannelStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parseState(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return DEFAULT_STATE
throw error
}
}
write(value) { return durableReplace(this.path, `${JSON.stringify(parseState(value))}\n`) }
setChannel(updateChannel) {
return this.exclusive(async () => {
const current = await this.read()
const next = parseState({ ...current, updateChannel })
await this.write(next)
return next
})
}
addHold({ type, dshVersion, environmentVersion = null, reason }) {
return this.exclusive(async () => {
const current = await this.read()
const identity = `${type}\0${dshVersion}\0${environmentVersion ?? ''}`
const id = createHash('sha256').update(identity).digest('hex').slice(0, 24)
const hold = parseHold({
id, type, dshVersion, environmentVersion, reason,
createdAt: this.now().toISOString(),
})
const holds = [...current.holds.filter(item => item.id !== id), hold]
const next = parseState({ ...current, holds })
await this.write(next)
return hold
})
}
retry(id) {
return this.exclusive(async () => {
const current = await this.read()
const holds = current.holds.filter(hold => hold.id !== id)
const clearsBlock = current.experimentalBlocked?.id === id
if (holds.length === current.holds.length && !clearsBlock) throw new TrustError('update hold does not exist')
const next = parseState({ ...current, holds, experimentalBlocked: clearsBlock ? null : current.experimentalBlocked })
await this.write(next)
return next
})
}
block({ dshVersion, environmentVersion, reason }) {
return this.exclusive(async () => {
const current = await this.read()
const blocked = parseHold({
id: `blocked-${randomUUID()}`, type: 'combination', dshVersion, environmentVersion, reason,
createdAt: this.now().toISOString(),
})
const next = parseState({ ...current, experimentalBlocked: blocked })
await this.write(next)
return next
})
}
}
function held(local, dshVersion, environmentVersion) {
return local.holds.find(hold => (
hold.dshVersion === dshVersion
&& (hold.type === 'version' || hold.environmentVersion === environmentVersion)
))
}
export function planDesiredState({ local, current, supported, stableTargetSequence, upstream = null }) {
const aheadOfStable = compareDshVersions(current.dsh, supported.dsh) > 0
const stableIdentityMismatch = Number.isSafeInteger(stableTargetSequence) && (
current.targetSequence !== stableTargetSequence
|| (local.updateChannel === 'stable'
? current.authority !== 'stable'
: !['stable', 'experimental'].includes(current.authority))
)
const base = {
updateChannel: local.updateChannel,
current,
supported,
upstream,
aheadOfStable,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
}
if (aheadOfStable) return Object.freeze({ ...base, action: 'frozen', reason: 'Stable has not caught up with the current DSH' })
if (compareDshVersions(current.dsh, supported.dsh) < 0 || current.environment !== supported.environment || stableIdentityMismatch) {
return Object.freeze({ ...base, action: 'stable', reason: 'converge the signed Stable target before Experimental DSH' })
}
if (local.updateChannel === 'stable' || upstream === null) {
return Object.freeze({ ...base, action: 'none', reason: null })
}
const matchingHold = held(local, upstream.version, supported.environment)
if (matchingHold !== undefined) return Object.freeze({ ...base, action: 'held', reason: matchingHold.reason, hold: matchingHold })
if (
local.experimentalBlocked?.dshVersion === upstream.version
&& local.experimentalBlocked.environmentVersion === supported.environment
) return Object.freeze({ ...base, action: 'blocked', reason: local.experimentalBlocked.reason })
return Object.freeze({ ...base, action: 'experimental', reason: null })
}
@@ -0,0 +1,66 @@
import { request } from 'node:http'
export class LocalApiClient {
constructor(socketPath) {
this.socketPath = socketPath
}
request(method, path, body, extraHeaders = {}) {
return new Promise((resolve, reject) => {
const bytes = body === undefined ? undefined : Buffer.from(JSON.stringify(body))
const req = request({
socketPath: this.socketPath,
method,
path,
headers: {
...extraHeaders,
...(bytes === undefined ? {} : {
'content-type': 'application/json',
'content-length': bytes.byteLength,
}),
},
}, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
let value
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch { value = {} }
if ((response.statusCode ?? 500) >= 400) {
const error = new Error(value.error ?? `local API returned ${String(response.statusCode)}`)
error.statusCode = response.statusCode
if (typeof value.code === 'string') error.code = value.code
if (Number.isInteger(value.retryAfterSeconds)) error.retryAfterSeconds = value.retryAfterSeconds
error.localApiPath = path
reject(error)
} else resolve(value)
})
})
req.once('error', reject)
req.end(bytes)
})
}
status() { return this.request('GET', '/v1/status') }
activeReceipts() { return this.request('GET', '/v1/receipts/active') }
acceptKeyring(document, signature) {
return this.request('POST', '/v1/keyring', { document: document.toString('base64'), signature })
}
acceptTarget(document, signature) {
return this.request('POST', '/v1/target', { document: document.toString('base64'), signature })
}
importArtifact(artifactId, sourcePath, parentReceipt = null) {
return this.request('POST', '/v1/artifacts/import', { artifactId, sourcePath, parentReceipt })
}
ensureOfficialDsh(version, metadataPath, tarballPath) {
return this.request('POST', '/v1/dsh/ensure', { version, metadataPath, tarballPath })
}
acceptManifest(receipt, signatureReceipt) {
return this.request('POST', '/v1/manifests/accept', { receipt, signatureReceipt })
}
activate(receipts) { return this.request('POST', '/v1/activate', { receipts }) }
collectGarbage() { return this.request('POST', '/v1/objects/collect', {}) }
stageBootstrap(receipt, version) {
return this.request('POST', '/v1/bootstrap/stage', { receipt, version })
}
collectBootstrap() { return this.request('POST', '/v1/bootstrap/collect', {}) }
}
@@ -0,0 +1,894 @@
import { randomUUID } from 'node:crypto'
import { EventEmitter } from 'node:events'
import { recoverInterruptedUpdate } from './recovery.mjs'
import { planDesiredState } from './channel-state.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export class UpdateConflictError extends Error {}
function healthMetrics(health) {
const components = Array.isArray(health?.components) ? health.components : []
return {
readyServices: components.filter(component => component?.healthy === true).length,
totalServices: components.length,
}
}
function journalDeployment(deployment) {
return Object.freeze({
dsh: deployment.dsh,
environment: deployment.environment,
runtime: deployment.runtime,
dataSnapshot: deployment.dataSnapshot,
receiptTokens: deployment.receiptTokens,
})
}
function metricPercentage(processed, total) {
return typeof processed === 'number' && typeof total === 'number' && total > 0
? Math.floor(Math.max(0, Math.min(1, processed / total)) * 100)
: null
}
function holdReason(message) {
const lines = message.split(/\r?\n/u).map(line => line.trim()).filter(Boolean)
const npmNotarget = lines.find(line => /^npm error notarget\b/u.test(line))
const selected = npmNotarget?.replace(/^npm error notarget\s*/u, '') ?? lines[0] ?? 'Experimental update failed'
const normalized = selected.replace(/[\u0000-\u001f\u007f]/gu, ' ').replace(/\s+/gu, ' ').trim()
return (normalized || 'Experimental update failed').slice(0, 512)
}
export class UpdateCoordinator extends EventEmitter {
constructor({
metadata, preparer, activator, state, npm, journal, snapshots, channelState, completeRecovery, automaticChecks,
allowUnavailableMetadata = false,
probationSeconds = 120,
report = async () => {},
now = () => new Date(),
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
super()
this.metadata = metadata
this.preparer = preparer
this.activator = activator
this.state = state
this.npm = npm
this.journal = journal
this.snapshots = snapshots
this.channelState = channelState
this.completeRecovery = completeRecovery
this.automaticChecks = automaticChecks
this.allowUnavailableMetadata = allowUnavailableMetadata
this.probationSeconds = probationSeconds
this.report = report
this.now = now
this.sleep = sleep
this.task = undefined
this.checkTask = undefined
this.reportTimes = new Map()
}
async transition(status, fields = {}) {
const previous = await this.state.read()
const phase = fields.phase ?? (
status === 'idle'
? null
: fields.rollbackPhase ?? (
['success', 'failed'].includes(status)
? previous.phase ?? previous.rollbackPhase ?? (['idle', 'success', 'failed'].includes(previous.status) ? null : previous.status)
: status
)
)
const contextChanged = phase !== (previous.phase ?? null)
|| (fields.operation !== undefined && fields.operation !== previous.operation)
const phaseMetrics = contextChanged ? {
processedBytes: null,
totalBytes: null,
processedItems: null,
totalItems: null,
readyServices: null,
totalServices: null,
detail: null,
} : {}
const value = await this.state.write(status, { ...phaseMetrics, ...fields, phase })
this.emit('state', value)
await this.record('update.phase.changed', {
status,
phase,
operation: value.operation ?? null,
rollbackPhase: value.rollbackPhase ?? null,
taskId: value.taskId ?? null,
progress: value.progress ?? null,
processedBytes: value.processedBytes ?? null,
totalBytes: value.totalBytes ?? null,
processedItems: value.processedItems ?? null,
totalItems: value.totalItems ?? null,
readyServices: value.readyServices ?? null,
totalServices: value.totalServices ?? null,
detail: value.detail ?? null,
targetSequence: value.targetSequence ?? value.available?.targetSequence ?? null,
outcome: value.outcome ?? null,
...(value.error === null || value.error === undefined ? {} : { error: value.error, level: 'error' }),
})
return value
}
async record(message, fields = {}) {
try {
const current = await this.state.read()
const context = {
...(current.taskId === null || current.taskId === undefined ? {} : { taskId: current.taskId }),
...(current.operation === null || current.operation === undefined ? {} : { operation: current.operation }),
...(current.phase === null || current.phase === undefined
? (current.status === null || current.status === undefined ? {} : { phase: current.status })
: { phase: current.phase }),
}
return await this.report(message, { ...context, ...fields })
} catch {}
}
async bestEffort(message, operation, fallback, fields = {}, cooldownMs = 0) {
try {
return await operation()
} catch (error) {
const timestamp = this.now().valueOf()
const previous = this.reportTimes.get(message)
if (cooldownMs === 0 || previous === undefined || timestamp - previous >= cooldownMs) {
this.reportTimes.set(message, timestamp)
await this.record(message, { ...fields, error, level: 'warning' })
}
return fallback
}
}
async reportHealth(status, fields) {
if (this.activator.health === undefined) return undefined
const health = await this.activator.health()
await this.transition(status, { ...fields, ...healthMetrics(health) })
return health
}
hasActiveTask() {
return this.task !== undefined
}
check(source = 'manual') {
if (this.task !== undefined && source === 'page-open') return Promise.resolve({ busy: true })
if (this.task !== undefined) return Promise.reject(new UpdateConflictError('an update task is already running'))
if (this.checkTask !== undefined) return this.checkTask
this.checkTask = this.runCheck(source).finally(() => { this.checkTask = undefined })
return this.checkTask
}
startTask(operation) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
const taskId = randomUUID()
const precedingCheck = this.checkTask
this.task = (async () => {
if (precedingCheck !== undefined) {
try { await precedingCheck } catch {}
}
return operation(taskId)
})().finally(() => { this.task = undefined })
return { taskId, completion: this.task }
}
async runCheck(source) {
await this.transition('checking', {
checkSource: source, taskId: null, progress: 0, operation: 'check', rollbackPhase: null, error: null,
})
try {
if (this.allowUnavailableMetadata) return await this.recordUnavailableMetadata(source)
if (this.channelState !== undefined) {
const plan = await this.desiredState()
const rollbackPlan = await this.rollbackPlan()
const updateAvailable = ['stable', 'experimental'].includes(plan.action)
await this.transition('idle', {
available: {
targetSequence: plan.target.targetSequence,
bootstrap: plan.target.desired.bootstrap.version,
environment: plan.target.desired.environment.version,
dsh: plan.target.desired.dsh.version,
},
supported: plan.supported,
...(plan.upstream === null ? {} : { upstream: { version: plan.upstream.version } }),
current: { dsh: plan.current.dsh, environment: plan.current.environment, runtime: plan.current.runtime },
aheadOfStable: plan.aheadOfStable,
experimentalBlocked: plan.experimentalBlocked,
holds: plan.holds,
rollbackPlan,
updateAvailable,
checkedAt: this.now().toISOString(),
metadataUnavailable: false,
remoteCheckError: null,
remoteCheckFailedAt: null,
remoteCheckSource: null,
})
if (source === 'automatic' && this.automaticChecks !== undefined) {
await this.automaticChecks.record({
channel: plan.updateChannel,
current: plan.current,
target: plan.target,
upstream: plan.upstream,
stableAvailable: plan.action === 'stable',
holds: plan.holds,
})
this.emit('state', await this.state.read())
}
return { value: plan.target, updateAvailable }
}
const target = await this.metadata.check()
await this.transition('idle', {
available: {
targetSequence: target.value.targetSequence,
bootstrap: target.value.desired.bootstrap.version,
environment: target.value.desired.environment.version,
dsh: target.value.desired.dsh.version,
},
checkedAt: new Date().toISOString(),
metadataUnavailable: false,
remoteCheckError: null,
remoteCheckFailedAt: null,
remoteCheckSource: null,
})
return target
} catch (error) {
await this.record('update.check.failed', { error, checkSource: source })
await this.transition('idle', {
error: null,
remoteCheckError: error instanceof Error ? error.message : 'update check failed',
remoteCheckFailedAt: this.now().toISOString(),
remoteCheckSource: error?.remoteCheckSource === 'upstream' ? 'upstream' : 'stable',
})
throw error
}
}
async recordUnavailableMetadata(source) {
const local = await this.channelState?.read()
const upstream = local?.updateChannel === 'experimental' && this.npm !== undefined
? await this.bestEffort('update.upstream-discovery.failed', () => this.npm.discover(), null, { checkSource: source })
: null
await this.transition('idle', {
metadataUnavailable: true,
...(upstream === null ? {} : { upstream }),
updateAvailable: false,
checkedAt: this.now().toISOString(),
error: null,
})
return { unavailable: true, upstream }
}
start() {
return this.startTask(taskId => this.run(taskId))
}
startExperimental() {
if (this.npm === undefined || this.journal === undefined || this.snapshots === undefined) {
throw new Error('Experimental updates are not configured')
}
return this.startTask(taskId => this.runExperimental(taskId))
}
startReconcile() {
return this.startTask(taskId => this.runReconcile(taskId))
}
async setChannel(updateChannel) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
if (this.channelState === undefined) throw new Error('update channels are not configured')
const result = await this.channelState.setChannel(updateChannel)
if (updateChannel === 'stable') await this.automaticChecks?.clearUpstream()
await this.record('update.channel.changed', { updateChannel })
return result
}
async retryHold(id) {
if (this.task !== undefined) throw new UpdateConflictError('an update task is already running')
if (this.channelState === undefined) throw new Error('update channels are not configured')
const result = await this.channelState.retry(id)
await this.record('update.hold.retried', { holdId: id })
return result
}
rollbackPlan() {
if (this.completeRecovery === undefined) return Promise.resolve(null)
return this.completeRecovery.plan()
}
startCompleteRollback(planId, options = {}) {
if (this.completeRecovery === undefined) throw new Error('complete rollback is not configured')
return this.startTask(taskId => this.runCompleteRollback(taskId, planId, options))
}
async runCompleteRollback(taskId, planId, options) {
const operation = options.requireConfirmation ? 'return-stable' : 'rollback'
try {
const rollbackPlan = await this.completeRecovery.plan()
let verifiedStable = null
await this.transition('restoring-data', {
taskId,
progress: 5,
operation,
rollbackPhase: 'preparing',
rollbackIncludesSnapshot: rollbackPlan?.snapshot !== null && rollbackPlan?.snapshot !== undefined,
error: null,
})
if (options.requireConfirmation) {
const [plan, target] = await Promise.all([this.completeRecovery.plan(), this.metadata.check()])
if (
plan === null
|| plan.planId !== planId
|| compareDshVersions(plan.previous.dsh, target.value.desired.dsh.version) > 0
) throw new Error('no verified pre-Experimental Stable recovery point is available')
verifiedStable = target.value
}
const result = await this.completeRecovery.restore(planId, {
...options,
onProgress: (() => {
let previousKey
return (rollbackPhase, progress, metrics = {}) => {
const key = [
rollbackPhase,
progress,
metricPercentage(metrics.processedBytes, metrics.totalBytes),
metricPercentage(metrics.processedItems, metrics.totalItems),
].join(':')
if (key === previousKey) return undefined
previousKey = key
return this.transition('restoring-data', {
taskId,
progress,
operation,
rollbackPhase,
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
}
})(),
})
await this.reportHealth('restoring-data', {
taskId,
progress: 95,
operation,
rollbackPhase: 'verifying',
})
const current = await this.activator.currentDeployment()
if (
rollbackPlan === null
|| current.dsh !== rollbackPlan.previous.dsh
|| current.environment !== rollbackPlan.previous.environment
|| current.runtime !== rollbackPlan.previous.runtime
) throw new Error('restored Deployment differs from the rollback plan')
const [stored, local] = await Promise.all([
this.state.read(),
this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }),
])
const supported = verifiedStable === null
? stored.supported ?? null
: {
dsh: verifiedStable.desired.dsh.version,
environment: verifiedStable.desired.environment.version,
}
let aheadOfStable = false
let updateAvailable = false
if (supported !== null) {
const upstream = local.updateChannel === 'experimental'
&& typeof stored.upstream?.version === 'string'
&& compareDshVersions(stored.upstream.version, supported.dsh) > 0
? stored.upstream
: null
const plan = planDesiredState({
local,
current,
supported,
stableTargetSequence: verifiedStable?.targetSequence ?? stored.available?.targetSequence,
upstream,
})
aheadOfStable = plan.aheadOfStable
updateAvailable = ['stable', 'experimental'].includes(plan.action)
}
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
await this.transition('success', {
taskId,
progress: 100,
current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
aheadOfStable,
updateAvailable,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
outcome: null,
probationUntil: null,
error: null,
})
return result
} catch (error) {
await this.record('update.rollback.failed', { error, taskId, operation })
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'rollback failed' })
throw error
}
}
async desiredState() {
if (this.channelState === undefined) throw new Error('update channels are not configured')
const stable = (await this.metadata.check()).value
const [current, local] = await Promise.all([this.activator.currentDeployment(), this.channelState.read()])
const supported = { dsh: stable.desired.dsh.version, environment: stable.desired.environment.version }
let upstream = null
let upstreamCandidate = null
if (
local.updateChannel === 'experimental'
&& compareDshVersions(current.dsh, supported.dsh) <= 0
&& current.environment === supported.environment
) {
try {
upstream = await this.npm.discover(stable.officialDshPolicy)
} catch (error) {
const scoped = new Error(error instanceof Error ? error.message : 'official DSH discovery failed', { cause: error })
scoped.name = error instanceof Error ? error.name : 'Error'
scoped.code = error?.code
scoped.remoteCheckSource = 'upstream'
throw scoped
}
upstreamCandidate = compareDshVersions(upstream.version, supported.dsh) > 0 ? upstream : null
}
return Object.freeze({
...planDesiredState({ local, current, supported, stableTargetSequence: stable.targetSequence, upstream: upstreamCandidate }),
upstream,
target: stable,
})
}
async publicStatus() {
const [update, local, current, rollbackPlan, journal, automatic] = await Promise.all([
this.state.read(),
this.channelState?.read() ?? Promise.resolve({ updateChannel: 'stable', holds: [], experimentalBlocked: null }),
this.bestEffort(
'update.status.current.failed',
() => this.activator.currentDeployment({ required: false }),
null,
{},
60_000,
),
this.bestEffort('update.status.rollback-plan.failed', () => this.rollbackPlan(), null, {}, 60_000),
this.journal === undefined
? Promise.resolve(undefined)
: this.bestEffort('update.status.journal.failed', () => this.journal.read(), undefined, {}, 60_000),
this.automaticChecks?.read() ?? Promise.resolve(null),
])
const returnStableAvailable = rollbackPlan !== null && rollbackPlan.snapshot !== null && update.supported?.dsh !== undefined
&& compareDshVersions(rollbackPlan.previous.dsh, update.supported.dsh) <= 0
return Object.freeze({
update,
updateChannel: local.updateChannel,
current: current === null ? null : { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
supported: update.supported ?? null,
upstream: update.upstream ?? null,
aheadOfStable: update.aheadOfStable ?? false,
experimentalBlocked: local.experimentalBlocked,
holds: local.holds,
probation: journal?.phase === 'probation' ? { until: journal.probationUntil } : null,
rollbackPlan: rollbackPlan === null ? null : { ...rollbackPlan, returnStableAvailable },
...(automatic === null ? {} : {
automaticCheck: automatic.automaticCheck,
latestAutomatic: automatic.latestAutomatic,
}),
})
}
async runReconcile(taskId) {
try {
const plan = await this.desiredState()
if (plan.action === 'stable') {
const blockExperimentalCombination = plan.current?.authority === 'experimental'
&& plan.current.environment !== plan.supported?.environment
await this.run(taskId, { complete: false })
const next = await this.desiredState()
if (next.action === 'experimental') {
return this.runExperimental(taskId, { blockCombination: blockExperimentalCombination })
}
return this.transition('success', {
taskId,
progress: 100,
current: { dsh: next.current.dsh, environment: next.current.environment, runtime: next.current.runtime },
aheadOfStable: next.aheadOfStable,
experimentalBlocked: next.experimentalBlocked,
holds: next.holds,
updateAvailable: ['stable', 'experimental'].includes(next.action),
error: null,
outcome: next.action,
})
}
if (plan.action === 'experimental') return this.runExperimental(taskId)
return this.transition('success', {
taskId, progress: 100, error: null,
outcome: plan.action,
})
} catch (error) {
await this.record('update.reconcile.failed', { error, taskId })
const current = await this.state.read()
if (current.status !== 'failed') {
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update planning failed' })
}
throw error
}
}
async run(taskId, { complete = true } = {}) {
try {
await this.transition('planning', {
taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null,
processedBytes: null, totalBytes: null, processedItems: null, totalItems: null,
readyServices: null, totalServices: null, detail: null, error: null,
})
const target = await this.metadata.check()
const artifacts = Array.isArray(target.value.artifacts) ? target.value.artifacts : []
await this.transition('downloading', {
taskId,
progress: 10,
targetSequence: target.value.targetSequence,
processedBytes: 0,
totalBytes: artifacts.reduce((total, artifact) => total + artifact.size, 0),
processedItems: 0,
totalItems: artifacts.length,
detail: 'downloading',
})
let previousDownloadKey
let previousDownloadProgress = 10
const prepared = await this.preparer.prepare(target.value, {
onProgress: metrics => {
const byteRatio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0
? metrics.processedBytes / metrics.totalBytes
: null
const itemRatio = Number.isFinite(metrics.totalItems) && metrics.totalItems > 0
? metrics.processedItems / metrics.totalItems
: null
const ratio = byteRatio ?? itemRatio
const measuredProgress = ratio === null ? 10 : 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 55)
const progress = Math.max(previousDownloadProgress, measuredProgress)
previousDownloadProgress = progress
const key = `${progress}:${metrics.processedItems}:${metrics.totalItems}`
if (key === previousDownloadKey) return undefined
previousDownloadKey = key
return this.transition('downloading', {
taskId,
progress,
processedBytes: metrics.processedBytes,
totalBytes: metrics.totalBytes,
processedItems: metrics.processedItems,
totalItems: metrics.totalItems,
detail: metrics.detail ?? null,
})
},
})
await this.transition('validating', { taskId, progress: 70 })
await this.transition('building-candidate', { taskId, progress: 75 })
let previousBuildProgress = 75
let buildComplete = false
await this.activator.activate(prepared, {
onProgress: progress => {
if (typeof progress === 'number') {
const stageProgress = Math.max(previousBuildProgress, progress)
if (stageProgress === previousBuildProgress) return undefined
previousBuildProgress = stageProgress
return this.transition('building-candidate', { taskId, progress: stageProgress })
}
const ratio = progress.totalBytes > 0 ? progress.processedBytes / progress.totalBytes : 0
const stageProgress = Math.max(
previousBuildProgress,
75 + Math.round(Math.max(0, Math.min(1, ratio)) * 14),
)
const complete = progress.processedBytes === progress.totalBytes
&& progress.processedItems === progress.totalItems
if (stageProgress === previousBuildProgress && (!complete || buildComplete)) return undefined
previousBuildProgress = stageProgress
buildComplete = complete
return this.transition('building-candidate', {
taskId,
progress: stageProgress,
processedBytes: progress.processedBytes,
totalBytes: progress.totalBytes,
processedItems: progress.processedItems,
totalItems: progress.totalItems,
})
},
onSwitching: () => this.transition('switching', { taskId, progress: 90 }),
})
const health = await this.reportHealth('switching', { taskId, progress: 95 })
const current = this.activator.currentDeployment === undefined
? null
: await this.activator.currentDeployment()
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
return complete ? this.transition('success', {
taskId,
progress: 100,
...(current === null ? {} : {
current: { dsh: current.dsh, environment: current.environment, runtime: current.runtime },
updateAvailable: false,
}),
error: null,
}) : health
} catch (error) {
await this.record('update.stable.failed', { error, taskId })
await this.transition('failed', { taskId, error: error instanceof Error ? error.message : 'update failed' })
throw error
}
}
trustActivate(prepared) {
return this.preparer.trust.activate(prepared.receiptTokens)
}
async runExperimental(taskId, { blockCombination = false } = {}) {
let transaction
let candidate
let failureClass = 'check'
let obsoleteSnapshotId = null
try {
await this.transition('checking-upstream', {
taskId, progress: 0, operation: 'update', rollbackPhase: null, rollbackIncludesSnapshot: null, error: null,
})
const stable = (await this.metadata.check()).value
candidate = await this.npm.latest(stable)
if (candidate === null) return this.transition('success', { taskId, progress: 100, error: null })
const from = await this.activator.currentDeployment()
const bootstrap = await this.activator.bootstrap.status()
if (
from.environment !== stable.desired.environment.version
|| bootstrap.bootstrapVersion !== stable.desired.bootstrap.version
) throw new Error('Stable Environment and Bootstrap must converge before Experimental DSH')
await this.transition('downloading', { taskId, progress: 10 })
failureClass = 'candidate'
let previousDownloadProgress = 10
const prepared = await this.preparer.prepareExperimental(candidate, {
onProgress: metrics => {
const ratio = Number.isFinite(metrics.totalBytes) && metrics.totalBytes > 0
? metrics.processedBytes / metrics.totalBytes
: metrics.processedItems / metrics.totalItems
const progress = Math.max(previousDownloadProgress, 10 + Math.round(Math.max(0, Math.min(1, ratio)) * 20))
previousDownloadProgress = progress
return this.transition('downloading', {
taskId,
progress,
processedBytes: metrics.processedBytes,
totalBytes: metrics.totalBytes,
processedItems: metrics.processedItems,
totalItems: metrics.totalItems,
detail: metrics.detail ?? null,
})
},
})
await this.transition('building-candidate', { taskId, progress: 35 })
const built = await this.activator.prepareExperimental(prepared, { targetSequence: stable.targetSequence })
const previousJournal = await this.journal.read()
if (previousJournal !== undefined && ['committed', 'rolled-back', 'failed'].includes(previousJournal.phase)) {
obsoleteSnapshotId = previousJournal.snapshotId
}
transaction = await this.journal.begin({
transactionId: taskId,
mode: 'experimental',
from: journalDeployment(from),
to: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: built.runtimeId },
})
transaction = await this.journal.transition('candidate-ready', { receiptTokens: prepared.receiptTokens })
await this.transition('snapshotting-data', { taskId, progress: 55 })
failureClass = 'snapshot'
await this.activator.suspendDsh()
transaction = await this.journal.transition('suspended')
let previousSnapshotProgress
const snapshot = await this.snapshots.create({
id: taskId,
runtimeId: from.runtime,
environmentVersion: from.environment,
dshVersion: from.dsh,
onProgress: metrics => {
const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems)
?? metricPercentage(metrics.processedBytes, metrics.totalBytes)
if (metricProgress === previousSnapshotProgress) return undefined
previousSnapshotProgress = metricProgress
return this.transition('snapshotting-data', {
taskId,
progress: 55,
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
},
})
const candidateRuntimeId = this.activator.bindExperimentalSnapshot === undefined
? built.runtimeId
: await this.activator.bindExperimentalSnapshot(built.runtimeId, snapshot.id)
transaction = await this.journal.transition('snapshot-created', {
snapshotId: snapshot.id,
to: { ...transaction.to, runtime: candidateRuntimeId },
})
await this.transition('switching', { taskId, progress: 70 })
await this.activator.switchExperimental(candidateRuntimeId)
failureClass = 'combination'
transaction = await this.journal.transition('switched')
const probationUntil = new Date(this.now().valueOf() + this.probationSeconds * 1000).toISOString()
transaction = await this.journal.transition('probation', { probationUntil })
await this.transition('probation', { taskId, progress: 85, probationUntil })
do {
const health = await this.activator.health()
const remainingMs = Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf())
const elapsedRatio = this.probationSeconds <= 0
? 1
: Math.max(0, Math.min(1, 1 - remainingMs / (this.probationSeconds * 1000)))
await this.transition('probation', {
taskId,
progress: 85 + Math.round(elapsedRatio * 14),
probationUntil,
detail: `probation:${String(Math.ceil(remainingMs / 1000))}`,
...healthMetrics(health),
})
if (!health.healthy) throw new Error('Experimental Runtime failed probation health checks')
if (this.now().valueOf() >= new Date(probationUntil).valueOf()) break
await this.sleep(Math.min(1_000, Math.max(0, new Date(probationUntil).valueOf() - this.now().valueOf())))
} while (true)
await this.activator.commitExperimental(candidateRuntimeId)
transaction = await this.journal.transition('committed')
await this.bestEffort('update.notifications.cleanup.failed', () => this.clearSatisfiedNotifications(), undefined, { taskId })
if (this.activator.cleanup !== undefined) {
await this.bestEffort('update.assets.cleanup.failed', () => this.activator.cleanup(), undefined, { taskId })
}
if (obsoleteSnapshotId !== null && obsoleteSnapshotId !== transaction.snapshotId) {
if (this.snapshots.remove !== undefined) {
await this.bestEffort('update.snapshot.cleanup.failed', () => this.snapshots.remove(obsoleteSnapshotId), undefined, {
snapshotId: obsoleteSnapshotId,
taskId,
})
}
}
return this.transition('success', {
taskId,
progress: 100,
current: { dsh: built.dshVersion, environment: built.environmentVersion, runtime: candidateRuntimeId },
aheadOfStable: true,
updateAvailable: false,
outcome: 'frozen',
probationUntil: null,
error: null,
})
} catch (error) {
let message = error instanceof Error ? error.message : 'Experimental update failed'
const reason = holdReason(message)
const failureState = await this.state.read()
const failurePhase = failureState.phase ?? failureState.status
let recoveryCompleted = false
await this.record('update.experimental.failed', { error, failureClass, taskId })
if (candidate !== undefined && this.channelState !== undefined) {
if (failureClass === 'candidate') {
await this.bestEffort('update.hold.persist.failed', () => this.channelState.addHold({
type: 'version', dshVersion: candidate.version, reason,
}), undefined, { dshVersion: candidate.version, holdType: 'version', taskId })
} else if (failureClass === 'combination') {
const environmentVersion = transaction?.to.environment
if (environmentVersion !== undefined) {
const persist = blockCombination
? () => this.channelState.block({ dshVersion: candidate.version, environmentVersion, reason })
: () => this.channelState.addHold({
type: 'combination', dshVersion: candidate.version, environmentVersion, reason,
})
await this.bestEffort(
blockCombination ? 'update.experimental-block.persist.failed' : 'update.hold.persist.failed',
persist,
undefined,
{
dshVersion: candidate.version,
environmentVersion,
...(blockCombination ? { blocked: true } : { holdType: 'combination' }),
taskId,
},
)
}
}
}
if (this.journal !== undefined) {
transaction = await this.bestEffort('update.journal.read.failed', () => this.journal.read(), transaction, { taskId })
}
if (transaction !== undefined && !['committed', 'rolled-back', 'failed'].includes(transaction.phase)) {
try {
if (['snapshot-created', 'switched', 'probation', 'restoring-data'].includes(transaction.phase)) {
if (transaction.phase !== 'restoring-data') transaction = await this.journal.transition('restoring-data', { error: message })
const recoveryProgress = offset => Math.min(99, Math.max(Number(failureState.progress) || 0, offset))
await this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(86),
detail: 'recovery:suspend',
rollbackIncludesSnapshot: transaction.snapshotId !== null,
error: null,
})
await this.bestEffort('update.rollback.suspend.failed', () => this.activator.suspendDsh(), undefined, { taskId })
await this.transition('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(88), detail: 'recovery:deployment',
})
await this.activator.restoreDeployment(transaction.from, { resume: false })
if (transaction.snapshotId !== null) {
await this.transition('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(90), detail: 'recovery:snapshot',
})
let previousRestoreProgress
await this.snapshots.restore(transaction.snapshotId, {
onProgress: metrics => {
const metricProgress = metricPercentage(metrics.processedItems, metrics.totalItems)
?? metricPercentage(metrics.processedBytes, metrics.totalBytes)
if (metricProgress === previousRestoreProgress) return undefined
previousRestoreProgress = metricProgress
return this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(90 + Math.round((metricProgress ?? 0) * 0.05)),
detail: 'recovery:snapshot',
processedBytes: metrics.processedBytes ?? null,
totalBytes: metrics.totalBytes ?? null,
processedItems: metrics.processedItems ?? null,
totalItems: metrics.totalItems ?? null,
})
},
})
}
await this.transition('restoring-data', {
taskId,
operation: 'update',
progress: recoveryProgress(96),
detail: 'recovery:runtime',
processedBytes: null,
totalBytes: null,
processedItems: null,
totalItems: null,
})
await this.activator.resumeDsh()
const health = await this.reportHealth('restoring-data', {
taskId, operation: 'update', progress: recoveryProgress(99), detail: 'recovery:health',
})
if (health?.healthy === false) throw new Error('restored Deployment failed health checks')
await this.journal.transition('rolled-back', { error: message })
recoveryCompleted = true
await this.record('update.experimental.rollback.completed', { taskId })
} else {
await this.bestEffort('update.rollback.resume.failed', () => this.activator.resumeDsh(), undefined, { taskId })
await this.journal.transition('failed', { error: message })
}
} catch (rollbackError) {
await this.record('update.experimental.rollback.failed', { error: rollbackError, taskId })
const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : 'unknown error'
message = `${message}; rollback failed: ${rollbackMessage}`
}
}
await this.transition('failed', {
taskId,
error: message,
...(recoveryCompleted ? {
phase: failurePhase,
progress: failureState.progress,
detail: failureState.detail,
processedBytes: failureState.processedBytes,
totalBytes: failureState.totalBytes,
processedItems: failureState.processedItems,
totalItems: failureState.totalItems,
readyServices: failureState.readyServices,
totalServices: failureState.totalServices,
} : {}),
})
throw error
}
}
async recover() {
if (this.journal === undefined || this.snapshots === undefined) return undefined
return recoverInterruptedUpdate({ journal: this.journal, snapshots: this.snapshots, activator: this.activator })
}
async clearSatisfiedNotifications() {
if (this.automaticChecks === undefined) return
await this.automaticChecks.clearSatisfied(await this.activator.currentDeployment())
}
}
@@ -0,0 +1,142 @@
import { open, readFile, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
import { exactKeys, isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
const PHASES = new Set([
'planning', 'candidate-ready', 'suspended', 'snapshot-created', 'switched', 'probation',
'committed', 'restoring-data', 'rolled-back', 'failed',
])
const TRANSITIONS = new Map([
['planning', new Set(['candidate-ready', 'failed'])],
['candidate-ready', new Set(['suspended', 'failed'])],
['suspended', new Set(['snapshot-created', 'failed'])],
['snapshot-created', new Set(['switched', 'restoring-data', 'failed'])],
['switched', new Set(['probation', 'restoring-data'])],
['probation', new Set(['committed', 'restoring-data'])],
['restoring-data', new Set(['rolled-back', 'failed'])],
['committed', new Set(['restoring-data'])],
['rolled-back', new Set()],
['failed', new Set()],
])
function deployment(value, label, snapshot) {
const object = plainObject(value, label)
exactKeys(object, ['dsh', 'environment', 'runtime', ...(snapshot ? ['dataSnapshot', 'receiptTokens'] : [])], label)
for (const name of ['dsh', 'environment', 'runtime']) {
if (typeof object[name] !== 'string' || object[name] === '') throw new TrustError(`${label}.${name} is invalid`)
}
if (snapshot && object.dataSnapshot !== null && typeof object.dataSnapshot !== 'string') {
throw new TrustError(`${label}.dataSnapshot is invalid`)
}
if (snapshot && (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string'))) {
throw new TrustError(`${label}.receiptTokens is invalid`)
}
return Object.freeze({ ...object, ...(snapshot ? { receiptTokens: Object.freeze([...object.receiptTokens]) } : {}) })
}
function parseJournal(value) {
const object = plainObject(value, 'update journal')
exactKeys(object, [
'error', 'from', 'mode', 'phase', 'probationUntil', 'receiptTokens', 'schema',
'snapshotId', 'to', 'transactionId', 'updatedAt',
], 'update journal')
if (object.schema !== 1) throw new TrustError('update journal schema must be 1')
if (typeof object.transactionId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(object.transactionId)) {
throw new TrustError('update journal transaction ID is invalid')
}
if (!['stable', 'experimental'].includes(object.mode)) throw new TrustError('update journal mode is invalid')
if (!PHASES.has(object.phase)) throw new TrustError('update journal phase is invalid')
if (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string')) {
throw new TrustError('update journal receipt tokens are invalid')
}
for (const name of ['snapshotId', 'probationUntil', 'error']) {
if (object[name] !== null && typeof object[name] !== 'string') throw new TrustError(`update journal ${name} is invalid`)
}
if (object.probationUntil !== null) isoTimestamp(object.probationUntil, 'update journal probation deadline')
isoTimestamp(object.updatedAt, 'update journal update time')
return Object.freeze({
...object,
from: deployment(object.from, 'update journal from', true),
to: deployment(object.to, 'update journal to', false),
receiptTokens: Object.freeze([...object.receiptTokens]),
})
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
export class UpdateJournal {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
this.queue = Promise.resolve()
}
exclusive(operation) {
const result = this.queue.then(operation, operation)
this.queue = result.then(() => undefined, () => undefined)
return result
}
async read() {
try { return parseJournal(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
if (error?.code === 'ENOENT') return undefined
throw error
}
}
begin(value) {
return this.exclusive(async () => {
const previous = await this.read()
if (previous !== undefined && !['committed', 'rolled-back', 'failed'].includes(previous.phase)) {
throw new TrustError('an update transaction journal already exists')
}
const journal = parseJournal({
schema: 1,
transactionId: value.transactionId,
mode: value.mode,
phase: 'planning',
from: value.from,
to: value.to,
snapshotId: null,
receiptTokens: [],
probationUntil: null,
error: null,
updatedAt: this.now().toISOString(),
})
await durableReplace(this.path, `${JSON.stringify(journal)}\n`)
return journal
})
}
transition(phase, fields = {}) {
return this.exclusive(async () => {
const current = await this.read()
if (current === undefined) throw new TrustError('update transaction journal does not exist')
if (!TRANSITIONS.get(current.phase)?.has(phase)) {
throw new TrustError(`update journal cannot transition from ${current.phase} to ${phase}`)
}
const next = parseJournal({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
if (next.transactionId !== current.transactionId || next.mode !== current.mode) {
throw new TrustError('update journal identity cannot change')
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return next
})
}
clear() {
return this.exclusive(async () => {
const current = await this.read()
if (current !== undefined && !['committed', 'rolled-back', 'failed'].includes(current.phase)) {
throw new TrustError('cannot clear a nonterminal update journal')
}
await rm(this.path, { force: true })
await syncDirectory(dirname(this.path)).catch(error => { if (error?.code !== 'ENOENT') throw error })
})
}
}
@@ -0,0 +1,275 @@
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { cp, lstat, mkdir, readFile, rename, rm, symlink } from 'node:fs/promises'
import { join } from 'node:path'
import { buildRuntime } from '../../patch-manager/index.mjs'
import { reconcileSystemPlugins } from '../../plugin-manager/system.mjs'
import { artifactForReference, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs'
import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs'
import { hashTree } from '../../../../platform/lib/tree-hash.mjs'
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolve(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(Buffer.concat(stderr).toString('utf8'))))
})
}
const NPM_INSTALL_ATTEMPTS = 7
const NPM_INSTALL_RETRY_MS = 5_000
function retryableNpmInstall(error) {
return /(?:^|\n)npm error (?:code (?:ETARGET|E404|ENOVERSIONS)|notarget\b)/u.test(
error instanceof Error ? error.message : '',
)
}
async function verifyDirectory(path, expectedHash) {
const details = await lstat(path)
if (!details.isDirectory() || details.isSymbolicLink()) throw new Error(`Managed Store asset is not an immutable directory: ${path}`)
const sha256 = await hashTree(path)
if (expectedHash !== undefined && sha256 !== expectedHash) throw new Error(`Managed Store asset content conflicts at ${path}`)
return sha256
}
async function publish(staging, root, id) {
const sha256 = await hashTree(staging)
const destination = join(root, id)
try {
await rename(staging, destination)
} catch (error) {
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
await verifyDirectory(destination, sha256)
}
return Object.freeze({ id, sha256, path: destination })
}
function storeReference(kind, asset) {
return Object.freeze({ storage: 'store', kind, id: asset.id, sha256: asset.sha256 })
}
export class ManagedDeploymentBuilder {
constructor({
paths,
npmInstall = (installRoot, archivePath, { preferOnline }) => run('npm', [
'install', '--global', '--prefix', installRoot, '--omit=dev', '--ignore-scripts',
'--no-audit', '--no-fund', preferOnline ? '--prefer-online' : '--prefer-offline',
'--cache', paths.npmCacheRoot, archivePath,
]),
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
this.paths = paths
this.npmInstall = npmInstall
this.sleep = sleep
}
async pristine(version, receipt) {
const id = `pristine-npm-${receipt.objectSha256}`
const destination = join(this.paths.pristineRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const installRoot = join(this.paths.pristineRoot, `.install.${randomUUID()}.tmp`)
const staging = join(this.paths.pristineRoot, `.${id}.${randomUUID()}.tmp`)
await mkdir(installRoot, { recursive: false })
try {
const entries = (await run('tar', ['-tzf', receipt.path])).split('\n').filter(Boolean)
if (!entries.includes('package/package.json') || entries.some(entry => (
entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..')
))) throw new Error('official DSH archive contains an unsafe path')
for (let attempt = 1; attempt <= NPM_INSTALL_ATTEMPTS; attempt += 1) {
const attemptRoot = join(installRoot, `attempt-${String(attempt)}`)
await mkdir(attemptRoot, { recursive: false })
try {
const archivePath = join(attemptRoot, 'official-dsh.tgz')
await symlink(receipt.path, archivePath, 'file')
await this.npmInstall(attemptRoot, archivePath, { preferOnline: attempt > 1 })
const installed = join(attemptRoot, 'lib', 'node_modules', '@deepseek-ai', 'dsh')
await rename(installed, staging)
break
} catch (error) {
if (attempt === NPM_INSTALL_ATTEMPTS || !retryableNpmInstall(error)) throw error
await this.sleep(Math.min(30_000, NPM_INSTALL_RETRY_MS * (2 ** (attempt - 1))))
} finally {
await rm(attemptRoot, { recursive: true, force: true })
}
}
const metadata = JSON.parse(await readFile(join(staging, 'package.json'), 'utf8'))
if (metadata.name !== '@deepseek-ai/dsh' || metadata.version !== version) {
throw new Error('official DSH package metadata differs from its requested version')
}
return await publish(staging, this.paths.pristineRoot, id)
} finally {
await rm(installRoot, { recursive: true, force: true })
await rm(staging, { recursive: true, force: true })
}
}
async environment(prepared) {
const manifest = prepared.environment.manifest
const identity = {
schema: 1,
version: manifest.version,
targetSequence: prepared.stable.targetSequence,
manifest: prepared.environment.manifestReceipt.objectSha256,
artifacts: manifest.artifacts.map(artifact => ({ id: artifact.id, sha256: artifact.sha256 })),
}
const id = deriveRecordId('environment', identity)
const destination = join(this.paths.environmentsRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const staging = join(this.paths.environmentsRoot, `.${id}.${randomUUID()}.tmp`)
await mkdir(join(staging, 'artifacts'), { recursive: true })
try {
await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json'))
for (const descriptor of manifest.artifacts) {
await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id))
}
return await publish(staging, this.paths.environmentsRoot, id)
} finally {
await rm(staging, { recursive: true, force: true })
}
}
async runtime(prepared, pristine, environment, { onProgress = async () => {} } = {}) {
const patches = prepared.environment.manifest.patches.map(reference => {
const artifact = artifactForReference(prepared.environment.manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const id = deriveRecordId('runtime', {
schema: 1,
pristine: pristine.sha256,
environment: environment.sha256,
patches,
})
const destination = join(this.paths.runtimesRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
await buildRuntime({
pristineRoot: pristine.path,
versionsRoot: this.paths.runtimesRoot,
runtimeId: id,
patchPaths: patches.map(patch => prepared.paths.get(patch.artifactId)),
onProgress,
})
return Object.freeze({ id, sha256: await verifyDirectory(destination), path: destination })
}
async systemPlugins(prepared, environment) {
const plugins = prepared.environment.manifest.systemPlugins.map(reference => {
const artifact = artifactForReference(prepared.environment.manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const id = deriveRecordId('system-plugins', {
schema: 1,
environment: environment.sha256,
plugins,
})
const destination = join(this.paths.systemPluginsRoot, id)
if (await exists(destination)) {
const sha256 = await verifyDirectory(destination)
return Object.freeze({ id, sha256, path: destination })
}
const buildRoot = join(this.paths.systemPluginsRoot, `.build.${randomUUID()}.tmp`)
try {
const built = await reconcileSystemPlugins({
root: buildRoot,
environmentVersion: id,
plugins: prepared.environment.manifest.systemPlugins,
artifactPath: reference => prepared.paths.get(artifactForReference(prepared.environment.manifest, reference).id),
})
return await publish(built, this.paths.systemPluginsRoot, id)
} finally {
await rm(buildRoot, { recursive: true, force: true })
}
}
async buildStable(prepared, { onProgress = async () => {} } = {}) {
const pristine = await this.pristine(prepared.dsh.version, prepared.dsh.receipt)
await onProgress(80)
const environment = await this.environment(prepared)
await onProgress(82)
const runtime = await this.runtime(prepared, pristine, environment, {
onProgress: metrics => onProgress({ stage: 'runtime', ...metrics }),
})
await onProgress(87)
const systemPlugins = await this.systemPlugins(prepared, environment)
await onProgress(89)
const content = {
schema: 1,
authority: 'stable',
targetSequence: prepared.stable.targetSequence,
dshVersion: prepared.dsh.version,
environmentVersion: prepared.environment.manifest.version,
environment: storeReference('environment', environment),
pristine: storeReference('pristine', pristine),
runtime: storeReference('runtime', runtime),
systemPlugins: storeReference('system-plugins', systemPlugins),
receiptTokens: [...prepared.receiptTokens],
snapshotId: null,
}
return Object.freeze({
record: parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) }),
assets: Object.freeze({ pristine, environment, runtime, systemPlugins }),
})
}
async buildExperimental(prepared, currentValue, receiptTokens, { targetSequence }) {
const current = parseDeploymentRecord(currentValue)
const pristine = await this.pristine(prepared.version, prepared.receipt)
const environmentRoot = join(this.paths.viewsRoot, 'environment')
const manifest = parseEnvironmentManifest(await readFile(join(environmentRoot, 'environment.manifest.json')))
if (manifest.version !== current.environmentVersion) throw new Error('current Environment view differs from its Deployment Record')
const patches = manifest.patches.map(reference => {
const artifact = artifactForReference(manifest, reference)
return { id: reference.id, artifactId: artifact.id, sha256: artifact.sha256 }
})
const runtimeId = deriveRecordId('runtime', {
schema: 1,
pristine: pristine.sha256,
environment: current.environment.sha256,
patches,
})
const runtimePath = join(this.paths.runtimesRoot, runtimeId)
if (!await exists(runtimePath)) {
await buildRuntime({
pristineRoot: pristine.path,
versionsRoot: this.paths.runtimesRoot,
runtimeId,
patchPaths: patches.map(patch => join(environmentRoot, 'artifacts', patch.artifactId)),
})
}
const runtime = Object.freeze({ id: runtimeId, sha256: await verifyDirectory(runtimePath), path: runtimePath })
const content = {
schema: 1,
authority: 'experimental',
targetSequence,
dshVersion: prepared.version,
environmentVersion: current.environmentVersion,
environment: current.environment,
pristine: storeReference('pristine', pristine),
runtime: storeReference('runtime', runtime),
systemPlugins: current.systemPlugins,
receiptTokens: [...receiptTokens],
snapshotId: null,
}
const record = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) })
return Object.freeze({ record, runtime })
}
}
@@ -0,0 +1,180 @@
import { setTimeout as delay } from 'node:timers/promises'
import { parseStable } from '../../../../platform/lib/contracts.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
export class MetadataUnavailableError extends Error {
constructor() {
super('signed update metadata has not been published')
this.name = 'MetadataUnavailableError'
this.code = 'METADATA_UNAVAILABLE'
}
}
async function responseBytes(response, label, maxBytes = 10 * 1024 * 1024) {
if (!response.ok) throw new HttpResponseError(label, response.status)
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`)
return bytes
}
class HttpResponseError extends Error {
constructor(label, status) {
super(`${label} returned HTTP ${String(status)}`)
this.name = 'HttpResponseError'
this.status = status
}
}
function retryableRequest(error) {
return !(error instanceof HttpResponseError)
|| error.status === 408
|| error.status === 429
|| error.status >= 500
}
function exhaustedRequest(label, error, attempts) {
const timeout = error?.name === 'TimeoutError'
const detail = error instanceof Error && error.message !== '' ? `: ${error.message}` : ''
const wrapped = new Error(
timeout
? `${label} timed out after ${String(attempts)} attempts`
: `${label} failed after ${String(attempts)} attempts${detail}`,
{ cause: error },
)
wrapped.name = timeout ? 'TimeoutError' : 'Error'
if (error?.code !== undefined) wrapped.code = error.code
wrapped.requestExhausted = true
return wrapped
}
function timeoutForAttempt(initial, retry, attempt) {
return attempt === 1 ? initial : retry
}
export class MetadataClient {
constructor({
baseUrl,
trust,
fetchImpl = fetch,
attempts = 2,
retryMs = 250,
requestTimeoutMs = 5_000,
retryRequestTimeoutMs = requestTimeoutMs,
}) {
this.baseUrl = new URL(baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)
if (this.baseUrl.protocol !== 'https:' && this.baseUrl.hostname !== '127.0.0.1' && this.baseUrl.hostname !== 'localhost') {
throw new Error('update metadata URL must use HTTPS')
}
this.trust = trust
this.fetchImpl = fetchImpl
this.attempts = attempts
this.retryMs = retryMs
this.requestTimeoutMs = requestTimeoutMs
this.retryRequestTimeoutMs = retryRequestTimeoutMs
}
async file(name) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const response = await this.fetchImpl(new URL(name, this.baseUrl), {
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
})
if (response.status === 404) throw new MetadataUnavailableError()
return await responseBytes(response, name)
} catch (error) {
if (error instanceof MetadataUnavailableError) throw error
if (!retryableRequest(error)) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
throw exhaustedRequest(name, lastError, this.attempts)
}
async verifiedPair(payloadName, signatureName, accept) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const [payload, signatureBytes] = await Promise.all([
this.file(payloadName), this.file(signatureName),
])
await accept(payload, JSON.parse(signatureBytes.toString('utf8')))
return payload
} catch (error) {
if (error instanceof MetadataUnavailableError
|| error instanceof HttpResponseError
|| error?.requestExhausted === true) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
throw lastError
}
async check() {
await this.verifiedPair('keyring.json', 'keyring.sig.json', (bytes, signature) => (
this.trust.acceptKeyring(bytes, signature)
))
const stable = await this.verifiedPair('stable.json', 'stable.sig.json', (bytes, signature) => (
this.trust.acceptTarget(bytes, signature)
))
return Object.freeze({ bytes: stable, value: parseStable(stable) })
}
}
export class NpmRegistryClient {
constructor({
fetchImpl = fetch,
registry = 'https://registry.npmjs.org/',
packageName = '@deepseek-ai/dsh',
attempts = 2,
retryMs = 250,
requestTimeoutMs = 5_000,
retryRequestTimeoutMs = requestTimeoutMs,
}) {
this.fetchImpl = fetchImpl
this.registry = registry
this.packageName = packageName
this.attempts = attempts
this.retryMs = retryMs
this.requestTimeoutMs = requestTimeoutMs
this.retryRequestTimeoutMs = retryRequestTimeoutMs
}
async discover(policy = {}) {
const registry = policy.registry ?? this.registry
const packageName = policy.packageName ?? this.packageName
const packagePath = encodeURIComponent(packageName)
let bytes
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
const response = await this.fetchImpl(new URL(packagePath, registry), {
headers: { accept: 'application/vnd.npm.install-v1+json' },
redirect: 'error',
signal: AbortSignal.timeout(timeoutForAttempt(this.requestTimeoutMs, this.retryRequestTimeoutMs, attempt)),
})
bytes = await responseBytes(response, 'npm packument', 20 * 1024 * 1024)
break
} catch (error) {
if (!retryableRequest(error)) throw error
lastError = error
if (attempt < this.attempts) await delay(this.retryMs)
}
}
if (bytes === undefined) throw exhaustedRequest('npm packument', lastError, this.attempts)
let packument
try { packument = JSON.parse(bytes.toString('utf8')) } catch { throw new Error('npm packument is not valid JSON') }
const latest = packument?.['dist-tags']?.latest
const version = typeof latest === 'string' ? packument?.versions?.[latest]?.version : undefined
if (version !== latest) throw new Error('npm packument has no coherent latest DSH version')
return Object.freeze({ version })
}
async latest(stable) {
const found = await this.discover(stable.officialDshPolicy)
const version = found.version
return compareDshVersions(version, stable.desired.dsh.version) > 0 ? Object.freeze({ version }) : null
}
}
@@ -0,0 +1,192 @@
import { constants } from 'node:fs'
import { createWriteStream } from 'node:fs'
import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { setTimeout as delay } from 'node:timers/promises'
import {
OFFICIAL_DSH_METADATA_LIMIT,
OFFICIAL_DSH_PACKAGE,
OFFICIAL_DSH_TARBALL_LIMIT,
officialDshMetadataUrl,
officialDshTarballUrl,
} from '../../../../platform/lib/official-dsh-contracts.mjs'
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
function exactResponse(response, url, label) {
if (response.redirected || (response.url !== undefined && response.url !== '' && response.url !== url.href)) {
throw new Error(`${label} changed origin or redirected`)
}
if (!response.ok) {
const error = new Error(`${label} returned HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
return response
}
function responseLength(response) {
const header = response.headers?.get?.('content-length')
if (header === null || header === undefined || header === '') return null
const value = Number(header)
return Number.isSafeInteger(value) && value >= 0 ? value : null
}
async function boundedBytes(response, label, maxBytes) {
const declared = responseLength(response)
if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`)
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.byteLength > maxBytes) throw new Error(`${label} exceeds the download limit`)
return bytes
}
async function atomicBytes(path, bytes) {
const temporary = `${path}.${randomUUID()}.tmp`
try {
const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
try {
await handle.writeFile(bytes)
await handle.sync()
} finally {
await handle.close()
}
await rename(temporary, path)
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
throw error
}
}
async function streamToFile(response, path, label, maxBytes, onProgress) {
if (response.body === null || response.body === undefined) throw new Error(`${label} has no response body`)
const declared = responseLength(response)
if (declared !== null && declared > maxBytes) throw new Error(`${label} exceeds the download limit`)
const temporary = `${path}.${randomUUID()}.tmp`
let processedBytes = 0
try {
const meter = new Transform({
transform(chunk, _encoding, callback) {
processedBytes += chunk.byteLength
if (processedBytes > maxBytes) {
callback(new Error(`${label} exceeds the download limit`))
return
}
Promise.resolve(onProgress({ processedBytes, totalBytes: Number.isFinite(declared) ? declared : null }))
.then(() => callback(null, chunk), callback)
},
})
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }))
const handle = await open(temporary, constants.O_RDWR)
try { await handle.sync() } finally { await handle.close() }
await rename(temporary, path)
return { processedBytes, totalBytes: declared }
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
throw error
}
}
export class OfficialDshDownloader {
constructor({ fetchImpl = fetch, attempts = 3, retryMs = 250 }) {
this.fetchImpl = fetchImpl
this.attempts = attempts
this.retryMs = retryMs
}
async retry(operation) {
let lastError
for (let attempt = 1; attempt <= this.attempts; attempt += 1) {
try {
return await operation(attempt)
} catch (error) {
lastError = error
const status = error?.status
if (error?.retryable === false
|| (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429)
|| attempt === this.attempts) {
throw error
}
await delay(this.retryMs)
}
}
throw lastError
}
async download(version, root, { onProgress = async () => {} } = {}) {
compareDshVersions(version, version)
await mkdir(root, { recursive: true })
const metadataUrl = officialDshMetadataUrl()
const metadata = await this.retry(async () => {
const metadataResponse = exactResponse(await this.fetchImpl(metadataUrl, {
headers: { accept: 'application/vnd.npm.install-v1+json', 'accept-encoding': 'identity' },
redirect: 'error',
signal: AbortSignal.timeout(15_000),
}), metadataUrl, 'official DSH metadata')
return boundedBytes(metadataResponse, 'official DSH metadata', OFFICIAL_DSH_METADATA_LIMIT)
})
let packument
try { packument = JSON.parse(metadata.toString('utf8')) } catch { throw new Error('official DSH metadata is not valid JSON') }
const selected = packument?.versions?.[version]
if (selected?.name !== OFFICIAL_DSH_PACKAGE || selected?.version !== version) {
throw new Error('official DSH metadata has no coherent requested version')
}
const tarballUrl = officialDshTarballUrl(version)
if (selected?.dist?.tarball !== tarballUrl.href) throw new Error('official DSH tarball URL is not canonical')
const metadataPath = join(root, 'official-dsh-packument.json')
const tarballPath = join(root, 'official-dsh.tgz')
await atomicBytes(metadataPath, metadata)
try {
let reportedBytes = metadata.byteLength
const result = await this.retry(async () => {
const tarballResponse = exactResponse(await this.fetchImpl(tarballUrl, {
headers: { accept: 'application/octet-stream', 'accept-encoding': 'identity' },
redirect: 'error',
}), tarballUrl, 'official DSH tarball')
if (tarballResponse.headers?.get?.('content-encoding')) {
const error = new Error('official DSH tarball must not use content encoding')
error.retryable = false
throw error
}
const declaredTarball = responseLength(tarballResponse)
const knownTotal = declaredTarball === null ? null : metadata.byteLength + declaredTarball
await onProgress({
processedBytes: reportedBytes,
totalBytes: knownTotal,
processedItems: 1,
totalItems: 2,
detail: 'official-dsh-metadata',
})
return streamToFile(
tarballResponse,
tarballPath,
'official DSH tarball',
OFFICIAL_DSH_TARBALL_LIMIT,
metrics => {
reportedBytes = Math.max(reportedBytes, metadata.byteLength + metrics.processedBytes)
return onProgress({
processedBytes: reportedBytes,
totalBytes: knownTotal,
processedItems: 1,
totalItems: 2,
detail: 'official-dsh-tarball',
})
},
)
})
await onProgress({
processedBytes: Math.max(reportedBytes, metadata.byteLength + result.processedBytes),
totalBytes: result.totalBytes === null ? null : metadata.byteLength + result.totalBytes,
processedItems: 2,
totalItems: 2,
detail: 'official-dsh-tarball',
})
return Object.freeze({ metadataPath, tarballPath })
} catch (error) {
await rm(metadataPath, { force: true }).catch(() => {})
await rm(tarballPath, { force: true }).catch(() => {})
throw error
}
}
}
@@ -0,0 +1,206 @@
import { createHash, randomUUID } from 'node:crypto'
import { createWriteStream } from 'node:fs'
import { link, mkdir, open, readFile, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { Readable, Transform } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { setTimeout as delay } from 'node:timers/promises'
import { parseBootstrapManifest, parseEnvironmentManifest } from '../../../../platform/lib/contracts.mjs'
import { OfficialDshDownloader } from './official-dsh-download.mjs'
async function verifyExisting(path, descriptor) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
size += chunk.byteLength
if (size > descriptor.size) return false
hash.update(chunk)
}
} finally {
await handle.close()
}
return size === descriptor.size && hash.digest('hex') === descriptor.sha256
}
async function download(descriptor, destination, fetchImpl, onChunk) {
let lastError
let reportedBytes = 0
for (let attempt = 1; attempt <= 3; attempt += 1) {
const temporary = `${destination}.${randomUUID()}.tmp`
try {
const response = await fetchImpl(descriptor.url, {
headers: { 'accept-encoding': 'identity' },
})
if (!response.ok) {
const error = new Error(`Artifact ${descriptor.id} returned HTTP ${String(response.status)}`)
error.status = response.status
throw error
}
if (response.body === null || response.body === undefined) throw new Error(`Artifact ${descriptor.id} has no response body`)
const contentEncoding = response.headers?.get?.('content-encoding')
if (contentEncoding !== null && contentEncoding !== undefined && contentEncoding !== '') {
const error = new Error(`Artifact ${descriptor.id} must not use content encoding`)
error.retryable = false
throw error
}
const declaredHeader = response.headers?.get?.('content-length')
if (declaredHeader !== null && declaredHeader !== undefined && declaredHeader !== '') {
const declared = Number(declaredHeader)
if (!Number.isSafeInteger(declared) || declared !== descriptor.size) {
throw new Error(`Artifact ${descriptor.id} does not match stable metadata`)
}
}
const hash = createHash('sha256')
let size = 0
const meter = new Transform({
transform(chunk, _encoding, callback) {
size += chunk.byteLength
if (size > descriptor.size) {
callback(new Error(`Artifact ${descriptor.id} does not match stable metadata`))
return
}
hash.update(chunk)
reportedBytes = Math.max(reportedBytes, size)
Promise.resolve(onChunk(reportedBytes)).then(() => callback(null, chunk), callback)
},
})
await pipeline(Readable.fromWeb(response.body), meter, createWriteStream(temporary, { flags: 'wx', mode: 0o600 }))
if (size !== descriptor.size || hash.digest('hex') !== descriptor.sha256) {
throw new Error(`Artifact ${descriptor.id} does not match stable metadata`)
}
const handle = await open(temporary, 'r+')
try { await handle.sync() } finally { await handle.close() }
try {
await link(temporary, destination)
await rm(temporary)
} catch (error) {
if (error?.code !== 'EEXIST') throw error
if (!await verifyExisting(destination, descriptor)) {
throw new Error(`existing Artifact ${descriptor.id} differs from the target`)
}
await rm(temporary, { force: true })
}
return destination
} catch (error) {
await rm(temporary, { force: true }).catch(() => {})
lastError = error
const status = error?.status
if (error?.retryable === false || (Number.isInteger(status) && status < 500 && status !== 408 && status !== 429) || attempt === 3) throw error
await delay(250)
}
}
throw lastError
}
export class TargetPreparer {
constructor({ untrustedRoot, trust, fetchImpl = fetch, officialDsh = new OfficialDshDownloader({ fetchImpl }) }) {
this.untrustedRoot = untrustedRoot
this.trust = trust
this.fetchImpl = fetchImpl
this.officialDsh = officialDsh
}
async prepare(stable, { onProgress = async () => {} } = {}) {
const taskRoot = join(this.untrustedRoot, `target-${String(stable.targetSequence)}`)
await mkdir(taskRoot, { recursive: true })
const downloads = new Map()
const paths = new Map()
const receipts = new Map()
const authorities = new Map()
let downloadedBytes = 0
let completedDownloads = 0
let totalBytes = stable.artifacts.reduce((total, descriptor) => total + descriptor.size, 0)
let totalItems = stable.artifacts.length
const importDescriptor = async (descriptor, parentReceipt = null) => {
const authority = parentReceipt ?? 'stable'
if (authorities.has(descriptor.id) && authorities.get(descriptor.id) !== authority) {
throw new Error(`Artifact ID ${descriptor.id} is reused across authorities`)
}
authorities.set(descriptor.id, authority)
if (!downloads.has(descriptor.id) && !stable.artifacts.some(root => root.id === descriptor.id)) {
totalBytes += descriptor.size
totalItems += 1
}
if (!downloads.has(descriptor.id)) {
const downloadBase = downloadedBytes
const downloaded = await download(
descriptor,
join(taskRoot, descriptor.id),
this.fetchImpl,
async processed => {
await onProgress({
processedBytes: downloadBase + processed,
totalBytes,
processedItems: completedDownloads,
totalItems,
})
},
)
downloadedBytes = downloadBase + descriptor.size
downloads.set(descriptor.id, downloaded)
completedDownloads += 1
await onProgress({ processedBytes: downloadedBytes, totalBytes, processedItems: completedDownloads, totalItems })
}
if (!receipts.has(descriptor.id)) {
const receipt = await this.trust.importArtifact(
descriptor.id,
downloads.get(descriptor.id),
parentReceipt,
)
receipts.set(descriptor.id, receipt)
paths.set(descriptor.id, receipt.path)
}
return receipts.get(descriptor.id)
}
for (const descriptor of stable.artifacts) await importDescriptor(descriptor)
const prepareManifest = async (reference, parser) => {
const manifestReceipt = receipts.get(reference.manifestArtifactId)
const signatureReceipt = receipts.get(reference.signatureArtifactId)
await this.trust.acceptManifest(manifestReceipt.token, signatureReceipt.token)
const manifest = parser(await readFile(paths.get(reference.manifestArtifactId)))
for (const descriptor of manifest.artifacts) await importDescriptor(descriptor, manifestReceipt.token)
return { manifest, manifestReceipt }
}
const bootstrap = await prepareManifest(stable.desired.bootstrap, parseBootstrapManifest)
const environment = await prepareManifest(stable.desired.environment, parseEnvironmentManifest)
const dshBaseBytes = downloadedBytes
const dshBaseItems = completedDownloads
const dshSources = await this.officialDsh.download(
stable.desired.dsh.version,
join(taskRoot, 'official-dsh'),
{
onProgress: metrics => onProgress({
processedBytes: dshBaseBytes + metrics.processedBytes,
totalBytes: metrics.totalBytes === null ? null : totalBytes + metrics.totalBytes,
processedItems: dshBaseItems + metrics.processedItems,
totalItems: totalItems + metrics.totalItems,
detail: metrics.detail,
}),
},
)
const dshReceipt = await this.trust.ensureOfficialDsh(
stable.desired.dsh.version,
dshSources.metadataPath,
dshSources.tarballPath,
)
return Object.freeze({
stable,
paths,
receipts,
bootstrap,
environment,
dsh: Object.freeze({ version: stable.desired.dsh.version, receipt: dshReceipt }),
receiptTokens: Object.freeze([...receipts.values(), dshReceipt].map(receipt => receipt.token)),
})
}
async prepareExperimental(candidate, { onProgress = async () => {} } = {}) {
const taskRoot = join(this.untrustedRoot, `experimental-${candidate.version}`)
const sources = await this.officialDsh.download(candidate.version, taskRoot, { onProgress })
const receipt = await this.trust.ensureOfficialDsh(candidate.version, sources.metadataPath, sources.tarballPath)
return Object.freeze({ version: candidate.version, receipt, receiptTokens: Object.freeze([receipt.token]) })
}
}
@@ -0,0 +1,82 @@
const TERMINAL_PHASES = new Set(['committed', 'rolled-back', 'failed'])
const PRE_SWITCH_PHASES = new Set(['planning', 'candidate-ready', 'suspended'])
export async function recoverInterruptedUpdate({ journal, snapshots, activator, resume = true }) {
const transaction = await journal.read()
if (transaction === undefined || TERMINAL_PHASES.has(transaction.phase)) return transaction
if (PRE_SWITCH_PHASES.has(transaction.phase)) {
if (resume) await activator.resumeDsh().catch(() => {})
return journal.transition('failed', { error: 'interrupted before Runtime switch' })
}
const restoring = transaction.phase === 'restoring-data'
? transaction
: await journal.transition('restoring-data', { error: 'recovering interrupted update' })
if (resume) await activator.suspendDsh().catch(() => {})
await activator.restoreDeployment(restoring.from, { resume: false })
if (restoring.snapshotId !== null) await snapshots.restore(restoring.snapshotId)
if (resume) await activator.resumeDsh()
return journal.transition('rolled-back', { error: 'recovered interrupted update' })
}
export async function reconcileRecoveredState({ journal, state }) {
const [transaction, persisted] = await Promise.all([journal.read(), state.read()])
const sameTask = transaction !== undefined && persisted.taskId === transaction.transactionId
// A metadata check has no journal and cannot be resumed after Management
// restarts. Leaving this state behind would keep every UI showing a spinner
// and could make startup mistake the check for an interrupted update.
if (persisted.status === 'checking' && !sameTask) {
return {
transaction,
persisted: await state.write('idle', {
taskId: null,
progress: 0,
error: null,
checkSource: null,
}),
}
}
if (sameTask && !['idle', 'success', 'failed'].includes(persisted.status)) {
if (transaction.phase === 'committed') {
return { transaction, persisted: await state.write('success', { progress: 100, error: null }) }
}
if (['rolled-back', 'failed'].includes(transaction.phase)) {
return {
transaction,
persisted: await state.write('failed', { error: transaction.error ?? 'interrupted update was not committed' }),
}
}
}
return { transaction, persisted }
}
export function resumeInterruptedReconcile({ coordinator, persisted, report, audit }) {
const task = coordinator.startReconcile()
const context = {
taskId: task.taskId,
interruptedTaskId: persisted.taskId ?? null,
}
const started = Promise.resolve()
.then(() => persisted.taskId === null || persisted.taskId === undefined
? undefined
: audit('update.failed', {
taskId: persisted.taskId,
outcome: 'interrupted',
resumedByTaskId: task.taskId,
level: 'warning',
}))
.then(() => report('update.resume.started', context))
.catch(() => {})
void task.completion.then(
async () => {
await started
await report('update.resume.completed', context)
},
async error => {
await started
await report('update.resume.failed', { ...context, error })
},
).catch(() => {})
return task
}
@@ -0,0 +1,143 @@
import { createHash } from 'node:crypto'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { TrustError } from '../../../../platform/lib/validation.mjs'
function deploymentIdentity(deployment) {
return {
dsh: deployment.dsh,
environment: deployment.environment,
runtime: deployment.runtime,
receiptTokens: [...deployment.receiptTokens].sort(),
}
}
function recordIdentity(record) {
return deploymentIdentity({
dsh: record.dshVersion,
environment: record.environmentVersion,
runtime: record.id,
receiptTokens: record.receiptTokens,
})
}
function recordMatchesDeployment(record, deployment) {
return record.dshVersion === deployment.dsh
&& record.environmentVersion === deployment.environment
&& [...record.receiptTokens].sort().join('\0') === [...deployment.receiptTokens].sort().join('\0')
}
function sameRecordContent(left, right) {
return left.authority === right.authority
&& left.targetSequence === right.targetSequence
&& left.dshVersion === right.dshVersion
&& left.environmentVersion === right.environmentVersion
&& left.environment.sha256 === right.environment.sha256
&& left.pristine.sha256 === right.pristine.sha256
&& left.runtime.sha256 === right.runtime.sha256
&& left.systemPlugins.sha256 === right.systemPlugins.sha256
}
function bindPlan(value) {
return Object.freeze({
...value,
planId: createHash('sha256').update(canonicalJson(value)).digest('hex'),
})
}
export class CompleteStateRecovery {
constructor({ journal, snapshots, activator }) {
this.journal = journal
this.snapshots = snapshots
this.activator = activator
}
async plan() {
const transaction = await this.journal.read()
if (transaction?.phase === 'committed' && transaction.snapshotId !== null) {
const current = await this.activator.currentDeployment()
if (
current.dsh === transaction.to.dsh
&& current.environment === transaction.to.environment
&& current.runtime === transaction.to.runtime
) {
let previous = deploymentIdentity(transaction.from)
if (this.activator.rollbackDeployments !== undefined) {
const slots = await this.activator.rollbackDeployments()
if (
slots.current?.id !== current.runtime
|| slots.previous === null
|| !recordMatchesDeployment(slots.previous, transaction.from)
) return null
previous = recordIdentity(slots.previous)
}
const snapshot = await this.snapshots.inspect(transaction.snapshotId)
if (
snapshot.runtimeId !== transaction.from.runtime
|| snapshot.environmentVersion !== transaction.from.environment
|| snapshot.dshVersion !== transaction.from.dsh
) throw new TrustError('rollback snapshot does not describe the previous deployment')
return bindPlan({
transactionId: transaction.transactionId,
mode: transaction.mode,
current: deploymentIdentity(current),
previous,
snapshot: {
id: snapshot.id,
createdAt: snapshot.createdAt,
sha256: snapshot.archiveSha256,
size: snapshot.archiveSize,
},
})
}
}
if (this.activator.rollbackDeployments === undefined) return null
const { current, previous } = await this.activator.rollbackDeployments()
if (current === null || previous === null) return null
if (sameRecordContent(current, previous)) return null
return bindPlan({
transactionId: null,
mode: 'stable',
current: recordIdentity(current),
previous: recordIdentity(previous),
snapshot: null,
})
}
async restore(planId, {
confirmDataLoss = false,
requireConfirmation = false,
onProgress = async () => {},
} = {}) {
if (requireConfirmation && confirmDataLoss !== true) {
throw new TrustError('return to Stable requires explicit data-loss confirmation')
}
const plan = await this.plan()
if (plan === null || plan.planId !== planId) throw new TrustError('rollback plan is stale or unavailable')
if (requireConfirmation && plan.snapshot === null) {
throw new TrustError('return to Stable requires a verified data snapshot')
}
if (plan.snapshot === null) {
await onProgress('switching', 35, {})
await this.activator.rollback(plan.previous.runtime)
await onProgress('verifying', 90, {})
return Object.freeze({ status: 'rolled-back', transactionId: null })
}
let transaction = await this.journal.transition('restoring-data', { error: 'manual complete-state rollback' })
await onProgress('stopping', 15, {})
await this.activator.suspendDsh()
try {
await onProgress('switching', 35, {})
await this.activator.restoreDeployment({ ...transaction.from, runtime: plan.previous.runtime }, { resume: false })
await onProgress('restoring-data', 65, {})
await this.snapshots.restore(transaction.snapshotId, {
onProgress: metrics => onProgress('restoring-data', 65, metrics),
})
await onProgress('verifying', 85, {})
await this.activator.resumeDsh()
transaction = await this.journal.transition('rolled-back', { error: 'manual complete-state rollback completed' })
return Object.freeze({ status: 'rolled-back', transactionId: transaction.transactionId })
} catch (error) {
throw error
}
}
}
@@ -0,0 +1,55 @@
export class UpdateScheduler {
constructor({
check,
intervalSeconds = 21_600,
onError = async () => {},
random = Math.random,
setTimer = setTimeout,
clearTimer = clearTimeout,
}) {
if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds')
this.check = check
this.onError = onError
this.intervalMs = intervalSeconds * 1_000
this.random = random
this.setTimer = setTimer
this.clearTimer = clearTimer
this.timer = undefined
this.enabled = false
}
schedule() {
const jitter = 0.9 + this.random() * 0.2
const timer = this.setTimer(async () => {
if (this.timer !== timer) return
this.timer = undefined
try {
await this.check()
} catch (error) {
await Promise.resolve().then(() => this.onError(error)).catch(() => {})
}
if (this.enabled && this.timer === undefined) this.schedule()
}, Math.round(this.intervalMs * jitter))
this.timer = timer
timer.unref?.()
}
start() {
this.enabled = true
if (this.timer === undefined) this.schedule()
}
configure({ enabled, intervalSeconds }) {
if (typeof enabled !== 'boolean') throw new Error('automatic check enabled must be boolean')
if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds < 60) throw new Error('update interval must be at least 60 seconds')
this.stop()
this.intervalMs = intervalSeconds * 1_000
if (enabled) this.start()
}
stop() {
this.enabled = false
if (this.timer !== undefined) this.clearTimer(this.timer)
this.timer = undefined
}
}
@@ -0,0 +1,29 @@
export class SnapshotClient {
constructor(api, scope) {
if (!['dsh', 'user-plugin'].includes(scope)) throw new Error('snapshot scope is invalid')
this.api = api
this.scope = scope
}
async call(operation, body, onProgress) {
const response = await this.api.request('POST', `/v1/${this.scope}/${operation}`, body)
if (response.progress !== null && onProgress !== undefined) await onProgress(response.progress)
return response.result
}
create(value = undefined) {
if (this.scope === 'dsh') {
const { onProgress, ...body } = value
return this.call('create', body, onProgress)
}
return this.call('create', { id: value })
}
inspect(id) { return this.call('inspect', { id }) }
restore(id, { onProgress } = {}) {
return this.call('restore', { id }, onProgress)
}
remove(id) { return this.call('remove', { id }) }
}
@@ -0,0 +1,199 @@
import { spawn } from 'node:child_process'
import { createHash, randomUUID } from 'node:crypto'
import { lstat, mkdir, open, readFile, readdir, rename, rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
import { exactKeys, isoTimestamp, parseJsonDocument, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
function validId(value) {
if (typeof value !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)) {
throw new TrustError('snapshot ID is invalid')
}
return value
}
function version(value, label) {
if (typeof value !== 'string' || value === '' || value.length > 128 || /[\0/\\]/.test(value)) {
throw new TrustError(`${label} is invalid`)
}
return value
}
function parseManifest(bytes) {
const value = parseJsonDocument(bytes, 'snapshot manifest')
exactKeys(value, [
'archiveSha256', 'archiveSize', 'createdAt', 'dshVersion', 'environmentVersion',
'id', 'runtimeId', 'schema', 'source',
], 'snapshot manifest')
if (value.schema !== 1) throw new TrustError('snapshot manifest schema must be 1')
validId(value.id)
if (value.source !== '/data/dsh') throw new TrustError('snapshot source is invalid')
if (typeof value.archiveSha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.archiveSha256)) {
throw new TrustError('snapshot archive SHA-256 is invalid')
}
if (!Number.isSafeInteger(value.archiveSize) || value.archiveSize < 0) {
throw new TrustError('snapshot archive size is invalid')
}
isoTimestamp(value.createdAt, 'snapshot creation time')
version(value.runtimeId, 'snapshot runtime ID')
version(value.environmentVersion, 'snapshot Environment version')
version(value.dshVersion, 'snapshot DSH version')
return Object.freeze(value)
}
function run(command, args) {
return new Promise((resolveRun, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolveRun(Buffer.concat(stdout).toString('utf8'))
: reject(new Error(`${command} failed: ${Buffer.concat(stderr).toString('utf8').trim()}`)))
})
}
async function hashFile(path) {
const handle = await open(path, 'r')
const hash = createHash('sha256')
let size = 0
try {
for await (const chunk of handle.createReadStream({ autoClose: false })) {
hash.update(chunk)
size += chunk.byteLength
}
} finally {
await handle.close()
}
return { sha256: hash.digest('hex'), size }
}
async function syncDirectory(path) {
const handle = await open(path, 'r')
try { await handle.sync() } finally { await handle.close() }
}
async function countTree(path) {
const details = await lstat(path)
if (!details.isDirectory() || details.isSymbolicLink()) return 1
let count = 0
for (const entry of await readdir(path)) count += await countTree(join(path, entry))
return count
}
export class PersistentStateSnapshots {
constructor({ root, sourceRoot = '/data/dsh', now = () => new Date(), runImpl = run }) {
this.root = resolve(root)
this.sourceRoot = resolve(sourceRoot)
this.now = now
this.run = runImpl
}
path(id) {
return join(this.root, 'versions', validId(id))
}
async create({ id = randomUUID(), runtimeId, environmentVersion, dshVersion, onProgress = async () => {} }) {
validId(id)
const sourceDetails = await lstat(this.sourceRoot)
if (!sourceDetails.isDirectory() || sourceDetails.isSymbolicLink()) {
throw new TrustError('snapshot source must be a real directory')
}
const versions = join(this.root, 'versions')
const destination = this.path(id)
await lstat(destination).then(
() => { throw new TrustError(`snapshot ${id} already exists`) },
error => { if (error?.code !== 'ENOENT') throw error },
)
await mkdir(versions, { recursive: true })
const staging = join(versions, `.${id}.${randomUUID()}.tmp`)
await mkdir(staging)
const archive = join(staging, 'data.tar.gz')
try {
const totalItems = await countTree(this.sourceRoot)
await onProgress({ processedItems: 0, totalItems })
await this.run('tar', ['-czpf', archive, '-C', this.sourceRoot, '.'])
await onProgress({ processedItems: totalItems, totalItems })
const measured = await hashFile(archive)
const manifest = canonicalJson({
schema: 1,
id,
createdAt: this.now().toISOString(),
source: '/data/dsh',
runtimeId: version(runtimeId, 'snapshot runtime ID'),
environmentVersion: version(environmentVersion, 'snapshot Environment version'),
dshVersion: version(dshVersion, 'snapshot DSH version'),
archiveSha256: measured.sha256,
archiveSize: measured.size,
})
parseManifest(manifest)
const handle = await open(join(staging, 'manifest.json'), 'wx', 0o600)
try { await handle.writeFile(manifest); await handle.sync() } finally { await handle.close() }
await syncDirectory(staging)
await rename(staging, destination)
await syncDirectory(versions)
return Object.freeze({ ...parseManifest(manifest), path: destination })
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async inspect(id) {
const root = this.path(id)
const manifest = parseManifest(await readFile(join(root, 'manifest.json')))
if (manifest.id !== id) throw new TrustError('snapshot manifest ID does not match its directory')
const archive = join(root, 'data.tar.gz')
const measured = await hashFile(archive)
if (measured.sha256 !== manifest.archiveSha256 || measured.size !== manifest.archiveSize) {
throw new TrustError('snapshot archive does not match its manifest', 'TRUST_ARTIFACT_MISMATCH')
}
const entries = (await this.run('tar', ['-tzf', archive])).split('\n').filter(Boolean)
if (entries.length === 0) throw new TrustError('snapshot archive is empty')
for (const entry of entries) {
if (entry.startsWith('/') || entry.split('/').includes('..')) {
throw new TrustError(`snapshot archive path is unsafe: ${entry}`)
}
}
return Object.freeze({ ...manifest, archive })
}
async restore(id, { onProgress = async () => {} } = {}) {
const snapshot = await this.inspect(id)
await mkdir(this.sourceRoot, { recursive: true })
for (const entry of await readdir(this.sourceRoot)) {
await rm(join(this.sourceRoot, entry), { recursive: true, force: true })
}
const entries = (await this.run('tar', ['-tzf', snapshot.archive])).split('\n').filter(Boolean)
await onProgress({ processedItems: 0, totalItems: entries.length })
const ownership = process.getuid?.() === 0 ? [] : ['--no-same-owner']
await this.run('tar', ['-xzpf', snapshot.archive, ...ownership, '-C', this.sourceRoot])
await onProgress({ processedItems: entries.length, totalItems: entries.length })
await syncDirectory(this.sourceRoot)
return snapshot
}
async list() {
let names
try { names = await readdir(join(this.root, 'versions')) } catch (error) {
if (error?.code === 'ENOENT') return []
throw error
}
const snapshots = []
for (const name of names.filter(name => !name.startsWith('.')).sort()) snapshots.push(await this.inspect(name))
return Object.freeze(snapshots)
}
async remove(id) {
try {
await this.inspect(id)
} catch (error) {
if (error?.code === 'ENOENT') return false
throw error
}
await rm(this.path(id), { recursive: true, force: true })
return true
}
}
@@ -0,0 +1,32 @@
import { readFile } from 'node:fs/promises'
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
export const TERMINAL_UPDATE_STATES = new Set(['idle', 'success', 'failed'])
export class UpdateStateStore {
constructor(path, now = () => new Date()) {
this.path = path
this.now = now
}
async read() {
try {
return JSON.parse(await readFile(this.path, 'utf8'))
} catch (error) {
if (error?.code === 'ENOENT') return { status: 'idle', taskId: null, progress: 0, error: null }
throw error
}
}
async write(status, fields = {}) {
const previous = await this.read()
const next = {
...previous,
...fields,
status,
updatedAt: this.now().toISOString(),
}
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
return Object.freeze(next)
}
}