mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
612 lines
18 KiB
JavaScript
612 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const crypto = require('node:crypto');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const {
|
|
RELEASE_SET_SCHEMA,
|
|
inspectReleaseSet,
|
|
} = require('./ql3-release-set-contract.cjs');
|
|
const {
|
|
auditCatalogPlan,
|
|
auditCatalogReceipt,
|
|
} = require('./ql3-release-catalog-contract.cjs');
|
|
const {
|
|
SCHEMA: DEPLOYMENT_READINESS_SCHEMA,
|
|
validateDeploymentReadinessReceipt,
|
|
} = require('./ql3-release-deployment-readiness-contract.cjs');
|
|
|
|
const PUBLICATION_PLAN_SCHEMA = 'qinglong/release-publication-plan@v2';
|
|
const TAG_OBSERVATION_SCHEMA =
|
|
'qinglong/release-publication-tag-observation@v1';
|
|
const CLOSURE_RECEIPT_SCHEMA =
|
|
'qinglong/release-publication-closure-receipt@v2';
|
|
const MAX_JSON_BYTES = 1024 * 1024;
|
|
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
|
const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/u;
|
|
const REGISTRY_REPOSITORY_PATTERN =
|
|
/^ghcr\.io\/[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
|
const REQUIRED_PREREQUISITES = Object.freeze({
|
|
releaseSetProvenance: 'attested_before_catalog_publication',
|
|
catalogSignature: 'verified_exact_workflow_identity',
|
|
catalogProvenance: 'verified_source_tag_and_revision',
|
|
catalogReceipt: 'attested_before_deployment_gates',
|
|
deploymentReadiness: 'scope_exact_receipt_attested_before_tag_promotion',
|
|
});
|
|
const PROMOTION_POLICY = Object.freeze({
|
|
authority: 'verified_catalog_bound_deployments',
|
|
inventory: 'bounded_exact_repository_tags',
|
|
conflict: 'fail_closed_before_any_tag_mutation',
|
|
recovery: 'reuse_exact_digest_only',
|
|
finalVerification: 'all_tags_exact_digest',
|
|
crossRepositoryAtomicity: false,
|
|
registryTagCas: false,
|
|
});
|
|
const CLOSURE_VERIFICATION = Object.freeze({
|
|
authority: 'verified_catalog_bound_deployments',
|
|
catalogReadyBeforeTagMutation: true,
|
|
deploymentReadyBeforeTagMutation: true,
|
|
allTagsExactDigest: true,
|
|
inventory: 'bounded_exact_repository_tags',
|
|
conflict: 'fail_closed_before_any_tag_mutation',
|
|
recovery: 'reuse_exact_digest_only',
|
|
crossRepositoryAtomicity: false,
|
|
registryTagCas: false,
|
|
});
|
|
|
|
class QingLong3ReleasePublicationClosureError extends Error {
|
|
constructor(message) {
|
|
super(`QingLong 3 release publication closure failed: ${message}`);
|
|
this.name = 'QingLong3ReleasePublicationClosureError';
|
|
}
|
|
}
|
|
|
|
function fail(message) {
|
|
throw new QingLong3ReleasePublicationClosureError(message);
|
|
}
|
|
|
|
function sha256(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function canonicalJson(value) {
|
|
return `${JSON.stringify(value)}\n`;
|
|
}
|
|
|
|
function exactKeys(value, expected) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
|
);
|
|
}
|
|
|
|
function resolveCanonicalAbsolute(input, label) {
|
|
if (typeof input !== 'string' || !path.isAbsolute(input)) {
|
|
fail(`${label} path must be absolute`);
|
|
}
|
|
const resolved = path.resolve(input);
|
|
if (resolved !== input) fail(`${label} path must be normalized`);
|
|
return resolved;
|
|
}
|
|
|
|
function readBoundedFile(filePath, label) {
|
|
const resolved = resolveCanonicalAbsolute(filePath, label);
|
|
const stat = fs.lstatSync(resolved);
|
|
if (
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
stat.size < 2 ||
|
|
stat.size > MAX_JSON_BYTES ||
|
|
fs.realpathSync(resolved) !== resolved ||
|
|
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
|
) {
|
|
fail(`${label} must be one bounded canonical regular file`);
|
|
}
|
|
return { contents: fs.readFileSync(resolved, 'utf8'), resolved };
|
|
}
|
|
|
|
function parseJson(contents, label, requireCanonical = true) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(contents);
|
|
} catch {
|
|
fail(`${label} must contain valid JSON`);
|
|
}
|
|
if (requireCanonical && canonicalJson(value) !== contents) {
|
|
fail(`${label} must use canonical JSON encoding`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function readCanonicalJson(filePath, label) {
|
|
return parseJson(readBoundedFile(filePath, label).contents, label);
|
|
}
|
|
|
|
function writeNoReplace(filePath, value) {
|
|
const resolved = resolveCanonicalAbsolute(filePath, 'output');
|
|
const parent = path.dirname(resolved);
|
|
const parentStat = fs.lstatSync(parent);
|
|
if (
|
|
!parentStat.isDirectory() ||
|
|
parentStat.isSymbolicLink() ||
|
|
fs.realpathSync(parent) !== parent
|
|
) {
|
|
fail('output parent must be one canonical directory');
|
|
}
|
|
const descriptor = fs.openSync(resolved, 'wx', 0o600);
|
|
try {
|
|
fs.writeFileSync(descriptor, canonicalJson(value));
|
|
fs.fsyncSync(descriptor);
|
|
} finally {
|
|
fs.closeSync(descriptor);
|
|
}
|
|
}
|
|
|
|
function validateIdentityOptions(options) {
|
|
for (const key of [
|
|
'version',
|
|
'sourceRevision',
|
|
'sourceRef',
|
|
'releaseScope',
|
|
'repositoryOwner',
|
|
'sourceRepository',
|
|
]) {
|
|
if (typeof options[key] !== 'string' || options[key].length === 0) {
|
|
fail('release identity options are incomplete');
|
|
}
|
|
}
|
|
}
|
|
|
|
function createPublicationPlan(
|
|
releaseSet,
|
|
catalogPlan,
|
|
catalogReceipt,
|
|
deploymentReadiness,
|
|
catalogManifestContents,
|
|
catalogManifestDigest,
|
|
options,
|
|
) {
|
|
validateIdentityOptions(options);
|
|
inspectReleaseSet(releaseSet, options);
|
|
auditCatalogPlan(catalogPlan, releaseSet, options);
|
|
auditCatalogReceipt(
|
|
catalogReceipt,
|
|
catalogPlan,
|
|
catalogManifestContents,
|
|
catalogManifestDigest,
|
|
);
|
|
validateDeploymentReadinessReceipt(deploymentReadiness, {
|
|
release: releaseSet.release,
|
|
sourceRepository: options.sourceRepository,
|
|
releaseSetDigest: releaseSet.releaseSetDigest,
|
|
catalogManifestDigest,
|
|
immutableReference: catalogReceipt.catalog.immutableReference,
|
|
});
|
|
const images = releaseSet.images.map((image) => {
|
|
const registryRepository = image.reference.slice(
|
|
0,
|
|
image.reference.lastIndexOf('@'),
|
|
);
|
|
return {
|
|
name: image.name,
|
|
registryRepository,
|
|
immutableReference: image.reference,
|
|
digest: image.digest,
|
|
tags: [
|
|
{ kind: 'version', reference: image.versionTag },
|
|
{ kind: 'source', reference: image.sourceTag },
|
|
],
|
|
};
|
|
});
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: PUBLICATION_PLAN_SCHEMA,
|
|
release: { ...releaseSet.release },
|
|
releaseSet: {
|
|
schema: RELEASE_SET_SCHEMA,
|
|
releaseSetDigest: releaseSet.releaseSetDigest,
|
|
contentDigest: sha256(canonicalJson(releaseSet)),
|
|
},
|
|
catalog: {
|
|
planDigest: catalogPlan.planDigest,
|
|
receiptDigest: catalogReceipt.receiptDigest,
|
|
manifestDigest: catalogManifestDigest,
|
|
immutableReference: catalogReceipt.catalog.immutableReference,
|
|
},
|
|
deploymentReadiness: {
|
|
schema: DEPLOYMENT_READINESS_SCHEMA,
|
|
receiptDigest: deploymentReadiness.receiptDigest,
|
|
finalizerConsumptionDigest:
|
|
deploymentReadiness.catalog.finalizerConsumptionDigest,
|
|
requiredDeploymentFamilies: [
|
|
...deploymentReadiness.requiredDeploymentFamilies,
|
|
],
|
|
},
|
|
requiredPrerequisites: { ...REQUIRED_PREREQUISITES },
|
|
promotionPolicy: { ...PROMOTION_POLICY },
|
|
images,
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
planDigest: sha256(JSON.stringify(unsigned)),
|
|
});
|
|
}
|
|
|
|
function validatePublicationPlan(plan) {
|
|
if (
|
|
!exactKeys(plan, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'release',
|
|
'releaseSet',
|
|
'catalog',
|
|
'deploymentReadiness',
|
|
'requiredPrerequisites',
|
|
'promotionPolicy',
|
|
'images',
|
|
'planDigest',
|
|
]) ||
|
|
plan.schemaVersion !== 1 ||
|
|
plan.schema !== PUBLICATION_PLAN_SCHEMA ||
|
|
!DIGEST_PATTERN.test(plan.planDigest || '') ||
|
|
!exactKeys(plan.release, [
|
|
'version',
|
|
'sourceRevision',
|
|
'sourceRef',
|
|
'scope',
|
|
]) ||
|
|
typeof plan.release.version !== 'string' ||
|
|
plan.release.version.length < 1 ||
|
|
plan.release.version.length > 64 ||
|
|
!SOURCE_REVISION_PATTERN.test(plan.release.sourceRevision || '') ||
|
|
plan.release.sourceRef !== `refs/tags/v${plan.release.version}` ||
|
|
!['local', 'cluster', 'all'].includes(plan.release.scope) ||
|
|
!exactKeys(plan.releaseSet, [
|
|
'schema',
|
|
'releaseSetDigest',
|
|
'contentDigest',
|
|
]) ||
|
|
plan.releaseSet.schema !== RELEASE_SET_SCHEMA ||
|
|
!DIGEST_PATTERN.test(plan.releaseSet.releaseSetDigest || '') ||
|
|
!DIGEST_PATTERN.test(plan.releaseSet.contentDigest || '') ||
|
|
!exactKeys(plan.catalog, [
|
|
'planDigest',
|
|
'receiptDigest',
|
|
'manifestDigest',
|
|
'immutableReference',
|
|
]) ||
|
|
!DIGEST_PATTERN.test(plan.catalog.planDigest || '') ||
|
|
!DIGEST_PATTERN.test(plan.catalog.receiptDigest || '') ||
|
|
!DIGEST_PATTERN.test(plan.catalog.manifestDigest || '') ||
|
|
typeof plan.catalog.immutableReference !== 'string' ||
|
|
!plan.catalog.immutableReference.endsWith(
|
|
`@${plan.catalog.manifestDigest}`,
|
|
) ||
|
|
!exactKeys(plan.deploymentReadiness, [
|
|
'schema',
|
|
'receiptDigest',
|
|
'finalizerConsumptionDigest',
|
|
'requiredDeploymentFamilies',
|
|
]) ||
|
|
plan.deploymentReadiness.schema !== DEPLOYMENT_READINESS_SCHEMA ||
|
|
!DIGEST_PATTERN.test(plan.deploymentReadiness.receiptDigest || '') ||
|
|
!DIGEST_PATTERN.test(
|
|
plan.deploymentReadiness.finalizerConsumptionDigest || '',
|
|
) ||
|
|
JSON.stringify(plan.deploymentReadiness.requiredDeploymentFamilies) !==
|
|
JSON.stringify(
|
|
plan.release.scope === 'local'
|
|
? ['local']
|
|
: plan.release.scope === 'cluster'
|
|
? ['cluster']
|
|
: ['local', 'cluster'],
|
|
) ||
|
|
JSON.stringify(plan.requiredPrerequisites) !==
|
|
JSON.stringify(REQUIRED_PREREQUISITES) ||
|
|
JSON.stringify(plan.promotionPolicy) !== JSON.stringify(PROMOTION_POLICY) ||
|
|
!Array.isArray(plan.images) ||
|
|
plan.images.length !==
|
|
(plan.release.scope === 'local'
|
|
? 1
|
|
: plan.release.scope === 'cluster'
|
|
? 4
|
|
: 5)
|
|
) {
|
|
fail('publication plan shape is invalid');
|
|
}
|
|
const { planDigest, ...unsigned } = plan;
|
|
if (planDigest !== sha256(JSON.stringify(unsigned))) {
|
|
fail('publication plan digest is invalid');
|
|
}
|
|
const references = [];
|
|
const names = [];
|
|
const repositories = [];
|
|
for (const image of plan.images) {
|
|
if (
|
|
!exactKeys(image, [
|
|
'name',
|
|
'registryRepository',
|
|
'immutableReference',
|
|
'digest',
|
|
'tags',
|
|
]) ||
|
|
!/^[a-z][a-z0-9-]{0,31}$/u.test(image.name || '') ||
|
|
!REGISTRY_REPOSITORY_PATTERN.test(image.registryRepository || '') ||
|
|
image.immutableReference !==
|
|
`${image.registryRepository}@${image.digest}` ||
|
|
!DIGEST_PATTERN.test(image.digest || '') ||
|
|
!Array.isArray(image.tags) ||
|
|
image.tags.length !== 2
|
|
) {
|
|
fail('publication plan image is invalid');
|
|
}
|
|
names.push(image.name);
|
|
repositories.push(image.registryRepository);
|
|
for (let index = 0; index < image.tags.length; index += 1) {
|
|
const tag = image.tags[index];
|
|
const kind = index === 0 ? 'version' : 'source';
|
|
const expectedReference =
|
|
kind === 'version'
|
|
? `${image.registryRepository}:${plan.release.version}`
|
|
: `${image.registryRepository}:sha-${plan.release.sourceRevision}`;
|
|
if (
|
|
!exactKeys(tag, ['kind', 'reference']) ||
|
|
tag.kind !== kind ||
|
|
tag.reference !== expectedReference
|
|
) {
|
|
fail('publication plan tag is invalid');
|
|
}
|
|
references.push(tag.reference);
|
|
}
|
|
}
|
|
if (
|
|
new Set(references).size !== references.length ||
|
|
new Set(names).size !== names.length ||
|
|
new Set(repositories).size !== repositories.length
|
|
) {
|
|
fail('publication plan contains duplicate images or tags');
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
function expectedObservedTags(plan) {
|
|
return plan.images.flatMap((image) =>
|
|
image.tags.map((tag) => ({
|
|
image: image.name,
|
|
kind: tag.kind,
|
|
reference: tag.reference,
|
|
digest: image.digest,
|
|
})),
|
|
);
|
|
}
|
|
|
|
function createPublicationTagObservation(plan, observedTags) {
|
|
validatePublicationPlan(plan);
|
|
if (
|
|
!Array.isArray(observedTags) ||
|
|
JSON.stringify(observedTags) !== JSON.stringify(expectedObservedTags(plan))
|
|
) {
|
|
fail('publication tag observations differ from the exact plan');
|
|
}
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: TAG_OBSERVATION_SCHEMA,
|
|
planDigest: plan.planDigest,
|
|
tags: observedTags.map((entry) => ({ ...entry })),
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
observationDigest: sha256(JSON.stringify(unsigned)),
|
|
});
|
|
}
|
|
|
|
function validateTagObservation(plan, observation) {
|
|
if (
|
|
!exactKeys(observation, [
|
|
'schemaVersion',
|
|
'schema',
|
|
'planDigest',
|
|
'tags',
|
|
'observationDigest',
|
|
]) ||
|
|
observation.schemaVersion !== 1 ||
|
|
observation.schema !== TAG_OBSERVATION_SCHEMA ||
|
|
observation.planDigest !== plan.planDigest ||
|
|
!DIGEST_PATTERN.test(observation.observationDigest || '')
|
|
) {
|
|
fail('publication tag observation shape is invalid');
|
|
}
|
|
const expected = createPublicationTagObservation(plan, observation.tags);
|
|
if (JSON.stringify(observation) !== JSON.stringify(expected)) {
|
|
fail('publication tag observation digest is invalid');
|
|
}
|
|
return observation;
|
|
}
|
|
|
|
function createClosureReceipt(plan, observation) {
|
|
validatePublicationPlan(plan);
|
|
validateTagObservation(plan, observation);
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: CLOSURE_RECEIPT_SCHEMA,
|
|
release: { ...plan.release },
|
|
planDigest: plan.planDigest,
|
|
releaseSet: { ...plan.releaseSet },
|
|
catalog: { ...plan.catalog },
|
|
deploymentReadiness: {
|
|
...plan.deploymentReadiness,
|
|
requiredDeploymentFamilies: [
|
|
...plan.deploymentReadiness.requiredDeploymentFamilies,
|
|
],
|
|
},
|
|
tagObservationDigest: observation.observationDigest,
|
|
publishedTags: observation.tags.map((entry) => ({ ...entry })),
|
|
verification: { ...CLOSURE_VERIFICATION },
|
|
};
|
|
return Object.freeze({
|
|
...unsigned,
|
|
receiptDigest: sha256(JSON.stringify(unsigned)),
|
|
});
|
|
}
|
|
|
|
function auditClosureReceipt(actual, plan, observation) {
|
|
const expected = createClosureReceipt(plan, observation);
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
fail('publication closure receipt differs from the verified final tags');
|
|
}
|
|
return Object.freeze({
|
|
compatible: true,
|
|
releaseSetDigest: actual.releaseSet.releaseSetDigest,
|
|
releaseScope: actual.release.scope,
|
|
catalogManifestDigest: actual.catalog.manifestDigest,
|
|
deploymentReadinessReceiptDigest: actual.deploymentReadiness.receiptDigest,
|
|
publishedTagCount: actual.publishedTags.length,
|
|
allTagsExactDigest: actual.verification.allTagsExactDigest,
|
|
registryTagCas: actual.verification.registryTagCas,
|
|
});
|
|
}
|
|
|
|
function parseArguments(argv) {
|
|
const values = {};
|
|
for (const argument of argv) {
|
|
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
|
if (!match || Object.hasOwn(values, match[1])) {
|
|
fail('arguments are invalid');
|
|
}
|
|
values[match[1]] = match[2];
|
|
}
|
|
const identity = [
|
|
'release-scope',
|
|
'repository-owner',
|
|
'source-ref',
|
|
'source-repository',
|
|
'source-revision',
|
|
'version',
|
|
];
|
|
const expected =
|
|
values.mode === 'plan'
|
|
? [
|
|
'catalog-manifest',
|
|
'catalog-manifest-digest',
|
|
'catalog-plan',
|
|
'catalog-receipt',
|
|
'deployment-readiness',
|
|
'mode',
|
|
'output',
|
|
'release-set',
|
|
...identity,
|
|
]
|
|
: values.mode === 'close'
|
|
? ['mode', 'observations', 'output', 'plan']
|
|
: values.mode === 'audit'
|
|
? ['mode', 'observations', 'plan', 'receipt']
|
|
: [];
|
|
if (
|
|
expected.length === 0 ||
|
|
JSON.stringify(Object.keys(values).sort()) !==
|
|
JSON.stringify(expected.sort())
|
|
) {
|
|
fail('arguments are invalid');
|
|
}
|
|
return Object.freeze({
|
|
mode: values.mode,
|
|
output: values.output,
|
|
plan: values.plan,
|
|
observations: values.observations,
|
|
receipt: values.receipt,
|
|
releaseSet: values['release-set'],
|
|
catalogPlan: values['catalog-plan'],
|
|
catalogReceipt: values['catalog-receipt'],
|
|
deploymentReadiness: values['deployment-readiness'],
|
|
catalogManifest: values['catalog-manifest'],
|
|
catalogManifestDigest: values['catalog-manifest-digest'],
|
|
version: values.version,
|
|
sourceRevision: values['source-revision'],
|
|
sourceRef: values['source-ref'],
|
|
releaseScope: values['release-scope'],
|
|
repositoryOwner: values['repository-owner'],
|
|
sourceRepository: values['source-repository'],
|
|
});
|
|
}
|
|
|
|
function runCli(argv, output = process.stdout) {
|
|
const options = parseArguments(argv);
|
|
if (options.mode === 'plan') {
|
|
const releaseSet = readCanonicalJson(options.releaseSet, 'release set');
|
|
const catalogPlan = readCanonicalJson(options.catalogPlan, 'catalog plan');
|
|
const catalogReceipt = readCanonicalJson(
|
|
options.catalogReceipt,
|
|
'catalog receipt',
|
|
);
|
|
const deploymentReadiness = readCanonicalJson(
|
|
options.deploymentReadiness,
|
|
'deployment readiness receipt',
|
|
);
|
|
const manifest = readBoundedFile(
|
|
options.catalogManifest,
|
|
'catalog manifest',
|
|
).contents;
|
|
const plan = createPublicationPlan(
|
|
releaseSet,
|
|
catalogPlan,
|
|
catalogReceipt,
|
|
deploymentReadiness,
|
|
manifest,
|
|
options.catalogManifestDigest,
|
|
options,
|
|
);
|
|
writeNoReplace(options.output, plan);
|
|
output.write(canonicalJson(plan));
|
|
return plan;
|
|
}
|
|
const plan = readCanonicalJson(options.plan, 'publication plan');
|
|
const observation = readCanonicalJson(
|
|
options.observations,
|
|
'tag observations',
|
|
);
|
|
if (options.mode === 'close') {
|
|
const receipt = createClosureReceipt(plan, observation);
|
|
writeNoReplace(options.output, receipt);
|
|
output.write(canonicalJson(receipt));
|
|
return receipt;
|
|
}
|
|
const receipt = readCanonicalJson(options.receipt, 'closure receipt');
|
|
const audit = auditClosureReceipt(receipt, plan, observation);
|
|
output.write(canonicalJson(audit));
|
|
return audit;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
runCli(process.argv.slice(2));
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`${
|
|
error instanceof Error
|
|
? error.message
|
|
: 'release publication closure failed'
|
|
}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = Object.freeze({
|
|
CLOSURE_RECEIPT_SCHEMA,
|
|
PUBLICATION_PLAN_SCHEMA,
|
|
TAG_OBSERVATION_SCHEMA,
|
|
QingLong3ReleasePublicationClosureError,
|
|
auditClosureReceipt,
|
|
createClosureReceipt,
|
|
createPublicationPlan,
|
|
createPublicationTagObservation,
|
|
parseArguments,
|
|
runCli,
|
|
validatePublicationPlan,
|
|
validateTagObservation,
|
|
});
|