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,206 @@
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { chmod, mkdir, rm } from 'node:fs/promises'
|
||||
import { createServer } from 'node:http'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
const MAX_BODY_BYTES = 4 * 1024
|
||||
|
||||
function exactObject(value, keys, name) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error(`${name} must be an object`)
|
||||
}
|
||||
const actual = Object.keys(value).sort()
|
||||
const expected = [...keys].sort()
|
||||
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
||||
throw new Error(`${name} fields are invalid`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
async function jsonBody(request) {
|
||||
const chunks = []
|
||||
let size = 0
|
||||
for await (const chunk of request) {
|
||||
size += chunk.byteLength
|
||||
if (size > MAX_BODY_BYTES) throw new Error('lifecycle request body is too large')
|
||||
chunks.push(chunk)
|
||||
}
|
||||
if (size === 0) throw new Error('lifecycle request body is required')
|
||||
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||
}
|
||||
|
||||
function send(response, status, value) {
|
||||
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
||||
response.end(`${JSON.stringify(value)}\n`)
|
||||
}
|
||||
|
||||
function matchesSecret(actual, expected) {
|
||||
if (typeof actual !== 'string' || typeof expected !== 'string') return false
|
||||
const left = Buffer.from(actual)
|
||||
const right = Buffer.from(expected)
|
||||
return left.byteLength === right.byteLength && timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
export class DshLifecycleBroker {
|
||||
constructor({ shouldTerminate = async () => false, report = async () => {} } = {}) {
|
||||
this.shouldTerminate = shouldTerminate
|
||||
this.report = report
|
||||
this.launch = null
|
||||
this.session = null
|
||||
this.published = false
|
||||
this.shuttingDown = false
|
||||
}
|
||||
|
||||
record(message, fields = {}) {
|
||||
return Promise.resolve().then(() => this.report(message, fields)).catch(() => {})
|
||||
}
|
||||
|
||||
prepareLaunch(componentId) {
|
||||
if (componentId !== 'dsh-runtime') return Object.freeze({ environment: Object.freeze({}), release: () => {} })
|
||||
const launch = Object.freeze({ id: randomUUID(), token: randomBytes(32).toString('base64url') })
|
||||
this.launch = launch
|
||||
this.session = null
|
||||
this.published = false
|
||||
void this.record('dsh.launch.authorized')
|
||||
return Object.freeze({
|
||||
environment: Object.freeze({ DSH_PLATFORM_LAUNCH_TOKEN: launch.token }),
|
||||
release: () => this.release(launch.id),
|
||||
})
|
||||
}
|
||||
|
||||
release(launchId) {
|
||||
if (this.launch?.id !== launchId) return
|
||||
this.launch = null
|
||||
this.session = null
|
||||
void this.record('dsh.launch.released')
|
||||
}
|
||||
|
||||
claim(launchToken) {
|
||||
if (this.launch === null || this.session !== null || !matchesSecret(launchToken, this.launch.token)) {
|
||||
throw Object.assign(new Error('DSH launch token is invalid or already consumed'), { statusCode: 409 })
|
||||
}
|
||||
const sessionId = randomUUID()
|
||||
this.session = Object.freeze({ id: sessionId, launchId: this.launch.id, ready: false, verified: false })
|
||||
this.launch = Object.freeze({ ...this.launch, token: null })
|
||||
void this.record('dsh.launch.claimed')
|
||||
return Object.freeze({ sessionId })
|
||||
}
|
||||
|
||||
ready(sessionId, readyUrl = null) {
|
||||
if (this.session === null || !matchesSecret(sessionId, this.session.id)) {
|
||||
throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 })
|
||||
}
|
||||
if (readyUrl !== null) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = new URL(readyUrl)
|
||||
} catch {
|
||||
throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 })
|
||||
}
|
||||
if (parsed.protocol !== 'http:' || parsed.hostname !== '127.0.0.1'
|
||||
|| parsed.pathname !== '/' || parsed.username !== '' || parsed.password !== ''
|
||||
|| parsed.hash !== '' || parsed.searchParams.getAll('token').length !== 1) {
|
||||
throw Object.assign(new Error('DSH Web readiness URL is invalid'), { statusCode: 400 })
|
||||
}
|
||||
}
|
||||
if (!this.session.ready) {
|
||||
this.session = Object.freeze({ ...this.session, ready: true, readyUrl })
|
||||
void this.record('dsh.launch.ready')
|
||||
}
|
||||
return Object.freeze({ ready: true })
|
||||
}
|
||||
|
||||
verify() {
|
||||
if (this.session?.ready !== true) throw new Error('DSH lifecycle is not ready for verification')
|
||||
if (!this.session.verified) {
|
||||
this.session = Object.freeze({ ...this.session, verified: true })
|
||||
return this.record('dsh.launch.verified')
|
||||
}
|
||||
}
|
||||
|
||||
publish() {
|
||||
if (this.session?.verified !== true) throw new Error('DSH lifecycle is not verified for publication')
|
||||
if (!this.published) {
|
||||
this.published = true
|
||||
return this.record('platform.public-ready')
|
||||
}
|
||||
}
|
||||
|
||||
readiness() {
|
||||
const ready = this.session?.ready === true
|
||||
const verified = this.session?.verified === true
|
||||
return Object.freeze({
|
||||
generation: this.session?.id ?? null,
|
||||
publicReady: ready && verified && this.published,
|
||||
ready,
|
||||
readyUrl: ready ? this.session.readyUrl ?? null : null,
|
||||
verified,
|
||||
})
|
||||
}
|
||||
|
||||
async signal(sessionId, signal) {
|
||||
if (signal !== 'SIGTERM') throw Object.assign(new Error('lifecycle signal is invalid'), { statusCode: 400 })
|
||||
if (this.session === null || !matchesSecret(sessionId, this.session.id)) {
|
||||
throw Object.assign(new Error('DSH lifecycle session is invalid'), { statusCode: 409 })
|
||||
}
|
||||
const disposition = this.session.ready !== true || this.shuttingDown || await this.shouldTerminate()
|
||||
? 'terminate'
|
||||
: 'request-restart'
|
||||
await this.record('dsh.signal.disposition', { disposition, signal })
|
||||
return Object.freeze({ disposition })
|
||||
}
|
||||
|
||||
beginShutdown() {
|
||||
this.published = false
|
||||
this.shuttingDown = true
|
||||
}
|
||||
}
|
||||
|
||||
export function createDshLifecycleServer(broker) {
|
||||
return createServer(async (request, response) => {
|
||||
let pathname = 'invalid-url'
|
||||
try {
|
||||
pathname = new URL(request.url ?? '/', 'http://dsh-lifecycle.internal').pathname
|
||||
if (request.method !== 'POST') {
|
||||
send(response, 404, { error: 'not found' })
|
||||
return
|
||||
}
|
||||
if (pathname === '/v1/runtime/claim') {
|
||||
const body = exactObject(await jsonBody(request), ['launchToken'], 'claim request')
|
||||
send(response, 200, broker.claim(body.launchToken))
|
||||
} else if (pathname === '/v1/runtime/ready') {
|
||||
const value = await jsonBody(request)
|
||||
const body = Object.hasOwn(value, 'readyUrl')
|
||||
? exactObject(value, ['readyUrl', 'sessionId'], 'ready request')
|
||||
: exactObject(value, ['sessionId'], 'ready request')
|
||||
send(response, 200, broker.ready(body.sessionId, body.readyUrl ?? null))
|
||||
} else if (pathname === '/v1/runtime/readiness') {
|
||||
exactObject(await jsonBody(request), [], 'readiness request')
|
||||
send(response, 200, broker.readiness())
|
||||
} else if (pathname === '/v1/runtime/signal') {
|
||||
const body = exactObject(await jsonBody(request), ['sessionId', 'signal'], 'signal request')
|
||||
send(response, 200, await broker.signal(body.sessionId, body.signal))
|
||||
} else send(response, 404, { error: 'not found' })
|
||||
} catch (error) {
|
||||
await broker.record('dsh.lifecycle-request.failed', {
|
||||
error,
|
||||
method: request.method ?? null,
|
||||
pathname,
|
||||
level: error?.statusCode === 409 ? 'warning' : 'error',
|
||||
})
|
||||
send(response, Number.isInteger(error?.statusCode) ? error.statusCode : 400, {
|
||||
error: error instanceof Error ? error.message : 'invalid lifecycle request',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function listenDshLifecycle(server, socketPath) {
|
||||
await mkdir(dirname(socketPath), { recursive: true })
|
||||
await rm(socketPath, { force: true })
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(socketPath, resolve)
|
||||
})
|
||||
await chmod(socketPath, 0o600)
|
||||
}
|
||||
Reference in new issue
Block a user