dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
import { parseStable } from '../lib/contracts.mjs'
|
||||
|
||||
const [configArg, privateKeyArg, outputArg] = process.argv.slice(2)
|
||||
if (configArg === undefined || privateKeyArg === undefined || outputArg === undefined) {
|
||||
console.error('usage: build-release.mjs <release-config.json> <current-release-private.pem> <output-dir>')
|
||||
process.exit(64)
|
||||
}
|
||||
const config = JSON.parse(await readFile(resolve(configArg), 'utf8'))
|
||||
const privateKey = createPrivateKey(await readFile(resolve(privateKeyArg)))
|
||||
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('Release private key must be Ed25519')
|
||||
const publicDer = createPublicKey(privateKey).export({ format: 'der', type: 'spki' })
|
||||
const keyId = createHash('sha256').update(publicDer).digest('hex')
|
||||
if (keyId !== config.currentKeyId) throw new Error('Release private key is not the configured current key')
|
||||
const artifacts = []
|
||||
for (const artifact of config.artifacts) {
|
||||
const bytes = await readFile(resolve(artifact.path))
|
||||
artifacts.push({
|
||||
id: artifact.id,
|
||||
mediaType: artifact.mediaType,
|
||||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||||
size: bytes.byteLength,
|
||||
url: new URL(basename(artifact.path), config.artifactBaseUrl).href,
|
||||
})
|
||||
}
|
||||
let issuedAt
|
||||
if (process.env.SOURCE_DATE_EPOCH === undefined) issuedAt = new Date().toISOString()
|
||||
else {
|
||||
const sourceDateEpoch = Number(process.env.SOURCE_DATE_EPOCH)
|
||||
if (!Number.isSafeInteger(sourceDateEpoch) || sourceDateEpoch < 0) throw new Error('SOURCE_DATE_EPOCH must be a non-negative safe integer')
|
||||
issuedAt = new Date(sourceDateEpoch * 1000).toISOString()
|
||||
}
|
||||
const stable = canonicalJson({
|
||||
schema: 1,
|
||||
updateApi: 1,
|
||||
keyringGeneration: config.keyringGeneration,
|
||||
targetSequence: config.targetSequence,
|
||||
issuedAt,
|
||||
artifacts,
|
||||
desired: config.desired,
|
||||
officialDshPolicy: config.officialDshPolicy,
|
||||
})
|
||||
parseStable(stable)
|
||||
const signature = canonicalJson({
|
||||
schema: 1,
|
||||
algorithm: 'Ed25519',
|
||||
keyId,
|
||||
signature: sign(null, stable, privateKey).toString('base64'),
|
||||
})
|
||||
const output = resolve(outputArg)
|
||||
await mkdir(output, { recursive: true })
|
||||
await writeFile(join(output, 'stable.json'), stable, { flag: 'wx' })
|
||||
await writeFile(join(output, 'stable.sig.json'), signature, { flag: 'wx' })
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { chmod, cp, mkdir, readFile, readdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { buildRuntime } from '../../control-plane/modules/patch-manager/index.mjs'
|
||||
import { reconcileSystemPlugins } from '../../control-plane/modules/plugin-manager/system.mjs'
|
||||
import { artifactForReference, parseEnvironmentManifest } from '../lib/contracts.mjs'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
import { deriveImageBuildId, deriveRecordId, parseImageInventory } from '../lib/deployment-contracts.mjs'
|
||||
import { parseBootstrapVersion } from '../lib/bootstrap-version.mjs'
|
||||
import { hashTree } from '../lib/tree-hash.mjs'
|
||||
import { verifyImageRelease } from './verify-image-release.mjs'
|
||||
import { verifyManagementDependencies } from './verify-management-dependencies.mjs'
|
||||
|
||||
const [installedArg, outputArg, imageInputArg = '-', platformRevisionArg = 'development'] = process.argv.slice(2)
|
||||
if (installedArg === undefined || outputArg === undefined) {
|
||||
console.error('usage: build-seed.mjs <installed-dsh-root> <output> [verified-image-input-dir|-] [platform-revision]')
|
||||
process.exit(64)
|
||||
}
|
||||
const installed = resolve(installedArg)
|
||||
const output = resolve(outputArg)
|
||||
const packageMetadata = JSON.parse(await readFile(join(installed, 'package.json'), 'utf8'))
|
||||
if (
|
||||
packageMetadata.name !== '@deepseek-ai/dsh'
|
||||
|| typeof packageMetadata.version !== 'string'
|
||||
|| !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(packageMetadata.version)
|
||||
) {
|
||||
throw new Error('installed DSH package metadata is invalid')
|
||||
}
|
||||
const version = packageMetadata.version
|
||||
const platformRoot = resolve(new URL('..', import.meta.url).pathname)
|
||||
const containerRoot = resolve(platformRoot, '..')
|
||||
const imageInput = imageInputArg === '-' ? undefined : resolve(imageInputArg)
|
||||
|
||||
async function makeFilesReadOnly(root) {
|
||||
for (const entry of await readdir(root, { withFileTypes: true })) {
|
||||
const path = join(root, entry.name)
|
||||
if (entry.isDirectory()) await makeFilesReadOnly(path)
|
||||
else if (entry.isFile()) await chmod(path, 0o444)
|
||||
}
|
||||
}
|
||||
|
||||
await rm(output, { recursive: true, force: true })
|
||||
await mkdir(output, { recursive: true })
|
||||
|
||||
const bootstrapVersion = parseBootstrapVersion(await readFile(join(platformRoot, 'bootstrap', 'VERSION'), 'utf8'))
|
||||
const bootstrapRoot = join(output, 'bootstrap', bootstrapVersion)
|
||||
const environmentDefinition = join(containerRoot, 'environment', 'definition.json')
|
||||
const environmentVersion = JSON.parse(await readFile(environmentDefinition, 'utf8')).version
|
||||
const environmentOutput = join(output, 'environments', environmentVersion)
|
||||
let environment
|
||||
let authority = 'development'
|
||||
let targetSequence = 0
|
||||
|
||||
if (imageInput !== undefined) {
|
||||
const verified = await verifyImageRelease({
|
||||
releaseRoot: join(imageInput, 'release'),
|
||||
recoveryPublicKeyPath: join(imageInput, 'recovery-root.spki.base64'),
|
||||
dshTarballPath: join(imageInput, 'dsh.tgz'),
|
||||
supportedTargetPath: join(imageInput, 'supported-target.json'),
|
||||
environmentDefinitionPath: join(imageInput, 'environment-definition.json'),
|
||||
})
|
||||
if (verified.stable.desired.dsh.version !== version) throw new Error('installed DSH differs from signed Stable metadata')
|
||||
if (verified.bootstrap.manifest.version !== bootstrapVersion) throw new Error('signed Bootstrap version is unsupported')
|
||||
if (verified.environment.manifest.version !== environmentVersion) throw new Error('signed Environment differs from the image definition')
|
||||
const archive = verified.bootstrap.paths.get(verified.bootstrap.manifest.artifacts[0]?.id)
|
||||
if (verified.bootstrap.manifest.artifacts.length !== 1 || archive === undefined) {
|
||||
throw new Error('signed Bootstrap must contain exactly one package')
|
||||
}
|
||||
const listing = spawnSync('tar', ['-tzf', archive], { encoding: 'utf8' })
|
||||
if (listing.status !== 0) throw new Error(listing.stderr || 'Bootstrap archive listing failed')
|
||||
const entries = listing.stdout.split('\n').filter(Boolean)
|
||||
if (entries.length === 0 || entries.some(name => (
|
||||
name.startsWith('/') || name.split('/').includes('..') || !['platform', 'control-plane'].includes(name.split('/')[0])
|
||||
))) throw new Error('signed Bootstrap archive contains an unsafe path')
|
||||
await mkdir(bootstrapRoot, { recursive: true })
|
||||
const extracted = spawnSync('tar', ['-xzf', archive, '--no-same-owner', '--no-same-permissions', '-C', bootstrapRoot], { encoding: 'utf8' })
|
||||
if (extracted.status !== 0) throw new Error(extracted.stderr || 'Bootstrap extraction failed')
|
||||
await mkdir(join(environmentOutput, 'artifacts'), { recursive: true })
|
||||
await writeFile(join(environmentOutput, 'environment.manifest.json'), verified.environment.manifestBytes)
|
||||
for (const descriptor of verified.environment.manifest.artifacts) {
|
||||
await cp(verified.environment.paths.get(descriptor.id), join(environmentOutput, 'artifacts', descriptor.id))
|
||||
}
|
||||
environment = verified.environment.manifest
|
||||
authority = 'stable'
|
||||
targetSequence = verified.stable.targetSequence
|
||||
} else {
|
||||
for (const directory of ['bootstrap', 'lib']) {
|
||||
await cp(join(platformRoot, directory), join(bootstrapRoot, 'platform', directory), { recursive: true })
|
||||
}
|
||||
for (const directory of ['file-manager', 'log-manager', 'patch-manager', 'plugin-manager', 'skill-manager', 'updater']) {
|
||||
await cp(
|
||||
join(containerRoot, 'control-plane', 'modules', directory),
|
||||
join(bootstrapRoot, 'control-plane', 'modules', directory),
|
||||
{ recursive: true },
|
||||
)
|
||||
}
|
||||
for (const directory of ['management', 'gateway', 'access-manager', 'outbound-proxy']) {
|
||||
await cp(
|
||||
join(containerRoot, 'control-plane', 'services', directory),
|
||||
join(bootstrapRoot, 'control-plane', 'services', directory),
|
||||
{ recursive: true },
|
||||
)
|
||||
}
|
||||
await cp(join(containerRoot, 'control-plane', 'hooks'), join(bootstrapRoot, 'control-plane', 'hooks'), { recursive: true })
|
||||
await cp(join(containerRoot, 'control-plane', 'definition.json'), join(bootstrapRoot, 'control-plane', 'definition.json'))
|
||||
const packaged = spawnSync(process.execPath, [
|
||||
join(platformRoot, 'tools', 'package-environment.mjs'),
|
||||
environmentDefinition,
|
||||
environmentOutput,
|
||||
], { encoding: 'utf8' })
|
||||
if (packaged.status !== 0) throw new Error(packaged.stderr || 'Environment packaging failed')
|
||||
environment = parseEnvironmentManifest(await readFile(join(environmentOutput, 'environment.manifest.json')))
|
||||
}
|
||||
await verifyManagementDependencies(join(bootstrapRoot, 'control-plane', 'services', 'management'))
|
||||
await writeFile(join(output, 'bootstrap', 'VERSION'), `${bootstrapVersion}\n`)
|
||||
await writeFile(join(output, 'environments', 'VERSION'), `${environmentVersion}\n`)
|
||||
await symlink('environments', join(output, 'environment'), 'dir')
|
||||
|
||||
await cp(installed, join(output, 'pristine', version), { recursive: true, verbatimSymlinks: true })
|
||||
await buildRuntime({
|
||||
pristineRoot: installed,
|
||||
versionsRoot: join(output, 'runtimes'),
|
||||
runtimeId: version,
|
||||
patchPaths: environment.patches.map(reference => (
|
||||
join(environmentOutput, 'artifacts', artifactForReference(environment, reference).id)
|
||||
)),
|
||||
})
|
||||
await writeFile(join(output, 'runtimes', 'VERSION'), `${version}\n`)
|
||||
await symlink('runtimes', join(output, 'runtime'), 'dir')
|
||||
|
||||
const pluginRoot = join(output, 'system-plugins', environmentVersion)
|
||||
const pluginBuildRoot = join(output, '.system-plugin-build')
|
||||
await reconcileSystemPlugins({
|
||||
root: pluginBuildRoot,
|
||||
environmentVersion,
|
||||
plugins: environment.systemPlugins,
|
||||
artifactPath: reference => join(environmentOutput, 'artifacts', artifactForReference(environment, reference).id),
|
||||
})
|
||||
await cp(join(pluginBuildRoot, 'versions', environmentVersion), pluginRoot, { recursive: true })
|
||||
await rm(pluginBuildRoot, { recursive: true, force: true })
|
||||
await makeFilesReadOnly(environmentOutput)
|
||||
|
||||
if (imageInput === undefined) {
|
||||
await cp(join(platformRoot, 'seed', 'trust'), join(output, 'trust'), { recursive: true })
|
||||
} else {
|
||||
const trustOutput = join(output, 'trust')
|
||||
await mkdir(trustOutput, { recursive: true })
|
||||
await cp(join(imageInput, 'recovery-root.spki.base64'), join(trustOutput, 'recovery-root.spki.base64'))
|
||||
await cp(join(imageInput, 'release', 'keyring.json'), join(trustOutput, 'keyring.json'))
|
||||
await cp(join(imageInput, 'release', 'keyring.sig.json'), join(trustOutput, 'keyring.sig.json'))
|
||||
}
|
||||
|
||||
const bootstrapSha256 = await hashTree(bootstrapRoot)
|
||||
const environmentSha256 = await hashTree(environmentOutput)
|
||||
const pristineSha256 = await hashTree(join(output, 'pristine', version))
|
||||
const runtimeSha256 = await hashTree(join(output, 'runtimes', version))
|
||||
const systemPluginsSha256 = await hashTree(pluginRoot)
|
||||
const deploymentIdentity = {
|
||||
authority,
|
||||
targetSequence,
|
||||
dshVersion: version,
|
||||
environmentVersion,
|
||||
environmentSha256,
|
||||
pristineSha256,
|
||||
runtimeSha256,
|
||||
systemPluginsSha256,
|
||||
}
|
||||
const inventoryContent = {
|
||||
schema: 1,
|
||||
authority,
|
||||
platformRevision: platformRevisionArg,
|
||||
targetSequence,
|
||||
bootstrapApi: 1,
|
||||
updateApi: 1,
|
||||
bootstrap: { version: bootstrapVersion, id: bootstrapVersion, sha256: bootstrapSha256 },
|
||||
deployment: {
|
||||
id: deriveRecordId('image-deployment', deploymentIdentity),
|
||||
dshVersion: version,
|
||||
environmentVersion,
|
||||
environment: { id: environmentVersion, sha256: environmentSha256 },
|
||||
pristine: { id: version, sha256: pristineSha256 },
|
||||
runtime: { id: version, sha256: runtimeSha256 },
|
||||
systemPlugins: { id: environmentVersion, sha256: systemPluginsSha256 },
|
||||
},
|
||||
}
|
||||
const inventory = { ...inventoryContent, imageBuildId: deriveImageBuildId(inventoryContent) }
|
||||
parseImageInventory(inventory)
|
||||
await writeFile(join(output, 'inventory.json'), canonicalJson(inventory))
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
function replaceOnce(source, pattern, replacement, label) {
|
||||
const next = source.replace(pattern, replacement)
|
||||
if (next === source) throw new Error(`client source is missing ${label}`)
|
||||
return next
|
||||
}
|
||||
|
||||
function cssModule(source, namespace) {
|
||||
const names = [...new Set([...source.matchAll(/\.([A-Za-z_][A-Za-z0-9_-]*)/g)].map(match => match[1]))].sort()
|
||||
const classes = Object.fromEntries(names.map(name => [name, `${namespace}_${name}`]))
|
||||
let css = source
|
||||
for (const name of names.sort((left, right) => right.length - left.length)) {
|
||||
css = css.replace(new RegExp(`\\.${name}(?=[^A-Za-z0-9_-]|$)`, 'g'), `.${classes[name]}`)
|
||||
}
|
||||
return { classes, css }
|
||||
}
|
||||
|
||||
export async function buildSystemPluginClient({ sourcePath, stylePath, pluginId }) {
|
||||
let source = await readFile(sourcePath, 'utf8')
|
||||
const namespace = pluginId === '@dsh-docker/platform-management'
|
||||
? 'dshPlatform'
|
||||
: `dsh${pluginId.split('/').at(-1).split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('')}`
|
||||
const style = cssModule(await readFile(stylePath, 'utf8'), namespace)
|
||||
source = replaceOnce(
|
||||
source,
|
||||
/^import React, \{ useCallback, useEffect, useRef, useState \} from 'react'\n/m,
|
||||
"const React = require('react')\nconst { useCallback, useEffect, useRef, useState } = React\n",
|
||||
'the React import',
|
||||
)
|
||||
source = replaceOnce(source, /^import css from '.\/style\.module\.css'\n/m, '', 'the CSS Module import')
|
||||
source = replaceOnce(source, 'export const inject =', 'const inject =', 'the inject export')
|
||||
source = replaceOnce(source, 'export function apply(ctx)', 'function apply(ctx)', 'the apply export')
|
||||
|
||||
const styleId = `${pluginId}/style.module.css`
|
||||
return [
|
||||
`window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
|
||||
'var module = { exports: {} }; var exports = module.exports;',
|
||||
`const css = Object.freeze(${JSON.stringify(style.classes)});`,
|
||||
`const styleId = ${JSON.stringify(styleId)};`,
|
||||
'if (typeof document !== \'undefined\' && ![...document.querySelectorAll(\'style[data-plugin-css]\')].some(tag => tag.dataset.pluginCss === styleId)) {',
|
||||
' const tag = document.createElement(\'style\');',
|
||||
` tag.dataset.plugin = ${JSON.stringify(pluginId)};`,
|
||||
' tag.dataset.pluginCss = styleId;',
|
||||
` tag.textContent = ${JSON.stringify(style.css)};`,
|
||||
' document.head.appendChild(tag);',
|
||||
'}',
|
||||
source.trim(),
|
||||
'exports.inject = inject;',
|
||||
'exports.apply = apply;',
|
||||
'return module.exports; } });',
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
if (process.argv.length !== 6) {
|
||||
console.error('usage: build-system-plugin-client.mjs <plugin-id> <source.js> <style.css> <output.js>')
|
||||
process.exit(64)
|
||||
}
|
||||
const [, , pluginId, sourcePath, stylePath, outputPath] = process.argv
|
||||
await writeFile(outputPath, await buildSystemPluginClient({ sourcePath, stylePath, pluginId }))
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
run_root="${DSH_PLATFORM_RUN:-/run/dsh-platform}"
|
||||
managed_cli="$run_root/views/bootstrap/control-plane/services/management/dsh-platform.mjs"
|
||||
image_cli="/opt/dsh-platform/runtime/control-plane/services/management/dsh-platform.mjs"
|
||||
|
||||
if [ -f "$managed_cli" ]; then
|
||||
exec /usr/local/bin/node "$managed_cli" "$@"
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/node "$image_cli" "$@"
|
||||
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
exec /usr/bin/setpriv \
|
||||
--reuid=1000 \
|
||||
--regid=1000 \
|
||||
--keep-groups \
|
||||
-- \
|
||||
/usr/bin/env \
|
||||
-u npm_config_userconfig \
|
||||
-u NPM_CONFIG_USERCONFIG \
|
||||
-u npm_config_cache \
|
||||
-u NPM_CONFIG_CACHE \
|
||||
-u npm_config_store_dir \
|
||||
-u NPM_CONFIG_STORE_DIR \
|
||||
-u npm_config_prefix \
|
||||
-u NPM_CONFIG_PREFIX \
|
||||
-u PNPM_HOME \
|
||||
HOME=/home/node \
|
||||
USER=node \
|
||||
LOGNAME=node \
|
||||
XDG_CACHE_HOME=/home/node/.cache \
|
||||
XDG_CONFIG_HOME=/home/node/.config \
|
||||
XDG_DATA_HOME=/home/node/.local/share \
|
||||
XDG_STATE_HOME=/home/node/.local/state \
|
||||
DSH_HOME="${DSH_HOME:-/data/dsh}" \
|
||||
DSH_PLATFORM_DATA="${DSH_PLATFORM_DATA:-/data/platform}" \
|
||||
DSH_PLATFORM_RUN="${DSH_PLATFORM_RUN:-/run/dsh-platform}" \
|
||||
DSH_PLATFORM_MANAGED="${DSH_PLATFORM_MANAGED:-1}" \
|
||||
PATH=/home/node/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
/run/dsh-platform/views/runtime/bin/dsh "$@"
|
||||
fi
|
||||
|
||||
exec /run/dsh-platform/views/runtime/bin/dsh "$@"
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
import { parseBootstrapManifest, parseEnvironmentManifest, parseOfficialDshPolicy } from '../lib/contracts.mjs'
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
function releaseManifest(document) {
|
||||
const normalized = structuredClone(document)
|
||||
delete normalized.issuedAt
|
||||
delete normalized.keyringGeneration
|
||||
delete normalized.targetSequence
|
||||
for (const artifact of normalized.artifacts) delete artifact.url
|
||||
return normalized
|
||||
}
|
||||
|
||||
export async function createEnvironmentRelease({ bootstrapManifestPath, environmentManifestPath, officialDshPolicyPath }) {
|
||||
const bootstrapBytes = await readFile(resolve(bootstrapManifestPath))
|
||||
const environmentBytes = await readFile(resolve(environmentManifestPath))
|
||||
const policy = parseOfficialDshPolicy(JSON.parse(await readFile(resolve(officialDshPolicyPath), 'utf8')))
|
||||
const bootstrap = parseBootstrapManifest(bootstrapBytes)
|
||||
const environment = parseEnvironmentManifest(environmentBytes)
|
||||
const bootstrapDocument = JSON.parse(bootstrapBytes)
|
||||
const environmentDocument = JSON.parse(environmentBytes)
|
||||
|
||||
return Object.freeze({
|
||||
schema: 1,
|
||||
version: environment.version,
|
||||
bootstrapVersion: bootstrap.version,
|
||||
bootstrapContentSha256: sha256(canonicalJson(releaseManifest(bootstrapDocument))),
|
||||
environmentContentSha256: sha256(canonicalJson(releaseManifest(environmentDocument))),
|
||||
officialDshPolicySha256: sha256(canonicalJson(policy)),
|
||||
})
|
||||
}
|
||||
|
||||
const invoked = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invoked) {
|
||||
const [bootstrapManifestPath, environmentManifestPath, officialDshPolicyPath, outputPath] = process.argv.slice(2)
|
||||
if ([bootstrapManifestPath, environmentManifestPath, officialDshPolicyPath, outputPath].includes(undefined)) {
|
||||
console.error('usage: environment-release.mjs <bootstrap.manifest.json> <environment.manifest.json> <official-dsh-policy.json> <output.json>')
|
||||
process.exit(64)
|
||||
}
|
||||
const release = await createEnvironmentRelease({ bootstrapManifestPath, environmentManifestPath, officialDshPolicyPath })
|
||||
await writeFile(resolve(outputPath), canonicalJson(release), { flag: 'wx' })
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
import { parseKeyring, validateKeyringTransition } from '../stage0/lib/keyring.mjs'
|
||||
|
||||
const [recoveryPrivateArg, currentPublicArg, nextPublicArg, generationArg, outputArg, previousArg, ...revoked] = process.argv.slice(2)
|
||||
if ([recoveryPrivateArg, currentPublicArg, nextPublicArg, generationArg, outputArg].some(value => value === undefined)) {
|
||||
console.error('usage: keyring.mjs <recovery-private.pem> <current-public.pem> <next-public.pem> <generation> <output-dir> [previous-keyring.json|-] [revoked-key-id...]')
|
||||
process.exit(64)
|
||||
}
|
||||
|
||||
function releaseKey(pem) {
|
||||
const der = createPublicKey(pem).export({ format: 'der', type: 'spki' })
|
||||
return {
|
||||
algorithm: 'Ed25519',
|
||||
keyId: createHash('sha256').update(der).digest('hex'),
|
||||
publicKey: der.toString('base64'),
|
||||
}
|
||||
}
|
||||
|
||||
const recoveryPrivate = createPrivateKey(await readFile(resolve(recoveryPrivateArg)))
|
||||
if (recoveryPrivate.asymmetricKeyType !== 'ed25519') throw new Error('Recovery private key must be Ed25519')
|
||||
const ring = {
|
||||
schema: 1,
|
||||
generation: Number(generationArg),
|
||||
current: releaseKey(await readFile(resolve(currentPublicArg))),
|
||||
next: releaseKey(await readFile(resolve(nextPublicArg))),
|
||||
revokedKeyIds: [...new Set(revoked)].sort(),
|
||||
issuedAt: new Date().toISOString(),
|
||||
}
|
||||
const bytes = canonicalJson(ring)
|
||||
const parsed = parseKeyring(bytes)
|
||||
if (previousArg !== undefined && previousArg !== '-') {
|
||||
validateKeyringTransition(parseKeyring(await readFile(resolve(previousArg))), parsed)
|
||||
}
|
||||
const recoveryDer = createPublicKey(recoveryPrivate).export({ format: 'der', type: 'spki' })
|
||||
const signature = canonicalJson({
|
||||
schema: 1,
|
||||
algorithm: 'Ed25519',
|
||||
keyId: createHash('sha256').update(recoveryDer).digest('hex'),
|
||||
signature: sign(null, bytes, recoveryPrivate).toString('base64'),
|
||||
})
|
||||
const output = resolve(outputArg)
|
||||
await mkdir(output, { recursive: true })
|
||||
await writeFile(join(output, 'recovery-root.spki.base64'), `${recoveryDer.toString('base64')}\n`, { flag: 'wx' })
|
||||
await writeFile(join(output, 'keyring.json'), bytes, { flag: 'wx' })
|
||||
await writeFile(join(output, 'keyring.sig.json'), signature, { flag: 'wx' })
|
||||
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { cp, mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
import { parseComponentManifest, parseEnvironmentManifest } from '../lib/contracts.mjs'
|
||||
|
||||
const [
|
||||
definitionArg,
|
||||
outputArg,
|
||||
generationArg = '1',
|
||||
sequenceArg = '1',
|
||||
baseUrlArg = 'https://github.com/yjrszcq/dsh-docker/releases/download/env-dev',
|
||||
layoutArg = 'nested',
|
||||
] = process.argv.slice(2)
|
||||
if (definitionArg === undefined || outputArg === undefined) {
|
||||
console.error('usage: package-environment.mjs <definition.json> <output-directory> [keyring-generation] [target-sequence] [artifact-base-url] [nested|flat]')
|
||||
process.exit(64)
|
||||
}
|
||||
if (!['nested', 'flat'].includes(layoutArg)) throw new Error('Artifact URL layout must be nested or flat')
|
||||
|
||||
const definitionPath = resolve(definitionArg)
|
||||
const definitionRoot = dirname(definitionPath)
|
||||
const output = resolve(outputArg)
|
||||
const staging = `${output}.${randomUUID()}.tmp`
|
||||
const allowedSourceRoot = resolve(definitionRoot, '..')
|
||||
const definition = JSON.parse(await readFile(definitionPath, 'utf8'))
|
||||
const generation = Number(generationArg)
|
||||
const sequence = Number(sequenceArg)
|
||||
const baseUrl = new URL(baseUrlArg.endsWith('/') ? baseUrlArg : `${baseUrlArg}/`)
|
||||
|
||||
if (definition.bootstrapApi !== 1) throw new Error('definition bootstrapApi must be 1')
|
||||
if (!Array.isArray(definition.components) || !Array.isArray(definition.patches) || !Array.isArray(definition.systemPlugins)) {
|
||||
throw new Error('definition resource lists must be arrays')
|
||||
}
|
||||
if (definition.systemSkillCatalog === null || typeof definition.systemSkillCatalog !== 'object' || Array.isArray(definition.systemSkillCatalog)) {
|
||||
throw new Error('definition systemSkillCatalog must be an object')
|
||||
}
|
||||
|
||||
const groups = ['components', 'patches', 'systemPlugins']
|
||||
const artifacts = []
|
||||
const references = {}
|
||||
const ids = new Set()
|
||||
const artifactIds = new Set()
|
||||
for (const group of groups) {
|
||||
for (const item of definition[group]) {
|
||||
const source = resolve(definitionRoot, item.source)
|
||||
if (source !== allowedSourceRoot && !source.startsWith(`${allowedSourceRoot}/`)) {
|
||||
throw new Error(`resource ${item.id} source escapes the container source root`)
|
||||
}
|
||||
if (ids.has(item.id)) throw new Error(`duplicate resource ID ${item.id}`)
|
||||
if (artifactIds.has(item.artifactId)) throw new Error(`duplicate Artifact ID ${item.artifactId}`)
|
||||
ids.add(item.id)
|
||||
artifactIds.add(item.artifactId)
|
||||
}
|
||||
}
|
||||
const systemSkillCatalog = definition.systemSkillCatalog
|
||||
if (Object.keys(systemSkillCatalog).sort().join(',') !== 'artifactId,mediaType,source'
|
||||
|| systemSkillCatalog.artifactId !== 'system-skill-catalog'
|
||||
|| systemSkillCatalog.mediaType !== 'application/vnd.dsh-platform.system-skill-catalog.v1+tar+gzip'
|
||||
|| typeof systemSkillCatalog.source !== 'string') {
|
||||
throw new Error('definition systemSkillCatalog is invalid')
|
||||
}
|
||||
const skillSource = resolve(definitionRoot, systemSkillCatalog.source)
|
||||
if (skillSource !== allowedSourceRoot && !skillSource.startsWith(`${allowedSourceRoot}/`)) {
|
||||
throw new Error('System Skill catalog source escapes the container source root')
|
||||
}
|
||||
if (artifactIds.has(systemSkillCatalog.artifactId)) throw new Error(`duplicate Artifact ID ${systemSkillCatalog.artifactId}`)
|
||||
artifactIds.add(systemSkillCatalog.artifactId)
|
||||
await mkdir(join(staging, 'artifacts'), { recursive: true })
|
||||
try {
|
||||
for (const group of groups) {
|
||||
references[group] = []
|
||||
for (const item of definition[group]) {
|
||||
const source = resolve(definitionRoot, item.source)
|
||||
const name = item.artifactId
|
||||
const destination = join(staging, 'artifacts', name)
|
||||
let bytes
|
||||
if (group === 'systemPlugins') {
|
||||
await new Promise((resolveArchive, reject) => {
|
||||
const child = spawn('tar', [
|
||||
'--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner',
|
||||
'-czf', destination, '-C', dirname(source), basename(source),
|
||||
])
|
||||
child.once('error', reject)
|
||||
child.once('exit', code => code === 0 ? resolveArchive() : reject(new Error(`tar exited with ${String(code)}`)))
|
||||
})
|
||||
bytes = await readFile(destination)
|
||||
} else {
|
||||
bytes = await readFile(source)
|
||||
}
|
||||
if (group === 'components') {
|
||||
const component = parseComponentManifest(bytes)
|
||||
if (component.id !== item.id) {
|
||||
throw new Error(`component ${item.id} metadata differs from its manifest`)
|
||||
}
|
||||
}
|
||||
if (group !== 'systemPlugins') await cp(source, destination, { errorOnExist: true, force: false })
|
||||
const sha256 = createHash('sha256').update(bytes).digest('hex')
|
||||
artifacts.push({
|
||||
id: item.artifactId,
|
||||
mediaType: item.mediaType,
|
||||
sha256,
|
||||
size: bytes.byteLength,
|
||||
url: new URL(layoutArg === 'flat' ? name : `artifacts/${name}`, baseUrl).href,
|
||||
})
|
||||
references[group].push({ id: item.id, sha256 })
|
||||
}
|
||||
}
|
||||
|
||||
const skillDestination = join(staging, 'artifacts', systemSkillCatalog.artifactId)
|
||||
await new Promise((resolveArchive, reject) => {
|
||||
const child = spawn('tar', [
|
||||
'--sort=name', '--mtime=@0', '--owner=0', '--group=0', '--numeric-owner',
|
||||
'-czf', skillDestination, '-C', dirname(skillSource), basename(skillSource),
|
||||
])
|
||||
child.once('error', reject)
|
||||
child.once('exit', code => code === 0 ? resolveArchive() : reject(new Error(`tar exited with ${String(code)}`)))
|
||||
})
|
||||
const skillBytes = await readFile(skillDestination)
|
||||
artifacts.push({
|
||||
id: systemSkillCatalog.artifactId,
|
||||
mediaType: systemSkillCatalog.mediaType,
|
||||
sha256: createHash('sha256').update(skillBytes).digest('hex'),
|
||||
size: skillBytes.byteLength,
|
||||
url: new URL(layoutArg === 'flat'
|
||||
? systemSkillCatalog.artifactId
|
||||
: `artifacts/${systemSkillCatalog.artifactId}`, baseUrl).href,
|
||||
})
|
||||
|
||||
const manifest = {
|
||||
schema: 1,
|
||||
manifestType: 'environment',
|
||||
version: definition.version,
|
||||
keyringGeneration: generation,
|
||||
targetSequence: sequence,
|
||||
issuedAt: process.env.SOURCE_DATE_EPOCH === undefined
|
||||
? '2026-08-19T00:00:00.000Z'
|
||||
: new Date(Number(process.env.SOURCE_DATE_EPOCH) * 1000).toISOString(),
|
||||
artifacts,
|
||||
bootstrapApi: 1,
|
||||
components: references.components,
|
||||
patches: references.patches,
|
||||
systemPlugins: references.systemPlugins,
|
||||
}
|
||||
const manifestBytes = canonicalJson(manifest)
|
||||
parseEnvironmentManifest(manifestBytes)
|
||||
await writeFile(join(staging, 'environment.manifest.json'), manifestBytes, { flag: 'wx' })
|
||||
await rename(staging, output)
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/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
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { canonicalJson } from '../lib/canonical-json.mjs'
|
||||
|
||||
function usage() {
|
||||
console.error('usage: sign.mjs canonicalize <input> <output> | key-id <public-key-pem> | sign <private-key-pem> <document> <signature-output>')
|
||||
process.exitCode = 64
|
||||
}
|
||||
|
||||
const [command, ...args] = process.argv.slice(2)
|
||||
if (command === 'canonicalize' && args.length === 2) {
|
||||
await writeFile(args[1], canonicalJson(JSON.parse(await readFile(args[0], 'utf8'))), { flag: 'wx' })
|
||||
} else if (command === 'key-id' && args.length === 1) {
|
||||
const key = createPublicKey(await readFile(args[0]))
|
||||
if (key.asymmetricKeyType !== 'ed25519') throw new Error('public key must be Ed25519')
|
||||
const der = key.export({ format: 'der', type: 'spki' })
|
||||
process.stdout.write(`${createHash('sha256').update(der).digest('hex')}\n`)
|
||||
} else if (command === 'sign' && args.length === 3) {
|
||||
const privateKey = createPrivateKey(await readFile(args[0]))
|
||||
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('private key must be Ed25519')
|
||||
const publicKey = createPublicKey(privateKey)
|
||||
const der = publicKey.export({ format: 'der', type: 'spki' })
|
||||
const document = await readFile(args[1])
|
||||
const signature = {
|
||||
schema: 1,
|
||||
algorithm: 'Ed25519',
|
||||
keyId: createHash('sha256').update(der).digest('hex'),
|
||||
signature: sign(null, document, privateKey).toString('base64'),
|
||||
}
|
||||
await writeFile(args[2], canonicalJson(signature), { flag: 'wx', mode: 0o600 })
|
||||
} else usage()
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { open, readFile, rename, rm } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { validateSupportedTarget, advanceSupportedDsh } from '../lib/supported-target.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const [command, targetArg, definitionArg, upstreamVersion] = args
|
||||
const expectedArguments = command === 'validate' ? 3 : command === 'advance' ? 4 : -1
|
||||
if (args.length !== expectedArguments) {
|
||||
console.error('usage: supported-target.mjs <validate|advance> <supported-target.json> <environment-definition.json> [upstream-version]')
|
||||
process.exit(64)
|
||||
}
|
||||
|
||||
const targetPath = resolve(targetArg)
|
||||
const definitionPath = resolve(definitionArg)
|
||||
const target = validateSupportedTarget(await readFile(targetPath), await readFile(definitionPath))
|
||||
if (command === 'validate') {
|
||||
process.stdout.write(`${JSON.stringify({ changed: false, target })}\n`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const advanced = advanceSupportedDsh(target, upstreamVersion)
|
||||
if (advanced.changed) {
|
||||
const temporary = `${targetPath}.${randomUUID()}.tmp`
|
||||
const handle = await open(temporary, 'wx', 0o644)
|
||||
try {
|
||||
await handle.writeFile(`${JSON.stringify(advanced.target, null, 2)}\n`)
|
||||
await handle.sync()
|
||||
await handle.close()
|
||||
await rename(temporary, targetPath)
|
||||
const directory = await open(dirname(targetPath), 'r')
|
||||
try {
|
||||
await directory.sync()
|
||||
} finally {
|
||||
await directory.close()
|
||||
}
|
||||
} catch (error) {
|
||||
await handle.close().catch(() => {})
|
||||
await rm(temporary, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(advanced)}\n`)
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve, join } from 'node:path'
|
||||
import { verifyDshCompatibility } from '../lib/dsh-compatibility.mjs'
|
||||
|
||||
const [packageRootArg, expectedVersion] = process.argv.slice(2)
|
||||
if (packageRootArg === undefined || expectedVersion === undefined) {
|
||||
console.error('usage: verify-dsh-compatibility.mjs <installed-dsh-package-root> <expected-version>')
|
||||
process.exit(64)
|
||||
}
|
||||
|
||||
const home = await mkdtemp(join(tmpdir(), 'dsh-compatibility-'))
|
||||
try {
|
||||
const result = await verifyDshCompatibility({
|
||||
packageRoot: resolve(packageRootArg),
|
||||
expectedVersion,
|
||||
home,
|
||||
})
|
||||
console.log(JSON.stringify(result))
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseBootstrapManifest, parseEnvironmentManifest, parseStable } from '../lib/contracts.mjs'
|
||||
import { validateSupportedTarget } from '../lib/supported-target.mjs'
|
||||
import { verifyRecoveryKeyring } from '../stage0/lib/keyring.mjs'
|
||||
import { verifyDetached } from '../stage0/lib/signature.mjs'
|
||||
|
||||
async function verifyFile(path, descriptor, label) {
|
||||
const bytes = await readFile(path)
|
||||
const digest = createHash('sha256').update(bytes).digest('hex')
|
||||
if (bytes.byteLength !== descriptor.size || digest !== descriptor.sha256) {
|
||||
throw new Error(`${label} does not match its signed descriptor`)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function descriptorById(artifacts, id, label) {
|
||||
const descriptor = artifacts.find(candidate => candidate.id === id)
|
||||
if (descriptor === undefined) throw new Error(`${label} ${id} is missing`)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
function assetPath(root, descriptor) {
|
||||
const name = basename(new URL(descriptor.url).pathname)
|
||||
if (name === '' || name === '.' || name === '..') throw new Error(`Artifact ${descriptor.id} has no safe filename`)
|
||||
return join(root, name)
|
||||
}
|
||||
|
||||
function assertChannelTarget(artifacts, targetSequence, label) {
|
||||
const immutableTarget = `/release-channel/targets/${String(targetSequence)}/`
|
||||
if (artifacts.some(descriptor => !new URL(descriptor.url).pathname.includes(immutableTarget))) {
|
||||
throw new Error(`${label} Artifact URLs must use its immutable signed channel target`)
|
||||
}
|
||||
}
|
||||
|
||||
async function verifiedManifest({ releaseRoot, stable, desired, keyring, parser, label }) {
|
||||
const manifestDescriptor = descriptorById(stable.artifacts, desired.manifestArtifactId, `${label} manifest`)
|
||||
const signatureDescriptor = descriptorById(stable.artifacts, desired.signatureArtifactId, `${label} signature`)
|
||||
const manifestBytes = await verifyFile(assetPath(releaseRoot, manifestDescriptor), manifestDescriptor, `${label} manifest`)
|
||||
const signatureBytes = await verifyFile(assetPath(releaseRoot, signatureDescriptor), signatureDescriptor, `${label} signature`)
|
||||
verifyDetached(manifestBytes, JSON.parse(signatureBytes.toString('utf8')), keyring.current.publicKey, `${label} manifest`)
|
||||
const manifest = parser(manifestBytes)
|
||||
if (manifest.version !== desired.version
|
||||
|| manifest.keyringGeneration !== stable.keyringGeneration
|
||||
|| manifest.targetSequence !== stable.targetSequence) {
|
||||
throw new Error(`${label} manifest does not match Stable metadata`)
|
||||
}
|
||||
assertChannelTarget(manifest.artifacts, stable.targetSequence, label)
|
||||
const paths = new Map()
|
||||
for (const descriptor of manifest.artifacts) {
|
||||
const path = assetPath(releaseRoot, descriptor)
|
||||
await verifyFile(path, descriptor, `${label} Artifact ${descriptor.id}`)
|
||||
paths.set(descriptor.id, path)
|
||||
}
|
||||
return Object.freeze({ manifest, manifestBytes, paths })
|
||||
}
|
||||
|
||||
export async function verifyImageRelease({ releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath }) {
|
||||
const root = resolve(releaseRoot)
|
||||
const recoveryPublicKey = (await readFile(resolve(recoveryPublicKeyPath), 'utf8')).trim()
|
||||
const keyringBytes = await readFile(join(root, 'keyring.json'))
|
||||
const keyring = verifyRecoveryKeyring(
|
||||
keyringBytes,
|
||||
JSON.parse(await readFile(join(root, 'keyring.sig.json'), 'utf8')),
|
||||
recoveryPublicKey,
|
||||
)
|
||||
const stableBytes = await readFile(join(root, 'stable.json'))
|
||||
const stable = parseStable(stableBytes)
|
||||
verifyDetached(
|
||||
stableBytes,
|
||||
JSON.parse(await readFile(join(root, 'stable.sig.json'), 'utf8')),
|
||||
keyring.current.publicKey,
|
||||
'Stable metadata',
|
||||
)
|
||||
if (stable.keyringGeneration !== keyring.generation) throw new Error('Stable metadata does not match keyring generation')
|
||||
const target = validateSupportedTarget(
|
||||
await readFile(resolve(supportedTargetPath)),
|
||||
await readFile(resolve(environmentDefinitionPath)),
|
||||
)
|
||||
if (stable.desired.dsh.version !== target.latestSupportedDsh
|
||||
|| stable.desired.environment.version !== target.environment) {
|
||||
throw new Error('Stable metadata does not match the reviewed Supported Target')
|
||||
}
|
||||
assertChannelTarget(stable.artifacts, stable.targetSequence, 'Stable')
|
||||
const bootstrap = await verifiedManifest({
|
||||
releaseRoot: root,
|
||||
stable,
|
||||
desired: stable.desired.bootstrap,
|
||||
keyring,
|
||||
parser: parseBootstrapManifest,
|
||||
label: 'Bootstrap',
|
||||
})
|
||||
const environment = await verifiedManifest({
|
||||
releaseRoot: root,
|
||||
stable,
|
||||
desired: stable.desired.environment,
|
||||
keyring,
|
||||
parser: parseEnvironmentManifest,
|
||||
label: 'Environment',
|
||||
})
|
||||
const dshBytes = await readFile(resolve(dshTarballPath))
|
||||
const actualIntegrity = `sha512-${createHash('sha512').update(dshBytes).digest('base64')}`
|
||||
if (actualIntegrity !== stable.desired.dsh.integrity) throw new Error('DSH tarball does not match signed npm integrity')
|
||||
return Object.freeze({ stable, keyring, bootstrap, environment, dshTarballPath: resolve(dshTarballPath) })
|
||||
}
|
||||
|
||||
const invoked = process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
if (invoked) {
|
||||
const [releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath] = process.argv.slice(2)
|
||||
if ([releaseRoot, recoveryPublicKeyPath, dshTarballPath, supportedTargetPath, environmentDefinitionPath].includes(undefined)) {
|
||||
console.error('usage: verify-image-release.mjs <release-dir> <recovery-root-public-key> <dsh-tarball> <supported-target.json> <environment-definition.json>')
|
||||
process.exit(64)
|
||||
}
|
||||
const verified = await verifyImageRelease({
|
||||
releaseRoot,
|
||||
recoveryPublicKeyPath,
|
||||
dshTarballPath,
|
||||
supportedTargetPath,
|
||||
environmentDefinitionPath,
|
||||
})
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
targetSequence: verified.stable.targetSequence,
|
||||
dshVersion: verified.stable.desired.dsh.version,
|
||||
environmentVersion: verified.environment.manifest.version,
|
||||
bootstrapVersion: verified.bootstrap.manifest.version,
|
||||
})}\n`)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { lstat, readFile, readdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const EXPECTED = Object.freeze({
|
||||
'@xterm/addon-fit': '0.11.0',
|
||||
'@xterm/xterm': '6.0.0',
|
||||
'qrcode-generator': '2.0.4',
|
||||
undici: '8.10.0',
|
||||
ws: '8.21.3',
|
||||
})
|
||||
|
||||
async function requireFile(root, path) {
|
||||
const value = await lstat(join(root, path))
|
||||
if (!value.isFile()) throw new Error(`Management dependency asset ${path} is not a file`)
|
||||
}
|
||||
|
||||
export async function verifyManagementDependencies(rootArg) {
|
||||
const root = resolve(rootArg)
|
||||
for (const [name, expectedVersion] of Object.entries(EXPECTED)) {
|
||||
const packageRoot = join(root, 'node_modules', ...name.split('/'))
|
||||
const metadata = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8'))
|
||||
if (metadata.name !== name || metadata.version !== expectedVersion) {
|
||||
throw new Error(`Management dependency ${name} must be exactly ${expectedVersion}`)
|
||||
}
|
||||
if (name === 'qrcode-generator') {
|
||||
if (metadata.license !== 'MIT') throw new Error('Management dependency qrcode-generator must remain MIT licensed')
|
||||
await requireFile(packageRoot, 'README.md')
|
||||
} else await requireFile(packageRoot, 'LICENSE')
|
||||
}
|
||||
for (const path of [
|
||||
'node_modules/ws/index.js',
|
||||
'node_modules/undici/index.js',
|
||||
'node_modules/@xterm/xterm/lib/xterm.mjs',
|
||||
'node_modules/@xterm/xterm/css/xterm.css',
|
||||
'node_modules/@xterm/addon-fit/lib/addon-fit.mjs',
|
||||
'terminal/pty-helper.py',
|
||||
'terminal/sessions.mjs',
|
||||
]) await requireFile(root, path)
|
||||
const entries = await readdir(join(root, 'node_modules'), { recursive: true, withFileTypes: true })
|
||||
const native = entries.find(entry => entry.isFile() && entry.name.endsWith('.node'))
|
||||
if (native !== undefined) throw new Error('Management dependencies must not contain native .node modules')
|
||||
return Object.freeze({ packages: EXPECTED, nativeModules: 0 })
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const root = process.argv[2]
|
||||
if (root === undefined) {
|
||||
console.error('usage: verify-management-dependencies.mjs <management-root>')
|
||||
process.exit(64)
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(await verifyManagementDependencies(root))}\n`)
|
||||
}
|
||||
Reference in new issue
Block a user