Files

230 lines
10 KiB
JavaScript

import { spawn } from 'node:child_process'
import { cp, lstat, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { RuntimeSlots } from '../../patch-manager/index.mjs'
import { PlatformPaths } from '../../../../platform/lib/paths.mjs'
import { LocalApiClient } from './client.mjs'
import { ManagedDeploymentBuilder } from './managed-store.mjs'
import { deriveRecordId, parseDeploymentRecord } from '../../../../platform/lib/deployment-contracts.mjs'
function run(command, args) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] })
const stdout = []
const stderr = []
child.stdout.on('data', chunk => stdout.push(chunk))
child.stderr.on('data', chunk => stderr.push(chunk))
child.once('error', reject)
child.once('exit', code => code === 0
? resolve(Buffer.concat(stdout))
: reject(new Error(Buffer.concat(stderr).toString('utf8'))))
})
}
async function exists(path) {
return lstat(path).then(() => true, error => error?.code === 'ENOENT' ? false : Promise.reject(error))
}
function matchesRecoveryJournal(record, deployment) {
if (record?.id === deployment.runtime) return true
if (record === null || record === undefined) return false
if (!Array.isArray(record.receiptTokens) || !Array.isArray(deployment.receiptTokens)) return false
const sameReceipts = [...record.receiptTokens].sort().join('\0')
=== [...deployment.receiptTokens].sort().join('\0')
return record.dshVersion === deployment.dsh
&& record.environmentVersion === deployment.environment
&& record.snapshotId === deployment.dataSnapshot
&& sameReceipts
}
export class PlatformActivator {
constructor({
dataRoot,
runRoot = process.env.DSH_PLATFORM_RUN ?? '/run/dsh-platform',
bootstrap,
stage0,
builder,
sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)),
}) {
this.dataRoot = dataRoot
this.paths = new PlatformPaths(dataRoot, runRoot)
this.bootstrap = bootstrap ?? new LocalApiClient(this.paths.bootstrapSocket)
this.stage0 = stage0 ?? new LocalApiClient(this.paths.trustSocket)
this.builder = builder ?? new ManagedDeploymentBuilder({ paths: this.paths })
this.sleep = sleep
this.runtimeSlots = new RuntimeSlots(this.paths.runtimesRoot)
this.environmentSlots = new RuntimeSlots(this.paths.environmentsRoot)
this.systemPluginSlots = new RuntimeSlots(this.paths.systemPluginsRoot)
this.experimentalCandidates = new Map()
}
prepareManaged(prepared, options) {
return this.builder.buildStable(prepared, options)
}
async pristine(version, receipt) {
const root = join(this.paths.pristineRoot, version)
if (!await exists(root)) {
const staging = `${root}.${randomUUID()}.tmp`
await mkdir(staging, { recursive: true })
try {
const entries = (await run('tar', ['-tzf', receipt.path])).toString('utf8').split('\n').filter(Boolean)
if (!entries.includes('package/package.json')) throw new Error('official DSH has no package metadata')
if (entries.some(entry => entry.startsWith('/') || !entry.startsWith('package/') || entry.split('/').includes('..'))) {
throw new Error('official DSH archive contains an unsafe path')
}
await run('tar', ['-xzf', receipt.path, '-C', staging])
await rename(staging, root)
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
const packageMetadata = JSON.parse(await readFile(join(root, 'package', 'package.json'), 'utf8'))
if (packageMetadata.name !== '@deepseek-ai/dsh' || packageMetadata.version !== version) {
throw new Error('official DSH package metadata differs from its requested version')
}
return join(root, 'package')
}
async stageEnvironment(prepared) {
const version = prepared.environment.manifest.version
const versions = join(this.paths.environmentsRoot, 'versions')
const destination = join(versions, version)
if (await exists(destination)) return version
const staging = join(versions, `.${version}.${randomUUID()}.tmp`)
await mkdir(join(staging, 'artifacts'), { recursive: true })
try {
await cp(prepared.paths.get(prepared.stable.desired.environment.manifestArtifactId), join(staging, 'environment.manifest.json'))
for (const descriptor of prepared.environment.manifest.artifacts) {
await cp(prepared.paths.get(descriptor.id), join(staging, 'artifacts', descriptor.id))
}
await rename(staging, destination)
return version
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}
}
async activate(prepared, { onProgress = async () => {}, onSwitching = async () => {} } = {}) {
const desiredBootstrap = prepared.stable.desired.bootstrap.version
const artifacts = prepared.bootstrap.manifest.artifacts
if (artifacts.length !== 1) throw new Error('Bootstrap manifest must contain exactly one package')
const receipt = prepared.receipts.get(artifacts[0].id)
if (receipt === undefined) throw new Error('Bootstrap package receipt is required')
const staged = await this.stage0.stageBootstrap(receipt.token, desiredBootstrap)
// Older Stage-0 responses have only status=switching. Preserve that handoff
// contract while allowing content-identical targets to continue in place.
if (staged.restartRequired === true || staged.status === 'switching') await new Promise(() => {})
const managed = await this.prepareManaged(prepared, { onProgress })
await onSwitching()
await this.bootstrap.request('POST', '/v1/deployments/activate', { record: managed.record })
return managed.record
}
async rollback(recordId) {
return this.bootstrap.request('POST', '/v1/deployments/rollback', recordId === undefined ? {} : { recordId })
}
rollbackDeployments() {
return this.bootstrap.request('GET', '/v1/deployments/rollback-plan')
}
async currentDeployment({ required = true } = {}) {
let record
for (let attempt = 0; attempt < 100; attempt += 1) {
({ record } = await this.bootstrap.request('GET', '/v1/deployments/current'))
if (record !== null || !required) break
const status = await this.bootstrap.request('GET', '/v1/status')
if (status.startupComplete === true) break
await this.sleep(100)
}
if (record === null) {
if (required) throw new Error('current Deployment is required')
return null
}
return Object.freeze({
authority: record.authority,
targetSequence: record.targetSequence,
dsh: record.dshVersion,
runtime: record.id,
environment: record.environmentVersion,
dataSnapshot: record.snapshotId,
receiptTokens: Object.freeze([...record.receiptTokens]),
})
}
async prepareExperimental(prepared, { targetSequence }) {
const { record: current } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (current === null) throw new Error('current Deployment is required')
const receiptTokens = await this.experimentalActivationTokens(prepared.receiptTokens)
const built = await this.builder.buildExperimental(prepared, current, receiptTokens, { targetSequence })
this.experimentalCandidates.set(built.record.id, built.record)
return Object.freeze({
runtimeId: built.record.id,
environmentVersion: built.record.environmentVersion,
dshVersion: built.record.dshVersion,
})
}
suspendDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/suspend') }
resumeDsh() { return this.bootstrap.request('POST', '/v1/components/dsh-runtime/resume') }
health() { return this.bootstrap.request('GET', '/v1/health') }
async experimentalActivationTokens(experimentalTokens) {
const active = (await this.stage0.activeReceipts()).receipts
return Object.freeze([
...active.filter(receipt => receipt.authorityType === 'stable').map(receipt => receipt.token),
...experimentalTokens,
])
}
async switchExperimental(runtimeId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
return this.bootstrap.request('POST', '/v1/deployments/candidate', { record })
}
async bindExperimentalSnapshot(runtimeId, snapshotId) {
const record = this.experimentalCandidates.get(runtimeId)
if (record === undefined) throw new Error('Experimental Deployment candidate is not prepared')
const content = { ...record, snapshotId }
delete content.id
const bound = parseDeploymentRecord({ ...content, id: deriveRecordId('deployment-record', content) })
this.experimentalCandidates.delete(runtimeId)
this.experimentalCandidates.set(bound.id, bound)
return bound.id
}
async commitExperimental(runtimeId) {
const result = await this.bootstrap.request('POST', '/v1/deployments/candidate/commit', { recordId: runtimeId })
this.experimentalCandidates.delete(runtimeId)
return result
}
async restoreDeployment(deployment, { resume = true } = {}) {
await this.bootstrap.request('POST', '/v1/deployments/candidate/cancel')
const { record } = await this.bootstrap.request('GET', '/v1/deployments/current')
if (!matchesRecoveryJournal(record, deployment)) {
const { previous } = await this.rollbackDeployments()
if (previous === null) throw new Error('previous Deployment is unavailable')
if (!matchesRecoveryJournal(previous, deployment)) {
throw new Error('previous Deployment differs from the recovery journal')
}
await this.bootstrap.request('POST', '/v1/deployments/rollback', { recordId: previous.id })
}
if (resume) await this.resumeDsh()
}
async cleanup() {
const platform = await this.bootstrap.request('POST', '/v1/platform/gc')
return Object.freeze({
platform,
bootstrap: await this.stage0.collectBootstrap(),
trustedObjects: (await this.stage0.collectGarbage()).removed,
})
}
}