230 lines
10 KiB
JavaScript
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,
|
|
})
|
|
}
|
|
}
|