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,201 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { request } from 'node:http'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const RUN_ROOT = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'
|
||||
|
||||
function fetchResponse(host, port, path, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request({ hostname: host, port, path, ...options }, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.once('end', () => {
|
||||
resolve({
|
||||
body: Buffer.concat(chunks),
|
||||
headers: response.headers,
|
||||
status: response.statusCode,
|
||||
})
|
||||
})
|
||||
})
|
||||
outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH Web request timed out')))
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(options.body)
|
||||
})
|
||||
}
|
||||
|
||||
async function fetch(host, port, path, options = {}) {
|
||||
const response = await fetchResponse(host, port, path, options)
|
||||
if (response.status !== 200) throw new Error(`${path} returned HTTP ${String(response.status)}`)
|
||||
return response.body
|
||||
}
|
||||
|
||||
function delay(milliseconds) {
|
||||
return new Promise(resolve => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
function lifecycleReadiness() {
|
||||
const body = Buffer.from('{}')
|
||||
return new Promise((resolve, reject) => {
|
||||
const outgoing = request({
|
||||
socketPath: `${RUN_ROOT}/dsh-lifecycle.sock`,
|
||||
path: '/v1/runtime/readiness',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-length': body.byteLength,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}, response => {
|
||||
const chunks = []
|
||||
response.on('data', chunk => chunks.push(chunk))
|
||||
response.once('end', () => {
|
||||
if (response.statusCode !== 200) {
|
||||
reject(new Error(`DSH lifecycle readiness returned HTTP ${String(response.statusCode)}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
resolve({
|
||||
generation: typeof value?.generation === 'string' ? value.generation : null,
|
||||
ready: value?.ready === true,
|
||||
readyUrl: typeof value?.readyUrl === 'string' ? value.readyUrl : null,
|
||||
})
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
outgoing.setTimeout(2_000, () => outgoing.destroy(new Error('DSH lifecycle readiness timed out')))
|
||||
outgoing.once('error', reject)
|
||||
outgoing.end(body)
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForManagedReady(timeoutMs = 20_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
const readiness = await lifecycleReadiness()
|
||||
if (readiness.ready) return readiness
|
||||
await delay(100)
|
||||
}
|
||||
throw new Error('DSH managed Web boot did not complete')
|
||||
}
|
||||
|
||||
async function authenticationHeaders(host, port, readyUrl) {
|
||||
if (readyUrl === null) return {}
|
||||
const url = new URL(readyUrl)
|
||||
if (url.protocol !== 'http:' || url.hostname !== host || Number(url.port) !== port
|
||||
|| url.pathname !== '/' || url.username !== '' || url.password !== '' || url.hash !== ''
|
||||
|| url.searchParams.getAll('token').length !== 1) {
|
||||
throw new Error('DSH Web readiness URL is invalid')
|
||||
}
|
||||
const exchange = await fetchResponse(host, port, `${url.pathname}${url.search}`, {
|
||||
headers: { 'cache-control': 'no-store' },
|
||||
})
|
||||
const cookie = exchange.headers['set-cookie']?.[0]?.split(';', 1)[0]
|
||||
// dsh 0.2.x redirects to './' instead of '/'
|
||||
if (exchange.status !== 303 || !['/', './'].includes(exchange.headers.location) || typeof cookie !== 'string' || cookie === '') {
|
||||
throw new Error('DSH Web authentication exchange failed')
|
||||
}
|
||||
return { cookie }
|
||||
}
|
||||
|
||||
async function verifyBootManifest(host, port, headers) {
|
||||
const html = (await fetch(host, port, '/', { headers })).toString('utf8')
|
||||
const match = /(?:window\.__DSH_BOOT__|globalThis\["__DSH_BOOT__"\])\s*=\s*(\{.*?\})<\/script>/s.exec(html)
|
||||
if (match === null) throw new Error('DSH boot manifest is unavailable')
|
||||
const manifest = JSON.parse(match[1])
|
||||
if (!Array.isArray(manifest.entries)) throw new Error('DSH boot manifest entries are invalid')
|
||||
if (manifest.batches !== undefined && !Array.isArray(manifest.batches)) {
|
||||
throw new Error('DSH boot manifest batches are invalid')
|
||||
}
|
||||
const resources = [
|
||||
...manifest.entries.map(entry => entry?.url),
|
||||
...(manifest.batches ?? []).map(batch => batch?.url),
|
||||
]
|
||||
await Promise.all([...new Set(resources)].map(async resource => {
|
||||
// dsh 0.2.x emits plugin URLs relative to the root ('plugins/…'), 0.1.x absolute ('/plugins/…')
|
||||
const url = typeof resource === 'string' ? `/${resource.replace(/^\/+/, '')}` : resource
|
||||
if (typeof resource !== 'string' || !url.startsWith('/plugins/')) {
|
||||
throw new Error('DSH boot manifest contains an invalid Plugin URL')
|
||||
}
|
||||
await fetch(host, port, url, { headers })
|
||||
}))
|
||||
return JSON.stringify({
|
||||
batches: manifest.batches ?? [],
|
||||
entries: manifest.entries,
|
||||
rev: manifest.rev ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
async function verifyPluginInventory(host, port, headers) {
|
||||
const body = JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId: 'dsh-platform-readiness',
|
||||
method: 'pluginInventory/list',
|
||||
payload: { args: {} },
|
||||
})
|
||||
const response = JSON.parse((await fetch(host, port, '/api/pluginInventory/list', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'content-length': Buffer.byteLength(body),
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body,
|
||||
})).toString('utf8'))
|
||||
if (response?.rpcId !== 'dsh-platform-readiness' || response?.result?.ok !== true) {
|
||||
const message = response?.result?.error?.message
|
||||
throw new Error(`DSH Plugin inventory is unavailable${typeof message === 'string' ? `: ${message}` : ''}`)
|
||||
}
|
||||
const entries = response.result.value?.entries
|
||||
if (!Array.isArray(entries)) throw new Error('DSH Plugin inventory entries are invalid')
|
||||
const unhealthy = entries.filter(entry => entry?.enabled === true && entry?.fiberPhase !== 'active')
|
||||
if (unhealthy.length > 0) {
|
||||
const detail = unhealthy.map(entry => `${String(entry?.moduleName ?? entry?.entryId ?? 'unknown')} (${String(entry?.fiberPhase)})`).join(', ')
|
||||
throw new Error(`DSH Plugins are not active: ${detail}`)
|
||||
}
|
||||
return JSON.stringify(entries.map(entry => ({
|
||||
enabled: entry?.enabled === true,
|
||||
entryId: entry?.entryId ?? null,
|
||||
fiberPhase: entry?.fiberPhase ?? null,
|
||||
moduleName: entry?.moduleName ?? null,
|
||||
})).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right))))
|
||||
}
|
||||
|
||||
function sameLifecycle(left, right) {
|
||||
return left?.generation === right?.generation && left?.readyUrl === right?.readyUrl
|
||||
}
|
||||
|
||||
export async function verifyDshWebReady({
|
||||
host = '127.0.0.1',
|
||||
port = 3079,
|
||||
managedReady = waitForManagedReady,
|
||||
} = {}) {
|
||||
const firstReadiness = await managedReady()
|
||||
const headers = await authenticationHeaders(host, port, firstReadiness?.readyUrl ?? null)
|
||||
const firstManifest = await verifyBootManifest(host, port, headers)
|
||||
const firstInventory = await verifyPluginInventory(host, port, headers)
|
||||
const middleReadiness = await managedReady()
|
||||
if (!sameLifecycle(firstReadiness, middleReadiness)) {
|
||||
throw new Error('DSH lifecycle changed during Web readiness verification')
|
||||
}
|
||||
const secondManifest = await verifyBootManifest(host, port, headers)
|
||||
const secondInventory = await verifyPluginInventory(host, port, headers)
|
||||
const finalReadiness = await managedReady()
|
||||
if (!sameLifecycle(firstReadiness, finalReadiness)) {
|
||||
throw new Error('DSH lifecycle changed during Web readiness verification')
|
||||
}
|
||||
if (firstManifest !== secondManifest) {
|
||||
throw new Error('DSH boot manifest changed during Web readiness verification')
|
||||
}
|
||||
if (firstInventory !== secondInventory) {
|
||||
throw new Error('DSH Plugin inventory changed during Web readiness verification')
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined
|
||||
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
await verifyDshWebReady()
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"id": "platform-recovery",
|
||||
"type": "oneshot",
|
||||
"command": {
|
||||
"executable": "/usr/local/bin/node",
|
||||
"args": ["/run/dsh-platform/views/bootstrap/control-plane/hooks/recovery/index.mjs"],
|
||||
"timeoutSeconds": 120
|
||||
},
|
||||
"environment": {},
|
||||
"lifecycle": {
|
||||
"prepare": null,
|
||||
"preStart": null,
|
||||
"postStart": null,
|
||||
"preStop": null,
|
||||
"stop": null,
|
||||
"postStop": null
|
||||
},
|
||||
"health": null,
|
||||
"logging": { "stdout": true, "stderr": true }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { PlatformActivator } from '../../modules/updater/lib/activator.mjs'
|
||||
import { UpdateJournal } from '../../modules/updater/lib/journal.mjs'
|
||||
import { recoverInterruptedUpdate } from '../../modules/updater/lib/recovery.mjs'
|
||||
import { PersistentStateSnapshots } from '../../modules/updater/lib/snapshots.mjs'
|
||||
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
|
||||
|
||||
export async function recoverPlatformUpdateBeforeDshStart({
|
||||
dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform',
|
||||
runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
|
||||
dshHome = process.env.DSH_HOME ?? '/data/dsh',
|
||||
} = {}) {
|
||||
const paths = new PlatformPaths(dataRoot, runRoot)
|
||||
return recoverInterruptedUpdate({
|
||||
journal: new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json')),
|
||||
snapshots: new PersistentStateSnapshots({ root: paths.snapshotsRoot, sourceRoot: dshHome }),
|
||||
activator: new PlatformActivator({ dataRoot, runRoot }),
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined
|
||||
&& realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
await recoverPlatformUpdateBeforeDshStart()
|
||||
}
|
||||
Reference in new issue
Block a user