250 lines
14 KiB
JavaScript
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
|
|
}
|