mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:36:13 +08:00
407 lines
12 KiB
JavaScript
407 lines
12 KiB
JavaScript
#!/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,
|
|
});
|