Files
dsh/upstream/container/platform/bootstrap/lib/control.mjs
T

171 lines
9.0 KiB
JavaScript

import { createServer } from 'node:http'
import { chmod, mkdir, rm } from 'node:fs/promises'
import { dirname } from 'node:path'
import { PlatformGarbageCollector } from './gc.mjs'
async function jsonBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.byteLength
if (size > 256 * 1024) throw new Error('Bootstrap request body is too large')
chunks.push(chunk)
}
return size === 0 ? {} : 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`)
}
export function createBootstrapControl(runner, { deployments, trust, systemPlugins, systemSkills, snapshots } = {}) {
return createServer(async (request, response) => {
let pathname = 'invalid-url'
try {
pathname = new URL(request.url ?? '/', 'http://bootstrap.internal').pathname
if (request.method === 'GET' && pathname === '/v1/status') send(response, 200, {
...runner.status(),
bootstrapVersion: process.env.DSH_BOOTSTRAP_VERSION ?? '1.0.0',
})
else if (request.method === 'POST' && pathname === '/v1/reload') send(response, 200, await runner.reload())
else if (request.method === 'GET' && pathname === '/v1/health') send(response, 200, await runner.health())
else if (request.method === 'GET' && pathname === '/v1/system-plugins' && systemPlugins !== undefined) {
send(response, 200, { plugins: await systemPlugins.list() })
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/action' && systemPlugins !== undefined) {
const body = await jsonBody(request)
send(response, 200, { plugins: await systemPlugins.configure(body.id, body.action) })
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/recovery-action' && systemPlugins !== undefined) {
const body = await jsonBody(request)
send(response, 200, { plugins: await systemPlugins.recover(body.id, body.action) })
} else if (request.method === 'POST' && pathname === '/v1/system-plugins/discard' && systemPlugins !== undefined) {
send(response, 200, { plugins: await systemPlugins.discard() })
} else if (request.method === 'GET' && pathname === '/v1/system-skills' && systemSkills !== undefined) {
send(response, 200, { skills: await systemSkills.list() })
} else if (request.method === 'POST' && pathname === '/v1/system-skills/action' && systemSkills !== undefined) {
const body = await jsonBody(request)
send(response, 200, { skills: await systemSkills.configure(body.skillId, body.action) })
}
else if (request.method === 'GET' && pathname === '/v1/deployments/current' && deployments !== undefined) {
const state = await deployments.state()
send(response, 200, { record: state.current === null ? null : await deployments.record(state.current) })
} else if (request.method === 'POST' && pathname === '/v1/deployments/activate' && deployments !== undefined && trust !== undefined) {
const body = await jsonBody(request)
const slots = await deployments.activateManaged(body.record, {
healthCheck: () => runner.reload(),
activateReceipts: tokens => trust.activate(tokens),
})
send(response, 200, { slots })
} else if (request.method === 'POST' && pathname === '/v1/deployments/rollback' && deployments !== undefined && trust !== undefined) {
const body = await jsonBody(request)
const state = await deployments.state()
if (state.previous === null) throw new Error('no previous Deployment exists')
if (body.recordId !== undefined && body.recordId !== state.previous) throw new Error('requested Deployment is not previous')
const slots = await deployments.rollback({
healthCheck: () => runner.reload(),
activateReceipts: tokens => trust.activate(tokens),
})
send(response, 200, { slots })
} else if (request.method === 'GET' && pathname === '/v1/deployments/rollback-plan' && deployments !== undefined) {
send(response, 200, await deployments.rollbackPlan())
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate' && deployments !== undefined) {
const body = await jsonBody(request)
const record = await deployments.stageCandidate(body.record, () => runner.reload())
send(response, 200, { recordId: record.id })
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/commit' && deployments !== undefined && trust !== undefined) {
const body = await jsonBody(request)
const slots = await deployments.commitCandidate(body.recordId, tokens => trust.activate(tokens))
send(response, 200, { slots })
} else if (request.method === 'POST' && pathname === '/v1/deployments/candidate/cancel' && deployments !== undefined) {
send(response, 200, await deployments.cancelCandidate())
} else if (request.method === 'POST' && pathname === '/v1/platform/gc' && deployments !== undefined) {
send(response, 200, await new PlatformGarbageCollector({ paths: deployments.paths, deployments, snapshots }).collect())
} else if (request.method === 'POST' && pathname === '/v1/deployments/runtime/reset' && deployments !== undefined) {
send(response, 200, await deployments.resetCurrentRuntime({
pauseDsh: () => runner.pause('dsh-runtime'),
restartDsh: () => runner.restart('dsh-runtime'),
}))
}
else {
const operation = /^\/v1\/components\/([a-z0-9][a-z0-9._-]{0,127})\/(pause|suspend|resume|restart)$/.exec(pathname)
if (request.method === 'POST' && operation !== null) {
const [componentId, action] = operation.slice(1)
if (componentId === 'dsh-runtime' && action === 'resume' && deployments !== undefined) {
const status = await deployments.exclusive(async () => {
await deployments.setOperation('starting')
try {
const value = await runner.resume(componentId)
await deployments.publishStatus()
return value
} catch (error) {
await deployments.publishStatus({
recoveryMode: runner.status().recoveryMode
?? (error instanceof Error ? error.message : String(error)),
})
throw error
}
})
send(response, 200, status)
} else if (componentId === 'dsh-runtime' && action === 'restart' && deployments !== undefined) {
const status = await deployments.exclusive(async () => {
await deployments.setOperation('restarting')
let transaction
try {
transaction = await systemPlugins?.prepare?.()
const value = await runner.restart(componentId, {
skipPreparation: true,
recoverStart: true,
beforeStart: () => transaction?.activate?.(),
onStartFailure: () => transaction?.rollback?.(),
})
if (transaction?.commit !== undefined) {
try {
await transaction.commit()
} catch (error) {
await runner.record?.('system-plugin.view.commit.failed', { error, level: 'warning' })
}
}
await deployments.publishStatus()
return value
} catch (error) {
try {
await deployments.publishStatus({
operation: 'restart-failed',
recoveryMode: runner.status().recoveryMode
?? (error instanceof Error ? error.message : String(error)),
})
} catch (statusError) {
await runner.record?.('deployment.status.failed', {
error: statusError,
level: 'warning',
originalError: error instanceof Error ? error.message : String(error),
})
}
throw error
}
})
send(response, 200, status)
} else send(response, 200, await runner[action](componentId))
} else send(response, 404, { error: 'not found' })
}
} catch (error) {
await runner.record?.('bootstrap.request.failed', {
error,
method: request.method ?? null,
pathname,
})
send(response, 500, { error: error instanceof Error ? error.message : 'Bootstrap operation failed' })
}
})
}
export async function listenBootstrapControl(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)
}