Files
dsh/upstream/container/platform/tools/package-environment.mjs
T

157 lines
6.5 KiB
JavaScript

#!/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
}