144 lines
5.4 KiB
JavaScript
144 lines
5.4 KiB
JavaScript
import { createHash } from 'node:crypto'
|
|
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
|
|
import { TrustError } from '../../../../platform/lib/validation.mjs'
|
|
|
|
function deploymentIdentity(deployment) {
|
|
return {
|
|
dsh: deployment.dsh,
|
|
environment: deployment.environment,
|
|
runtime: deployment.runtime,
|
|
receiptTokens: [...deployment.receiptTokens].sort(),
|
|
}
|
|
}
|
|
|
|
function recordIdentity(record) {
|
|
return deploymentIdentity({
|
|
dsh: record.dshVersion,
|
|
environment: record.environmentVersion,
|
|
runtime: record.id,
|
|
receiptTokens: record.receiptTokens,
|
|
})
|
|
}
|
|
|
|
function recordMatchesDeployment(record, deployment) {
|
|
return record.dshVersion === deployment.dsh
|
|
&& record.environmentVersion === deployment.environment
|
|
&& [...record.receiptTokens].sort().join('\0') === [...deployment.receiptTokens].sort().join('\0')
|
|
}
|
|
|
|
function sameRecordContent(left, right) {
|
|
return left.authority === right.authority
|
|
&& left.targetSequence === right.targetSequence
|
|
&& left.dshVersion === right.dshVersion
|
|
&& left.environmentVersion === right.environmentVersion
|
|
&& left.environment.sha256 === right.environment.sha256
|
|
&& left.pristine.sha256 === right.pristine.sha256
|
|
&& left.runtime.sha256 === right.runtime.sha256
|
|
&& left.systemPlugins.sha256 === right.systemPlugins.sha256
|
|
}
|
|
|
|
function bindPlan(value) {
|
|
return Object.freeze({
|
|
...value,
|
|
planId: createHash('sha256').update(canonicalJson(value)).digest('hex'),
|
|
})
|
|
}
|
|
|
|
export class CompleteStateRecovery {
|
|
constructor({ journal, snapshots, activator }) {
|
|
this.journal = journal
|
|
this.snapshots = snapshots
|
|
this.activator = activator
|
|
}
|
|
|
|
async plan() {
|
|
const transaction = await this.journal.read()
|
|
if (transaction?.phase === 'committed' && transaction.snapshotId !== null) {
|
|
const current = await this.activator.currentDeployment()
|
|
if (
|
|
current.dsh === transaction.to.dsh
|
|
&& current.environment === transaction.to.environment
|
|
&& current.runtime === transaction.to.runtime
|
|
) {
|
|
let previous = deploymentIdentity(transaction.from)
|
|
if (this.activator.rollbackDeployments !== undefined) {
|
|
const slots = await this.activator.rollbackDeployments()
|
|
if (
|
|
slots.current?.id !== current.runtime
|
|
|| slots.previous === null
|
|
|| !recordMatchesDeployment(slots.previous, transaction.from)
|
|
) return null
|
|
previous = recordIdentity(slots.previous)
|
|
}
|
|
const snapshot = await this.snapshots.inspect(transaction.snapshotId)
|
|
if (
|
|
snapshot.runtimeId !== transaction.from.runtime
|
|
|| snapshot.environmentVersion !== transaction.from.environment
|
|
|| snapshot.dshVersion !== transaction.from.dsh
|
|
) throw new TrustError('rollback snapshot does not describe the previous deployment')
|
|
return bindPlan({
|
|
transactionId: transaction.transactionId,
|
|
mode: transaction.mode,
|
|
current: deploymentIdentity(current),
|
|
previous,
|
|
snapshot: {
|
|
id: snapshot.id,
|
|
createdAt: snapshot.createdAt,
|
|
sha256: snapshot.archiveSha256,
|
|
size: snapshot.archiveSize,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
if (this.activator.rollbackDeployments === undefined) return null
|
|
const { current, previous } = await this.activator.rollbackDeployments()
|
|
if (current === null || previous === null) return null
|
|
if (sameRecordContent(current, previous)) return null
|
|
return bindPlan({
|
|
transactionId: null,
|
|
mode: 'stable',
|
|
current: recordIdentity(current),
|
|
previous: recordIdentity(previous),
|
|
snapshot: null,
|
|
})
|
|
}
|
|
|
|
async restore(planId, {
|
|
confirmDataLoss = false,
|
|
requireConfirmation = false,
|
|
onProgress = async () => {},
|
|
} = {}) {
|
|
if (requireConfirmation && confirmDataLoss !== true) {
|
|
throw new TrustError('return to Stable requires explicit data-loss confirmation')
|
|
}
|
|
const plan = await this.plan()
|
|
if (plan === null || plan.planId !== planId) throw new TrustError('rollback plan is stale or unavailable')
|
|
if (requireConfirmation && plan.snapshot === null) {
|
|
throw new TrustError('return to Stable requires a verified data snapshot')
|
|
}
|
|
if (plan.snapshot === null) {
|
|
await onProgress('switching', 35, {})
|
|
await this.activator.rollback(plan.previous.runtime)
|
|
await onProgress('verifying', 90, {})
|
|
return Object.freeze({ status: 'rolled-back', transactionId: null })
|
|
}
|
|
let transaction = await this.journal.transition('restoring-data', { error: 'manual complete-state rollback' })
|
|
await onProgress('stopping', 15, {})
|
|
await this.activator.suspendDsh()
|
|
try {
|
|
await onProgress('switching', 35, {})
|
|
await this.activator.restoreDeployment({ ...transaction.from, runtime: plan.previous.runtime }, { resume: false })
|
|
await onProgress('restoring-data', 65, {})
|
|
await this.snapshots.restore(transaction.snapshotId, {
|
|
onProgress: metrics => onProgress('restoring-data', 65, metrics),
|
|
})
|
|
await onProgress('verifying', 85, {})
|
|
await this.activator.resumeDsh()
|
|
transaction = await this.journal.transition('rolled-back', { error: 'manual complete-state rollback completed' })
|
|
return Object.freeze({ status: 'rolled-back', transactionId: transaction.transactionId })
|
|
} catch (error) {
|
|
throw error
|
|
}
|
|
}
|
|
}
|