Files
dsh/upstream/container/control-plane/services/management/index.mjs
T

438 lines
20 KiB
JavaScript

#!/usr/bin/env node
import { readFile } from 'node:fs/promises'
import { randomBytes } from 'node:crypto'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { JsonlLogManager } from '../../modules/log-manager/index.mjs'
import { createManagementServer, listenManagement } from './server.mjs'
import { LocalApiClient } from '../../modules/updater/lib/client.mjs'
import { consumeInternalCapability } from '../../../platform/lib/access-capability.mjs'
import { MetadataClient, NpmRegistryClient } from '../../modules/updater/lib/metadata.mjs'
import { TargetPreparer } from '../../modules/updater/lib/preparer.mjs'
import { UpdateCoordinator } from '../../modules/updater/lib/coordinator.mjs'
import { UpdateScheduler } from '../../modules/updater/lib/scheduler.mjs'
import { UpdateStateStore } from '../../modules/updater/lib/state.mjs'
import { AutomaticCheckStateStore } from '../../modules/updater/lib/automatic-check.mjs'
import { PlatformActivator } from '../../modules/updater/lib/activator.mjs'
import { UpdateJournal } from '../../modules/updater/lib/journal.mjs'
import { SnapshotClient } from '../../modules/updater/lib/snapshot-client.mjs'
import { reconcileRecoveredState, resumeInterruptedReconcile } from '../../modules/updater/lib/recovery.mjs'
import { ChannelStateStore } from '../../modules/updater/lib/channel-state.mjs'
import { CompleteStateRecovery } from '../../modules/updater/lib/rollback.mjs'
import { PlatformPaths } from '../../../platform/lib/paths.mjs'
import { readDeploymentStatus } from '../../../platform/lib/deployment-status.mjs'
import { parseImageInventory } from '../../../platform/lib/deployment-contracts.mjs'
import { SettingsDocumentStore } from './settings-document.mjs'
import { markUserPluginRestartState, UserPluginInventory } from '../../modules/plugin-manager/user-inventory.mjs'
import { UserPluginJournal } from '../../modules/plugin-manager/user-journal.mjs'
import { UserPluginSelectionStore } from '../../modules/plugin-manager/user-state.mjs'
import { UserPluginTransactionManager } from '../../modules/plugin-manager/user-transaction.mjs'
import { UserSkillManager } from './user-skills.mjs'
import { createScopedFetch } from './scoped-fetch.mjs'
import { OutboundProxyControlClient } from './outbound-proxy-client.mjs'
import { ProviderInventory } from './provider-inventory.mjs'
import { DshUpstreamAuthentication } from '../gateway/lib/dsh-upstream-auth.mjs'
import { PROXY_SCOPE_CATALOG } from '../outbound-proxy/lib/scope-catalog.mjs'
import { defaultProxyConfiguration } from '../outbound-proxy/lib/contracts.mjs'
import { requestOrigin } from '../gateway/lib/browser-auth.mjs'
import { createRestrictedCliServer, restrictedCliRoute } from './restricted-cli.mjs'
import qrcode from 'qrcode-generator'
const dataRoot = process.env.DSH_PLATFORM_DATA ?? '/data/platform'
const runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform'
const paths = new PlatformPaths(dataRoot, runRoot)
const seedRoot = process.env.DSH_PLATFORM_SEED ?? '/opt/dsh-platform/seed'
const imageInventory = parseImageInventory(await readFile(join(seedRoot, 'inventory.json')))
const trust = new LocalApiClient(paths.trustSocket)
const bootstrap = new LocalApiClient(paths.bootstrapSocket)
const access = new LocalApiClient(paths.accessSocket)
const snapshotApi = new LocalApiClient(paths.snapshotSocket)
const userSkillApi = new LocalApiClient(paths.userSkillSocket)
const outboundProxy = new OutboundProxyControlClient(paths.proxyControlSocket)
const dshHome = process.env.DSH_HOME ?? '/data/dsh'
const logs = new JsonlLogManager({
root: paths.logsRoot,
maxBytes: Number(process.env.DSH_LOG_MAX_BYTES ?? 104857600),
retentionDays: Number(process.env.DSH_LOG_RETENTION_DAYS ?? 14),
output: { stdout: process.stdout, stderr: process.stderr },
})
const updateFetch = createScopedFetch('updates')
const dshUpstreamAuthentication = new DshUpstreamAuthentication({ socketPath: paths.dshLifecycleSocket })
const providerInventory = new ProviderInventory({
cachePath: paths.proxyProviderInventoryPath,
authentication: dshUpstreamAuthentication,
})
function qrCodeDataUri(value) {
const code = qrcode(0, 'M')
code.addData(value, 'Byte')
code.make()
const svg = code.createSvgTag({ cellSize: 5, margin: 10, scalable: true })
return `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`
}
let lastProxyConfiguration
function unavailableProxyConfiguration() {
const fallback = lastProxyConfiguration ?? {
...defaultProxyConfiguration(),
revision: null,
routeHealth: {},
lastTest: null,
}
return Object.freeze({
...fallback,
componentReady: false,
configurationAvailable: lastProxyConfiguration !== undefined,
scopeCatalog: PROXY_SCOPE_CATALOG,
})
}
function proxySnapshot(view) {
return Object.freeze({
revision: view.revision,
configuration: Object.freeze({
schema: view.schema,
enabled: view.enabled,
proxy: view.proxy,
scopes: view.scopes,
environment: view.environment,
modelApi: view.modelApi,
noProxy: view.noProxy,
bypass: view.bypass,
}),
})
}
const proxyConfiguration = async () => {
try {
lastProxyConfiguration = await outboundProxy.configuration()
return Object.freeze({
...lastProxyConfiguration,
configurationAvailable: true,
scopeCatalog: PROXY_SCOPE_CATALOG,
})
} catch (error) {
if (error?.code === 'PROXY_MANAGER_UNAVAILABLE') return unavailableProxyConfiguration()
throw error
}
}
void proxyConfiguration().catch(error => logs.diagnostic('platform-management', 'proxy.configuration.cache.failed', { error }))
logs.on('error', error => { void logs.diagnostic('log-manager', 'capture.failed', { error }) })
await logs.diagnostic('platform-management', 'management.starting', {
imageBuildId: imageInventory.imageBuildId,
targetSequence: imageInventory.targetSequence,
})
const metadata = new MetadataClient({
baseUrl: process.env.DSH_UPDATE_METADATA_URL,
trust,
fetchImpl: updateFetch,
retryRequestTimeoutMs: 15_000,
})
const preparer = new TargetPreparer({ untrustedRoot: paths.downloadsRoot, trust, fetchImpl: updateFetch })
const activator = new PlatformActivator({ dataRoot, runRoot, stage0: trust })
const journal = new UpdateJournal(join(paths.updaterStateRoot, 'transaction.json'))
const snapshots = new SnapshotClient(snapshotApi, 'dsh')
const completeRecovery = new CompleteStateRecovery({ journal, snapshots, activator })
const automaticChecks = new AutomaticCheckStateStore(join(paths.updaterStateRoot, 'automatic-check.json'))
const coordinator = new UpdateCoordinator({
metadata,
preparer,
activator,
state: new UpdateStateStore(join(paths.updaterStateRoot, 'status.json')),
npm: new NpmRegistryClient({ fetchImpl: updateFetch, retryRequestTimeoutMs: 15_000 }),
journal,
snapshots,
probationSeconds: Number(process.env.DSH_EXPERIMENTAL_PROBATION_SECONDS ?? 120),
channelState: new ChannelStateStore(join(paths.updaterStateRoot, 'channel.json')),
completeRecovery,
automaticChecks,
allowUnavailableMetadata: imageInventory.authority === 'development',
report: (message, fields) => logs.diagnostic('updater', message, fields),
})
const settingsDocument = new SettingsDocumentStore(dshHome)
const listBundledPlugins = async () => (await bootstrap.request('GET', '/v1/system-plugins')).plugins
const listSystemSkills = async () => (await bootstrap.request('GET', '/v1/system-skills')).skills
const userSkills = new UserSkillManager({
dshHome,
agentsHome: process.env.DSH_AGENTS_HOME ?? '/home/node/.agents',
})
const userPluginInventory = new UserPluginInventory({
dshHome,
selectionPath: paths.userPluginStatePath,
systemPluginNames: async () => (await listBundledPlugins()).map(plugin => `@dsh-docker/${plugin.id}`),
})
const userPluginTransactions = new UserPluginTransactionManager({
inventory: userPluginInventory,
selectionStore: new UserPluginSelectionStore(paths.userPluginStatePath),
snapshots: new SnapshotClient(snapshotApi, 'user-plugin'),
journal: new UserPluginJournal(paths.userPluginJournalPath),
pauseDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
report: (message, fields) => logs.diagnostic('user-plugin-manager', message, fields),
})
const initialUserPluginTransaction = await userPluginTransactions.recoverBeforeDshStart()
if (initialUserPluginTransaction !== undefined) {
await logs.diagnostic('user-plugin-manager', 'user-plugin.startup-state.reconciled', {
taskId: initialUserPluginTransaction.taskId,
phase: initialUserPluginTransaction.phase,
recoveryResult: initialUserPluginTransaction.recoveryResult,
})
}
let loadedUserPluginInventory
const markUserPluginsLoaded = async () => {
loadedUserPluginInventory = await userPluginInventory.read()
return loadedUserPluginInventory
}
const listUserPlugins = async () => markUserPluginRestartState(
await userPluginInventory.read(),
loadedUserPluginInventory,
)
const waitForBootstrapStartup = async () => {
for (;;) {
try {
const status = await bootstrap.status()
if (status.startupComplete === true) return status
} catch (error) {
await logs.diagnostic('user-plugin-manager', 'bootstrap.startup-status.retrying', {
error,
level: 'warning',
})
}
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const scheduler = new UpdateScheduler({
check: () => coordinator.check('automatic'),
onError: error => logs.diagnostic('updater', 'update.automatic-check.failed', { error }),
})
let server
const restrictedCliToken = randomBytes(32).toString('base64url')
server = createManagementServer({
coordinator,
logs,
authorizeInternal: async request => {
const token = request.headers['x-dsh-internal-capability']
const audience = request.headers['x-dsh-internal-capability-audience'] === 'plugin' ? 'plugin' : 'management'
const restrictedToken = request.headers['x-dsh-restricted-cli']
if (restrictedToken === restrictedCliToken
&& restrictedCliRoute(request.method ?? 'GET', request.url ?? '/')) return true
if (typeof token !== 'string') return false
if (['/_dsh_platform/api/v1/auth-settings', '/_dsh_platform/api/v1/auth-totp/enrollments', '/_dsh_platform/api/v1/auth-totp/enrollments/confirm', '/_dsh_platform/api/v1/auth-totp/enrollments/cancel', '/_dsh_platform/api/v1/auth-totp/disable-confirmations', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/confirm', '/_dsh_platform/api/v1/auth-totp/disable-confirmations/cancel', '/_dsh_platform/api/v1/management-origin/transitions', '/_dsh_platform/api/v1/management-origin/transitions/commit', '/_dsh_platform/api/v1/management-origin/transitions/failure', '/_dsh_platform/api/v1/auth-sessions/revoke'].includes(request.url)) return true
return consumeInternalCapability(access, {
token, audience, method: request.method ?? 'GET', target: request.url ?? '/',
})
},
createManagementOriginTransition: async (value, request) => {
return access.request('POST', '/v1/management/transitions', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
commitManagementOriginTransition: async (value, request) => {
return access.request('POST', '/v1/management/transitions/commit', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
reportManagementOriginTransitionFailure: async value => {
if (value?.stage !== 'browser-probe' || !['compat', 'isolated'].includes(value?.mode)) {
throw Object.assign(new Error('Management origin transition failure report is invalid'), { statusCode: 400 })
}
await logs.diagnosticRateLimited('management-origin.probe.failed', 'platform-management', 'management-origin.probe.failed', {
level: 'warning', stage: value.stage, mode: value.mode,
})
return { reported: true }
},
getAuthenticationSettings: async request => {
return access.request('POST', '/v1/management/settings', {
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
origin: requestOrigin(request, { requireHeader: false }),
})
},
updateAuthenticationSettings: async (value, request) => {
return access.request('POST', '/v1/management/auth-settings', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
beginTotpEnrollment: async (value, request) => {
const enrollment = await access.request('POST', '/v1/management/totp/enrollments', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
return { ...enrollment, qrCode: qrCodeDataUri(enrollment.uri), uri: undefined }
},
confirmTotpEnrollment: async (value, request) => {
return access.request('POST', '/v1/management/totp/enrollments/confirm', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
cancelTotpEnrollment: async (value, request) => {
return access.request('POST', '/v1/management/totp/enrollments/cancel', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
beginTotpDisableConfirmation: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
confirmTotpDisable: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations/confirm', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
cancelTotpDisableConfirmation: async (value, request) => {
return access.request('POST', '/v1/management/totp/disable-confirmations/cancel', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
revokeAuthenticationSessions: async (value, request) => {
return access.request('POST', '/v1/management/sessions/revoke', {
...value,
internalCapability: request.headers['x-dsh-internal-capability'],
method: request.method,
target: request.url,
})
},
platformStatus: async () => ({
...await readDeploymentStatus(paths.deploymentStatusPath),
trust: await trust.status(),
dshLifecycle: (await bootstrap.status()).dshLifecycle,
}),
startDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/resume'),
stopDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/pause'),
restartDsh: () => bootstrap.request('POST', '/v1/components/dsh-runtime/restart'),
resetRuntime: () => bootstrap.request('POST', '/v1/deployments/runtime/reset'),
listBundledPlugins,
configureBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/action', { id, action }),
recoverBundledPlugin: (id, action) => bootstrap.request('POST', '/v1/system-plugins/recovery-action', { id, action }),
discardBundledPluginChanges: () => bootstrap.request('POST', '/v1/system-plugins/discard'),
listSystemSkills,
configureSystemSkill: (skillId, action) => bootstrap.request('POST', '/v1/system-skills/action', { skillId, action }),
listUserSkills: () => userSkills.list(),
validateUserSkillAction: value => userSkills.validate(value),
configureUserSkill: value => userSkillApi.request('POST', '/v1/action', value),
listUserPlugins,
markUserPluginsLoaded,
validateUserPluginActions: value => userPluginTransactions.validate(value),
applyUserPluginActions: value => userPluginTransactions.apply(value),
initialUserPluginTransaction,
recoverUserPluginTransaction: initialUserPluginTransaction?.phase === 'restarting' ? async () => {
const status = await waitForBootstrapStartup()
try {
const health = await bootstrap.request('GET', '/v1/health')
const dsh = health.components?.find(component => component.id === 'dsh-runtime')
return userPluginTransactions.completeDshStartup({
healthy: status.recoveryMode === null && dsh?.healthy === true,
error: status.recoveryMode,
})
} catch (error) {
return userPluginTransactions.completeDshStartup({
healthy: false,
error: error instanceof Error ? error.message : String(error),
})
}
} : undefined,
settingsDocument,
privilegedMutationActive: () => existsSync(paths.maintenanceLeasePath),
updateAutomaticCheck: async value => {
const state = await automaticChecks.configure(value)
scheduler.configure(state.automaticCheck)
return state.automaticCheck
},
getProxyConfiguration: proxyConfiguration,
updateProxyConfiguration: async value => Object.freeze({
...await outboundProxy.updateConfiguration(value),
scopeCatalog: PROXY_SCOPE_CATALOG,
}),
listProxyProviders: async () => {
const configuration = await proxyConfiguration()
return providerInventory.list(proxySnapshot(configuration))
},
startProxyTest: value => outboundProxy.startTest(value),
getProxyTest: taskId => outboundProxy.test(taskId),
cancelProxyTest: taskId => outboundProxy.cancelTest(taskId),
})
await listenManagement(server, paths.managementSocket)
const restrictedCliServer = createRestrictedCliServer({
managementSocketPath: paths.managementSocket,
token: restrictedCliToken,
})
await listenManagement(restrictedCliServer, paths.managementCliSocket)
void waitForBootstrapStartup()
.then(markUserPluginsLoaded)
.catch(error => logs.diagnostic('user-plugin-manager', 'user-plugin.loaded-state.capture.failed', { error }))
scheduler.configure((await automaticChecks.read()).automaticCheck)
let recovered
try {
recovered = await reconcileRecoveredState({ journal, state: coordinator.state })
} catch (error) {
await logs.diagnostic('updater', 'update.recovery-state.failed', { error })
throw error
}
const { transaction, persisted } = recovered
const journalOwnsState = transaction !== undefined && persisted.taskId === transaction.transactionId
const resumeUpdate = !journalOwnsState && !['idle', 'success', 'failed'].includes(persisted.status)
if (resumeUpdate) {
setImmediate(() => {
try {
resumeInterruptedReconcile({
coordinator,
persisted,
report: (message, fields) => logs.diagnostic('updater', message, fields),
audit: (message, fields) => logs.diagnostic('audit', message, { stream: 'audit', ...fields }),
})
} catch (error) {
void logs.diagnostic('updater', 'update.resume.failed', { error })
}
})
}
await logs.diagnostic('platform-management', 'management.ready', { resumedUpdate: resumeUpdate })
let stopping = false
const stop = signal => {
if (stopping) return
stopping = true
scheduler.stop()
void logs.diagnostic('platform-management', 'management.stopping', { signal }).then(async () => {
await updateFetch.close().catch(error => logs.diagnostic('platform-management', 'proxy.dispatcher.close.failed', { error }))
let closed = 0
const finish = () => {
closed += 1
if (closed === 2) void logs.diagnostic('platform-management', 'management.stopped').finally(() => process.exit(0))
}
restrictedCliServer.close(finish)
server.close(finish)
})
}
process.once('SIGINT', () => stop('SIGINT'))
process.once('SIGTERM', () => stop('SIGTERM'))