Files
qinglong/scripts/ql3-local-alpha-trial-kit-bundle.cjs
T

1266 lines
39 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const crypto = require('node:crypto');
const childProcess = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs');
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
const DEFAULT_ROOT = path.resolve(__dirname, '..');
const SCHEMA = 'qinglong/alpha-local-trial-kit@v11';
const VERIFICATION_SCHEMA = 'qinglong/alpha-local-trial-kit-verification@v9';
const QUICKSTART_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-quickstart.sh',
);
const UPGRADE_READINESS_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-readiness.sh',
);
const UPGRADE_REHEARSAL_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-rehearsal.sh',
);
const UPGRADE_CUTOVER_REHEARSAL_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-upgrade-cutover-rehearsal.sh',
);
const UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE = path.join(
DEFAULT_ROOT,
'scripts/templates/ql3-local-alpha-reconciliation-rehearsal.sh',
);
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
const VARIANTS = Object.freeze(['headless', 'console']);
const ARCHIVE_MIN_BYTES = 1024;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
const MAX_README_BYTES = 512 * 1024;
const MAX_QUICKSTART_BYTES = 256 * 1024;
const MAX_UPGRADE_READINESS_BYTES = 256 * 1024;
const MAX_UPGRADE_REHEARSAL_BYTES = 256 * 1024;
const MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES = 512 * 1024;
const MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES = 512 * 1024;
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
const FILES = Object.freeze({
applicationSbom: 'qinglong3-local-application.cdx.json',
operatorSbom: 'qinglong3-local-operator.cdx.json',
verificationEvidence: 'verification-evidence.json',
quickstart: 'quickstart.sh',
upgradeReadiness: 'upgrade-readiness.sh',
upgradeRehearsal: 'upgrade-rehearsal.sh',
upgradeCutoverRehearsal: 'upgrade-cutover-rehearsal.sh',
upgradeReconciliationRehearsal: 'reconciliation-rehearsal.sh',
readme: 'README.md',
manifest: 'manifest.json',
checksums: 'SHA256SUMS',
});
const VERIFICATION = Object.freeze({
osVulnerabilityPolicy: 'passed',
sbomInventoryReconciliation: 'passed',
router128MiBEntrypoint: 'passed',
operator128MiBEntrypoint: 'passed',
operatorPackageInventory: 'passed',
freshOwnerJourney: 'passed',
ownerCredentialPresentation: 'passed',
edgeFreshLifecycle: 'passed',
standaloneFreshLifecycle: 'passed',
localApiCancellation: 'passed',
legacyUpgradeReadiness: 'passed',
legacyUpgradeStage: 'passed',
legacyUpgradeCutover: 'passed',
legacyUpgradeReconciliationCapture: 'passed',
legacyUpgradeReconciliationAutomationRollback: 'passed',
legacyUpgradeReconciliationCompletion: 'passed',
});
function verificationGates(variant) {
return Object.freeze({
...VERIFICATION,
consoleLiveJourney: variant === 'console' ? 'passed' : 'not_applicable',
firstAutomationJourney: variant === 'console' ? 'passed' : 'not_applicable',
});
}
const WORKFLOW_IDENTITY = Object.freeze({
repository: 'whyour/qinglong',
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
event: 'workflow_dispatch',
job: 'local-image',
});
const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u;
const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u;
function fail(message) {
throw new Error(message);
}
function exactKeys(value, expected) {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
);
}
function assertCanonicalFile(filePath, maximumBytes, label) {
const resolved = path.resolve(filePath);
const stat = fs.lstatSync(resolved);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.size < 2 ||
stat.size > maximumBytes ||
fs.realpathSync(resolved) !== resolved
) {
fail(`${label} must be one bounded canonical regular file`);
}
return resolved;
}
function readBoundedJson(filePath, label) {
const resolved = assertCanonicalFile(filePath, MAX_JSON_BYTES, label);
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(resolved, 'utf8'));
} catch {
fail(`${label} must contain valid JSON`);
}
return parsed;
}
function sha256File(filePath) {
const descriptor = fs.openSync(filePath, 'r');
const hash = crypto.createHash('sha256');
const buffer = Buffer.allocUnsafe(1024 * 1024);
try {
let bytesRead;
do {
bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null);
if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead));
} while (bytesRead > 0);
} finally {
fs.closeSync(descriptor);
}
return `sha256:${hash.digest('hex')}`;
}
function writeExclusive(filePath, contents, mode = 0o600) {
const descriptor = fs.openSync(
filePath,
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL,
mode,
);
try {
fs.writeFileSync(descriptor, contents);
fs.fsyncSync(descriptor);
} finally {
fs.closeSync(descriptor);
}
}
function copyExclusive(source, destination) {
fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL);
fs.chmodSync(destination, 0o600);
}
function inspectDockerImage(image) {
const output = childProcess.execFileSync(
'docker',
['image', 'inspect', image],
{
encoding: 'utf8',
maxBuffer: 4 * 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const parsed = JSON.parse(output);
if (!Array.isArray(parsed) || parsed.length !== 1) {
fail(`docker returned an invalid inspection for ${image}`);
}
return parsed[0];
}
function saveDockerImages(images, archivePath) {
childProcess.execFileSync(
'docker',
['image', 'save', '--output', archivePath, ...images],
{ stdio: ['ignore', 'ignore', 'pipe'] },
);
}
function validateImageReference(value, label) {
if (
typeof value !== 'string' ||
!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{2,255}$/u.test(value)
) {
fail(`${label} image reference is invalid`);
}
return value;
}
function normalizeImageInspection(inspection, options) {
const { architecture, reference, revision, role, variant, version } = options;
const labels = inspection?.Config?.Labels;
const expectedTitle =
role === 'application'
? variant === 'console'
? 'QingLong 3.0 Local Console Application'
: 'QingLong 3.0 Local Application'
: 'QingLong 3.0 Local Operator';
if (
!SHA256_PATTERN.test(inspection?.Id || '') ||
inspection?.Os !== 'linux' ||
inspection?.Architecture !== architecture ||
inspection?.Config?.User !== '65532:65532' ||
labels?.['org.opencontainers.image.title'] !== expectedTitle ||
labels?.['org.opencontainers.image.source'] !==
'https://github.com/whyour/qinglong' ||
labels?.['org.opencontainers.image.revision'] !== revision ||
labels?.['org.opencontainers.image.version'] !== version
) {
fail(`${role} image identity is incompatible`);
}
if (
role === 'application' &&
(labels?.['io.qinglong.profile'] !==
(variant === 'console'
? 'edge-application-api,standalone-application-api'
: 'edge,standalone') ||
labels?.['io.qinglong.ai'] !== 'excluded' ||
(variant === 'console'
? labels?.['io.qinglong.local.console'] !== 'offline-loopback'
: labels?.['io.qinglong.local.console'] !== undefined))
) {
fail('application image profile is incompatible');
}
if (
role === 'operator' &&
(labels?.['io.qinglong.lifecycle'] !== 'short-lived' ||
labels?.['io.qinglong.authority'] !== 'local-owner-management' ||
labels?.['io.qinglong.network'] !== 'none-by-default')
) {
fail('operator image authority is incompatible');
}
return {
reference,
id: inspection.Id,
os: 'linux',
architecture,
user: '65532:65532',
};
}
function validateSbom(document, options) {
auditClusterImageSbom(document, {
root: options.root,
image: options.profile,
});
const properties = Object.fromEntries(
(document.metadata?.properties || []).map((entry) => [
entry.name,
entry.value,
]),
);
if (
document.metadata?.component?.version !== options.version ||
properties['qinglong:image-profile'] !== options.profile
) {
fail(`${options.profile} SBOM identity is incompatible`);
}
}
function validateOfflineSbom(document, profile, version) {
const properties = Object.fromEntries(
(document?.metadata?.properties || []).map((entry) => [
entry?.name,
entry?.value,
]),
);
if (
document?.bomFormat !== 'CycloneDX' ||
document?.specVersion !== '1.5' ||
document?.version !== 1 ||
!Array.isArray(document.components) ||
!Array.isArray(document.dependencies) ||
document.metadata?.component?.version !== version ||
properties['qinglong:image-profile'] !== profile
) {
fail(`${profile} offline SBOM identity is incompatible`);
}
}
function validateVerificationEvidence(document, expected) {
if (
!exactKeys(document, [
'schemaVersion',
'schema',
'subject',
'workflow',
'gates',
]) ||
document.schemaVersion !== 1 ||
document.schema !== VERIFICATION_SCHEMA ||
!exactKeys(document.subject, [
'version',
'sourceRevision',
'architecture',
'variant',
'applicationImageId',
'operatorImageId',
]) ||
document.subject.version !== expected.version ||
document.subject.sourceRevision !== expected.sourceRevision ||
document.subject.architecture !== expected.architecture ||
document.subject.variant !== expected.variant ||
document.subject.applicationImageId !== expected.applicationImageId ||
document.subject.operatorImageId !== expected.operatorImageId ||
document.subject.applicationImageId === document.subject.operatorImageId ||
!exactKeys(document.workflow, [
'repository',
'workflowRef',
'workflowSha',
'event',
'job',
'runId',
'runAttempt',
]) ||
document.workflow.repository !== WORKFLOW_IDENTITY.repository ||
document.workflow.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
document.workflow.workflowSha !== expected.sourceRevision ||
document.workflow.event !== WORKFLOW_IDENTITY.event ||
document.workflow.job !== WORKFLOW_IDENTITY.job ||
!DECIMAL_ID_PATTERN.test(document.workflow.runId || '') ||
!ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') ||
!exactKeys(
document.gates,
Object.keys(verificationGates(expected.variant)),
) ||
JSON.stringify(document.gates) !==
JSON.stringify(verificationGates(expected.variant))
) {
fail('trial kit verification evidence is incompatible');
}
return document;
}
function validateVerificationOptions(options) {
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
const output = path.resolve(options.output || '');
const parent = path.dirname(output);
if (
!ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(output) ||
fs.existsSync(output) ||
fs.realpathSync(parent) !== parent ||
options.repository !== WORKFLOW_IDENTITY.repository ||
options.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
options.workflowSha !== options.sourceRevision ||
options.eventName !== WORKFLOW_IDENTITY.event ||
options.job !== WORKFLOW_IDENTITY.job ||
!DECIMAL_ID_PATTERN.test(options.runId || '') ||
!ATTEMPT_PATTERN.test(options.runAttempt || '')
) {
fail('verification evidence identity or output is invalid');
}
return {
root,
output,
architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision,
applicationImage: validateImageReference(
options.applicationImage,
'application',
),
operatorImage: validateImageReference(options.operatorImage, 'operator'),
repository: options.repository,
workflowRef: options.workflowRef,
workflowSha: options.workflowSha,
eventName: options.eventName,
job: options.job,
runId: options.runId,
runAttempt: options.runAttempt,
};
}
function createLocalAlphaTrialKitVerificationEvidence(options, adapters = {}) {
const normalized = validateVerificationOptions(options);
const release = readReleaseIdentity(normalized.root);
const inspectImage = adapters.inspectImage || inspectDockerImage;
const application = normalizeImageInspection(
inspectImage(normalized.applicationImage),
{
architecture: normalized.architecture,
reference: normalized.applicationImage,
revision: normalized.sourceRevision,
role: 'application',
variant: normalized.variant,
version: release.version,
},
);
const operator = normalizeImageInspection(
inspectImage(normalized.operatorImage),
{
architecture: normalized.architecture,
reference: normalized.operatorImage,
revision: normalized.sourceRevision,
role: 'operator',
variant: normalized.variant,
version: release.version,
},
);
if (application.id === operator.id) fail('trial kit images must be distinct');
const evidence = {
schemaVersion: 1,
schema: VERIFICATION_SCHEMA,
subject: {
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id,
operatorImageId: operator.id,
},
workflow: {
repository: normalized.repository,
workflowRef: normalized.workflowRef,
workflowSha: normalized.workflowSha,
event: normalized.eventName,
job: normalized.job,
runId: normalized.runId,
runAttempt: normalized.runAttempt,
},
gates: { ...verificationGates(normalized.variant) },
};
validateVerificationEvidence(evidence, evidence.subject);
writeExclusive(normalized.output, `${JSON.stringify(evidence, null, 2)}\n`);
return evidence;
}
function archiveName(architecture, variant = 'headless') {
return variant === 'console'
? `qinglong3-local-console-trial-kit-${architecture}.docker.tar`
: `qinglong3-local-trial-kit-${architecture}.docker.tar`;
}
function renderQuickstart(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
QUICKSTART_TEMPLATE,
MAX_QUICKSTART_BYTES,
'quickstart template',
),
'utf8',
);
const replacements = Object.freeze({
'@@APPLICATION_IMAGE@@': identity.images.application.reference,
'@@APPLICATION_ID@@': identity.images.application.id,
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
'@@VARIANT@@': identity.variant,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('quickstart template contains an unresolved token');
}
return rendered;
}
function renderUpgradeReadiness(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_READINESS_TEMPLATE,
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade readiness template contains an unresolved token');
}
return rendered;
}
function renderUpgradeRehearsal(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_REHEARSAL_TEMPLATE,
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade rehearsal template contains an unresolved token');
}
return rendered;
}
function renderUpgradeCutoverRehearsal(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_CUTOVER_REHEARSAL_TEMPLATE,
MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES,
'upgrade cutover rehearsal template',
),
'utf8',
);
const replacements = Object.freeze({
'@@APPLICATION_IMAGE@@': identity.images.application.reference,
'@@APPLICATION_ID@@': identity.images.application.id,
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
'@@VARIANT@@': identity.variant,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail('upgrade cutover rehearsal template contains an unresolved token');
}
return rendered;
}
function renderUpgradeReconciliationRehearsal(identity) {
const template = fs.readFileSync(
assertCanonicalFile(
UPGRADE_RECONCILIATION_REHEARSAL_TEMPLATE,
MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES,
'upgrade reconciliation rehearsal template',
),
'utf8',
);
const replacements = Object.freeze({
'@@OPERATOR_IMAGE@@': identity.images.operator.reference,
'@@OPERATOR_ID@@': identity.images.operator.id,
'@@ARCHITECTURE@@': identity.architecture,
'@@SOURCE_REVISION@@': identity.sourceRevision,
'@@ARCHIVE@@': identity.archive.file,
'@@VARIANT@@': identity.variant,
});
let rendered = template;
for (const [token, value] of Object.entries(replacements)) {
rendered = rendered.replaceAll(token, value);
}
if (/@@[A-Z_]+@@/u.test(rendered)) {
fail(
'upgrade reconciliation rehearsal template contains an unresolved token',
);
}
return rendered;
}
function fileRecord(bundleRoot, name) {
const filePath = path.join(bundleRoot, name);
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) {
fail(`bundle file is invalid: ${name}`);
}
return {
file: name,
sha256: sha256File(filePath),
bytes: stat.size,
};
}
function checksumContents(bundleRoot, names) {
return `${names
.map(
(name) => `${sha256File(path.join(bundleRoot, name)).slice(7)} ${name}`,
)
.join('\n')}\n`;
}
function validateCreateOptions(options) {
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
const outputRoot = path.resolve(options.outputRoot || '');
const parent = path.dirname(outputRoot);
if (
!ARCHITECTURES.includes(options.architecture) ||
!VARIANTS.includes(options.variant) ||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
!path.isAbsolute(outputRoot) ||
fs.existsSync(outputRoot) ||
fs.realpathSync(parent) !== parent
) {
fail('create identity or output is invalid');
}
return {
root,
outputRoot,
architecture: options.architecture,
variant: options.variant,
sourceRevision: options.sourceRevision,
applicationImage: validateImageReference(
options.applicationImage,
'application',
),
operatorImage: validateImageReference(options.operatorImage, 'operator'),
applicationSbom: assertCanonicalFile(
options.applicationSbom,
MAX_JSON_BYTES,
'application SBOM',
),
operatorSbom: assertCanonicalFile(
options.operatorSbom,
MAX_JSON_BYTES,
'operator SBOM',
),
verificationEvidence: assertCanonicalFile(
options.verificationEvidence,
MAX_JSON_BYTES,
'trial kit verification evidence',
),
readme: assertCanonicalFile(
options.readme,
MAX_README_BYTES,
'trial kit README',
),
};
}
function createLocalAlphaTrialKit(options, adapters = {}) {
const normalized = validateCreateOptions(options);
const release = readReleaseIdentity(normalized.root);
const inspectImage = adapters.inspectImage || inspectDockerImage;
const saveImages = adapters.saveImages || saveDockerImages;
const applicationSbom = readBoundedJson(
normalized.applicationSbom,
'application SBOM',
);
const operatorSbom = readBoundedJson(
normalized.operatorSbom,
'operator SBOM',
);
const verificationEvidence = readBoundedJson(
normalized.verificationEvidence,
'trial kit verification evidence',
);
validateSbom(applicationSbom, {
root: normalized.root,
profile: normalized.variant === 'console' ? 'local-console' : 'local',
version: release.version,
});
validateSbom(operatorSbom, {
root: normalized.root,
profile: 'local-operator',
version: release.version,
});
const application = normalizeImageInspection(
inspectImage(normalized.applicationImage),
{
architecture: normalized.architecture,
reference: normalized.applicationImage,
revision: normalized.sourceRevision,
role: 'application',
variant: normalized.variant,
version: release.version,
},
);
const operator = normalizeImageInspection(
inspectImage(normalized.operatorImage),
{
architecture: normalized.architecture,
reference: normalized.operatorImage,
revision: normalized.sourceRevision,
role: 'operator',
variant: normalized.variant,
version: release.version,
},
);
if (application.id === operator.id) fail('trial kit images must be distinct');
validateVerificationEvidence(verificationEvidence, {
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
applicationImageId: application.id,
operatorImageId: operator.id,
});
let created = false;
try {
fs.mkdirSync(normalized.outputRoot, { mode: 0o700 });
created = true;
const archive = archiveName(normalized.architecture, normalized.variant);
const archivePath = path.join(normalized.outputRoot, archive);
saveImages(
[normalized.applicationImage, normalized.operatorImage],
archivePath,
);
const archiveStat = fs.lstatSync(archivePath);
if (
!archiveStat.isFile() ||
archiveStat.isSymbolicLink() ||
archiveStat.size < ARCHIVE_MIN_BYTES
) {
fail('Docker archive is invalid or unexpectedly small');
}
fs.chmodSync(archivePath, 0o600);
copyExclusive(
normalized.applicationSbom,
path.join(normalized.outputRoot, FILES.applicationSbom),
);
copyExclusive(
normalized.operatorSbom,
path.join(normalized.outputRoot, FILES.operatorSbom),
);
copyExclusive(
normalized.verificationEvidence,
path.join(normalized.outputRoot, FILES.verificationEvidence),
);
copyExclusive(
normalized.readme,
path.join(normalized.outputRoot, FILES.readme),
);
const manifestIdentity = {
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
archive: { file: archive },
images: { application, operator },
};
writeExclusive(
path.join(normalized.outputRoot, FILES.quickstart),
renderQuickstart(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeReadiness),
renderUpgradeReadiness(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeRehearsal),
renderUpgradeRehearsal(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeCutoverRehearsal),
renderUpgradeCutoverRehearsal(manifestIdentity),
0o700,
);
writeExclusive(
path.join(normalized.outputRoot, FILES.upgradeReconciliationRehearsal),
renderUpgradeReconciliationRehearsal(manifestIdentity),
0o700,
);
const manifest = {
schemaVersion: 12,
schema: SCHEMA,
maturity: 'alpha_candidate_not_public_release',
product: 'local',
version: release.version,
sourceRevision: normalized.sourceRevision,
architecture: normalized.architecture,
variant: normalized.variant,
archive: fileRecord(normalized.outputRoot, archive),
images: { application, operator },
sboms: {
application: fileRecord(normalized.outputRoot, FILES.applicationSbom),
operator: fileRecord(normalized.outputRoot, FILES.operatorSbom),
},
quickstart: fileRecord(normalized.outputRoot, FILES.quickstart),
upgradeReadiness: fileRecord(
normalized.outputRoot,
FILES.upgradeReadiness,
),
upgradeRehearsal: fileRecord(
normalized.outputRoot,
FILES.upgradeRehearsal,
),
upgradeCutoverRehearsal: fileRecord(
normalized.outputRoot,
FILES.upgradeCutoverRehearsal,
),
upgradeReconciliationRehearsal: fileRecord(
normalized.outputRoot,
FILES.upgradeReconciliationRehearsal,
),
readme: fileRecord(normalized.outputRoot, FILES.readme),
verification: fileRecord(
normalized.outputRoot,
FILES.verificationEvidence,
),
};
writeExclusive(
path.join(normalized.outputRoot, FILES.manifest),
`${JSON.stringify(manifest, null, 2)}\n`,
);
const checkedFiles = [
archive,
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.upgradeCutoverRehearsal,
FILES.upgradeReconciliationRehearsal,
FILES.readme,
FILES.manifest,
];
writeExclusive(
path.join(normalized.outputRoot, FILES.checksums),
checksumContents(normalized.outputRoot, checkedFiles),
);
auditLocalAlphaTrialKit({ bundleRoot: normalized.outputRoot });
return manifest;
} catch (error) {
if (created) {
fs.rmSync(normalized.outputRoot, { recursive: true, force: true });
}
throw error;
}
}
function validateFileRecord(record, expectedName, bundleRoot) {
if (
!exactKeys(record, ['file', 'sha256', 'bytes']) ||
record.file !== expectedName ||
!SHA256_PATTERN.test(record.sha256 || '') ||
!Number.isSafeInteger(record.bytes) ||
record.bytes < 2
) {
fail(`manifest file record is invalid: ${expectedName}`);
}
const actual = fileRecord(bundleRoot, expectedName);
if (actual.sha256 !== record.sha256 || actual.bytes !== record.bytes) {
fail(`bundle file differs from manifest: ${expectedName}`);
}
}
function validateOfflineImage(image, role, manifest) {
if (
!exactKeys(image, ['reference', 'id', 'os', 'architecture', 'user']) ||
validateImageReference(image.reference, role) !== image.reference ||
!SHA256_PATTERN.test(image.id || '') ||
image.os !== 'linux' ||
image.architecture !== manifest.architecture ||
image.user !== '65532:65532'
) {
fail(`${role} manifest image identity is incompatible`);
}
}
function auditLocalAlphaTrialKit(options) {
const bundleRoot = fs.realpathSync(path.resolve(options.bundleRoot || ''));
if (!fs.lstatSync(bundleRoot).isDirectory()) {
fail('bundle root must be a canonical directory');
}
const manifest = readBoundedJson(
path.join(bundleRoot, FILES.manifest),
'trial kit manifest',
);
if (
!exactKeys(manifest, [
'schemaVersion',
'schema',
'maturity',
'product',
'version',
'sourceRevision',
'architecture',
'variant',
'archive',
'images',
'sboms',
'quickstart',
'upgradeReadiness',
'upgradeRehearsal',
'upgradeCutoverRehearsal',
'upgradeReconciliationRehearsal',
'readme',
'verification',
]) ||
manifest.schemaVersion !== 12 ||
manifest.schema !== SCHEMA ||
manifest.maturity !== 'alpha_candidate_not_public_release' ||
manifest.product !== 'local' ||
typeof manifest.version !== 'string' ||
!REVISION_PATTERN.test(manifest.sourceRevision || '') ||
!ARCHITECTURES.includes(manifest.architecture) ||
!VARIANTS.includes(manifest.variant) ||
!exactKeys(manifest.images, ['application', 'operator']) ||
!exactKeys(manifest.sboms, ['application', 'operator'])
) {
fail('trial kit manifest identity or shape is incompatible');
}
validateOfflineImage(manifest.images.application, 'application', manifest);
validateOfflineImage(manifest.images.operator, 'operator', manifest);
if (manifest.images.application.id === manifest.images.operator.id) {
fail('trial kit images must be distinct');
}
const expectedArchive = archiveName(manifest.architecture, manifest.variant);
validateFileRecord(manifest.archive, expectedArchive, bundleRoot);
if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) {
fail('Docker archive is unexpectedly small');
}
validateFileRecord(
manifest.sboms.application,
FILES.applicationSbom,
bundleRoot,
);
validateFileRecord(manifest.sboms.operator, FILES.operatorSbom, bundleRoot);
validateFileRecord(
manifest.verification,
FILES.verificationEvidence,
bundleRoot,
);
validateFileRecord(manifest.quickstart, FILES.quickstart, bundleRoot);
const expectedQuickstart = renderQuickstart(manifest);
const actualQuickstart = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.quickstart),
MAX_QUICKSTART_BYTES,
'quickstart',
),
'utf8',
);
if (actualQuickstart !== expectedQuickstart) {
fail('quickstart differs from the canonical deployment journey');
}
validateFileRecord(
manifest.upgradeReadiness,
FILES.upgradeReadiness,
bundleRoot,
);
const expectedUpgradeReadiness = renderUpgradeReadiness(manifest);
const actualUpgradeReadiness = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeReadiness),
MAX_UPGRADE_READINESS_BYTES,
'upgrade readiness',
),
'utf8',
);
if (actualUpgradeReadiness !== expectedUpgradeReadiness) {
fail('upgrade readiness differs from the canonical inspection journey');
}
validateFileRecord(
manifest.upgradeRehearsal,
FILES.upgradeRehearsal,
bundleRoot,
);
const expectedUpgradeRehearsal = renderUpgradeRehearsal(manifest);
const actualUpgradeRehearsal = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeRehearsal),
MAX_UPGRADE_REHEARSAL_BYTES,
'upgrade rehearsal',
),
'utf8',
);
if (actualUpgradeRehearsal !== expectedUpgradeRehearsal) {
fail('upgrade rehearsal differs from the canonical staging journey');
}
validateFileRecord(
manifest.upgradeCutoverRehearsal,
FILES.upgradeCutoverRehearsal,
bundleRoot,
);
const expectedUpgradeCutoverRehearsal =
renderUpgradeCutoverRehearsal(manifest);
const actualUpgradeCutoverRehearsal = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeCutoverRehearsal),
MAX_UPGRADE_CUTOVER_REHEARSAL_BYTES,
'upgrade cutover rehearsal',
),
'utf8',
);
if (actualUpgradeCutoverRehearsal !== expectedUpgradeCutoverRehearsal) {
fail(
'upgrade cutover rehearsal differs from the canonical cutover journey',
);
}
validateFileRecord(
manifest.upgradeReconciliationRehearsal,
FILES.upgradeReconciliationRehearsal,
bundleRoot,
);
const expectedUpgradeReconciliationRehearsal =
renderUpgradeReconciliationRehearsal(manifest);
const actualUpgradeReconciliationRehearsal = fs.readFileSync(
assertCanonicalFile(
path.join(bundleRoot, FILES.upgradeReconciliationRehearsal),
MAX_UPGRADE_RECONCILIATION_REHEARSAL_BYTES,
'upgrade reconciliation rehearsal',
),
'utf8',
);
if (
actualUpgradeReconciliationRehearsal !==
expectedUpgradeReconciliationRehearsal
) {
fail(
'upgrade reconciliation rehearsal differs from the canonical reviewed rollback and completion journey',
);
}
validateFileRecord(manifest.readme, FILES.readme, bundleRoot);
validateOfflineSbom(
readBoundedJson(
path.join(bundleRoot, FILES.applicationSbom),
'application SBOM',
),
manifest.variant === 'console' ? 'local-console' : 'local',
manifest.version,
);
validateOfflineSbom(
readBoundedJson(path.join(bundleRoot, FILES.operatorSbom), 'operator SBOM'),
'local-operator',
manifest.version,
);
const verificationEvidence = validateVerificationEvidence(
readBoundedJson(
path.join(bundleRoot, FILES.verificationEvidence),
'trial kit verification evidence',
),
{
version: manifest.version,
sourceRevision: manifest.sourceRevision,
architecture: manifest.architecture,
variant: manifest.variant,
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
},
);
const expectedFiles = [
FILES.checksums,
FILES.manifest,
FILES.readme,
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.upgradeCutoverRehearsal,
FILES.upgradeReconciliationRehearsal,
expectedArchive,
].sort();
const actualFiles = fs
.readdirSync(bundleRoot, { withFileTypes: true })
.map((entry) => {
if (!entry.isFile() || entry.isSymbolicLink()) {
fail(`bundle contains a non-regular entry: ${entry.name}`);
}
return entry.name;
})
.sort();
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
fail('bundle file set is not closed');
}
const checkedFiles = [
expectedArchive,
FILES.applicationSbom,
FILES.operatorSbom,
FILES.verificationEvidence,
FILES.quickstart,
FILES.upgradeReadiness,
FILES.upgradeRehearsal,
FILES.upgradeCutoverRehearsal,
FILES.upgradeReconciliationRehearsal,
FILES.readme,
FILES.manifest,
];
const expectedChecksums = checksumContents(bundleRoot, checkedFiles);
const actualChecksums = fs.readFileSync(
path.join(bundleRoot, FILES.checksums),
'utf8',
);
if (actualChecksums !== expectedChecksums) {
fail('SHA256SUMS differs from the closed bundle file set');
}
return Object.freeze({
schemaVersion: 1,
schema: 'qinglong/alpha-local-trial-kit-audit@v8',
sourceRevision: manifest.sourceRevision,
version: manifest.version,
architecture: manifest.architecture,
variant: manifest.variant,
archiveSha256: manifest.archive.sha256,
applicationImageId: manifest.images.application.id,
operatorImageId: manifest.images.operator.id,
quickstartSha256: manifest.quickstart.sha256,
upgradeReadinessSha256: manifest.upgradeReadiness.sha256,
upgradeRehearsalSha256: manifest.upgradeRehearsal.sha256,
upgradeCutoverRehearsalSha256: manifest.upgradeCutoverRehearsal.sha256,
upgradeReconciliationRehearsalSha256:
manifest.upgradeReconciliationRehearsal.sha256,
verificationSha256: manifest.verification.sha256,
workflowRunId: verificationEvidence.workflow.runId,
workflowRunAttempt: verificationEvidence.workflow.runAttempt,
compatible: true,
});
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const match = /^--([a-z0-9-]+)=(.+)$/u.exec(argument);
if (!match || Object.hasOwn(values, match[1]))
fail('arguments are invalid');
values[match[1]] = match[2];
}
if (values.mode === 'audit') {
if (
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify(['bundle', 'mode'])
) {
fail('audit arguments are invalid');
}
return { mode: 'audit', bundleRoot: path.resolve(values.bundle) };
}
if (values.mode === 'record-verification') {
const expected = [
'application-image',
'architecture',
'event',
'job',
'mode',
'operator-image',
'output',
'repository',
'run-attempt',
'run-id',
'source-revision',
'variant',
'workflow-ref',
'workflow-sha',
];
if (
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
) {
fail('record-verification arguments are invalid');
}
return {
mode: 'record-verification',
output: path.resolve(values.output),
architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'],
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
repository: values.repository,
workflowRef: values['workflow-ref'],
workflowSha: values['workflow-sha'],
eventName: values.event,
job: values.job,
runId: values['run-id'],
runAttempt: values['run-attempt'],
};
}
if (values.mode === 'create') {
const expected = [
'application-image',
'application-sbom',
'architecture',
'mode',
'operator-image',
'operator-sbom',
'output',
'readme',
'source-revision',
'variant',
'verification-evidence',
];
if (
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
) {
fail('create arguments are invalid');
}
return {
mode: 'create',
outputRoot: path.resolve(values.output),
architecture: values.architecture,
variant: values.variant,
sourceRevision: values['source-revision'],
applicationImage: values['application-image'],
operatorImage: values['operator-image'],
applicationSbom: path.resolve(values['application-sbom']),
operatorSbom: path.resolve(values['operator-sbom']),
verificationEvidence: path.resolve(values['verification-evidence']),
readme: path.resolve(values.readme),
};
}
fail('mode is invalid');
}
function runCli(argv) {
const options = parseArguments(argv);
let report;
if (options.mode === 'record-verification') {
report = createLocalAlphaTrialKitVerificationEvidence(options);
} else if (options.mode === 'create') {
report = createLocalAlphaTrialKit(options);
} else {
report = auditLocalAlphaTrialKit(options);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
return report;
}
if (require.main === module) {
try {
runCli(process.argv.slice(2));
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : 'trial kit bundle failed'}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
FILES,
SCHEMA,
VERIFICATION,
VERIFICATION_SCHEMA,
VARIANTS,
archiveName,
auditLocalAlphaTrialKit,
createLocalAlphaTrialKit,
createLocalAlphaTrialKitVerificationEvidence,
parseArguments,
runCli,
sha256File,
});