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 { readFile } from 'node:fs/promises'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { compareDshVersions } from '../../../../platform/lib/supported-target.mjs'
|
||||
|
||||
export const AUTOMATIC_CHECK_INTERVALS = Object.freeze([3_600, 10_800, 21_600, 43_200, 86_400])
|
||||
|
||||
const DEFAULT_STATE = Object.freeze({
|
||||
schema: 1,
|
||||
automaticCheck: Object.freeze({
|
||||
enabled: true,
|
||||
intervalSeconds: 21_600,
|
||||
notificationsEnabled: true,
|
||||
}),
|
||||
latestAutomatic: Object.freeze({ stable: null, upstream: null }),
|
||||
})
|
||||
|
||||
function exactKeys(value, expected, label) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label} must be an object`)
|
||||
const keys = Object.keys(value).sort()
|
||||
const wanted = [...expected].sort()
|
||||
if (keys.length !== wanted.length || keys.some((key, index) => key !== wanted[index])) {
|
||||
throw new Error(`${label} fields are invalid`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(value) {
|
||||
exactKeys(value, ['enabled', 'intervalSeconds', 'notificationsEnabled'], 'automatic check config')
|
||||
if (typeof value.enabled !== 'boolean') throw new Error('automatic check enabled must be boolean')
|
||||
if (!AUTOMATIC_CHECK_INTERVALS.includes(value.intervalSeconds)) throw new Error('automatic check interval is invalid')
|
||||
if (typeof value.notificationsEnabled !== 'boolean') throw new Error('update notifications enabled must be boolean')
|
||||
return Object.freeze({ ...value })
|
||||
}
|
||||
|
||||
function parseStable(value) {
|
||||
if (value === null) return null
|
||||
exactKeys(value, ['checkedAt', 'dsh', 'environment', 'targetSequence'], 'Stable notification')
|
||||
if (!Number.isSafeInteger(value.targetSequence) || value.targetSequence < 1) throw new Error('Stable notification sequence is invalid')
|
||||
compareDshVersions(value.dsh, value.dsh)
|
||||
if (typeof value.environment !== 'string' || value.environment.length === 0) throw new Error('Stable notification Environment is invalid')
|
||||
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Stable notification timestamp is invalid')
|
||||
return Object.freeze({ ...value })
|
||||
}
|
||||
|
||||
function parseUpstream(value) {
|
||||
if (value === null) return null
|
||||
exactKeys(value, ['checkedAt', 'version'], 'Upstream notification')
|
||||
compareDshVersions(value.version, value.version)
|
||||
if (Number.isNaN(Date.parse(value.checkedAt))) throw new Error('Upstream notification timestamp is invalid')
|
||||
return Object.freeze({ ...value })
|
||||
}
|
||||
|
||||
function parseState(value) {
|
||||
exactKeys(value, ['automaticCheck', 'latestAutomatic', 'schema'], 'automatic check state')
|
||||
if (value.schema !== 1) throw new Error('automatic check state schema must be 1')
|
||||
exactKeys(value.latestAutomatic, ['stable', 'upstream'], 'automatic notification state')
|
||||
return Object.freeze({
|
||||
schema: 1,
|
||||
automaticCheck: parseConfig(value.automaticCheck),
|
||||
latestAutomatic: Object.freeze({
|
||||
stable: parseStable(value.latestAutomatic.stable),
|
||||
upstream: parseUpstream(value.latestAutomatic.upstream),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
function isHeld(version, environment, holds) {
|
||||
return holds.some(hold => hold.dshVersion === version
|
||||
&& (hold.type !== 'combination' || hold.environmentVersion === environment))
|
||||
}
|
||||
|
||||
function stableSatisfied(candidate, current) {
|
||||
return current.environment === candidate.environment && compareDshVersions(current.dsh, candidate.dsh) >= 0
|
||||
}
|
||||
|
||||
export class AutomaticCheckStateStore {
|
||||
constructor(path, now = () => new Date()) {
|
||||
this.path = path
|
||||
this.now = now
|
||||
this.pending = Promise.resolve()
|
||||
}
|
||||
|
||||
async read() {
|
||||
try {
|
||||
return parseState(JSON.parse(await readFile(this.path, 'utf8')))
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return DEFAULT_STATE
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async replace(update) {
|
||||
const operation = this.pending.then(async () => {
|
||||
const next = parseState(await update(await this.read()))
|
||||
await durableReplace(this.path, `${JSON.stringify(next)}\n`)
|
||||
return next
|
||||
})
|
||||
this.pending = operation.catch(() => {})
|
||||
return operation
|
||||
}
|
||||
|
||||
configure(value) {
|
||||
const config = parseConfig(value)
|
||||
return this.replace(previous => ({
|
||||
...previous,
|
||||
automaticCheck: config,
|
||||
latestAutomatic: !config.enabled || !config.notificationsEnabled
|
||||
? { stable: null, upstream: null }
|
||||
: previous.latestAutomatic,
|
||||
}))
|
||||
}
|
||||
|
||||
record({ channel, current, target, upstream, stableAvailable, holds = [] }) {
|
||||
return this.replace(previous => {
|
||||
if (!previous.automaticCheck.enabled || !previous.automaticCheck.notificationsEnabled) return previous
|
||||
const checkedAt = this.now().toISOString()
|
||||
let stable = previous.latestAutomatic.stable
|
||||
let latestUpstream = previous.latestAutomatic.upstream
|
||||
|
||||
if (stable !== null && stableSatisfied(stable, current)) stable = null
|
||||
if (
|
||||
stableAvailable === true
|
||||
&& (stable === null || target.targetSequence > stable.targetSequence)
|
||||
) {
|
||||
stable = {
|
||||
targetSequence: target.targetSequence,
|
||||
dsh: target.desired.dsh.version,
|
||||
environment: target.desired.environment.version,
|
||||
checkedAt,
|
||||
}
|
||||
}
|
||||
|
||||
if (channel !== 'experimental') {
|
||||
latestUpstream = null
|
||||
} else {
|
||||
if (latestUpstream !== null && compareDshVersions(current.dsh, latestUpstream.version) >= 0) latestUpstream = null
|
||||
if (
|
||||
upstream !== null
|
||||
&& compareDshVersions(upstream.version, current.dsh) > 0
|
||||
&& !isHeld(upstream.version, target.desired.environment.version, holds)
|
||||
&& (latestUpstream === null || compareDshVersions(upstream.version, latestUpstream.version) > 0)
|
||||
) latestUpstream = { version: upstream.version, checkedAt }
|
||||
}
|
||||
|
||||
return { ...previous, latestAutomatic: { stable, upstream: latestUpstream } }
|
||||
})
|
||||
}
|
||||
|
||||
clearSatisfied(current) {
|
||||
return this.replace(previous => ({
|
||||
...previous,
|
||||
latestAutomatic: {
|
||||
stable: previous.latestAutomatic.stable !== null
|
||||
&& stableSatisfied(previous.latestAutomatic.stable, current)
|
||||
? null
|
||||
: previous.latestAutomatic.stable,
|
||||
upstream: previous.latestAutomatic.upstream !== null
|
||||
&& compareDshVersions(current.dsh, previous.latestAutomatic.upstream.version) >= 0
|
||||
? null
|
||||
: previous.latestAutomatic.upstream,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
clearUpstream() {
|
||||
return this.replace(previous => ({
|
||||
...previous,
|
||||
latestAutomatic: { ...previous.latestAutomatic, upstream: null },
|
||||
}))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user