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,142 @@
|
||||
import { open, readFile, rm } from 'node:fs/promises'
|
||||
import { dirname } from 'node:path'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { exactKeys, isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
|
||||
|
||||
const PHASES = new Set([
|
||||
'planning', 'candidate-ready', 'suspended', 'snapshot-created', 'switched', 'probation',
|
||||
'committed', 'restoring-data', 'rolled-back', 'failed',
|
||||
])
|
||||
|
||||
const TRANSITIONS = new Map([
|
||||
['planning', new Set(['candidate-ready', 'failed'])],
|
||||
['candidate-ready', new Set(['suspended', 'failed'])],
|
||||
['suspended', new Set(['snapshot-created', 'failed'])],
|
||||
['snapshot-created', new Set(['switched', 'restoring-data', 'failed'])],
|
||||
['switched', new Set(['probation', 'restoring-data'])],
|
||||
['probation', new Set(['committed', 'restoring-data'])],
|
||||
['restoring-data', new Set(['rolled-back', 'failed'])],
|
||||
['committed', new Set(['restoring-data'])],
|
||||
['rolled-back', new Set()],
|
||||
['failed', new Set()],
|
||||
])
|
||||
|
||||
function deployment(value, label, snapshot) {
|
||||
const object = plainObject(value, label)
|
||||
exactKeys(object, ['dsh', 'environment', 'runtime', ...(snapshot ? ['dataSnapshot', 'receiptTokens'] : [])], label)
|
||||
for (const name of ['dsh', 'environment', 'runtime']) {
|
||||
if (typeof object[name] !== 'string' || object[name] === '') throw new TrustError(`${label}.${name} is invalid`)
|
||||
}
|
||||
if (snapshot && object.dataSnapshot !== null && typeof object.dataSnapshot !== 'string') {
|
||||
throw new TrustError(`${label}.dataSnapshot is invalid`)
|
||||
}
|
||||
if (snapshot && (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string'))) {
|
||||
throw new TrustError(`${label}.receiptTokens is invalid`)
|
||||
}
|
||||
return Object.freeze({ ...object, ...(snapshot ? { receiptTokens: Object.freeze([...object.receiptTokens]) } : {}) })
|
||||
}
|
||||
|
||||
function parseJournal(value) {
|
||||
const object = plainObject(value, 'update journal')
|
||||
exactKeys(object, [
|
||||
'error', 'from', 'mode', 'phase', 'probationUntil', 'receiptTokens', 'schema',
|
||||
'snapshotId', 'to', 'transactionId', 'updatedAt',
|
||||
], 'update journal')
|
||||
if (object.schema !== 1) throw new TrustError('update journal schema must be 1')
|
||||
if (typeof object.transactionId !== 'string' || !/^[A-Za-z0-9-]{1,128}$/.test(object.transactionId)) {
|
||||
throw new TrustError('update journal transaction ID is invalid')
|
||||
}
|
||||
if (!['stable', 'experimental'].includes(object.mode)) throw new TrustError('update journal mode is invalid')
|
||||
if (!PHASES.has(object.phase)) throw new TrustError('update journal phase is invalid')
|
||||
if (!Array.isArray(object.receiptTokens) || object.receiptTokens.some(token => typeof token !== 'string')) {
|
||||
throw new TrustError('update journal receipt tokens are invalid')
|
||||
}
|
||||
for (const name of ['snapshotId', 'probationUntil', 'error']) {
|
||||
if (object[name] !== null && typeof object[name] !== 'string') throw new TrustError(`update journal ${name} is invalid`)
|
||||
}
|
||||
if (object.probationUntil !== null) isoTimestamp(object.probationUntil, 'update journal probation deadline')
|
||||
isoTimestamp(object.updatedAt, 'update journal update time')
|
||||
return Object.freeze({
|
||||
...object,
|
||||
from: deployment(object.from, 'update journal from', true),
|
||||
to: deployment(object.to, 'update journal to', false),
|
||||
receiptTokens: Object.freeze([...object.receiptTokens]),
|
||||
})
|
||||
}
|
||||
|
||||
async function syncDirectory(path) {
|
||||
const handle = await open(path, 'r')
|
||||
try { await handle.sync() } finally { await handle.close() }
|
||||
}
|
||||
|
||||
export class UpdateJournal {
|
||||
constructor(path, now = () => new Date()) {
|
||||
this.path = path
|
||||
this.now = now
|
||||
this.queue = Promise.resolve()
|
||||
}
|
||||
|
||||
exclusive(operation) {
|
||||
const result = this.queue.then(operation, operation)
|
||||
this.queue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
async read() {
|
||||
try { return parseJournal(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
|
||||
if (error?.code === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
begin(value) {
|
||||
return this.exclusive(async () => {
|
||||
const previous = await this.read()
|
||||
if (previous !== undefined && !['committed', 'rolled-back', 'failed'].includes(previous.phase)) {
|
||||
throw new TrustError('an update transaction journal already exists')
|
||||
}
|
||||
const journal = parseJournal({
|
||||
schema: 1,
|
||||
transactionId: value.transactionId,
|
||||
mode: value.mode,
|
||||
phase: 'planning',
|
||||
from: value.from,
|
||||
to: value.to,
|
||||
snapshotId: null,
|
||||
receiptTokens: [],
|
||||
probationUntil: null,
|
||||
error: null,
|
||||
updatedAt: this.now().toISOString(),
|
||||
})
|
||||
await durableReplace(this.path, `${JSON.stringify(journal)}\n`)
|
||||
return journal
|
||||
})
|
||||
}
|
||||
|
||||
transition(phase, fields = {}) {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
if (current === undefined) throw new TrustError('update transaction journal does not exist')
|
||||
if (!TRANSITIONS.get(current.phase)?.has(phase)) {
|
||||
throw new TrustError(`update journal cannot transition from ${current.phase} to ${phase}`)
|
||||
}
|
||||
const next = parseJournal({ ...current, ...fields, phase, updatedAt: this.now().toISOString() })
|
||||
if (next.transactionId !== current.transactionId || next.mode !== current.mode) {
|
||||
throw new TrustError('update journal identity cannot change')
|
||||
}
|
||||
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
clear() {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
if (current !== undefined && !['committed', 'rolled-back', 'failed'].includes(current.phase)) {
|
||||
throw new TrustError('cannot clear a nonterminal update journal')
|
||||
}
|
||||
await rm(this.path, { force: true })
|
||||
await syncDirectory(dirname(this.path)).catch(error => { if (error?.code !== 'ENOENT') throw error })
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user