207 lines
7.6 KiB
JavaScript
207 lines
7.6 KiB
JavaScript
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)
|
|
}
|