Files

250 lines
14 KiB
JavaScript

#!/usr/bin/env node
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
import { cp, lstat, mkdir, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomUUID } from 'node:crypto'
import { spawnSync } from 'node:child_process'
import { canonicalJson } from '../lib/canonical-json.mjs'
import { parseBootstrapVersion, validateBootstrapContentTransition } from '../lib/bootstrap-version.mjs'
import { parseBootstrapManifest, parseEnvironmentManifest, parseOfficialDshPolicy, parseStable } from '../lib/contracts.mjs'
import { validateSupportedTarget } from '../lib/supported-target.mjs'
import { positiveSafeInteger } from '../lib/validation.mjs'
import { validateKeyringTransition, verifyRecoveryKeyring } from '../stage0/lib/keyring.mjs'
import { verifyDetached } from '../stage0/lib/signature.mjs'
import { verifyManagementDependencies } from './verify-management-dependencies.mjs'
const args = process.argv.slice(2)
if (args.length !== 10) {
console.error('usage: prepare-release.mjs <supported-target.json> <environment-definition.json> <official-dsh-policy.json> <trust-dir> <current-release-private.pem> <dsh-tarball.tgz> <previous-release-dir|-> <target-sequence> <artifact-base-url> <output-dir>')
process.exit(64)
}
const [targetArg, definitionArg, policyArg, trustArg, privateKeyArg, tarballArg, previousArg, sequenceArg, baseUrlArg, outputArg] = args
const platformRoot = dirname(dirname(fileURLToPath(import.meta.url)))
const containerRoot = dirname(platformRoot)
const targetPath = resolve(targetArg)
const definitionPath = resolve(definitionArg)
const officialDshPolicy = parseOfficialDshPolicy(JSON.parse(await readFile(resolve(policyArg), 'utf8')))
const trustRoot = resolve(trustArg)
const privateKeyPath = resolve(privateKeyArg)
const tarballPath = resolve(tarballArg)
const output = resolve(outputArg)
const targetSequence = positiveSafeInteger(Number(sequenceArg), 'target sequence')
const artifactBaseUrl = new URL(baseUrlArg.endsWith('/') ? baseUrlArg : `${baseUrlArg}/`)
if (artifactBaseUrl.protocol !== 'https:') throw new Error('Artifact base URL must use HTTPS')
const bootstrapVersion = parseBootstrapVersion(await readFile(join(platformRoot, 'bootstrap', 'VERSION'), 'utf8'))
const target = validateSupportedTarget(await readFile(targetPath), await readFile(definitionPath))
const recoveryPublicKey = (await readFile(join(trustRoot, 'recovery-root.spki.base64'), 'utf8')).trim()
const keyringBytes = await readFile(join(trustRoot, 'keyring.json'))
const keyringSignatureBytes = await readFile(join(trustRoot, 'keyring.sig.json'))
const keyring = verifyRecoveryKeyring(keyringBytes, JSON.parse(keyringSignatureBytes.toString('utf8')), recoveryPublicKey)
let previousTarget
let previousBootstrap
if (previousArg === '-') {
if (targetSequence !== 1) throw new Error('The first target sequence must be 1')
} else {
const previousRoot = resolve(previousArg)
const previousKeyringBytes = await readFile(join(previousRoot, 'keyring.json'))
const previousKeyring = verifyRecoveryKeyring(
previousKeyringBytes,
JSON.parse(await readFile(join(previousRoot, 'keyring.sig.json'), 'utf8')),
recoveryPublicKey,
)
const previousStableBytes = await readFile(join(previousRoot, 'stable.json'))
previousTarget = parseStable(previousStableBytes)
verifyDetached(
previousStableBytes,
JSON.parse(await readFile(join(previousRoot, 'stable.sig.json'), 'utf8')),
previousKeyring.current.publicKey,
)
if (previousTarget.keyringGeneration !== previousKeyring.generation) {
throw new Error('previous stable target does not match its keyring generation')
}
if (keyring.generation < previousTarget.keyringGeneration) {
throw new Error('keyring generation must not roll back from the previous stable target')
}
if (keyring.generation === previousKeyring.generation) {
if (!keyringBytes.equals(previousKeyringBytes)) throw new Error('same keyring generation must be byte-identical')
} else validateKeyringTransition(previousKeyring, keyring)
if (targetSequence <= previousTarget.targetSequence) throw new Error('target sequence must increase')
const manifestDescriptor = previousTarget.artifacts.find(({ id }) => id === previousTarget.desired.bootstrap.manifestArtifactId)
const signatureDescriptor = previousTarget.artifacts.find(({ id }) => id === previousTarget.desired.bootstrap.signatureArtifactId)
if (manifestDescriptor === undefined || signatureDescriptor === undefined) throw new Error('previous Bootstrap metadata is incomplete')
const previousBootstrapBytes = await readFile(join(previousRoot, basename(new URL(manifestDescriptor.url).pathname)))
const previousBootstrapSignatureBytes = await readFile(join(previousRoot, basename(new URL(signatureDescriptor.url).pathname)))
if (
previousBootstrapBytes.byteLength !== manifestDescriptor.size
|| createHash('sha256').update(previousBootstrapBytes).digest('hex') !== manifestDescriptor.sha256
|| previousBootstrapSignatureBytes.byteLength !== signatureDescriptor.size
|| createHash('sha256').update(previousBootstrapSignatureBytes).digest('hex') !== signatureDescriptor.sha256
) throw new Error('previous Bootstrap metadata differs from signed Stable descriptors')
verifyDetached(
previousBootstrapBytes,
JSON.parse(previousBootstrapSignatureBytes.toString('utf8')),
previousKeyring.current.publicKey,
)
previousBootstrap = parseBootstrapManifest(previousBootstrapBytes)
if (previousBootstrap.version !== previousTarget.desired.bootstrap.version) {
throw new Error('previous Stable and Bootstrap manifest versions differ')
}
if (
previousBootstrap.targetSequence !== previousTarget.targetSequence
|| previousBootstrap.keyringGeneration !== previousTarget.keyringGeneration
) throw new Error('previous Stable and Bootstrap manifest generations differ')
}
const privateKey = createPrivateKey(await readFile(privateKeyPath))
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('Release private key must be Ed25519')
const publicDer = createPublicKey(privateKey).export({ format: 'der', type: 'spki' })
const currentKeyId = createHash('sha256').update(publicDer).digest('hex')
if (currentKeyId !== keyring.current.keyId) throw new Error('Release private key is not the keyring current key')
const packageResult = spawnSync('tar', ['-xOf', tarballPath, 'package/package.json'], { encoding: 'utf8' })
if (packageResult.status !== 0) throw new Error(packageResult.stderr || 'Unable to read DSH npm package metadata')
const packageMetadata = JSON.parse(packageResult.stdout)
if (packageMetadata.name !== '@deepseek-ai/dsh' || packageMetadata.version !== target.latestSupportedDsh) {
throw new Error('DSH npm tarball does not match the Supported Target')
}
const dshBytes = await readFile(tarballPath)
const dshIntegrity = `sha512-${createHash('sha512').update(dshBytes).digest('base64')}`
const staging = `${output}.${randomUUID()}.tmp`
const environmentOutput = join(staging, '.environment')
const metadataOutput = join(staging, '.metadata')
const releaseConfigPath = join(staging, '.release-config.json')
const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH
let issuedAt
if (sourceDateEpoch === undefined) issuedAt = new Date().toISOString()
else {
const epoch = Number(sourceDateEpoch)
if (!Number.isSafeInteger(epoch) || epoch < 0) throw new Error('SOURCE_DATE_EPOCH must be a non-negative safe integer')
issuedAt = new Date(epoch * 1000).toISOString()
}
function run(command, commandArgs, label) {
const result = spawnSync(command, commandArgs, { encoding: 'utf8', env: process.env })
if (result.status !== 0) throw new Error(result.stderr || `${label} failed`)
}
function signature(document) {
return canonicalJson({
schema: 1,
algorithm: 'Ed25519',
keyId: currentKeyId,
signature: sign(null, document, privateKey).toString('base64'),
})
}
async function descriptor(id, path, mediaType) {
const bytes = await readFile(path)
return {
id,
mediaType,
sha256: createHash('sha256').update(bytes).digest('hex'),
size: bytes.byteLength,
url: new URL(basename(path), artifactBaseUrl).href,
}
}
await lstat(output).then(
() => { throw new Error('Release output already exists') },
error => { if (error?.code !== 'ENOENT') throw error },
)
await mkdir(dirname(output), { recursive: true })
await mkdir(staging, { recursive: false })
try {
await verifyManagementDependencies(join(containerRoot, 'control-plane', 'services', 'management'))
run(process.execPath, [
join(platformRoot, 'tools', 'package-environment.mjs'), definitionPath, environmentOutput,
String(keyring.generation), String(targetSequence), artifactBaseUrl.href, 'flat',
], 'Environment packaging')
const environmentManifestPath = join(staging, 'environment.manifest.json')
await cp(join(environmentOutput, 'environment.manifest.json'), environmentManifestPath)
for (const name of await readdir(join(environmentOutput, 'artifacts'))) {
await cp(join(environmentOutput, 'artifacts', name), join(staging, name), { errorOnExist: true, force: false })
}
const environmentManifest = await readFile(environmentManifestPath)
const parsedEnvironment = parseEnvironmentManifest(environmentManifest)
if (parsedEnvironment.version !== target.environment) throw new Error('Packaged Environment does not match the Supported Target')
await writeFile(join(staging, 'environment.manifest.sig.json'), signature(environmentManifest), { flag: 'wx', mode: 0o600 })
const bootstrapPath = join(staging, 'bootstrap.tgz')
run('tar', [
'--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner',
'-czf', bootstrapPath, '-C', containerRoot,
'platform/bootstrap', 'platform/lib',
'control-plane/definition.json', 'control-plane/hooks/recovery', 'control-plane/hooks/dsh-web-ready.mjs',
'control-plane/services/gateway', 'control-plane/services/access-manager',
'control-plane/services/outbound-proxy', 'control-plane/modules/log-manager',
'control-plane/services/management', 'control-plane/modules/patch-manager',
'control-plane/modules/plugin-manager', 'control-plane/modules/skill-manager',
'control-plane/modules/updater', 'control-plane/modules/file-manager',
], 'Bootstrap packaging')
const bootstrapPackage = await descriptor('bootstrap-package', bootstrapPath, 'application/gzip')
if (previousBootstrap !== undefined) {
const previousBootstrapPackage = previousBootstrap.artifacts.find(({ id }) => id === 'bootstrap-package')
if (previousBootstrapPackage === undefined) throw new Error('previous Bootstrap package is missing')
validateBootstrapContentTransition({
previousVersion: previousBootstrap.version,
previousSha256: previousBootstrapPackage.sha256,
nextVersion: bootstrapVersion,
nextSha256: bootstrapPackage.sha256,
})
}
const bootstrapManifest = canonicalJson({
schema: 1,
manifestType: 'bootstrap',
version: bootstrapVersion,
keyringGeneration: keyring.generation,
targetSequence,
issuedAt,
artifacts: [bootstrapPackage],
bootstrapApi: 1,
entrypoint: '/platform/bootstrap/index.mjs',
})
parseBootstrapManifest(bootstrapManifest)
await writeFile(join(staging, 'bootstrap.manifest.json'), bootstrapManifest, { flag: 'wx' })
await writeFile(join(staging, 'bootstrap.manifest.sig.json'), signature(bootstrapManifest), { flag: 'wx', mode: 0o600 })
const releaseConfig = {
currentKeyId,
keyringGeneration: keyring.generation,
targetSequence,
artifactBaseUrl: artifactBaseUrl.href,
officialDshPolicy,
artifacts: [
['environment-manifest', environmentManifestPath, 'application/vnd.dsh-platform.manifest.v1+json'],
['environment-signature', join(staging, 'environment.manifest.sig.json'), 'application/vnd.dsh-platform.signature.v1+json'],
['bootstrap-manifest', join(staging, 'bootstrap.manifest.json'), 'application/vnd.dsh-platform.manifest.v1+json'],
['bootstrap-signature', join(staging, 'bootstrap.manifest.sig.json'), 'application/vnd.dsh-platform.signature.v1+json'],
].map(([id, path, mediaType]) => ({ id, path, mediaType })),
desired: {
bootstrap: { version: bootstrapVersion, manifestArtifactId: 'bootstrap-manifest', signatureArtifactId: 'bootstrap-signature' },
environment: { version: target.environment, manifestArtifactId: 'environment-manifest', signatureArtifactId: 'environment-signature' },
dsh: { version: target.latestSupportedDsh, integrity: dshIntegrity },
},
}
await writeFile(releaseConfigPath, JSON.stringify(releaseConfig), { flag: 'wx' })
run(process.execPath, [join(platformRoot, 'tools', 'build-release.mjs'), releaseConfigPath, privateKeyPath, metadataOutput], 'Stable metadata build')
await cp(join(metadataOutput, 'stable.json'), join(staging, 'stable.json'))
await cp(join(metadataOutput, 'stable.sig.json'), join(staging, 'stable.sig.json'))
await cp(join(trustRoot, 'keyring.json'), join(staging, 'keyring.json'))
await cp(join(trustRoot, 'keyring.sig.json'), join(staging, 'keyring.sig.json'))
const stableBytes = await readFile(join(staging, 'stable.json'))
const stable = parseStable(stableBytes)
verifyDetached(stableBytes, JSON.parse(await readFile(join(staging, 'stable.sig.json'), 'utf8')), keyring.current.publicKey)
if (stable.desired.dsh.version !== target.latestSupportedDsh || stable.desired.environment.version !== target.environment) {
throw new Error('Generated stable metadata differs from the Supported Target')
}
await rm(environmentOutput, { recursive: true, force: true })
await rm(metadataOutput, { recursive: true, force: true })
await rm(releaseConfigPath, { force: true })
await rename(staging, output)
} catch (error) {
await rm(staging, { recursive: true, force: true })
throw error
}