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:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user