171 lines
9.0 KiB
JavaScript
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)
|
|
}
|