mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): validate release tags before registry mutation
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user