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,169 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
|
||||
import { isoTimestamp, plainObject, TrustError } from '../../../../platform/lib/validation.mjs'
|
||||
|
||||
const DEFAULT_STATE = Object.freeze({
|
||||
schema: 1,
|
||||
updateChannel: 'stable',
|
||||
holds: Object.freeze([]),
|
||||
experimentalBlocked: null,
|
||||
})
|
||||
|
||||
function text(value, label) {
|
||||
if (typeof value !== 'string' || value === '' || value.length > 512 || value.includes('\0')) {
|
||||
throw new TrustError(`${label} is invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function parseHold(value) {
|
||||
const hold = plainObject(value, 'update hold')
|
||||
const keys = Object.keys(hold).sort()
|
||||
const expected = ['createdAt', 'dshVersion', 'environmentVersion', 'id', 'reason', 'type']
|
||||
if (JSON.stringify(keys) !== JSON.stringify(expected)) throw new TrustError('update hold has unexpected fields')
|
||||
if (!['version', 'combination'].includes(hold.type)) throw new TrustError('update hold type is invalid')
|
||||
if (hold.type === 'version' && hold.environmentVersion !== null) throw new TrustError('version hold cannot name an Environment')
|
||||
if (hold.type === 'combination' && typeof hold.environmentVersion !== 'string') {
|
||||
throw new TrustError('combination hold requires an Environment')
|
||||
}
|
||||
isoTimestamp(hold.createdAt, 'update hold creation time')
|
||||
return Object.freeze({
|
||||
id: text(hold.id, 'update hold ID'),
|
||||
type: hold.type,
|
||||
dshVersion: text(hold.dshVersion, 'update hold DSH version'),
|
||||
environmentVersion: hold.environmentVersion === null ? null : text(hold.environmentVersion, 'update hold Environment'),
|
||||
reason: text(hold.reason, 'update hold reason'),
|
||||
createdAt: hold.createdAt,
|
||||
})
|
||||
}
|
||||
|
||||
function parseState(value) {
|
||||
const state = plainObject(value, 'channel state')
|
||||
const keys = Object.keys(state).sort()
|
||||
if (JSON.stringify(keys) !== JSON.stringify(['experimentalBlocked', 'holds', 'schema', 'updateChannel'])) {
|
||||
throw new TrustError('channel state has unexpected fields')
|
||||
}
|
||||
if (state.schema !== 1) throw new TrustError('channel state schema must be 1')
|
||||
if (!['stable', 'experimental'].includes(state.updateChannel)) throw new TrustError('update channel is invalid')
|
||||
if (!Array.isArray(state.holds)) throw new TrustError('channel holds must be an array')
|
||||
const holds = state.holds.map(parseHold)
|
||||
if (new Set(holds.map(hold => hold.id)).size !== holds.length) throw new TrustError('update hold IDs must be unique')
|
||||
const blocked = state.experimentalBlocked === null ? null : parseHold(state.experimentalBlocked)
|
||||
if (blocked !== null && blocked.type !== 'combination') throw new TrustError('Experimental block must identify a combination')
|
||||
return Object.freeze({ schema: 1, updateChannel: state.updateChannel, holds: Object.freeze(holds), experimentalBlocked: blocked })
|
||||
}
|
||||
|
||||
export class ChannelStateStore {
|
||||
constructor(path, now = () => new Date()) {
|
||||
this.path = path
|
||||
this.now = now
|
||||
this.queue = Promise.resolve()
|
||||
}
|
||||
|
||||
exclusive(operation) {
|
||||
const result = this.queue.then(operation, operation)
|
||||
this.queue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
async read() {
|
||||
try { return parseState(JSON.parse(await readFile(this.path, 'utf8'))) } catch (error) {
|
||||
if (error?.code === 'ENOENT') return DEFAULT_STATE
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
write(value) { return durableReplace(this.path, `${JSON.stringify(parseState(value))}\n`) }
|
||||
|
||||
setChannel(updateChannel) {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
const next = parseState({ ...current, updateChannel })
|
||||
await this.write(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
addHold({ type, dshVersion, environmentVersion = null, reason }) {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
const identity = `${type}\0${dshVersion}\0${environmentVersion ?? ''}`
|
||||
const id = createHash('sha256').update(identity).digest('hex').slice(0, 24)
|
||||
const hold = parseHold({
|
||||
id, type, dshVersion, environmentVersion, reason,
|
||||
createdAt: this.now().toISOString(),
|
||||
})
|
||||
const holds = [...current.holds.filter(item => item.id !== id), hold]
|
||||
const next = parseState({ ...current, holds })
|
||||
await this.write(next)
|
||||
return hold
|
||||
})
|
||||
}
|
||||
|
||||
retry(id) {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
const holds = current.holds.filter(hold => hold.id !== id)
|
||||
const clearsBlock = current.experimentalBlocked?.id === id
|
||||
if (holds.length === current.holds.length && !clearsBlock) throw new TrustError('update hold does not exist')
|
||||
const next = parseState({ ...current, holds, experimentalBlocked: clearsBlock ? null : current.experimentalBlocked })
|
||||
await this.write(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
block({ dshVersion, environmentVersion, reason }) {
|
||||
return this.exclusive(async () => {
|
||||
const current = await this.read()
|
||||
const blocked = parseHold({
|
||||
id: `blocked-${randomUUID()}`, type: 'combination', dshVersion, environmentVersion, reason,
|
||||
createdAt: this.now().toISOString(),
|
||||
})
|
||||
const next = parseState({ ...current, experimentalBlocked: blocked })
|
||||
await this.write(next)
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function held(local, dshVersion, environmentVersion) {
|
||||
return local.holds.find(hold => (
|
||||
hold.dshVersion === dshVersion
|
||||
&& (hold.type === 'version' || hold.environmentVersion === environmentVersion)
|
||||
))
|
||||
}
|
||||
|
||||
export function planDesiredState({ local, current, supported, stableTargetSequence, upstream = null }) {
|
||||
const aheadOfStable = compareDshVersions(current.dsh, supported.dsh) > 0
|
||||
const stableIdentityMismatch = Number.isSafeInteger(stableTargetSequence) && (
|
||||
current.targetSequence !== stableTargetSequence
|
||||
|| (local.updateChannel === 'stable'
|
||||
? current.authority !== 'stable'
|
||||
: !['stable', 'experimental'].includes(current.authority))
|
||||
)
|
||||
const base = {
|
||||
updateChannel: local.updateChannel,
|
||||
current,
|
||||
supported,
|
||||
upstream,
|
||||
aheadOfStable,
|
||||
experimentalBlocked: local.experimentalBlocked,
|
||||
holds: local.holds,
|
||||
}
|
||||
if (aheadOfStable) return Object.freeze({ ...base, action: 'frozen', reason: 'Stable has not caught up with the current DSH' })
|
||||
if (compareDshVersions(current.dsh, supported.dsh) < 0 || current.environment !== supported.environment || stableIdentityMismatch) {
|
||||
return Object.freeze({ ...base, action: 'stable', reason: 'converge the signed Stable target before Experimental DSH' })
|
||||
}
|
||||
if (local.updateChannel === 'stable' || upstream === null) {
|
||||
return Object.freeze({ ...base, action: 'none', reason: null })
|
||||
}
|
||||
const matchingHold = held(local, upstream.version, supported.environment)
|
||||
if (matchingHold !== undefined) return Object.freeze({ ...base, action: 'held', reason: matchingHold.reason, hold: matchingHold })
|
||||
if (
|
||||
local.experimentalBlocked?.dshVersion === upstream.version
|
||||
&& local.experimentalBlocked.environmentVersion === supported.environment
|
||||
) return Object.freeze({ ...base, action: 'blocked', reason: local.experimentalBlocked.reason })
|
||||
return Object.freeze({ ...base, action: 'experimental', reason: null })
|
||||
}
|
||||
Reference in new issue
Block a user