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,170 @@
|
||||
import { createServer } from 'node:http'
|
||||
import { chmod, mkdir, rm } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { PlatformGarbageCollector } from './gc.mjs'
|
||||
|
||||
async function jsonBody(request) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.byteLength
|
||||
if (size > 256 * 1024) throw new Error('Bootstrap request body is too large')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return size === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function send(response, status, value) {
|
||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
response.end(`${JSON.stringify(value)}\n`)
|
||||
}
|
||||
|
||||
export function createBootstrapControl(runner, { deployments, trust, systemPlugins, systemSkills, snapshots } = {}) {
|
||||
return createServer(async (request, response) => {
|
||||
let pathname = 'invalid-url'
|
||||
try {
|
||||
pathname = new URL(request.url ?? '/', 'http://bootstrap.internal').pathname
|
||||
if (request.method === 'GET' && pathname === '/v1/status') send(response, 200, {
|
||||
...runner.status(),
|
||||
bootstrapVersion: process.env.DSH_BOOTSTRAP_VERSION ?? '1.0.0',
|
||||
})
|
||||
else if (request.method === 'POST' && pathname === '/v1/reload') send(response, 200, await runner.reload())
|
||||
else if (request.method === 'GET' && pathname === '/v1/health') send(response, 200, await runner.health())
|
||||
else if (request.method === 'GET' && pathname === '/v1/system-plugins' && systemPlugins !== undefined) {
|
||||
send(response, 200, { plugins: await systemPlugins.list() })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/action' && systemPlugins !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
send(response, 200, { plugins: await systemPlugins.configure(body.id, body.action) })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/recovery-action' && systemPlugins !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
send(response, 200, { plugins: await systemPlugins.recover(body.id, body.action) })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/discard' && systemPlugins !== undefined) {
|
||||
send(response, 200, { plugins: await systemPlugins.discard() })
|
||||
} else if (request.method === 'GET' && pathname === '/v1/system-skills' && systemSkills !== undefined) {
|
||||
send(response, 200, { skills: await systemSkills.list() })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/system-skills/action' && systemSkills !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
send(response, 200, { skills: await systemSkills.configure(body.skillId, body.action) })
|
||||
}
|
||||
else if (request.method === 'GET' && pathname === '/v1/deployments/current' && deployments !== undefined) {
|
||||
const state = await deployments.state()
|
||||
send(response, 200, { record: state.current === null ? null : await deployments.record(state.current) })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/activate' && deployments !== undefined && trust !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
const slots = await deployments.activateManaged(body.record, {
|
||||
healthCheck: () => runner.reload(),
|
||||
activateReceipts: tokens => trust.activate(tokens),
|
||||
})
|
||||
send(response, 200, { slots })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/rollback' && deployments !== undefined && trust !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
const state = await deployments.state()
|
||||
if (state.previous === null) throw new Error('no previous Deployment exists')
|
||||
if (body.recordId !== undefined && body.recordId !== state.previous) throw new Error('requested Deployment is not previous')
|
||||
const slots = await deployments.rollback({
|
||||
healthCheck: () => runner.reload(),
|
||||
activateReceipts: tokens => trust.activate(tokens),
|
||||
})
|
||||
send(response, 200, { slots })
|
||||
} else if (request.method === 'GET' && pathname === '/v1/deployments/rollback-plan' && deployments !== undefined) {
|
||||
send(response, 200, await deployments.rollbackPlan())
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate' && deployments !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
const record = await deployments.stageCandidate(body.record, () => runner.reload())
|
||||
send(response, 200, { recordId: record.id })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/commit' && deployments !== undefined && trust !== undefined) {
|
||||
const body = await jsonBody(request)
|
||||
const slots = await deployments.commitCandidate(body.recordId, tokens => trust.activate(tokens))
|
||||
send(response, 200, { slots })
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/cancel' && deployments !== undefined) {
|
||||
send(response, 200, await deployments.cancelCandidate())
|
||||
} else if (request.method === 'POST' && pathname === '/v1/platform/gc' && deployments !== undefined) {
|
||||
send(response, 200, await new PlatformGarbageCollector({ paths: deployments.paths, deployments, snapshots }).collect())
|
||||
} else if (request.method === 'POST' && pathname === '/v1/deployments/runtime/reset' && deployments !== undefined) {
|
||||
send(response, 200, await deployments.resetCurrentRuntime({
|
||||
pauseDsh: () => runner.pause('dsh-runtime'),
|
||||
restartDsh: () => runner.restart('dsh-runtime'),
|
||||
}))
|
||||
}
|
||||
else {
|
||||
const operation = /^\/v1\/components\/([a-z0-9][a-z0-9._-]{0,127})\/(pause|suspend|resume|restart)$/.exec(pathname)
|
||||
if (request.method === 'POST' && operation !== null) {
|
||||
const [componentId, action] = operation.slice(1)
|
||||
if (componentId === 'dsh-runtime' && action === 'resume' && deployments !== undefined) {
|
||||
const status = await deployments.exclusive(async () => {
|
||||
await deployments.setOperation('starting')
|
||||
try {
|
||||
const value = await runner.resume(componentId)
|
||||
await deployments.publishStatus()
|
||||
return value
|
||||
} catch (error) {
|
||||
await deployments.publishStatus({
|
||||
recoveryMode: runner.status().recoveryMode
|
||||
?? (error instanceof Error ? error.message : String(error)),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
send(response, 200, status)
|
||||
} else if (componentId === 'dsh-runtime' && action === 'restart' && deployments !== undefined) {
|
||||
const status = await deployments.exclusive(async () => {
|
||||
await deployments.setOperation('restarting')
|
||||
let transaction
|
||||
try {
|
||||
transaction = await systemPlugins?.prepare?.()
|
||||
const value = await runner.restart(componentId, {
|
||||
skipPreparation: true,
|
||||
recoverStart: true,
|
||||
beforeStart: () => transaction?.activate?.(),
|
||||
onStartFailure: () => transaction?.rollback?.(),
|
||||
})
|
||||
if (transaction?.commit !== undefined) {
|
||||
try {
|
||||
await transaction.commit()
|
||||
} catch (error) {
|
||||
await runner.record?.('system-plugin.view.commit.failed', { error, level: 'warning' })
|
||||
}
|
||||
}
|
||||
await deployments.publishStatus()
|
||||
return value
|
||||
} catch (error) {
|
||||
try {
|
||||
await deployments.publishStatus({
|
||||
operation: 'restart-failed',
|
||||
recoveryMode: runner.status().recoveryMode
|
||||
?? (error instanceof Error ? error.message : String(error)),
|
||||
})
|
||||
} catch (statusError) {
|
||||
await runner.record?.('deployment.status.failed', {
|
||||
error: statusError,
|
||||
level: 'warning',
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
send(response, 200, status)
|
||||
} else send(response, 200, await runner[action](componentId))
|
||||
} else send(response, 404, { error: 'not found' })
|
||||
}
|
||||
} catch (error) {
|
||||
await runner.record?.('bootstrap.request.failed', {
|
||||
error,
|
||||
method: request.method ?? null,
|
||||
pathname,
|
||||
})
|
||||
send(response, 500, { error: error instanceof Error ? error.message : 'Bootstrap operation failed' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listenBootstrapControl(server, socketPath) {
|
||||
await mkdir(dirname(socketPath), { recursive: true })
|
||||
await rm(socketPath, { force: true })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(socketPath, resolve)
|
||||
})
|
||||
await chmod(socketPath, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,808 @@
|
||||
import { lstat, mkdir, readFile, rename, rm, symlink } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { canonicalJson } from '../../lib/canonical-json.mjs'
|
||||
import {
|
||||
deriveRecordId,
|
||||
parseDeploymentRecord,
|
||||
parseSlots,
|
||||
recordsFromImageInventory,
|
||||
} from '../../lib/deployment-contracts.mjs'
|
||||
import { durableCreate, durableReplace } from '../../lib/atomic.mjs'
|
||||
import { readDeploymentStatus, writeDeploymentStatus } from '../../lib/deployment-status.mjs'
|
||||
import { replaceDeploymentView, replaceSystemPluginView } from '../../lib/paths.mjs'
|
||||
import { hashTree, hashTreeContent } from '../../lib/tree-hash.mjs'
|
||||
import { compareDshVersions } from '../../lib/supported-target.mjs'
|
||||
import { TrustError } from '../../lib/validation.mjs'
|
||||
import { buildRuntime, cloneTree, loadEnvironmentPatchSet } from '../../../control-plane/modules/patch-manager/index.mjs'
|
||||
|
||||
const KINDS = Object.freeze(['environment', 'pristine', 'runtime', 'system-plugins'])
|
||||
|
||||
export class DeploymentResolutionError extends Error {
|
||||
constructor(message, cause) {
|
||||
super(message, { cause })
|
||||
this.name = 'DeploymentResolutionError'
|
||||
}
|
||||
}
|
||||
|
||||
function sameDeployment(left, right) {
|
||||
return left.dshVersion === right.dshVersion
|
||||
&& left.environmentVersion === right.environmentVersion
|
||||
&& KINDS.every(kind => {
|
||||
const field = kind === 'system-plugins' ? 'systemPlugins' : kind
|
||||
return left[field].sha256 === right[field].sha256
|
||||
})
|
||||
}
|
||||
|
||||
function validateCandidate(from, candidate, experimental) {
|
||||
if (experimental) {
|
||||
if (candidate.authority !== 'experimental') throw new TrustError('probation candidate must use Experimental authority')
|
||||
if (candidate.targetSequence !== from.targetSequence) throw new TrustError('Experimental candidate must retain the Stable targetSequence')
|
||||
if (compareDshVersions(candidate.dshVersion, from.dshVersion) <= 0) {
|
||||
throw new TrustError('Experimental candidate DSH must advance the current version')
|
||||
}
|
||||
if (
|
||||
candidate.environment.sha256 !== from.environment.sha256
|
||||
|| candidate.systemPlugins.sha256 !== from.systemPlugins.sha256
|
||||
) throw new TrustError('Experimental candidate must retain the current Environment and System Plugins')
|
||||
return
|
||||
}
|
||||
if (candidate.authority !== 'stable') throw new TrustError('managed release activation requires Stable authority')
|
||||
if (candidate.targetSequence < from.targetSequence) throw new TrustError('Stable candidate targetSequence would roll back')
|
||||
if (from.authority === 'stable' && candidate.targetSequence === from.targetSequence && !sameDeployment(from, candidate)) {
|
||||
throw new TrustError('Stable candidate conflicts with current content at the same targetSequence')
|
||||
}
|
||||
if (from.authority === 'experimental' && compareDshVersions(candidate.dshVersion, from.dshVersion) < 0) {
|
||||
throw new TrustError('Stable candidate has not caught up with Experimental DSH')
|
||||
}
|
||||
}
|
||||
|
||||
async function readOptional(path) {
|
||||
try { return await readFile(path) } catch (error) {
|
||||
if (error?.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function exists(path) {
|
||||
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
|
||||
}
|
||||
|
||||
async function replaceRuntimeDirectory(staging, destination) {
|
||||
const backup = `${destination}.${randomUUID()}.reset-backup`
|
||||
const hadDestination = await exists(destination)
|
||||
if (hadDestination) await rename(destination, backup)
|
||||
try {
|
||||
await rename(staging, destination)
|
||||
} catch (error) {
|
||||
if (hadDestination) await rename(backup, destination).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
return Object.freeze({
|
||||
commit: () => hadDestination ? rm(backup, { recursive: true, force: true }) : Promise.resolve(),
|
||||
rollback: async () => {
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
if (hadDestination) await rename(backup, destination)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export class DeploymentManager {
|
||||
constructor({ paths, seedRoot, inventory }) {
|
||||
this.paths = paths
|
||||
this.seedRoot = resolve(seedRoot)
|
||||
this.inventory = inventory
|
||||
this.imageRecord = recordsFromImageInventory(inventory).deployment
|
||||
this.stateRoot = paths.deploymentStateRoot
|
||||
this.recordsRoot = join(this.stateRoot, 'records')
|
||||
this.slotsPath = join(this.stateRoot, 'slots.json')
|
||||
this.activationPath = join(this.stateRoot, 'activation.json')
|
||||
this.queue = Promise.resolve()
|
||||
}
|
||||
|
||||
exclusive(operation) {
|
||||
const result = this.queue.then(operation, operation)
|
||||
this.queue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
recordPath(id) {
|
||||
return join(this.recordsRoot, `${id}.json`)
|
||||
}
|
||||
|
||||
async writeRecord(value) {
|
||||
const record = parseDeploymentRecord(value)
|
||||
const bytes = canonicalJson(record)
|
||||
const path = this.recordPath(record.id)
|
||||
const existing = await readOptional(path)
|
||||
if (existing === undefined) await durableCreate(path, bytes)
|
||||
else if (!existing.equals(bytes)) throw new TrustError('Deployment Record ID identifies conflicting content')
|
||||
return record
|
||||
}
|
||||
|
||||
async repairImageRecord(value) {
|
||||
const record = parseDeploymentRecord(value)
|
||||
const bytes = canonicalJson(record)
|
||||
if (!bytes.equals(canonicalJson(this.imageRecord))) {
|
||||
throw new TrustError('image recovery Record differs from the current image inventory')
|
||||
}
|
||||
await durableReplace(this.recordPath(record.id), bytes)
|
||||
return record
|
||||
}
|
||||
|
||||
async record(id) {
|
||||
const bytes = await readOptional(this.recordPath(id))
|
||||
if (bytes === undefined) throw new TrustError(`Deployment Record ${id} does not exist`)
|
||||
return parseDeploymentRecord(bytes)
|
||||
}
|
||||
|
||||
async state() {
|
||||
const bytes = await readOptional(this.slotsPath)
|
||||
if (bytes === undefined) return Object.freeze({ generation: 0, current: null, previous: null })
|
||||
return parseSlots(bytes, 'deployment-record', 'Deployment slots')
|
||||
}
|
||||
|
||||
availableInCurrentImage(record) {
|
||||
return KINDS.every(kind => {
|
||||
const reference = record[kind === 'system-plugins' ? 'systemPlugins' : kind]
|
||||
return reference.storage !== 'image' || reference.imageBuildId === this.inventory.imageBuildId
|
||||
})
|
||||
}
|
||||
|
||||
imageRecordFromAnotherImage(record) {
|
||||
return KINDS.every(kind => {
|
||||
const reference = record[kind === 'system-plugins' ? 'systemPlugins' : kind]
|
||||
return reference.storage === 'image' && reference.imageBuildId !== this.inventory.imageBuildId
|
||||
})
|
||||
}
|
||||
|
||||
async rollbackPlan() {
|
||||
const state = await this.state()
|
||||
const current = state.current === null ? null : await this.record(state.current)
|
||||
let previous = state.previous === null ? null : await this.record(state.previous)
|
||||
if (previous !== null && !this.availableInCurrentImage(previous)) previous = null
|
||||
return Object.freeze({ current, previous })
|
||||
}
|
||||
|
||||
async commit(current, previous) {
|
||||
const state = await this.state()
|
||||
if (current === previous) previous = null
|
||||
const slots = parseSlots({
|
||||
schema: 1,
|
||||
generation: state.generation + 1,
|
||||
current,
|
||||
previous,
|
||||
}, 'deployment-record', 'Deployment slots')
|
||||
await durableReplace(this.slotsPath, canonicalJson(slots))
|
||||
return slots
|
||||
}
|
||||
|
||||
imageAsset(reference) {
|
||||
if (reference.imageBuildId !== this.inventory.imageBuildId) {
|
||||
throw new TrustError('Deployment Image Reference belongs to a different image')
|
||||
}
|
||||
const expected = reference.kind === 'system-plugins'
|
||||
? this.inventory.deployment.systemPlugins
|
||||
: this.inventory.deployment[reference.kind]
|
||||
if (expected === undefined || reference.id !== expected.id || reference.sha256 !== expected.sha256) {
|
||||
throw new TrustError(`Deployment Image Reference for ${reference.kind} is absent from inventory`)
|
||||
}
|
||||
const roots = {
|
||||
environment: 'environments',
|
||||
pristine: 'pristine',
|
||||
runtime: 'runtimes',
|
||||
'system-plugins': 'system-plugins',
|
||||
}
|
||||
return join(this.seedRoot, roots[reference.kind], reference.id)
|
||||
}
|
||||
|
||||
storeAsset(reference) {
|
||||
const roots = {
|
||||
environment: this.paths.environmentsRoot,
|
||||
pristine: this.paths.pristineRoot,
|
||||
runtime: this.paths.runtimesRoot,
|
||||
'system-plugins': this.paths.systemPluginsRoot,
|
||||
}
|
||||
return join(roots[reference.kind], reference.id)
|
||||
}
|
||||
|
||||
async resolveReference(reference) {
|
||||
const path = reference.storage === 'image' ? this.imageAsset(reference) : this.storeAsset(reference)
|
||||
const details = await lstat(path)
|
||||
if (!details.isDirectory() || details.isSymbolicLink()) {
|
||||
throw new TrustError(`resolved ${reference.kind} must be an immutable directory`)
|
||||
}
|
||||
// Image bytes are bound to the signed inventory during image assembly. Rehashing a large
|
||||
// Runtime on every boot would defeat zero-copy startup; managed objects are checked here.
|
||||
if (reference.storage === 'store' && await hashTree(path) !== reference.sha256) {
|
||||
throw new TrustError(`resolved ${reference.kind} content hash differs from its Record`)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async resolveRecord(recordId) {
|
||||
const record = await this.record(recordId)
|
||||
const entries = await Promise.all(KINDS.map(async kind => [kind, await this.resolveReference(record[kind === 'system-plugins' ? 'systemPlugins' : kind])]))
|
||||
return Object.freeze({ record, paths: Object.freeze(Object.fromEntries(entries)) })
|
||||
}
|
||||
|
||||
async sameImageDeployment(current, image) {
|
||||
if (sameDeployment(current, image)) return true
|
||||
if (
|
||||
current.dshVersion !== image.dshVersion
|
||||
|| current.environmentVersion !== image.environmentVersion
|
||||
|| current.pristine.sha256 !== image.pristine.sha256
|
||||
|| current.runtime.sha256 !== image.runtime.sha256
|
||||
|| current.systemPlugins.sha256 !== image.systemPlugins.sha256
|
||||
) return false
|
||||
const [currentEnvironment, imageEnvironment] = await Promise.all([
|
||||
this.resolveReference(current.environment),
|
||||
this.resolveReference(image.environment),
|
||||
])
|
||||
const [currentContent, imageContent] = await Promise.all([
|
||||
hashTreeContent(currentEnvironment),
|
||||
hashTreeContent(imageEnvironment),
|
||||
])
|
||||
return currentContent === imageContent
|
||||
}
|
||||
|
||||
async selected() {
|
||||
const recordId = this.selectedRecordId ?? (await this.state()).current
|
||||
return recordId === null ? null : this.resolveRecord(recordId)
|
||||
}
|
||||
|
||||
async prepareView(recordId) {
|
||||
const resolved = await this.resolveRecord(recordId)
|
||||
const destination = join(this.paths.deploymentViewsRoot, recordId)
|
||||
const temporary = join(this.paths.deploymentViewsRoot, `.${recordId}.${randomUUID()}.tmp`)
|
||||
await mkdir(temporary, { recursive: false })
|
||||
try {
|
||||
for (const kind of KINDS) await symlink(resolved.paths[kind], join(temporary, kind), 'dir')
|
||||
try {
|
||||
await rename(temporary, destination)
|
||||
} catch (error) {
|
||||
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error
|
||||
}
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
return Object.freeze({ ...resolved, view: destination })
|
||||
}
|
||||
|
||||
async select(recordId) {
|
||||
const candidate = await this.prepareView(recordId)
|
||||
await replaceDeploymentView(this.paths, candidate.view)
|
||||
await replaceSystemPluginView(this.paths)
|
||||
this.selectedRecordId = recordId
|
||||
return candidate
|
||||
}
|
||||
|
||||
async initialize(record) {
|
||||
const plan = await this.prepareImage(record)
|
||||
return this.acceptImage(plan)
|
||||
}
|
||||
|
||||
async prepareImage(record) {
|
||||
return this.exclusive(async () => {
|
||||
const image = await this.writeRecord(record)
|
||||
const state = await this.state()
|
||||
if (state.current === null) {
|
||||
await this.select(image.id)
|
||||
return Object.freeze({
|
||||
baseGeneration: 0,
|
||||
target: image.id,
|
||||
fallback: null,
|
||||
action: 'initialize',
|
||||
imageBehindCurrent: false,
|
||||
requiresExperimentalRebuild: false,
|
||||
})
|
||||
}
|
||||
|
||||
const current = await this.record(state.current)
|
||||
let target = current.id
|
||||
let action = 'retain'
|
||||
let imageBehindCurrent = false
|
||||
let requiresExperimentalRebuild = false
|
||||
|
||||
if (
|
||||
image.authority === 'development'
|
||||
&& image.targetSequence === 0
|
||||
&& current.id !== image.id
|
||||
&& compareDshVersions(image.dshVersion, current.dshVersion) >= 0
|
||||
) {
|
||||
target = image.id
|
||||
action = 'development-refresh'
|
||||
} else if (
|
||||
image.targetSequence < current.targetSequence
|
||||
&& current.receiptTokens.length === 0
|
||||
&& this.imageRecordFromAnotherImage(current)
|
||||
) {
|
||||
target = image.id
|
||||
action = 'image-rollback'
|
||||
} else if (current.authority === 'experimental') {
|
||||
const dshCaughtUp = compareDshVersions(image.dshVersion, current.dshVersion) >= 0
|
||||
if (image.targetSequence >= current.targetSequence && dshCaughtUp) {
|
||||
target = image.id
|
||||
action = 'stable-caught-up'
|
||||
} else {
|
||||
imageBehindCurrent = image.targetSequence < current.targetSequence
|
||||
requiresExperimentalRebuild = image.targetSequence > current.targetSequence && !dshCaughtUp
|
||||
}
|
||||
} else if (image.targetSequence > current.targetSequence) {
|
||||
target = image.id
|
||||
action = 'image-forward'
|
||||
} else if (image.targetSequence < current.targetSequence) {
|
||||
imageBehindCurrent = true
|
||||
} else if (!await this.sameImageDeployment(current, image)) {
|
||||
throw new TrustError('Deployment image conflicts with current content at the same targetSequence')
|
||||
} else if (current.id !== image.id) {
|
||||
target = image.id
|
||||
action = 'prefer-image'
|
||||
}
|
||||
|
||||
let candidateError = null
|
||||
try {
|
||||
await this.select(target)
|
||||
} catch (error) {
|
||||
if (target === current.id) {
|
||||
throw new DeploymentResolutionError('current Deployment assets could not be resolved', error)
|
||||
}
|
||||
try {
|
||||
await this.select(current.id)
|
||||
} catch (fallbackError) {
|
||||
throw new DeploymentResolutionError(
|
||||
'image candidate and current Deployment assets could not be resolved',
|
||||
new AggregateError([error, fallbackError]),
|
||||
)
|
||||
}
|
||||
target = current.id
|
||||
action = 'image-rejected'
|
||||
candidateError = error instanceof Error ? error.message : 'image candidate could not be resolved'
|
||||
}
|
||||
return Object.freeze({
|
||||
baseGeneration: state.generation,
|
||||
target,
|
||||
fallback: target === current.id || action === 'development-refresh' ? null : current.id,
|
||||
action,
|
||||
imageBehindCurrent,
|
||||
requiresExperimentalRebuild,
|
||||
candidateError,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async acceptImage(plan) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.generation !== plan.baseGeneration) throw new TrustError('Deployment slots changed during image startup')
|
||||
if (state.current === plan.target) return state
|
||||
return this.commit(plan.target, plan.action === 'development-refresh' ? null : state.current)
|
||||
})
|
||||
}
|
||||
|
||||
async rejectImage(plan) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.generation !== plan.baseGeneration) throw new TrustError('Deployment slots changed during image startup')
|
||||
if (plan.fallback === null) return false
|
||||
await this.select(plan.fallback)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async preparePreviousRecovery(failedRecordId) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
const failed = failedRecordId ?? state.current
|
||||
if (failed === null || state.current !== failed) {
|
||||
throw new TrustError('failed Deployment is no longer current')
|
||||
}
|
||||
if (state.previous === null || state.previous === failed) {
|
||||
throw new TrustError('no previous Deployment exists')
|
||||
}
|
||||
await this.record(failed)
|
||||
await this.select(state.previous)
|
||||
return Object.freeze({
|
||||
baseGeneration: state.generation,
|
||||
failed,
|
||||
target: state.previous,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async acceptPreviousRecovery(plan, activateReceipts) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.generation !== plan.baseGeneration || state.current !== plan.failed || state.previous !== plan.target) {
|
||||
throw new TrustError('Deployment slots changed during automatic recovery')
|
||||
}
|
||||
const previous = await this.record(plan.target)
|
||||
await activateReceipts(previous.receiptTokens)
|
||||
await this.writeActivation({ phase: 'receipts-active', from: plan.failed, to: plan.target })
|
||||
const committed = await this.commit(plan.target, plan.failed)
|
||||
await this.clearActivation()
|
||||
return committed
|
||||
})
|
||||
}
|
||||
|
||||
async activate(recordId, healthCheck) {
|
||||
return this.exclusive(async () => {
|
||||
await this.record(recordId)
|
||||
const state = await this.state()
|
||||
if (state.current === recordId) {
|
||||
await this.select(recordId)
|
||||
return state
|
||||
}
|
||||
await this.select(recordId)
|
||||
try {
|
||||
await healthCheck()
|
||||
} catch (error) {
|
||||
if (state.current !== null) {
|
||||
await this.select(state.current)
|
||||
await healthCheck().catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
return this.commit(recordId, state.current)
|
||||
})
|
||||
}
|
||||
|
||||
async rollback({ healthCheck, activateReceipts }) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.previous === null) throw new TrustError('no previous Deployment exists')
|
||||
const from = await this.record(state.current)
|
||||
const previous = await this.record(state.previous)
|
||||
await this.writeActivation({ phase: 'prepared', from: from.id, to: previous.id })
|
||||
await this.publishStatus({ operation: 'recovering' })
|
||||
let receiptsActive = false
|
||||
try {
|
||||
await this.select(previous.id)
|
||||
await healthCheck()
|
||||
await this.writeActivation({ phase: 'healthy', from: from.id, to: previous.id })
|
||||
await activateReceipts(previous.receiptTokens)
|
||||
receiptsActive = true
|
||||
await this.writeActivation({ phase: 'receipts-active', from: from.id, to: previous.id })
|
||||
const committed = await this.commit(previous.id, from.id)
|
||||
await this.clearActivation()
|
||||
await this.publishStatus()
|
||||
return committed
|
||||
} catch (error) {
|
||||
if (receiptsActive) await activateReceipts(from.receiptTokens).catch(() => {})
|
||||
await this.select(from.id).catch(() => {})
|
||||
await healthCheck().catch(() => {})
|
||||
await this.clearActivation().catch(() => {})
|
||||
await this.publishStatus().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async materializeRecord(recordId) {
|
||||
const record = await this.record(recordId)
|
||||
const fields = {
|
||||
environment: 'environment',
|
||||
pristine: 'pristine',
|
||||
runtime: 'runtime',
|
||||
systemPlugins: 'system-plugins',
|
||||
}
|
||||
const references = {}
|
||||
for (const [field, kind] of Object.entries(fields)) {
|
||||
const reference = record[field]
|
||||
if (reference.storage === 'store') {
|
||||
references[field] = reference
|
||||
continue
|
||||
}
|
||||
const source = await this.resolveReference(reference)
|
||||
const id = `image-${kind}-${reference.sha256}`
|
||||
const destination = this.storeAsset({ kind, id })
|
||||
if (!await exists(destination)) {
|
||||
const temporary = `${destination}.${randomUUID()}.tmp`
|
||||
try {
|
||||
await cloneTree(source, temporary)
|
||||
if (await hashTree(temporary) !== reference.sha256) throw new TrustError(`materialized ${kind} differs from Image Reference`)
|
||||
await rename(temporary, destination)
|
||||
} finally {
|
||||
await rm(temporary, { recursive: true, force: true })
|
||||
}
|
||||
} else if (await hashTree(destination) !== reference.sha256) {
|
||||
throw new TrustError(`materialized ${kind} conflicts with Managed Store`)
|
||||
}
|
||||
references[field] = { storage: 'store', kind, id, sha256: reference.sha256 }
|
||||
}
|
||||
const content = {
|
||||
schema: 1,
|
||||
authority: record.authority,
|
||||
targetSequence: record.targetSequence,
|
||||
dshVersion: record.dshVersion,
|
||||
environmentVersion: record.environmentVersion,
|
||||
...references,
|
||||
receiptTokens: [...record.receiptTokens],
|
||||
snapshotId: record.snapshotId,
|
||||
}
|
||||
return this.writeRecord({ ...content, id: deriveRecordId('deployment-record', content) })
|
||||
}
|
||||
|
||||
async materializeCurrent() {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.current === null) throw new TrustError('no current Deployment exists')
|
||||
const current = await this.record(state.current)
|
||||
const references = [current.environment, current.pristine, current.runtime, current.systemPlugins]
|
||||
if (references.every(reference => reference.storage === 'store')) return current
|
||||
const materialized = await this.materializeRecord(current.id)
|
||||
await this.select(materialized.id)
|
||||
await this.commit(materialized.id, state.previous)
|
||||
return materialized
|
||||
})
|
||||
}
|
||||
|
||||
async resetCurrentRuntime({ pauseDsh, restartDsh }) {
|
||||
return this.exclusive(async () => {
|
||||
const state = await this.state()
|
||||
if (state.current === null) throw new TrustError('no current Deployment exists')
|
||||
const current = await this.record(state.current)
|
||||
const [pristineRoot, environmentRoot] = await Promise.all([
|
||||
this.resolveReference(current.pristine),
|
||||
this.resolveReference(current.environment),
|
||||
])
|
||||
const patchSet = await loadEnvironmentPatchSet(environmentRoot)
|
||||
const stagingId = `runtime-reset-${randomUUID()}`
|
||||
const versionsRoot = this.paths.runtimesRoot
|
||||
const staging = join(versionsRoot, stagingId)
|
||||
const destinationId = current.runtime.storage === 'store'
|
||||
? current.runtime.id
|
||||
: `reset-${current.runtime.sha256}`
|
||||
const destination = join(this.paths.runtimesRoot, destinationId)
|
||||
let replacement
|
||||
let paused = false
|
||||
await this.publishStatus({ operation: 'runtime-reset-building' })
|
||||
try {
|
||||
await buildRuntime({
|
||||
pristineRoot,
|
||||
versionsRoot,
|
||||
runtimeId: stagingId,
|
||||
patchPaths: patchSet.paths,
|
||||
})
|
||||
await this.publishStatus({ operation: 'runtime-reset-verifying' })
|
||||
const sha256 = await hashTree(staging)
|
||||
if (sha256 !== current.runtime.sha256) {
|
||||
throw new TrustError('rebuilt Runtime differs from the current Deployment Record')
|
||||
}
|
||||
|
||||
await this.publishStatus({ operation: 'runtime-reset-switching' })
|
||||
await pauseDsh()
|
||||
paused = true
|
||||
replacement = await replaceRuntimeDirectory(staging, destination)
|
||||
const runtime = Object.freeze({
|
||||
storage: 'store', kind: 'runtime', id: destinationId, sha256,
|
||||
})
|
||||
const content = {
|
||||
schema: 1,
|
||||
authority: current.authority,
|
||||
targetSequence: current.targetSequence,
|
||||
dshVersion: current.dshVersion,
|
||||
environmentVersion: current.environmentVersion,
|
||||
environment: current.environment,
|
||||
pristine: current.pristine,
|
||||
runtime,
|
||||
systemPlugins: current.systemPlugins,
|
||||
receiptTokens: [...current.receiptTokens],
|
||||
snapshotId: current.snapshotId,
|
||||
}
|
||||
const repaired = current.runtime.storage === 'store'
|
||||
? current
|
||||
: await this.writeRecord({ ...content, id: deriveRecordId('deployment-record', content) })
|
||||
await this.select(repaired.id)
|
||||
await this.publishStatus({ operation: 'runtime-reset-starting' })
|
||||
await restartDsh()
|
||||
const slots = repaired.id === current.id
|
||||
? state
|
||||
: await this.commit(repaired.id, state.previous)
|
||||
await replacement.commit()
|
||||
await this.publishStatus({ recoveryMode: null })
|
||||
return Object.freeze({ recordId: repaired.id, slots })
|
||||
} catch (error) {
|
||||
if (replacement !== undefined) await replacement.rollback().catch(() => {})
|
||||
if (paused) {
|
||||
await this.publishStatus({ operation: 'runtime-reset-recovering' }).catch(() => {})
|
||||
await this.select(current.id).catch(() => {})
|
||||
await restartDsh().catch(() => {})
|
||||
}
|
||||
await this.publishStatus({ operation: 'runtime-reset-failed' }).catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async activation() {
|
||||
const bytes = await readOptional(this.activationPath)
|
||||
if (bytes === undefined) return undefined
|
||||
const value = JSON.parse(bytes.toString('utf8'))
|
||||
if (
|
||||
value?.schema !== 1
|
||||
|| !['prepared', 'healthy', 'probation', 'receipts-active'].includes(value.phase)
|
||||
|| typeof value.from !== 'string'
|
||||
|| typeof value.to !== 'string'
|
||||
) throw new TrustError('Deployment activation journal is invalid')
|
||||
return Object.freeze(value)
|
||||
}
|
||||
|
||||
async writeActivation(value) {
|
||||
const journal = Object.freeze({ schema: 1, ...value, updatedAt: new Date().toISOString() })
|
||||
await durableReplace(this.activationPath, canonicalJson(journal))
|
||||
return journal
|
||||
}
|
||||
|
||||
async clearActivation() {
|
||||
await rm(this.activationPath, { force: true })
|
||||
}
|
||||
|
||||
async recoverActivation(trust) {
|
||||
const journal = await this.activation()
|
||||
if (journal === undefined) return null
|
||||
const [from, to, active] = await Promise.all([
|
||||
this.record(journal.from),
|
||||
this.record(journal.to),
|
||||
trust.activeReceipts(),
|
||||
])
|
||||
const activeTokens = new Set(active.receipts.map(receipt => receipt.token))
|
||||
const candidateReceiptsActive = to.receiptTokens.every(token => activeTokens.has(token))
|
||||
if (journal.phase === 'receipts-active' || (journal.phase === 'healthy' && candidateReceiptsActive)) {
|
||||
await this.select(to.id)
|
||||
const state = await this.state()
|
||||
if (state.current !== to.id) await this.commit(to.id, from.id)
|
||||
await this.clearActivation()
|
||||
return Object.freeze({ action: 'committed', recordId: to.id })
|
||||
}
|
||||
await trust.activate(from.receiptTokens)
|
||||
await this.select(from.id)
|
||||
const state = await this.state()
|
||||
if (state.current !== from.id) await this.commit(from.id, state.current)
|
||||
await this.clearActivation()
|
||||
return Object.freeze({ action: 'rolled-back', recordId: from.id })
|
||||
}
|
||||
|
||||
async activateManaged(value, { healthCheck, activateReceipts }) {
|
||||
const candidate = await this.writeRecord(value)
|
||||
await this.materializeCurrent()
|
||||
const state = await this.state()
|
||||
const from = await this.record(state.current)
|
||||
if (candidate.id === from.id) return state
|
||||
validateCandidate(from, candidate, false)
|
||||
await this.writeActivation({ phase: 'prepared', from: from.id, to: candidate.id })
|
||||
await this.publishStatus({ operation: 'switching' })
|
||||
let receiptsActive = false
|
||||
try {
|
||||
await this.select(candidate.id)
|
||||
await healthCheck()
|
||||
await this.writeActivation({ phase: 'healthy', from: from.id, to: candidate.id })
|
||||
await activateReceipts(candidate.receiptTokens)
|
||||
receiptsActive = true
|
||||
await this.writeActivation({ phase: 'receipts-active', from: from.id, to: candidate.id })
|
||||
const committed = await this.commit(candidate.id, from.id)
|
||||
await this.clearActivation()
|
||||
await this.publishStatus()
|
||||
return committed
|
||||
} catch (error) {
|
||||
await this.publishStatus({ operation: 'recovering' }).catch(() => {})
|
||||
if (receiptsActive) await activateReceipts(from.receiptTokens).catch(() => {})
|
||||
await this.select(from.id).catch(() => {})
|
||||
await healthCheck().catch(() => {})
|
||||
await this.clearActivation().catch(() => {})
|
||||
await this.publishStatus().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stageCandidate(value, healthCheck) {
|
||||
const candidate = await this.writeRecord(value)
|
||||
await this.materializeCurrent()
|
||||
const state = await this.state()
|
||||
const from = await this.record(state.current)
|
||||
validateCandidate(from, candidate, true)
|
||||
await this.writeActivation({ phase: 'prepared', from: from.id, to: candidate.id })
|
||||
try {
|
||||
await this.select(candidate.id)
|
||||
await healthCheck()
|
||||
await this.writeActivation({ phase: 'probation', from: from.id, to: candidate.id })
|
||||
return candidate
|
||||
} catch (error) {
|
||||
await this.select(from.id).catch(() => {})
|
||||
await this.clearActivation().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async commitCandidate(recordId, activateReceipts) {
|
||||
const journal = await this.activation()
|
||||
if (journal?.phase !== 'probation' || journal.to !== recordId) {
|
||||
throw new TrustError('Deployment candidate is not in probation')
|
||||
}
|
||||
const candidate = await this.record(recordId)
|
||||
await activateReceipts(candidate.receiptTokens)
|
||||
await this.writeActivation({ phase: 'receipts-active', from: journal.from, to: journal.to })
|
||||
const committed = await this.commit(candidate.id, journal.from)
|
||||
await this.clearActivation()
|
||||
await this.publishStatus()
|
||||
return committed
|
||||
}
|
||||
|
||||
async cancelCandidate() {
|
||||
const journal = await this.activation()
|
||||
if (journal === undefined) return Object.freeze({ cancelled: false, slots: await this.state() })
|
||||
const from = await this.record(journal.from)
|
||||
await this.select(from.id)
|
||||
await this.clearActivation()
|
||||
await this.publishStatus()
|
||||
return Object.freeze({ cancelled: true, slots: await this.state() })
|
||||
}
|
||||
|
||||
async recoverImageBaseline(value, { healthCheck, activateReceipts }) {
|
||||
const previousStatus = await readDeploymentStatus(this.paths.deploymentStatusPath)
|
||||
await this.publishStatus({ operation: 'recovering', recoveryMode: previousStatus.recoveryMode })
|
||||
const image = await this.repairImageRecord(value)
|
||||
await this.prepareView(image.id)
|
||||
const state = await this.state()
|
||||
await this.select(image.id)
|
||||
try {
|
||||
await healthCheck()
|
||||
await activateReceipts(image.receiptTokens)
|
||||
const committed = state.current === image.id ? state : await this.commit(image.id, state.current)
|
||||
await this.clearActivation()
|
||||
await this.publishStatus({ recoveryMode: null })
|
||||
return committed
|
||||
} catch (error) {
|
||||
if (state.current !== null) await this.select(state.current).catch(() => {})
|
||||
await this.publishStatus({
|
||||
recoveryMode: error instanceof Error ? error.message : 'image baseline recovery failed',
|
||||
}).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
describe(record) {
|
||||
const references = [record.environment, record.pristine, record.runtime, record.systemPlugins]
|
||||
return Object.freeze({
|
||||
recordId: record.id,
|
||||
source: references.every(reference => reference.storage === 'image') ? 'image' : 'managed',
|
||||
authority: record.authority,
|
||||
targetSequence: record.targetSequence,
|
||||
dsh: record.dshVersion,
|
||||
environment: record.environmentVersion,
|
||||
})
|
||||
}
|
||||
|
||||
async publishStatus({ plan = null, recoveryMode = null, currentId, operation = null } = {}) {
|
||||
const state = await this.state().catch(() => ({ current: null }))
|
||||
const selectedId = currentId === undefined ? state.current : currentId
|
||||
const current = selectedId === null
|
||||
? null
|
||||
: await this.record(selectedId).then(record => this.describe(record), () => null)
|
||||
const value = Object.freeze({
|
||||
platformLayout: 1,
|
||||
imageBaseline: Object.freeze({
|
||||
imageBuildId: this.inventory.imageBuildId,
|
||||
targetSequence: this.inventory.targetSequence,
|
||||
dsh: this.inventory.deployment.dshVersion,
|
||||
environment: this.inventory.deployment.environmentVersion,
|
||||
}),
|
||||
current,
|
||||
imageBehindCurrent: plan?.imageBehindCurrent
|
||||
?? (current !== null && current.targetSequence > this.inventory.targetSequence),
|
||||
recoveryMode,
|
||||
operation,
|
||||
})
|
||||
return writeDeploymentStatus(this.paths.deploymentStatusPath, value)
|
||||
}
|
||||
|
||||
async setOperation(operation) {
|
||||
const status = await readDeploymentStatus(this.paths.deploymentStatusPath)
|
||||
return writeDeploymentStatus(this.paths.deploymentStatusPath, Object.freeze({
|
||||
...status,
|
||||
operation,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { chmod, mkdir, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
|
||||
function exactObject(value, keys, name) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${name} must be an object`)
|
||||
}
|
||||
const actual = Object.keys(value).sort()
|
||||
const expected = [...keys].sort()
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
throw new Error(`${name} fields are invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function jsonBody(request) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_BODY_BYTES) throw new Error('lifecycle request body is too large')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
if (size === 0) throw new Error('lifecycle request body is required')
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function send(response, status, value) {
|
||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
response.end(`${JSON.stringify(value)}\n`)
|
||||
}
|
||||
|
||||
function matchesSecret(actual, expected) {
|
||||
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
||||
const left = Buffer.from(actual)
|
||||
const right = Buffer.from(expected)
|
||||
return left.byteLength === right.byteLength && timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
export class DshLifecycleBroker {
|
||||
constructor({ shouldTerminate = async () => false, report = async () => {} } = {}) {
|
||||
this.shouldTerminate = shouldTerminate
|
||||
this.report = report
|
||||
this.launch = null
|
||||
this.session = null
|
||||
this.published = false
|
||||
this.shuttingDown = false
|
||||
}
|
||||
|
||||
record(message, fields = {}) {
|
||||
return Promise.resolve().then(() => this.report(message, fields)).catch(() => {})
|
||||
}
|
||||
|
||||
prepareLaunch(componentId) {
|
||||
if (componentId !== 'dsh-runtime') return Object.freeze({ environment: Object.freeze({}), release: () => {} })
|
||||
const launch = Object.freeze({ id: randomUUID(), token: randomBytes(32).toString('base64url') })
|
||||
this.launch = launch
|
||||
this.session = null
|
||||
this.published = false
|
||||
void this.record('dsh.launch.authorized')
|
||||
return Object.freeze({
|
||||
environment: Object.freeze({ DSH_PLATFORM_LAUNCH_TOKEN: launch.token }),
|
||||
release: () => this.release(launch.id),
|
||||
})
|
||||
}
|
||||
|
||||
release(launchId) {
|
||||
if (this.launch?.id !== launchId) return
|
||||
this.launch = null
|
||||
this.session = null
|
||||
void this.record('dsh.launch.released')
|
||||
}
|
||||
|
||||
claim(launchToken) {
|
||||
if (this.launch === null || this.session !== null || !matchesSecret(launchToken, this.launch.token)) {
|
||||
throw Object.assign(new Error('DSH launch token is invalid or already consumed'), { statusCode: 409 })
|
||||
}
|
||||
const sessionId = randomUUID()
|
||||
this.session = Object.freeze({ id: sessionId, launchId: this.launch.id, ready: false, verified: false })
|
||||
this.launch = Object.freeze({ ...this.launch, token: null })
|
||||
void this.record('dsh.launch.claimed')
|
||||
return Object.freeze({ sessionId })
|
||||
}
|
||||
|
||||
ready(sessionId, readyUrl = null) {
|
||||
if (this.session === null || !matchesSecret(sessionId, this.session.id)) {
|
||||
throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 })
|
||||
}
|
||||
if (readyUrl !== null) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(readyUrl)
|
||||
} catch {
|
||||
throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 })
|
||||
}
|
||||
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
|
||||
|| parsed.pathname !== '/' || parsed.username !== '' || parsed.password !== ''
|
||||
|| parsed.hash !== '' || parsed.searchParams.getAll('token').length !== 1) {
|
||||
throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 })
|
||||
}
|
||||
}
|
||||
if (!this.session.ready) {
|
||||
this.session = Object.freeze({ ...this.session, ready: true, readyUrl })
|
||||
void this.record('dsh.launch.ready')
|
||||
}
|
||||
return Object.freeze({ ready: true })
|
||||
}
|
||||
|
||||
verify() {
|
||||
if (this.session?.ready !== true) throw new Error('DSH lifecycle is not ready for verification')
|
||||
if (!this.session.verified) {
|
||||
this.session = Object.freeze({ ...this.session, verified: true })
|
||||
return this.record('dsh.launch.verified')
|
||||
}
|
||||
}
|
||||
|
||||
publish() {
|
||||
if (this.session?.verified !== true) throw new Error('DSH lifecycle is not verified for publication')
|
||||
if (!this.published) {
|
||||
this.published = true
|
||||
return this.record('platform.public-ready')
|
||||
}
|
||||
}
|
||||
|
||||
readiness() {
|
||||
const ready = this.session?.ready === true
|
||||
const verified = this.session?.verified === true
|
||||
return Object.freeze({
|
||||
generation: this.session?.id ?? null,
|
||||
publicReady: ready && verified && this.published,
|
||||
ready,
|
||||
readyUrl: ready ? this.session.readyUrl ?? null : null,
|
||||
verified,
|
||||
})
|
||||
}
|
||||
|
||||
async signal(sessionId, signal) {
|
||||
if (signal !== 'SIGTERM') throw Object.assign(new Error('lifecycle signal is invalid'), { statusCode: 400 })
|
||||
if (this.session === null || !matchesSecret(sessionId, this.session.id)) {
|
||||
throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 })
|
||||
}
|
||||
const disposition = this.session.ready !== true || this.shuttingDown || await this.shouldTerminate()
|
||||
? 'terminate'
|
||||
: 'request-restart'
|
||||
await this.record('dsh.signal.disposition', { disposition, signal })
|
||||
return Object.freeze({ disposition })
|
||||
}
|
||||
|
||||
beginShutdown() {
|
||||
this.published = false
|
||||
this.shuttingDown = true
|
||||
}
|
||||
}
|
||||
|
||||
export function createDshLifecycleServer(broker) {
|
||||
return createServer(async (request, response) => {
|
||||
let pathname = 'invalid-url'
|
||||
try {
|
||||
pathname = new URL(request.url ?? '/', 'http://dsh-lifecycle.internal').pathname
|
||||
if (request.method !== 'POST') {
|
||||
send(response, 404, { error: 'not found' })
|
||||
return
|
||||
}
|
||||
if (pathname === '/v1/runtime/claim') {
|
||||
const body = exactObject(await jsonBody(request), ['launchToken'], 'claim request')
|
||||
send(response, 200, broker.claim(body.launchToken))
|
||||
} else if (pathname === '/v1/runtime/ready') {
|
||||
const value = await jsonBody(request)
|
||||
const body = Object.hasOwn(value, 'readyUrl')
|
||||
? exactObject(value, ['readyUrl', 'sessionId'], 'ready request')
|
||||
: exactObject(value, ['sessionId'], 'ready request')
|
||||
send(response, 200, broker.ready(body.sessionId, body.readyUrl ?? null))
|
||||
} else if (pathname === '/v1/runtime/readiness') {
|
||||
exactObject(await jsonBody(request), [], 'readiness request')
|
||||
send(response, 200, broker.readiness())
|
||||
} else if (pathname === '/v1/runtime/signal') {
|
||||
const body = exactObject(await jsonBody(request), ['sessionId', 'signal'], 'signal request')
|
||||
send(response, 200, await broker.signal(body.sessionId, body.signal))
|
||||
} else send(response, 404, { error: 'not found' })
|
||||
} catch (error) {
|
||||
await broker.record('dsh.lifecycle-request.failed', {
|
||||
error,
|
||||
method: request.method ?? null,
|
||||
pathname,
|
||||
level: error?.statusCode === 409 ? 'warning' : 'error',
|
||||
})
|
||||
send(response, Number.isInteger(error?.statusCode) ? error.statusCode : 400, {
|
||||
error: error instanceof Error ? error.message : 'invalid lifecycle request',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listenDshLifecycle(server, socketPath) {
|
||||
await mkdir(dirname(socketPath), { recursive: true })
|
||||
await rm(socketPath, { force: true })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(socketPath, resolve)
|
||||
})
|
||||
await chmod(socketPath, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { lstat, readFile, readdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { parseDeploymentRecord } from '../../lib/deployment-contracts.mjs'
|
||||
|
||||
async function jsonOptional(path) {
|
||||
try { return JSON.parse(await readFile(path, 'utf8')) } catch (error) {
|
||||
if (error?.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function files(root, suffix = '') {
|
||||
try { return (await readdir(root)).filter(name => !name.startsWith('.') && name.endsWith(suffix)).sort() } catch (error) {
|
||||
if (error?.code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUnmarked(root, retained, reserved = new Set()) {
|
||||
const removed = []
|
||||
for (const name of await files(root)) {
|
||||
if (reserved.has(name) || retained.has(name)) continue
|
||||
const path = join(root, name)
|
||||
const details = await lstat(path)
|
||||
if (!details.isDirectory() || details.isSymbolicLink()) continue
|
||||
await rm(path, { recursive: true, force: true })
|
||||
removed.push(name)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async function deploymentRecords(root) {
|
||||
const records = new Map()
|
||||
for (const name of await files(root, '.json')) {
|
||||
const record = parseDeploymentRecord(await readFile(join(root, name)))
|
||||
records.set(record.id, record)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function recordIds(value, output) {
|
||||
if (typeof value === 'string' && value.startsWith('deployment-record-')) output.add(value)
|
||||
else if (Array.isArray(value)) for (const entry of value) recordIds(entry, output)
|
||||
else if (value !== null && typeof value === 'object') for (const entry of Object.values(value)) recordIds(entry, output)
|
||||
}
|
||||
|
||||
function heldRecords(channel, records, roots) {
|
||||
const held = [...(channel?.holds ?? []), ...(channel?.experimentalBlocked === null || channel?.experimentalBlocked === undefined
|
||||
? []
|
||||
: [channel.experimentalBlocked])]
|
||||
for (const hold of held) {
|
||||
for (const record of records.values()) {
|
||||
if (
|
||||
record.dshVersion === hold.dshVersion
|
||||
&& (hold.environmentVersion === null || hold.environmentVersion === undefined || record.environmentVersion === hold.environmentVersion)
|
||||
) roots.add(record.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class PlatformGarbageCollector {
|
||||
constructor({ paths, deployments, snapshots }) {
|
||||
this.paths = paths
|
||||
this.deployments = deployments
|
||||
this.snapshots = snapshots
|
||||
}
|
||||
|
||||
async removeSnapshots(retained) {
|
||||
if (this.snapshots?.remove === undefined) throw new Error('privileged snapshot cleanup is unavailable')
|
||||
const removed = []
|
||||
for (const name of await files(join(this.paths.snapshotsRoot, 'versions'))) {
|
||||
if (retained.has(name)) continue
|
||||
const details = await lstat(join(this.paths.snapshotsRoot, 'versions', name))
|
||||
if (!details.isDirectory() || details.isSymbolicLink()) continue
|
||||
if (await this.snapshots.remove(name)) removed.push(name)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async roots(records) {
|
||||
const roots = new Set()
|
||||
const slots = await this.deployments.state()
|
||||
if (slots.current !== null) roots.add(slots.current)
|
||||
if (slots.previous !== null) roots.add(slots.previous)
|
||||
recordIds(await jsonOptional(this.deployments.activationPath), roots)
|
||||
const transaction = await jsonOptional(join(this.paths.updaterStateRoot, 'transaction.json'))
|
||||
recordIds(transaction, roots)
|
||||
heldRecords(await jsonOptional(join(this.paths.updaterStateRoot, 'channel.json')), records, roots)
|
||||
return { roots, transaction }
|
||||
}
|
||||
|
||||
async collectDeployments() {
|
||||
const records = await deploymentRecords(this.deployments.recordsRoot)
|
||||
const { roots, transaction } = await this.roots(records)
|
||||
const retained = {
|
||||
environments: new Set(),
|
||||
pristine: new Set(),
|
||||
runtimes: new Set(),
|
||||
systemPlugins: new Set(),
|
||||
snapshots: new Set(),
|
||||
}
|
||||
for (const id of roots) {
|
||||
const record = records.get(id)
|
||||
if (record === undefined) continue
|
||||
for (const [field, output] of [
|
||||
['environment', retained.environments],
|
||||
['pristine', retained.pristine],
|
||||
['runtime', retained.runtimes],
|
||||
['systemPlugins', retained.systemPlugins],
|
||||
]) if (record[field].storage === 'store') output.add(record[field].id)
|
||||
if (record.snapshotId !== null) retained.snapshots.add(record.snapshotId)
|
||||
}
|
||||
if (typeof transaction?.snapshotId === 'string') retained.snapshots.add(transaction.snapshotId)
|
||||
|
||||
const reserved = new Set(['current', 'previous', 'versions'])
|
||||
const removed = {
|
||||
environments: await removeUnmarked(this.paths.environmentsRoot, retained.environments, reserved),
|
||||
pristine: await removeUnmarked(this.paths.pristineRoot, retained.pristine, reserved),
|
||||
runtimes: await removeUnmarked(this.paths.runtimesRoot, retained.runtimes, reserved),
|
||||
systemPlugins: await removeUnmarked(this.paths.systemPluginsRoot, retained.systemPlugins, reserved),
|
||||
snapshots: await this.removeSnapshots(retained.snapshots),
|
||||
records: [],
|
||||
}
|
||||
for (const [id] of records) {
|
||||
if (!roots.has(id)) {
|
||||
await rm(join(this.deployments.recordsRoot, `${id}.json`), { force: true })
|
||||
removed.records.push(id)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async cleanCache() {
|
||||
const removed = []
|
||||
for (const name of await files(this.paths.downloadsRoot)) {
|
||||
await rm(join(this.paths.downloadsRoot, name), { recursive: true, force: true })
|
||||
removed.push(name)
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
async collect() {
|
||||
return Object.freeze({
|
||||
deployments: await this.collectDeployments(),
|
||||
cache: await this.cleanCache(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import { request } from 'node:http'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { artifactForReference, parseComponentManifest, parseEnvironmentManifest } from '../../lib/contracts.mjs'
|
||||
import { exactKeys, parseJsonDocument, plainObject, TrustError } from '../../lib/validation.mjs'
|
||||
|
||||
const STARTUP_HEALTH_RETRY_MS = 100
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolveDelay => {
|
||||
const timer = setTimeout(resolveDelay, milliseconds)
|
||||
timer.unref?.()
|
||||
})
|
||||
}
|
||||
|
||||
function terminate(child, graceMs = 5_000) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
|
||||
return new Promise(resolveTerminate => {
|
||||
const timer = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||||
}, graceMs)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timer)
|
||||
resolveTerminate()
|
||||
})
|
||||
child.kill('SIGTERM')
|
||||
})
|
||||
}
|
||||
|
||||
function runCommand(spec, options, captureOutput = true) {
|
||||
return new Promise((resolveCommand, reject) => {
|
||||
const child = options.spawnImpl(spec.executable, spec.args, {
|
||||
env: options.environment,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
if (captureOutput) options.capture(child, options.componentId, options.logging)
|
||||
else {
|
||||
child.stdout?.resume()
|
||||
child.stderr?.resume()
|
||||
}
|
||||
const timer = setTimeout(() => child.kill('SIGKILL'), spec.timeoutSeconds * 1000)
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
clearTimeout(timer)
|
||||
if (code === 0) resolveCommand()
|
||||
else reject(new Error(`${options.componentId} command failed (code=${String(code)}, signal=${String(signal)})`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function probeHttp(health) {
|
||||
return new Promise(resolveProbe => {
|
||||
const req = request({ hostname: health.host, port: health.port, path: health.path }, response => {
|
||||
response.resume()
|
||||
resolveProbe((response.statusCode ?? 500) < 500)
|
||||
})
|
||||
req.setTimeout(Math.min(health.intervalSeconds * 1000, 1_000), () => req.destroy())
|
||||
req.once('error', () => resolveProbe(false))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForHealth(component, running, options) {
|
||||
if (component.health === null) return
|
||||
const deadline = Date.now() + component.health.timeoutSeconds * 1000
|
||||
while (Date.now() < deadline) {
|
||||
if (running?.child.exitCode !== null) throw new Error(`${component.id} exited before health check passed`)
|
||||
const healthy = component.health.type === 'http'
|
||||
? await probeHttp(component.health)
|
||||
: await runCommand(component.health.command, options, false).then(() => true, () => false)
|
||||
if (healthy) return
|
||||
await delay(Math.min(component.health.intervalSeconds * 1000, STARTUP_HEALTH_RETRY_MS))
|
||||
}
|
||||
if (running?.child !== undefined && running.child.exitCode !== null) {
|
||||
throw new Error(`${component.id} exited before health check passed`)
|
||||
}
|
||||
throw new Error(`${component.id} health check timed out`)
|
||||
}
|
||||
|
||||
function defaultCapture(child, componentId) {
|
||||
for (const [stream, source] of [[child.stdout, 'stdout'], [child.stderr, 'stderr']]) {
|
||||
stream?.on('data', chunk => process.stdout.write(`[${componentId}:${source}] ${chunk.toString()}`))
|
||||
}
|
||||
}
|
||||
|
||||
export class ComponentExitError extends Error {
|
||||
constructor(componentId, code, signal) {
|
||||
super(`${componentId} exited unexpectedly (code=${String(code)}, signal=${String(signal)})`)
|
||||
this.name = 'ComponentExitError'
|
||||
this.componentId = componentId
|
||||
this.exitCode = code
|
||||
this.signal = signal
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadEnvironment(root) {
|
||||
const environmentRoot = resolve(root)
|
||||
const manifest = parseEnvironmentManifest(await readFile(join(environmentRoot, 'environment.manifest.json')))
|
||||
const components = []
|
||||
for (const reference of manifest.components) {
|
||||
const artifact = artifactForReference(manifest, reference)
|
||||
const component = parseComponentManifest(await readFile(join(environmentRoot, 'artifacts', artifact.id)))
|
||||
if (component.id !== reference.id) {
|
||||
throw new Error(`component ${reference.id} differs from the Environment manifest`)
|
||||
}
|
||||
components.push(component)
|
||||
}
|
||||
return Object.freeze({ root: environmentRoot, manifest, components: Object.freeze(components) })
|
||||
}
|
||||
|
||||
export async function loadControlPlane(root) {
|
||||
const controlPlaneRoot = resolve(root)
|
||||
const definition = parseJsonDocument(await readFile(join(controlPlaneRoot, 'definition.json')), 'control plane')
|
||||
exactKeys(definition, ['components', 'schema'], 'control plane')
|
||||
if (definition.schema !== 1) throw new TrustError('control plane schema must be 1')
|
||||
if (!Array.isArray(definition.components) || definition.components.length === 0) {
|
||||
throw new TrustError('control plane components must be a non-empty array')
|
||||
}
|
||||
const components = []
|
||||
const ids = new Set()
|
||||
for (const [index, value] of definition.components.entries()) {
|
||||
const reference = plainObject(value, `control plane components[${String(index)}]`)
|
||||
exactKeys(reference, ['id', 'source'], `control plane components[${String(index)}]`)
|
||||
if (typeof reference.id !== 'string' || !/^[a-z0-9][a-z0-9._-]{0,127}$/.test(reference.id)) {
|
||||
throw new TrustError('control plane component ID is invalid')
|
||||
}
|
||||
if (ids.has(reference.id)) throw new TrustError('control plane component IDs must be unique')
|
||||
if (typeof reference.source !== 'string' || reference.source.startsWith('/')) {
|
||||
throw new TrustError('control plane component source is invalid')
|
||||
}
|
||||
const source = resolve(controlPlaneRoot, reference.source)
|
||||
if (source === controlPlaneRoot || !source.startsWith(`${controlPlaneRoot}/`)) {
|
||||
throw new TrustError('control plane component source escapes its root')
|
||||
}
|
||||
const component = parseComponentManifest(await readFile(source))
|
||||
if (component.id !== reference.id) throw new TrustError(`control plane component ${reference.id} differs from its manifest`)
|
||||
ids.add(reference.id)
|
||||
components.push(component)
|
||||
}
|
||||
return Object.freeze({
|
||||
root: controlPlaneRoot,
|
||||
manifest: Object.freeze({ version: null }),
|
||||
components: Object.freeze(components),
|
||||
})
|
||||
}
|
||||
|
||||
export class EnvironmentRunner {
|
||||
constructor({
|
||||
environmentRoot,
|
||||
spawnImpl = spawn,
|
||||
capture = defaultCapture,
|
||||
loader = loadEnvironment,
|
||||
prepareService = async () => ({ environment: {}, release: () => {} }),
|
||||
recoverableComponents = [],
|
||||
recoveryDelaysMs = [0, 2_000, 5_000],
|
||||
report = () => {},
|
||||
startupGroups = null,
|
||||
}) {
|
||||
if (!Array.isArray(recoverableComponents)
|
||||
|| recoverableComponents.some(value => typeof value !== 'string' || value.length === 0)) {
|
||||
throw new Error('Recoverable component IDs must be non-empty strings')
|
||||
}
|
||||
if (!Array.isArray(recoveryDelaysMs) || recoveryDelaysMs.length === 0
|
||||
|| recoveryDelaysMs.some(value => !Number.isFinite(value) || value < 0)) {
|
||||
throw new Error('Component recovery delays must be non-negative durations')
|
||||
}
|
||||
if (startupGroups !== null && (!Array.isArray(startupGroups) || startupGroups.length === 0
|
||||
|| startupGroups.some(group => !Array.isArray(group) || group.length === 0
|
||||
|| group.some(value => typeof value !== 'string' || value.length === 0)))) {
|
||||
throw new Error('Startup groups must contain non-empty component ID lists')
|
||||
}
|
||||
this.environmentRoot = environmentRoot
|
||||
this.spawnImpl = spawnImpl
|
||||
this.capture = capture
|
||||
this.loader = loader
|
||||
this.prepareService = prepareService
|
||||
this.recoverableComponents = new Set(recoverableComponents)
|
||||
this.recoveryDelaysMs = Object.freeze([...recoveryDelaysMs])
|
||||
this.report = report
|
||||
this.startupGroups = startupGroups === null ? null : Object.freeze(
|
||||
startupGroups.map(group => Object.freeze([...group])),
|
||||
)
|
||||
this.running = []
|
||||
this.environment = undefined
|
||||
this.operation = Promise.resolve()
|
||||
this.stopping = false
|
||||
this.componentRecoveries = new Map()
|
||||
this.fatalListeners = new Set()
|
||||
this.fatal = new Promise(resolveFatal => { this.resolveFatal = resolveFatal })
|
||||
}
|
||||
|
||||
onFatal(listener) {
|
||||
this.fatalListeners.add(listener)
|
||||
return () => this.fatalListeners.delete(listener)
|
||||
}
|
||||
|
||||
emitFatal(error) {
|
||||
this.resolveFatal(error)
|
||||
for (const listener of this.fatalListeners) {
|
||||
try { listener(error) } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
emitLifecycle(message, fields = {}) {
|
||||
void Promise.resolve().then(() => this.report(message, fields)).catch(() => {})
|
||||
}
|
||||
|
||||
serialized(operation) {
|
||||
const result = this.operation.then(operation, operation)
|
||||
this.operation = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
recoverable(component) {
|
||||
return this.recoverableComponents.has(component.id) && component.type === 'service'
|
||||
}
|
||||
|
||||
scheduleRecovery(component, originalError) {
|
||||
if (this.stopping || !this.recoverable(component) || this.componentRecoveries.has(component.id)) return
|
||||
const recover = () => this.recoverComponentUnlocked(component, originalError)
|
||||
const pending = this.serialized(recover).finally(() => {
|
||||
if (this.componentRecoveries.get(component.id) === pending) this.componentRecoveries.delete(component.id)
|
||||
})
|
||||
this.componentRecoveries.set(component.id, pending)
|
||||
void pending.catch(() => {})
|
||||
}
|
||||
|
||||
async recoverComponentUnlocked(component, originalError) {
|
||||
const stale = this.running.find(value => value.component.id === component.id)
|
||||
if (stale !== undefined) {
|
||||
try { await this.stopComponentUnlocked(stale) } catch {}
|
||||
}
|
||||
for (let index = 0; index < this.recoveryDelaysMs.length; index += 1) {
|
||||
if (this.stopping) return
|
||||
const delayMs = this.recoveryDelaysMs[index]
|
||||
if (delayMs > 0) await delay(delayMs)
|
||||
if (this.stopping) return
|
||||
const attempt = index + 1
|
||||
this.emitLifecycle('component.recovery.started', {
|
||||
componentId: component.id,
|
||||
attempt,
|
||||
maxAttempts: this.recoveryDelaysMs.length,
|
||||
})
|
||||
try {
|
||||
await this.startComponentUnlocked(component, true)
|
||||
this.emitLifecycle('component.recovery.completed', {
|
||||
componentId: component.id,
|
||||
attempt,
|
||||
maxAttempts: this.recoveryDelaysMs.length,
|
||||
})
|
||||
return
|
||||
} catch (error) {
|
||||
this.emitLifecycle('component.recovery.failed', {
|
||||
componentId: component.id,
|
||||
attempt,
|
||||
maxAttempts: this.recoveryDelaysMs.length,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
level: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
this.emitLifecycle('component.isolated', {
|
||||
componentId: component.id,
|
||||
error: originalError instanceof Error ? originalError.message : String(originalError),
|
||||
level: 'error',
|
||||
})
|
||||
}
|
||||
|
||||
commandOptions(component) {
|
||||
return {
|
||||
spawnImpl: this.spawnImpl,
|
||||
capture: this.capture,
|
||||
componentId: component.id,
|
||||
logging: component.logging,
|
||||
environment: { ...process.env, ...component.environment },
|
||||
}
|
||||
}
|
||||
|
||||
async phase(component, name) {
|
||||
const spec = component.lifecycle[name]
|
||||
if (spec !== null) await runCommand(spec, this.commandOptions(component))
|
||||
}
|
||||
|
||||
startupSequence() {
|
||||
if (this.startupGroups === null) return this.environment.components.map(component => [component])
|
||||
const components = new Map(this.environment.components.map(component => [component.id, component]))
|
||||
const seen = new Set()
|
||||
const groups = this.startupGroups.map(group => group.map(componentId => {
|
||||
const component = components.get(componentId)
|
||||
if (component === undefined) throw new Error(`Startup group component ${componentId} does not exist`)
|
||||
if (seen.has(componentId)) throw new Error(`Startup group component ${componentId} is duplicated`)
|
||||
seen.add(componentId)
|
||||
return component
|
||||
}))
|
||||
const missing = this.environment.components.filter(component => !seen.has(component.id))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`Startup groups omit components: ${missing.map(component => component.id).join(', ')}`)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
start() {
|
||||
return this.serialized(() => this.startUnlocked())
|
||||
}
|
||||
|
||||
async startUnlocked() {
|
||||
if (this.running.length > 0) throw new Error('Environment is already running')
|
||||
this.environment = await this.loader(this.environmentRoot)
|
||||
try {
|
||||
const startupGroups = this.startupSequence()
|
||||
const recoveries = []
|
||||
for (const component of this.environment.components) await this.phase(component, 'prepare')
|
||||
for (const group of startupGroups) {
|
||||
const results = await Promise.allSettled(group.map(component => this.startComponentUnlocked(component)))
|
||||
const failures = []
|
||||
for (const [index, result] of results.entries()) {
|
||||
if (result.status === 'fulfilled') continue
|
||||
const component = group[index]
|
||||
if (!this.recoverable(component)) {
|
||||
failures.push(result.reason)
|
||||
continue
|
||||
}
|
||||
this.emitLifecycle('component.isolated', {
|
||||
componentId: component.id,
|
||||
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
|
||||
level: 'error',
|
||||
})
|
||||
recoveries.push({ component, error: result.reason })
|
||||
}
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'Startup group failed')
|
||||
}
|
||||
for (const recovery of recoveries) this.scheduleRecovery(recovery.component, recovery.error)
|
||||
return this.status()
|
||||
} catch (error) {
|
||||
try {
|
||||
await this.stopUnlocked()
|
||||
} catch (cleanupError) {
|
||||
this.emitLifecycle('environment.cleanup.failed', {
|
||||
error: cleanupError,
|
||||
level: 'warning',
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async startComponentUnlocked(component, prepare = false) {
|
||||
if (this.running.some(running => running.component.id === component.id)) {
|
||||
throw new Error(`component ${component.id} is already running`)
|
||||
}
|
||||
const startedAt = Date.now()
|
||||
this.emitLifecycle('component.starting', { componentId: component.id, componentType: component.type })
|
||||
let running
|
||||
let releaseService = () => {}
|
||||
try {
|
||||
if (prepare) await this.phase(component, 'prepare')
|
||||
await this.phase(component, 'preStart')
|
||||
if (component.type === 'service') {
|
||||
const options = this.commandOptions(component)
|
||||
const launch = await this.prepareService(component)
|
||||
let released = false
|
||||
const release = () => {
|
||||
if (released) return
|
||||
released = true
|
||||
launch.release?.()
|
||||
}
|
||||
releaseService = release
|
||||
const child = this.spawnImpl(component.command.executable, component.command.args, {
|
||||
env: { ...options.environment, ...launch.environment },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
this.capture(child, component.id, component.logging)
|
||||
await Promise.race([
|
||||
once(child, 'spawn'),
|
||||
once(child, 'error').then(([error]) => { throw error }),
|
||||
])
|
||||
running = { component, child, ready: false, release }
|
||||
this.emitLifecycle('component.spawned', { componentId: component.id, pid: child.pid ?? null })
|
||||
child.once('exit', (code, signal) => {
|
||||
release()
|
||||
if (running.ready && !this.stopping && this.running.includes(running)) {
|
||||
const error = new ComponentExitError(component.id, code, signal)
|
||||
this.emitLifecycle('component.exited', {
|
||||
componentId: component.id,
|
||||
code,
|
||||
signal,
|
||||
error: error.message,
|
||||
level: 'error',
|
||||
})
|
||||
if (this.recoverable(component)) this.scheduleRecovery(component, error)
|
||||
else this.emitFatal(error)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await runCommand(component.command, this.commandOptions(component))
|
||||
running = { component, child: undefined }
|
||||
}
|
||||
this.running.push(running)
|
||||
this.running.sort((left, right) => (
|
||||
this.environment.components.indexOf(left.component) - this.environment.components.indexOf(right.component)
|
||||
))
|
||||
await waitForHealth(component, running, this.commandOptions(component))
|
||||
await this.phase(component, 'postStart')
|
||||
if (running.child !== undefined && running.child.exitCode !== null) {
|
||||
throw new Error(`${component.id} exited before startup completed`)
|
||||
}
|
||||
running.ready = true
|
||||
this.emitLifecycle('component.ready', { componentId: component.id, elapsedMs: Date.now() - startedAt })
|
||||
return running
|
||||
} catch (error) {
|
||||
this.emitLifecycle('component.start.failed', {
|
||||
componentId: component.id,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
level: 'error',
|
||||
})
|
||||
if (running !== undefined) {
|
||||
try {
|
||||
await this.stopComponentUnlocked(running)
|
||||
} catch (cleanupError) {
|
||||
this.emitLifecycle('component.cleanup.failed', {
|
||||
componentId: component.id,
|
||||
error: cleanupError,
|
||||
level: 'warning',
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
} else if (component.type === 'service') {
|
||||
releaseService()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async stopComponentUnlocked(running) {
|
||||
const { component, child } = running
|
||||
const failures = []
|
||||
this.emitLifecycle('component.stopping', { componentId: component.id, pid: child?.pid ?? null })
|
||||
this.running = this.running.filter(value => value !== running)
|
||||
for (const phase of ['preStop', 'stop']) {
|
||||
try { await this.phase(component, phase) } catch (error) { failures.push(error) }
|
||||
}
|
||||
if (child !== undefined) {
|
||||
try { await terminate(child) } catch (error) { failures.push(error) }
|
||||
}
|
||||
try { await this.phase(component, 'postStop') } catch (error) { failures.push(error) }
|
||||
if (failures.length > 0) {
|
||||
this.emitLifecycle('component.stop.failed', {
|
||||
componentId: component.id,
|
||||
errors: failures.map(error => error instanceof Error ? error.message : String(error)),
|
||||
level: 'error',
|
||||
})
|
||||
throw new AggregateError(failures, `component ${component.id} shutdown failed`)
|
||||
}
|
||||
this.emitLifecycle('component.stopped', { componentId: component.id })
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.stopping = true
|
||||
return this.serialized(() => this.stopUnlocked())
|
||||
}
|
||||
|
||||
async stopUnlocked() {
|
||||
this.stopping = true
|
||||
const failures = []
|
||||
for (const running of [...this.running].reverse()) {
|
||||
try { await this.stopComponentUnlocked(running) } catch (error) { failures.push(error) }
|
||||
}
|
||||
this.running = []
|
||||
this.stopping = false
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'Environment shutdown failed')
|
||||
}
|
||||
|
||||
reload() {
|
||||
return this.serialized(async () => {
|
||||
await this.stopUnlocked()
|
||||
return this.startUnlocked()
|
||||
})
|
||||
}
|
||||
|
||||
suspend(componentId) {
|
||||
return this.serialized(async () => {
|
||||
const running = this.running.find(value => value.component.id === componentId)
|
||||
if (running === undefined) throw new Error(`component ${componentId} is not running`)
|
||||
if (running.component.type !== 'service') throw new Error(`component ${componentId} is not a service`)
|
||||
await this.stopComponentUnlocked(running)
|
||||
return this.status()
|
||||
})
|
||||
}
|
||||
|
||||
pause(componentId, { allowMissing = false } = {}) {
|
||||
return this.serialized(async () => {
|
||||
const running = this.running.find(value => value.component.id === componentId)
|
||||
const component = running?.component ?? this.environment?.components.find(value => value.id === componentId)
|
||||
if (component === undefined) {
|
||||
if (allowMissing) return this.status()
|
||||
throw new Error(`component ${componentId} does not exist`)
|
||||
}
|
||||
if (component.type !== 'service') throw new Error(`component ${componentId} is not a service`)
|
||||
if (running === undefined) return this.status()
|
||||
await this.stopComponentUnlocked(running)
|
||||
return this.status()
|
||||
})
|
||||
}
|
||||
|
||||
resume(componentId) {
|
||||
return this.serialized(async () => {
|
||||
if (this.environment === undefined) throw new Error('Environment is not loaded')
|
||||
const component = this.environment.components.find(value => value.id === componentId)
|
||||
if (component === undefined) throw new Error(`component ${componentId} does not exist`)
|
||||
if (this.running.some(value => value.component.id === componentId)) return this.status()
|
||||
await this.startComponentUnlocked(component, true)
|
||||
return this.status()
|
||||
})
|
||||
}
|
||||
|
||||
restart(componentId, { beforeStart, recoverStart = false, onStartFailure } = {}) {
|
||||
return this.serialized(async () => {
|
||||
if (this.environment === undefined) this.environment = await this.loader(this.environmentRoot)
|
||||
const running = this.running.find(value => value.component.id === componentId)
|
||||
const component = running?.component
|
||||
?? this.environment.components.find(value => value.id === componentId)
|
||||
if (component === undefined) throw new Error(`component ${componentId} does not exist`)
|
||||
if (component.type !== 'service') throw new Error(`component ${componentId} is not a service`)
|
||||
if (running !== undefined) await this.stopComponentUnlocked(running)
|
||||
try {
|
||||
await beforeStart?.()
|
||||
await this.startComponentUnlocked(component, true)
|
||||
return this.status()
|
||||
} catch (error) {
|
||||
const failures = [error]
|
||||
try { await onStartFailure?.(error) } catch (recoveryError) { failures.push(recoveryError) }
|
||||
if (recoverStart && failures.length === 1) {
|
||||
try { await this.startComponentUnlocked(component, true) } catch (recoveryError) { failures.push(recoveryError) }
|
||||
}
|
||||
if (failures.length > 1) throw new AggregateError(failures, `component ${componentId} restart recovery failed`)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
health() {
|
||||
return this.serialized(async () => {
|
||||
const components = await Promise.all((this.environment?.components ?? []).map(async component => {
|
||||
const running = this.running.find(value => value.component.id === component.id)
|
||||
if (running === undefined) return { id: component.id, running: false, healthy: false }
|
||||
const healthy = component.health === null
|
||||
? running.child === undefined || running.child.exitCode === null
|
||||
: component.health.type === 'http'
|
||||
? await probeHttp(component.health)
|
||||
: await runCommand(component.health.command, this.commandOptions(component), false).then(() => true, () => false)
|
||||
return { id: component.id, running: true, healthy }
|
||||
}))
|
||||
return { healthy: components.length > 0 && components.every(component => component.healthy), components }
|
||||
})
|
||||
}
|
||||
|
||||
status() {
|
||||
return Object.freeze({
|
||||
environmentVersion: this.environment?.manifest.version ?? null,
|
||||
components: this.running.map(({ component, child }) => ({
|
||||
id: component.id,
|
||||
type: component.type,
|
||||
pid: child?.pid ?? null,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
export class BootstrapRuntime {
|
||||
constructor({
|
||||
controlPlane,
|
||||
environment,
|
||||
validateDeployment = async () => {},
|
||||
prepareDeployment = async () => {},
|
||||
beforeReady = async () => {},
|
||||
onEnvironmentVerified = async () => {},
|
||||
onDshAvailable = async () => {},
|
||||
onEnvironmentFatal = async () => {},
|
||||
onDshRecovered = async () => {},
|
||||
ownsDshLifecycle = async () => false,
|
||||
recoveryDelaysMs = [0, 2_000, 5_000],
|
||||
sleep = (milliseconds, signal) => new Promise(resolve => {
|
||||
if (milliseconds === 0 || signal?.aborted) return resolve()
|
||||
const timer = setTimeout(resolve, milliseconds)
|
||||
timer.unref?.()
|
||||
signal?.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
||||
}),
|
||||
report = async () => {},
|
||||
}) {
|
||||
if (!Array.isArray(recoveryDelaysMs) || recoveryDelaysMs.length !== 3
|
||||
|| recoveryDelaysMs.some(value => !Number.isFinite(value) || value < 0)) {
|
||||
throw new Error('DSH recovery delays must contain three non-negative durations')
|
||||
}
|
||||
this.controlPlane = controlPlane
|
||||
this.environment = environment
|
||||
this.fatal = controlPlane.fatal
|
||||
this.recoveryMode = null
|
||||
this.startupComplete = false
|
||||
this.dshLifecycle = Object.freeze({
|
||||
state: 'starting', action: 'start', taskId: null, attempt: 0, maxAttempts: 3,
|
||||
error: null, updatedAt: new Date().toISOString(),
|
||||
})
|
||||
this.recovery = Promise.resolve()
|
||||
this.recoveryAbort = new AbortController()
|
||||
this.validateDeployment = validateDeployment
|
||||
this.prepareDeployment = prepareDeployment
|
||||
this.beforeReady = beforeReady
|
||||
this.onEnvironmentVerified = onEnvironmentVerified
|
||||
this.onDshAvailable = onDshAvailable
|
||||
this.onEnvironmentFatal = onEnvironmentFatal
|
||||
this.onDshRecovered = onDshRecovered
|
||||
this.ownsDshLifecycle = ownsDshLifecycle
|
||||
this.recoveryDelaysMs = Object.freeze([...recoveryDelaysMs])
|
||||
this.sleep = sleep
|
||||
this.report = report
|
||||
const handleEnvironmentFatal = error => {
|
||||
const recover = () => this.handleEnvironmentFatal(error)
|
||||
this.recovery = this.recovery.then(recover, recover).catch(recoveryError => this.record(
|
||||
'environment.recovery.failed', { error: recoveryError, originalError: String(error) },
|
||||
))
|
||||
}
|
||||
if (typeof environment.onFatal === 'function') {
|
||||
this.offEnvironmentFatal = environment.onFatal(handleEnvironmentFatal)
|
||||
} else {
|
||||
void environment.fatal.then(handleEnvironmentFatal)
|
||||
this.offEnvironmentFatal = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async lifecycleOwned() {
|
||||
try {
|
||||
return await this.ownsDshLifecycle()
|
||||
} catch (error) {
|
||||
await this.record('dsh.recovery-ownership.failed', { error, level: 'warning' })
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async handleEnvironmentFatal(error) {
|
||||
if (error?.componentId !== 'dsh-runtime') return this.isolateEnvironmentFailure(error)
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (await this.lifecycleOwned()) {
|
||||
this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt: 0, error: message })
|
||||
await this.record('dsh.recovery.deferred', { error, reason: 'deployment-transaction-active', level: 'warning' })
|
||||
return
|
||||
}
|
||||
for (let index = 0; index < this.recoveryDelaysMs.length; index += 1) {
|
||||
const attempt = index + 1
|
||||
await this.sleep(this.recoveryDelaysMs[index], this.recoveryAbort.signal)
|
||||
if (this.recoveryAbort.signal.aborted) return
|
||||
if (await this.lifecycleOwned()) {
|
||||
this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt: attempt - 1, error: message })
|
||||
await this.record('dsh.recovery.deferred', { error, reason: 'deployment-transaction-active', level: 'warning' })
|
||||
return
|
||||
}
|
||||
this.publishDshLifecycle({ state: 'recovering', action: 'auto-recover', attempt, error: message })
|
||||
await this.record('dsh.recovery.attempt.started', { attempt, maxAttempts: this.recoveryDelaysMs.length })
|
||||
try {
|
||||
await this.validateDeployment()
|
||||
await this.prepareDeployment()
|
||||
await this.environment.restart('dsh-runtime')
|
||||
await this.onEnvironmentVerified()
|
||||
this.recoveryMode = null
|
||||
this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null })
|
||||
let recoveryStatusPublished = true
|
||||
try {
|
||||
await this.onDshRecovered()
|
||||
} catch (statusError) {
|
||||
recoveryStatusPublished = false
|
||||
await this.record('dsh.recovery-status.failed', { error: statusError, level: 'warning' })
|
||||
}
|
||||
if (recoveryStatusPublished) await this.onDshAvailable()
|
||||
await this.record('dsh.recovery.completed', { attempt, maxAttempts: this.recoveryDelaysMs.length })
|
||||
return
|
||||
} catch (recoveryError) {
|
||||
await this.record('dsh.recovery.attempt.failed', {
|
||||
attempt,
|
||||
error: recoveryError,
|
||||
maxAttempts: this.recoveryDelaysMs.length,
|
||||
level: 'warning',
|
||||
})
|
||||
}
|
||||
}
|
||||
await this.isolateEnvironmentFailure(error)
|
||||
}
|
||||
|
||||
async isolateEnvironmentFailure(error) {
|
||||
this.recoveryMode = error instanceof Error ? error.message : String(error)
|
||||
this.publishDshLifecycle({
|
||||
state: 'failed', action: null,
|
||||
attempt: error?.componentId === 'dsh-runtime' ? this.recoveryDelaysMs.length : 0,
|
||||
error: this.recoveryMode,
|
||||
})
|
||||
try {
|
||||
await this.onEnvironmentFatal(error)
|
||||
} catch (reportError) {
|
||||
await this.record('environment.recovery-report.failed', { error: reportError, originalError: String(error) })
|
||||
}
|
||||
try {
|
||||
await this.environment.stop()
|
||||
} catch (stopError) {
|
||||
await this.record('environment.recovery-stop.failed', { error: stopError, originalError: String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
record(message, fields = {}) {
|
||||
return Promise.resolve().then(() => this.report(message, fields)).catch(() => {})
|
||||
}
|
||||
|
||||
publishDshLifecycle(value) {
|
||||
this.dshLifecycle = Object.freeze({
|
||||
...this.dshLifecycle,
|
||||
...value,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
return this.dshLifecycle
|
||||
}
|
||||
|
||||
async stopControlPlane(phase, cause) {
|
||||
try {
|
||||
await this.controlPlane.stop()
|
||||
} catch (error) {
|
||||
await this.record('control-plane.cleanup.failed', {
|
||||
error,
|
||||
phase,
|
||||
originalError: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async stopEnvironment(phase, cause) {
|
||||
try {
|
||||
await this.environment.stop()
|
||||
} catch (error) {
|
||||
await this.record('environment.cleanup.failed', {
|
||||
error,
|
||||
phase,
|
||||
originalError: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async startEnvironment({ onEnvironmentFailure, allowRecovery }) {
|
||||
try {
|
||||
await this.validateDeployment()
|
||||
await this.prepareDeployment()
|
||||
await this.environment.start()
|
||||
await this.onEnvironmentVerified()
|
||||
} catch (error) {
|
||||
let retry
|
||||
try {
|
||||
retry = await onEnvironmentFailure?.(error)
|
||||
} catch (recoveryError) {
|
||||
const failure = new AggregateError([error, recoveryError], 'Deployment failed and its fallback could not be resolved')
|
||||
if (allowRecovery) {
|
||||
this.recoveryMode = failure.message
|
||||
return
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
if (retry === true) {
|
||||
try {
|
||||
await this.validateDeployment()
|
||||
await this.prepareDeployment()
|
||||
await this.environment.start()
|
||||
await this.onEnvironmentVerified()
|
||||
} catch (fallbackError) {
|
||||
const failure = new AggregateError([error, fallbackError], 'Deployment candidate and fallback both failed')
|
||||
if (allowRecovery) this.recoveryMode = failure.message
|
||||
else throw failure
|
||||
}
|
||||
} else if (allowRecovery) {
|
||||
this.recoveryMode = error instanceof Error ? error.message : 'Deployment failed to start'
|
||||
} else throw error
|
||||
}
|
||||
}
|
||||
|
||||
async start({ onEnvironmentFailure, allowRecovery = false } = {}) {
|
||||
const readiness = Promise.resolve().then(async () => {
|
||||
await this.controlPlane.start()
|
||||
await this.beforeReady()
|
||||
})
|
||||
const environment = Promise.resolve().then(() => this.startEnvironment({ onEnvironmentFailure, allowRecovery }))
|
||||
const results = await Promise.allSettled([readiness, environment])
|
||||
const failures = results.filter(result => result.status === 'rejected').map(result => result.reason)
|
||||
if (failures.length > 0) {
|
||||
const failure = failures.length === 1 ? failures[0] : new AggregateError(failures, 'Platform startup failed')
|
||||
await Promise.all([
|
||||
this.stopEnvironment('concurrent-start', failure),
|
||||
this.stopControlPlane('concurrent-start', failure),
|
||||
])
|
||||
throw failure
|
||||
}
|
||||
return this.status()
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this.offEnvironmentFatal?.()
|
||||
this.recoveryAbort.abort()
|
||||
await this.recovery
|
||||
const failures = []
|
||||
try { await this.environment.stop() } catch (error) { failures.push(error) }
|
||||
try { await this.controlPlane.stop() } catch (error) { failures.push(error) }
|
||||
if (failures.length > 0) throw new AggregateError(failures, 'Bootstrap shutdown failed')
|
||||
}
|
||||
|
||||
async reload() {
|
||||
await this.validateDeployment()
|
||||
await this.prepareDeployment()
|
||||
const status = await this.environment.reload()
|
||||
await this.onEnvironmentVerified()
|
||||
this.recoveryMode = null
|
||||
if (this.environment.status().components.some(value => value.id === 'dsh-runtime')) {
|
||||
this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null })
|
||||
await this.onDshAvailable()
|
||||
}
|
||||
return status
|
||||
}
|
||||
async suspend(componentId) {
|
||||
if (componentId !== 'dsh-runtime') return this.environment.suspend(componentId)
|
||||
this.publishDshLifecycle({ state: 'stopping', action: 'stop', attempt: 0, error: null })
|
||||
try {
|
||||
const status = await this.environment.suspend(componentId)
|
||||
this.publishDshLifecycle({ state: 'stopped', action: null, attempt: 0, error: null })
|
||||
return status
|
||||
} catch (error) {
|
||||
this.publishDshLifecycle({
|
||||
state: 'failed', action: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async pause(componentId) {
|
||||
if (componentId !== 'dsh-runtime') return this.environment.pause(componentId)
|
||||
this.publishDshLifecycle({ state: 'stopping', action: 'stop', attempt: 0, error: null })
|
||||
try {
|
||||
const status = await this.environment.pause(componentId, { allowMissing: true })
|
||||
this.publishDshLifecycle({ state: 'stopped', action: null, attempt: 0, error: null })
|
||||
return status
|
||||
} catch (error) {
|
||||
this.publishDshLifecycle({
|
||||
state: 'failed', action: null,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async resume(componentId, options) {
|
||||
if (componentId === 'dsh-runtime') this.publishDshLifecycle({ state: 'starting', action: 'start', attempt: 0, error: null })
|
||||
try {
|
||||
if (componentId === 'dsh-runtime') {
|
||||
if (options?.skipValidation !== true) await this.validateDeployment()
|
||||
if (options?.skipPreparation !== true) await this.prepareDeployment()
|
||||
}
|
||||
const status = await this.environment.resume(componentId)
|
||||
if (componentId === 'dsh-runtime') {
|
||||
await this.onEnvironmentVerified()
|
||||
this.recoveryMode = null
|
||||
this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null })
|
||||
await this.onDshAvailable()
|
||||
}
|
||||
return status
|
||||
} catch (error) {
|
||||
if (componentId === 'dsh-runtime') this.enterRecovery(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
async restart(componentId, options) {
|
||||
if (componentId === 'dsh-runtime') this.publishDshLifecycle({ state: 'restarting', action: 'restart', attempt: 0, error: null })
|
||||
try {
|
||||
if (componentId === 'dsh-runtime') {
|
||||
if (options?.skipValidation !== true) await this.validateDeployment()
|
||||
if (options?.skipPreparation !== true) await this.prepareDeployment()
|
||||
}
|
||||
const status = await this.environment.restart(componentId, options)
|
||||
if (componentId === 'dsh-runtime') {
|
||||
await this.onEnvironmentVerified()
|
||||
this.recoveryMode = null
|
||||
this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null })
|
||||
await this.onDshAvailable()
|
||||
}
|
||||
return status
|
||||
} catch (error) {
|
||||
if (componentId === 'dsh-runtime') this.enterRecovery(error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
health() { return this.environment.health() }
|
||||
|
||||
markStartupComplete() {
|
||||
this.startupComplete = true
|
||||
if (this.recoveryMode === null && this.environment.status().components.some(value => value.id === 'dsh-runtime')) {
|
||||
this.publishDshLifecycle({ state: 'running', action: null, attempt: 0, error: null })
|
||||
} else if (this.recoveryMode !== null) {
|
||||
this.publishDshLifecycle({ state: 'failed', action: null, error: this.recoveryMode })
|
||||
}
|
||||
}
|
||||
|
||||
enterRecovery(error) {
|
||||
this.recoveryMode = error instanceof Error ? error.message : String(error)
|
||||
this.publishDshLifecycle({ state: 'failed', action: null, attempt: 0, error: this.recoveryMode })
|
||||
}
|
||||
|
||||
status() {
|
||||
const environment = this.environment.status()
|
||||
return Object.freeze({
|
||||
...environment,
|
||||
controlPlane: this.controlPlane.status().components,
|
||||
recoveryMode: this.recoveryMode,
|
||||
dshLifecycle: this.dshLifecycle,
|
||||
startupComplete: this.startupComplete,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user