feat(ql3): validate release tags before registry mutation

This commit is contained in:
whyour
2026-08-18 05:23:48 +08:00
parent 1c4e3269b4
commit ca19423f1d
14 changed files with 950 additions and 104 deletions
@@ -258,6 +258,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
'Verify the durable catalog and create its immutable receipt',
'Attest the immutable release-catalog receipt',
'Attest deployment readiness before any final tag mutation',
'scripts/ql3-release-tag-finalizer.cjs',
'Promote final tags only after every required deployment gate',
'Close and audit the deployment-ready public tag set',
'Attest the deployment-ready release publication closure receipt',
+8 -3
View File
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
);
requirePattern(
source,
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3PrivateReleaseEvidenceReceiptContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseDeploymentReadinessContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseTagFinalizer\.test\.cjs[\s\S]*test\/back\/ql3ReleasePublicationClosureContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
'cluster image CI must run SBOM, candidate, private evidence receipt, release-set, durable catalog, deployment-lock, tag finalizer and workflow negative tests; catalog consumption ceremony is mandatory',
);
requirePattern(
source,
@@ -1024,9 +1024,10 @@ function auditReleaseWorkflow(source) {
}) ||
finalizationSteps[11]?.uses !==
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
!/createPublicationTagObservation[\s\S]*maxInventoryBytes = 1024 \* 1024[\s\S]*tagPattern[\s\S]*for \(const image of plan\.images\)[\s\S]*image\.immutableReference[\s\S]*'tag',[\s\S]*'ls',[\s\S]*image\.registryRepository[\s\S]*inventoryContents\.endsWith\('\\n'\)[\s\S]*new Set\(inventory\)\.size !== inventory\.length[\s\S]*release tag already points at another digest[\s\S]*for \(const state of states\)[\s\S]*'image',[\s\S]*'copy',[\s\S]*state\.image\.immutableReference[\s\S]*promoted tag does not resolve to the release-set digest[\s\S]*createPublicationTagObservation\(plan, observedTags\)[\s\S]*fs\.openSync\(process\.env\.TAG_OBSERVATIONS, 'wx', 0o600\)/u.test(
!/ql3-release-tag-finalizer\.cjs[\s\S]*--mode=finalize[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--output="\$\{TAG_OBSERVATIONS\}"[\s\S]*ql3-release-tag-finalizer\.cjs[\s\S]*--mode=audit[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--regctl="\$\{REGCTL\}"[\s\S]*--observation="\$\{TAG_OBSERVATIONS\}"[\s\S]*release-tag-finalization-audit\.json/u.test(
finalizationSteps[12]?.run ?? '',
) ||
/node\s+<<['"]?NODE/u.test(finalizationSteps[12]?.run ?? '') ||
!/ql3-release-publication-closure-contract\.cjs[\s\S]*--mode=close[\s\S]*--plan="\$\{PUBLICATION_PLAN\}"[\s\S]*--observations="\$\{TAG_OBSERVATIONS\}"[\s\S]*--output="\$\{CLOSURE_RECEIPT\}"[\s\S]*--mode=audit[\s\S]*--receipt="\$\{CLOSURE_RECEIPT\}"/u.test(
finalizationSteps[13]?.run ?? '',
) ||
@@ -1454,9 +1455,13 @@ function auditReleaseWorkflow(source) {
receiptAttested: true,
},
finalPublicationClosure: {
finalizer: 'scripts/ql3-release-tag-finalizer.cjs',
planSchema: 'qinglong/release-publication-plan@v2',
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
planValidatedBeforeRegistryAccess: true,
hermeticResponseLossRehearsal: true,
liveTerminalAuditBeforeClosure: true,
catalogReadyBeforeTagMutation: true,
deploymentReadyBeforeTagMutation: true,
allTagsExactDigest: true,
@@ -606,4 +606,6 @@ module.exports = Object.freeze({
createPublicationTagObservation,
parseArguments,
runCli,
validatePublicationPlan,
validateTagObservation,
});
+406
View File
@@ -0,0 +1,406 @@
#!/usr/bin/env node
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const {
createPublicationTagObservation,
validatePublicationPlan,
validateTagObservation,
} = require('./ql3-release-publication-closure-contract.cjs');
const MAX_JSON_BYTES = 1024 * 1024;
const MAX_INVENTORY_BYTES = 1024 * 1024;
const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/u;
class QingLong3ReleaseTagFinalizerError extends Error {
constructor(message) {
super(`QingLong 3 release tag finalizer failed: ${message}`);
this.name = 'QingLong3ReleaseTagFinalizerError';
}
}
function fail(message) {
throw new QingLong3ReleaseTagFinalizerError(message);
}
function canonicalJson(value) {
return `${JSON.stringify(value)}\n`;
}
function currentUid() {
if (typeof process.getuid !== 'function') {
fail('a POSIX current user is required');
}
return process.getuid();
}
function resolveCanonicalAbsolute(input, label) {
if (
typeof input !== 'string' ||
!path.isAbsolute(input) ||
path.resolve(input) !== input
) {
fail(`${label} path must be canonical and absolute`);
}
return input;
}
function validatePrivateParent(parent, label) {
const uid = currentUid();
const stat = fs.lstatSync(parent);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
stat.uid !== uid ||
(stat.mode & 0o777) !== 0o700 ||
fs.realpathSync(parent) !== parent
) {
fail(`${label} parent must be a canonical current-user 0700 directory`);
}
}
function readPrivateCanonicalJson(filePath, label) {
const resolved = resolveCanonicalAbsolute(filePath, label);
validatePrivateParent(path.dirname(resolved), label);
let descriptor;
let before;
let after;
let bytes;
try {
descriptor = fs.openSync(
resolved,
fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW,
);
before = fs.fstatSync(descriptor);
if (
!before.isFile() ||
before.uid !== currentUid() ||
(before.mode & 0o777) !== 0o600 ||
before.nlink !== 1 ||
before.size < 2 ||
before.size > MAX_JSON_BYTES ||
fs.realpathSync(resolved) !== resolved
) {
fail(`${label} must be a bounded current-user 0600 regular file`);
}
bytes = fs.readFileSync(descriptor);
after = fs.fstatSync(descriptor);
} catch (error) {
if (error instanceof QingLong3ReleaseTagFinalizerError) throw error;
fail(`${label} cannot be read through a stable descriptor`);
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
if (
before.dev !== after.dev ||
before.ino !== after.ino ||
before.size !== after.size ||
before.mtimeMs !== after.mtimeMs ||
before.ctimeMs !== after.ctimeMs ||
bytes.byteLength !== before.size
) {
fail(`${label} changed while being read`);
}
const contents = bytes.toString('utf8');
if (!Buffer.from(contents, 'utf8').equals(bytes)) {
fail(`${label} must contain valid UTF-8`);
}
let value;
try {
value = JSON.parse(contents);
} catch {
fail(`${label} must contain valid JSON`);
}
if (canonicalJson(value) !== contents) {
fail(`${label} must use canonical JSON encoding`);
}
return value;
}
function writePrivateNoReplace(filePath, value) {
const resolved = resolveCanonicalAbsolute(filePath, 'output');
validatePrivateParent(path.dirname(resolved), 'output');
let descriptor;
try {
descriptor = fs.openSync(resolved, 'wx', 0o600);
fs.writeFileSync(descriptor, canonicalJson(value));
fs.fsyncSync(descriptor);
} catch {
fail('output must be published once as a private regular file');
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function validateRegistryAdapter(registry) {
if (
registry === null ||
typeof registry !== 'object' ||
typeof registry.resolveDigest !== 'function' ||
typeof registry.listTags !== 'function' ||
typeof registry.copyImage !== 'function'
) {
fail('registry adapter is incomplete');
}
return registry;
}
function parseTagInventory(contents) {
if (
typeof contents !== 'string' ||
Buffer.byteLength(contents, 'utf8') > MAX_INVENTORY_BYTES ||
(contents.length > 0 && !contents.endsWith('\n'))
) {
fail('release tag inventory is invalid or unbounded');
}
const tags = contents.length === 0 ? [] : contents.slice(0, -1).split('\n');
if (
tags.some((tag) => !TAG_PATTERN.test(tag)) ||
new Set(tags).size !== tags.length
) {
fail('release tag inventory is malformed');
}
return Object.freeze([...tags]);
}
function tagName(image, tag) {
return tag.reference.slice(image.registryRepository.length + 1);
}
function preflightReleaseTags(plan, registryInput) {
validatePublicationPlan(plan);
const registry = validateRegistryAdapter(registryInput);
const states = [];
for (const image of plan.images) {
const sourceDigest = registry.resolveDigest(image.immutableReference);
if (sourceDigest !== image.digest) {
fail('source digest drifted before promotion');
}
const inventory = new Set(
parseTagInventory(registry.listTags(image.registryRepository)),
);
for (const tag of image.tags) {
const present = inventory.has(tagName(image, tag));
if (present && registry.resolveDigest(tag.reference) !== image.digest) {
fail('release tag already points at another digest');
}
states.push(Object.freeze({ image, tag, present }));
}
}
return Object.freeze(states);
}
function observeExactTags(plan, states, registry) {
const observedTags = [];
for (const state of states) {
const digest = registry.resolveDigest(state.tag.reference);
if (digest !== state.image.digest) {
fail('promoted tag does not resolve to the release-set digest');
}
observedTags.push({
image: state.image.name,
kind: state.tag.kind,
reference: state.tag.reference,
digest,
});
}
return createPublicationTagObservation(plan, observedTags);
}
function finalizeReleaseTags(plan, registryInput) {
validatePublicationPlan(plan);
const registry = validateRegistryAdapter(registryInput);
const states = preflightReleaseTags(plan, registry);
for (const state of states) {
if (!state.present) {
registry.copyImage(state.image.immutableReference, state.tag.reference);
}
}
return observeExactTags(plan, states, registry);
}
function auditReleaseTags(plan, observation, registryInput) {
validatePublicationPlan(plan);
validateTagObservation(plan, observation);
const registry = validateRegistryAdapter(registryInput);
const states = preflightReleaseTags(plan, registry);
if (states.some((state) => !state.present)) {
fail('publication audit found an absent final tag');
}
const observed = observeExactTags(plan, states, registry);
if (JSON.stringify(observed) !== JSON.stringify(observation)) {
fail('publication observation differs from live registry state');
}
return Object.freeze({
schemaVersion: 1,
planDigest: plan.planDigest,
observationDigest: observation.observationDigest,
tagCount: observation.tags.length,
allTagsExactDigest: true,
registryMutation: false,
compatible: true,
});
}
function resolveRegctlExecutable(input) {
const resolved = resolveCanonicalAbsolute(input, 'regctl');
const stat = fs.lstatSync(resolved);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.uid !== currentUid() ||
stat.nlink !== 1 ||
(stat.mode & 0o111) === 0 ||
(stat.mode & 0o022) !== 0 ||
fs.realpathSync(resolved) !== resolved
) {
fail('regctl must be a canonical current-user non-writable executable');
}
return Object.freeze({
path: resolved,
dev: stat.dev,
ino: stat.ino,
size: stat.size,
uid: stat.uid,
mode: stat.mode,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
});
}
function revalidateRegctlExecutable(identity) {
const stat = fs.lstatSync(identity.path);
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
fs.realpathSync(identity.path) !== identity.path ||
stat.dev !== identity.dev ||
stat.ino !== identity.ino ||
stat.size !== identity.size ||
stat.uid !== identity.uid ||
stat.mode !== identity.mode ||
stat.mtimeMs !== identity.mtimeMs ||
stat.ctimeMs !== identity.ctimeMs
) {
fail('regctl executable identity changed during finalization');
}
}
function createRegctlAdapter(regctlInput) {
const identity = resolveRegctlExecutable(regctlInput);
const run = (operation, args, timeout = 30_000) => {
revalidateRegctlExecutable(identity);
const result = spawnSync(identity.path, args, {
encoding: 'utf8',
maxBuffer: MAX_INVENTORY_BYTES,
timeout,
killSignal: 'SIGKILL',
});
revalidateRegctlExecutable(identity);
if (result.error || result.status !== 0) {
fail(`registry command failed during ${operation}`);
}
return result.stdout;
};
return Object.freeze({
resolveDigest(reference) {
return run('digest resolution', ['image', 'digest', reference]).trim();
},
listTags(repository) {
return run('bounded tag inventory', [
'tag',
'ls',
repository,
'--format',
'{{ range .Tags }}{{ println . }}{{ end }}',
]);
},
copyImage(source, target) {
run('tag promotion', ['image', 'copy', source, target], 120_000);
},
});
}
function parseArguments(argv) {
const values = {};
for (const argument of argv) {
const separator = argument.indexOf('=');
if (
!argument.startsWith('--') ||
separator < 3 ||
separator === argument.length - 1
) {
fail('arguments must use --name=value');
}
const key = argument.slice(2, separator);
if (Object.hasOwn(values, key)) fail('arguments must be unique');
values[key] = argument.slice(separator + 1);
}
const expected =
values.mode === 'finalize'
? ['mode', 'output', 'plan', 'regctl']
: values.mode === 'audit'
? ['mode', 'observation', 'plan', 'regctl']
: [];
if (
expected.length === 0 ||
JSON.stringify(Object.keys(values).sort()) !==
JSON.stringify([...expected].sort())
) {
fail(
'usage: --mode=finalize --plan=/absolute/plan.json --regctl=/absolute/regctl --output=/absolute/observation.json or --mode=audit --plan=/absolute/plan.json --regctl=/absolute/regctl --observation=/absolute/observation.json',
);
}
return Object.freeze(values);
}
function runCli(argv, output = process.stdout, dependencies = {}) {
const options = parseArguments(argv);
const plan = readPrivateCanonicalJson(options.plan, 'publication plan');
validatePublicationPlan(plan);
const registry = dependencies.registry ?? createRegctlAdapter(options.regctl);
if (options.mode === 'finalize') {
const observation = finalizeReleaseTags(plan, registry);
writePrivateNoReplace(options.output, observation);
output.write(canonicalJson(observation));
return observation;
}
const observation = readPrivateCanonicalJson(
options.observation,
'tag observation',
);
const audit = auditReleaseTags(plan, observation, registry);
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 tag finalization failed'
}\n`,
);
process.exitCode = 1;
}
}
module.exports = Object.freeze({
MAX_INVENTORY_BYTES,
QingLong3ReleaseTagFinalizerError,
auditReleaseTags,
createRegctlAdapter,
finalizeReleaseTags,
parseArguments,
parseTagInventory,
preflightReleaseTags,
revalidateRegctlExecutable,
runCli,
});