809 lines
31 KiB
JavaScript
809 lines
31 KiB
JavaScript
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,
|
|
}))
|
|
}
|
|
}
|