mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): close cluster alpha milestone
This commit is contained in:
@@ -0,0 +1,745 @@
|
||||
#!/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-cluster-image@v1';
|
||||
const VERIFICATION_SCHEMA = 'qinglong/alpha-cluster-image-verification@v1';
|
||||
const ARCHITECTURES = Object.freeze(['amd64', 'arm64']);
|
||||
const ROLES = Object.freeze({
|
||||
control: Object.freeze({
|
||||
repository: 'qinglong3-cluster-control',
|
||||
title: 'QingLong 3.0 Cluster Control',
|
||||
user: '10001:10001',
|
||||
}),
|
||||
'control-ai': Object.freeze({
|
||||
repository: 'qinglong3-cluster-control-ai',
|
||||
title: 'QingLong 3.0 Cluster Control AI',
|
||||
user: '10001:10001',
|
||||
}),
|
||||
admin: Object.freeze({
|
||||
repository: 'qinglong3-cluster-admin',
|
||||
title: 'QingLong 3.0 Cluster Admin',
|
||||
user: '10001:10001',
|
||||
}),
|
||||
worker: Object.freeze({
|
||||
repository: 'qinglong3-worker',
|
||||
title: 'QingLong 3.0 Worker',
|
||||
user: '65532:65532',
|
||||
}),
|
||||
});
|
||||
const WORKFLOW_IDENTITY = Object.freeze({
|
||||
repository: 'whyour/qinglong',
|
||||
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
|
||||
event: 'workflow_dispatch',
|
||||
job: 'cluster-image',
|
||||
});
|
||||
const ARCHIVE_MIN_BYTES = 1024;
|
||||
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_README_BYTES = 512 * 1024;
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
|
||||
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 roleConfig(role) {
|
||||
const config = ROLES[role];
|
||||
if (!config) fail('Cluster image role is invalid');
|
||||
return config;
|
||||
}
|
||||
|
||||
function names(role, architecture) {
|
||||
const repository = roleConfig(role).repository;
|
||||
return Object.freeze({
|
||||
archive: `${repository}-${architecture}.docker.tar`,
|
||||
sbom: `${repository}.cdx.json`,
|
||||
verification: 'verification-evidence.json',
|
||||
readme: 'README.md',
|
||||
manifest: 'manifest.json',
|
||||
checksums: 'SHA256SUMS',
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
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 saveDockerImage(image, archivePath) {
|
||||
childProcess.execFileSync(
|
||||
'docker',
|
||||
['image', 'save', '--output', archivePath, image],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||
);
|
||||
}
|
||||
|
||||
function validateImageReference(value, role, architecture) {
|
||||
const expected = `${roleConfig(role).repository}:ci-${architecture}`;
|
||||
if (value !== expected) fail('Cluster image reference is incompatible');
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeImageInspection(inspection, options) {
|
||||
const config = roleConfig(options.role);
|
||||
const labels = inspection?.Config?.Labels;
|
||||
if (
|
||||
!SHA256_PATTERN.test(inspection?.Id || '') ||
|
||||
inspection?.Os !== 'linux' ||
|
||||
inspection?.Architecture !== options.architecture ||
|
||||
inspection?.Config?.User !== config.user ||
|
||||
labels?.['org.opencontainers.image.title'] !== config.title ||
|
||||
labels?.['org.opencontainers.image.source'] !==
|
||||
'https://github.com/whyour/qinglong' ||
|
||||
labels?.['org.opencontainers.image.revision'] !== options.sourceRevision ||
|
||||
labels?.['org.opencontainers.image.version'] !== options.version
|
||||
) {
|
||||
fail('Cluster image identity is incompatible');
|
||||
}
|
||||
return Object.freeze({
|
||||
reference: options.reference,
|
||||
id: inspection.Id,
|
||||
os: 'linux',
|
||||
architecture: options.architecture,
|
||||
user: config.user,
|
||||
});
|
||||
}
|
||||
|
||||
function validateSbom(document, options, offline = false) {
|
||||
if (!offline) {
|
||||
auditClusterImageSbom(document, {
|
||||
root: options.root,
|
||||
image: options.role,
|
||||
});
|
||||
}
|
||||
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 !== options.version ||
|
||||
properties['qinglong:image-profile'] !== options.role
|
||||
) {
|
||||
fail('Cluster image SBOM identity is incompatible');
|
||||
}
|
||||
}
|
||||
|
||||
function gates(role) {
|
||||
return Object.freeze({
|
||||
osVulnerabilityPolicy: 'passed',
|
||||
sbomInventoryReconciliation: 'passed',
|
||||
nonRootRuntimeIdentity: 'passed',
|
||||
clusterAdminProductFacade: role === 'admin' ? 'passed' : 'not_applicable',
|
||||
});
|
||||
}
|
||||
|
||||
function validateVerificationEvidence(document, expected) {
|
||||
if (
|
||||
!exactKeys(document, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'subject',
|
||||
'workflow',
|
||||
'gates',
|
||||
]) ||
|
||||
document.schemaVersion !== 1 ||
|
||||
document.schema !== VERIFICATION_SCHEMA ||
|
||||
!exactKeys(document.subject, [
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'role',
|
||||
'architecture',
|
||||
'imageId',
|
||||
]) ||
|
||||
document.subject.version !== expected.version ||
|
||||
document.subject.sourceRevision !== expected.sourceRevision ||
|
||||
document.subject.role !== expected.role ||
|
||||
document.subject.architecture !== expected.architecture ||
|
||||
document.subject.imageId !== expected.imageId ||
|
||||
!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(gates(expected.role))) ||
|
||||
JSON.stringify(document.gates) !== JSON.stringify(gates(expected.role))
|
||||
) {
|
||||
fail('Cluster image verification evidence is incompatible');
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
function validateCommonIdentity(options) {
|
||||
if (
|
||||
!ARCHITECTURES.includes(options.architecture) ||
|
||||
!Object.hasOwn(ROLES, options.role) ||
|
||||
!REVISION_PATTERN.test(options.sourceRevision || '')
|
||||
) {
|
||||
fail('Cluster image identity is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function createClusterAlphaVerificationEvidence(options, adapters = {}) {
|
||||
validateCommonIdentity(options);
|
||||
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||
const output = path.resolve(options.output || '');
|
||||
const parent = path.dirname(output);
|
||||
if (
|
||||
!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('Cluster verification workflow identity or output is invalid');
|
||||
}
|
||||
const release = readReleaseIdentity(root);
|
||||
const reference = validateImageReference(
|
||||
options.image,
|
||||
options.role,
|
||||
options.architecture,
|
||||
);
|
||||
const inspectImage = adapters.inspectImage || inspectDockerImage;
|
||||
const image = normalizeImageInspection(inspectImage(reference), {
|
||||
reference,
|
||||
role: options.role,
|
||||
architecture: options.architecture,
|
||||
sourceRevision: options.sourceRevision,
|
||||
version: release.version,
|
||||
});
|
||||
const evidence = {
|
||||
schemaVersion: 1,
|
||||
schema: VERIFICATION_SCHEMA,
|
||||
subject: {
|
||||
version: release.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
role: options.role,
|
||||
architecture: options.architecture,
|
||||
imageId: image.id,
|
||||
},
|
||||
workflow: {
|
||||
repository: options.repository,
|
||||
workflowRef: options.workflowRef,
|
||||
workflowSha: options.workflowSha,
|
||||
event: options.eventName,
|
||||
job: options.job,
|
||||
runId: options.runId,
|
||||
runAttempt: options.runAttempt,
|
||||
},
|
||||
gates: { ...gates(options.role) },
|
||||
};
|
||||
validateVerificationEvidence(evidence, evidence.subject);
|
||||
writeExclusive(output, `${JSON.stringify(evidence, null, 2)}\n`);
|
||||
return Object.freeze(evidence);
|
||||
}
|
||||
|
||||
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 Object.freeze({
|
||||
file: name,
|
||||
sha256: sha256File(filePath),
|
||||
bytes: stat.size,
|
||||
});
|
||||
}
|
||||
|
||||
function checksumContents(bundleRoot, checkedNames) {
|
||||
return `${checkedNames
|
||||
.map(
|
||||
(name) => `${sha256File(path.join(bundleRoot, name)).slice(7)} ${name}`,
|
||||
)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function createClusterAlphaBundle(options, adapters = {}) {
|
||||
validateCommonIdentity(options);
|
||||
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||
const outputRoot = path.resolve(options.outputRoot || '');
|
||||
const parent = path.dirname(outputRoot);
|
||||
if (
|
||||
!path.isAbsolute(outputRoot) ||
|
||||
fs.existsSync(outputRoot) ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
) {
|
||||
fail('Cluster bundle output is invalid');
|
||||
}
|
||||
const sbomPath = assertCanonicalFile(
|
||||
options.sbom,
|
||||
MAX_JSON_BYTES,
|
||||
'Cluster SBOM',
|
||||
);
|
||||
const evidencePath = assertCanonicalFile(
|
||||
options.verificationEvidence,
|
||||
MAX_JSON_BYTES,
|
||||
'Cluster verification evidence',
|
||||
);
|
||||
const readmePath = assertCanonicalFile(
|
||||
options.readme,
|
||||
MAX_README_BYTES,
|
||||
'Cluster README',
|
||||
);
|
||||
const release = readReleaseIdentity(root);
|
||||
const reference = validateImageReference(
|
||||
options.image,
|
||||
options.role,
|
||||
options.architecture,
|
||||
);
|
||||
const inspectImage = adapters.inspectImage || inspectDockerImage;
|
||||
const saveImage = adapters.saveImage || saveDockerImage;
|
||||
const image = normalizeImageInspection(inspectImage(reference), {
|
||||
reference,
|
||||
role: options.role,
|
||||
architecture: options.architecture,
|
||||
sourceRevision: options.sourceRevision,
|
||||
version: release.version,
|
||||
});
|
||||
const sbom = readBoundedJson(sbomPath, 'Cluster SBOM');
|
||||
validateSbom(sbom, { root, role: options.role, version: release.version });
|
||||
const evidence = readBoundedJson(
|
||||
evidencePath,
|
||||
'Cluster verification evidence',
|
||||
);
|
||||
validateVerificationEvidence(evidence, {
|
||||
version: release.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
role: options.role,
|
||||
architecture: options.architecture,
|
||||
imageId: image.id,
|
||||
});
|
||||
const bundleNames = names(options.role, options.architecture);
|
||||
let created = false;
|
||||
try {
|
||||
fs.mkdirSync(outputRoot, { mode: 0o700 });
|
||||
created = true;
|
||||
const archivePath = path.join(outputRoot, bundleNames.archive);
|
||||
saveImage(reference, archivePath);
|
||||
const archiveStat = fs.lstatSync(archivePath);
|
||||
if (
|
||||
!archiveStat.isFile() ||
|
||||
archiveStat.isSymbolicLink() ||
|
||||
archiveStat.size < ARCHIVE_MIN_BYTES
|
||||
) {
|
||||
fail('Cluster Docker archive is invalid or unexpectedly small');
|
||||
}
|
||||
fs.chmodSync(archivePath, 0o600);
|
||||
copyExclusive(sbomPath, path.join(outputRoot, bundleNames.sbom));
|
||||
copyExclusive(
|
||||
evidencePath,
|
||||
path.join(outputRoot, bundleNames.verification),
|
||||
);
|
||||
copyExclusive(readmePath, path.join(outputRoot, bundleNames.readme));
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
schema: SCHEMA,
|
||||
maturity: 'cluster_integration_candidate_not_public_release',
|
||||
product: 'cluster',
|
||||
role: options.role,
|
||||
version: release.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
architecture: options.architecture,
|
||||
image,
|
||||
archive: fileRecord(outputRoot, bundleNames.archive),
|
||||
sbom: fileRecord(outputRoot, bundleNames.sbom),
|
||||
readme: fileRecord(outputRoot, bundleNames.readme),
|
||||
verification: fileRecord(outputRoot, bundleNames.verification),
|
||||
};
|
||||
writeExclusive(
|
||||
path.join(outputRoot, bundleNames.manifest),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
writeExclusive(
|
||||
path.join(outputRoot, bundleNames.checksums),
|
||||
checksumContents(outputRoot, [
|
||||
bundleNames.archive,
|
||||
bundleNames.sbom,
|
||||
bundleNames.verification,
|
||||
bundleNames.readme,
|
||||
bundleNames.manifest,
|
||||
]),
|
||||
);
|
||||
auditClusterAlphaBundle({ bundleRoot: outputRoot });
|
||||
return Object.freeze(manifest);
|
||||
} catch (error) {
|
||||
if (created) fs.rmSync(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 auditClusterAlphaBundle(options) {
|
||||
const bundleRoot = fs.realpathSync(path.resolve(options.bundleRoot || ''));
|
||||
if (!fs.lstatSync(bundleRoot).isDirectory()) {
|
||||
fail('Cluster bundle root must be a canonical directory');
|
||||
}
|
||||
const manifest = readBoundedJson(
|
||||
path.join(bundleRoot, 'manifest.json'),
|
||||
'manifest',
|
||||
);
|
||||
if (
|
||||
!exactKeys(manifest, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'maturity',
|
||||
'product',
|
||||
'role',
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'architecture',
|
||||
'image',
|
||||
'archive',
|
||||
'sbom',
|
||||
'readme',
|
||||
'verification',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'cluster_integration_candidate_not_public_release' ||
|
||||
manifest.product !== 'cluster' ||
|
||||
!Object.hasOwn(ROLES, manifest.role) ||
|
||||
!ARCHITECTURES.includes(manifest.architecture) ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
manifest.version.length < 3 ||
|
||||
manifest.version.length > 64 ||
|
||||
!REVISION_PATTERN.test(manifest.sourceRevision || '')
|
||||
) {
|
||||
fail('Cluster bundle manifest identity or shape is incompatible');
|
||||
}
|
||||
const bundleNames = names(manifest.role, manifest.architecture);
|
||||
const expectedFiles = Object.values(bundleNames).sort();
|
||||
const actualFiles = fs
|
||||
.readdirSync(bundleRoot, { withFileTypes: true })
|
||||
.map((entry) => {
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
fail(`Cluster bundle contains a non-regular entry: ${entry.name}`);
|
||||
}
|
||||
return entry.name;
|
||||
})
|
||||
.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail('Cluster bundle file set is not closed');
|
||||
}
|
||||
if (
|
||||
!exactKeys(manifest.image, [
|
||||
'reference',
|
||||
'id',
|
||||
'os',
|
||||
'architecture',
|
||||
'user',
|
||||
]) ||
|
||||
manifest.image.reference !==
|
||||
`${roleConfig(manifest.role).repository}:ci-${manifest.architecture}` ||
|
||||
!SHA256_PATTERN.test(manifest.image.id || '') ||
|
||||
manifest.image.os !== 'linux' ||
|
||||
manifest.image.architecture !== manifest.architecture ||
|
||||
manifest.image.user !== roleConfig(manifest.role).user
|
||||
) {
|
||||
fail('Cluster bundle image identity is incompatible');
|
||||
}
|
||||
validateFileRecord(manifest.archive, bundleNames.archive, bundleRoot);
|
||||
if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) {
|
||||
fail('Cluster bundle archive is unexpectedly small');
|
||||
}
|
||||
validateFileRecord(manifest.sbom, bundleNames.sbom, bundleRoot);
|
||||
validateFileRecord(manifest.readme, bundleNames.readme, bundleRoot);
|
||||
validateFileRecord(
|
||||
manifest.verification,
|
||||
bundleNames.verification,
|
||||
bundleRoot,
|
||||
);
|
||||
const sbom = readBoundedJson(path.join(bundleRoot, bundleNames.sbom), 'SBOM');
|
||||
validateSbom(sbom, { role: manifest.role, version: manifest.version }, true);
|
||||
const evidence = readBoundedJson(
|
||||
path.join(bundleRoot, bundleNames.verification),
|
||||
'verification evidence',
|
||||
);
|
||||
validateVerificationEvidence(evidence, {
|
||||
version: manifest.version,
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
role: manifest.role,
|
||||
architecture: manifest.architecture,
|
||||
imageId: manifest.image.id,
|
||||
});
|
||||
const expectedChecksums = checksumContents(bundleRoot, [
|
||||
bundleNames.archive,
|
||||
bundleNames.sbom,
|
||||
bundleNames.verification,
|
||||
bundleNames.readme,
|
||||
bundleNames.manifest,
|
||||
]);
|
||||
if (
|
||||
fs.readFileSync(path.join(bundleRoot, bundleNames.checksums), 'utf8') !==
|
||||
expectedChecksums
|
||||
) {
|
||||
fail('Cluster bundle SHA256SUMS differs from the closed file set');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/alpha-cluster-image-audit@v1',
|
||||
role: manifest.role,
|
||||
architecture: manifest.architecture,
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
version: manifest.version,
|
||||
workflowRunId: evidence.workflow.runId,
|
||||
workflowRunAttempt: evidence.workflow.runAttempt,
|
||||
imageId: manifest.image.id,
|
||||
archiveSha256: manifest.archive.sha256,
|
||||
verificationSha256: manifest.verification.sha256,
|
||||
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' || values.mode === 'offline-audit') {
|
||||
if (
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(['bundle', 'mode'])
|
||||
) {
|
||||
fail('audit arguments are invalid');
|
||||
}
|
||||
return { mode: values.mode, bundleRoot: path.resolve(values.bundle) };
|
||||
}
|
||||
const common = {
|
||||
architecture: values.architecture,
|
||||
image: values.image,
|
||||
role: values.role,
|
||||
sourceRevision: values['source-revision'],
|
||||
};
|
||||
if (values.mode === 'record-verification') {
|
||||
const expected = [
|
||||
'architecture',
|
||||
'event',
|
||||
'image',
|
||||
'job',
|
||||
'mode',
|
||||
'output',
|
||||
'repository',
|
||||
'role',
|
||||
'run-attempt',
|
||||
'run-id',
|
||||
'source-revision',
|
||||
'workflow-ref',
|
||||
'workflow-sha',
|
||||
].sort();
|
||||
if (
|
||||
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||
) {
|
||||
fail('verification arguments are invalid');
|
||||
}
|
||||
return {
|
||||
mode: values.mode,
|
||||
...common,
|
||||
output: path.resolve(values.output),
|
||||
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'],
|
||||
};
|
||||
}
|
||||
const expected = [
|
||||
'architecture',
|
||||
'image',
|
||||
'mode',
|
||||
'output',
|
||||
'readme',
|
||||
'role',
|
||||
'sbom',
|
||||
'source-revision',
|
||||
'verification-evidence',
|
||||
].sort();
|
||||
if (
|
||||
values.mode !== 'create' ||
|
||||
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||
) {
|
||||
fail('create arguments are invalid');
|
||||
}
|
||||
return {
|
||||
mode: values.mode,
|
||||
...common,
|
||||
outputRoot: path.resolve(values.output),
|
||||
sbom: path.resolve(values.sbom),
|
||||
verificationEvidence: path.resolve(values['verification-evidence']),
|
||||
readme: path.resolve(values.readme),
|
||||
};
|
||||
}
|
||||
|
||||
function runCli(argv) {
|
||||
const options = parseArguments(argv);
|
||||
let report;
|
||||
if (options.mode === 'record-verification') {
|
||||
report = createClusterAlphaVerificationEvidence(options);
|
||||
} else if (options.mode === 'create') {
|
||||
report = createClusterAlphaBundle(options);
|
||||
} else {
|
||||
report = auditClusterAlphaBundle(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 : 'Cluster Alpha bundle failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
ARCHITECTURES,
|
||||
ROLES,
|
||||
SCHEMA,
|
||||
VERIFICATION_SCHEMA,
|
||||
auditClusterAlphaBundle,
|
||||
createClusterAlphaBundle,
|
||||
createClusterAlphaVerificationEvidence,
|
||||
names,
|
||||
parseArguments,
|
||||
runCli,
|
||||
sha256File,
|
||||
});
|
||||
@@ -0,0 +1,647 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
ARCHITECTURES,
|
||||
ROLES,
|
||||
auditClusterAlphaBundle,
|
||||
sha256File,
|
||||
} = require('./ql3-cluster-alpha-bundle.cjs');
|
||||
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/alpha-cluster-milestone@v1';
|
||||
const SUBJECTS = Object.freeze(
|
||||
Object.keys(ROLES).flatMap((role) =>
|
||||
ARCHITECTURES.map((architecture) => `${role}-${architecture}`),
|
||||
),
|
||||
);
|
||||
const FILES = Object.freeze({
|
||||
readme: 'README.md',
|
||||
manifest: 'manifest.json',
|
||||
checksums: 'SHA256SUMS',
|
||||
});
|
||||
const WORKFLOW_IDENTITY = Object.freeze({
|
||||
repository: 'whyour/qinglong',
|
||||
workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next',
|
||||
event: 'workflow_dispatch',
|
||||
job: 'cluster-alpha-milestone',
|
||||
});
|
||||
const REQUIRED_WORKFLOW_NEEDS = Object.freeze([
|
||||
'backend',
|
||||
'service-manager-bridge',
|
||||
'linux-resource-envelopes',
|
||||
'linux-resource-release-evidence',
|
||||
'supply-chain',
|
||||
'local-image',
|
||||
'cluster-image',
|
||||
'cluster-console-capacity-release-evidence',
|
||||
'image-oci',
|
||||
'worker-runtime',
|
||||
'local-profiles',
|
||||
'cluster-postgres',
|
||||
'cluster-postgres-ha',
|
||||
'cluster-cloudnativepg-live',
|
||||
'cluster-provider-credential-test-kubernetes-live',
|
||||
'cluster-secret-binding-mounted-provider-kubernetes-live',
|
||||
'cluster-vault-kv-worker-secret-live',
|
||||
'cluster-plugin-package-kubernetes-live',
|
||||
'cluster-plugin-package-recovery-e2e',
|
||||
]);
|
||||
const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
||||
const REVISION_PATTERN = /^[0-9a-f]{40}$/u;
|
||||
const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u;
|
||||
const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u;
|
||||
const MAX_JSON_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_README_BYTES = 512 * 1024;
|
||||
|
||||
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);
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
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 fileRecord(filePath, name) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) {
|
||||
fail(`milestone file is invalid: ${name}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
file: name,
|
||||
sha256: sha256File(filePath),
|
||||
bytes: stat.size,
|
||||
});
|
||||
}
|
||||
|
||||
function checksumContents(root, checkedNames) {
|
||||
return `${checkedNames
|
||||
.map((name) => `${sha256File(path.join(root, name)).slice(7)} ${name}`)
|
||||
.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function artifactName(sourceRevision, role, architecture) {
|
||||
return `ql3-alpha-${sourceRevision}-${role}-${architecture}`;
|
||||
}
|
||||
|
||||
function splitSubject(subject) {
|
||||
const architecture = ARCHITECTURES.find((value) =>
|
||||
subject.endsWith(`-${value}`),
|
||||
);
|
||||
if (!architecture) fail(`milestone subject is invalid: ${subject}`);
|
||||
return Object.freeze({
|
||||
role: subject.slice(0, -(architecture.length + 1)),
|
||||
architecture,
|
||||
});
|
||||
}
|
||||
|
||||
function validateIdentity(options) {
|
||||
if (
|
||||
!REVISION_PATTERN.test(options.sourceRevision || '') ||
|
||||
options.repository !== WORKFLOW_IDENTITY.repository ||
|
||||
options.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||
options.workflowSha !== options.sourceRevision ||
|
||||
options.eventName !== WORKFLOW_IDENTITY.event ||
|
||||
!DECIMAL_ID_PATTERN.test(options.runId || '') ||
|
||||
!ATTEMPT_PATTERN.test(options.runAttempt || '')
|
||||
) {
|
||||
fail('Cluster milestone workflow identity is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
function validateFinalizeOptions(options) {
|
||||
validateIdentity(options);
|
||||
const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT));
|
||||
const outputRoot = path.resolve(options.outputRoot || '');
|
||||
const parent = path.dirname(outputRoot);
|
||||
if (
|
||||
!path.isAbsolute(outputRoot) ||
|
||||
fs.existsSync(outputRoot) ||
|
||||
fs.realpathSync(parent) !== parent
|
||||
) {
|
||||
fail('Cluster milestone output is invalid');
|
||||
}
|
||||
const bundles = {};
|
||||
for (const subject of SUBJECTS) {
|
||||
const bundleRoot = fs.realpathSync(
|
||||
path.resolve(options.bundles?.[subject] || ''),
|
||||
);
|
||||
if (!fs.lstatSync(bundleRoot).isDirectory()) {
|
||||
fail(`${subject} bundle root is invalid`);
|
||||
}
|
||||
bundles[subject] = bundleRoot;
|
||||
}
|
||||
if (new Set(Object.values(bundles)).size !== SUBJECTS.length) {
|
||||
fail('Cluster milestone subjects must use distinct bundles');
|
||||
}
|
||||
return Object.freeze({
|
||||
root,
|
||||
outputRoot,
|
||||
bundles: Object.freeze(bundles),
|
||||
readme: assertCanonicalFile(
|
||||
options.readme,
|
||||
MAX_README_BYTES,
|
||||
'Cluster milestone README',
|
||||
),
|
||||
sourceRevision: options.sourceRevision,
|
||||
repository: options.repository,
|
||||
workflowRef: options.workflowRef,
|
||||
workflowSha: options.workflowSha,
|
||||
eventName: options.eventName,
|
||||
runId: options.runId,
|
||||
runAttempt: options.runAttempt,
|
||||
});
|
||||
}
|
||||
|
||||
function bundleRecord(options, subject) {
|
||||
const { role, architecture } = splitSubject(subject);
|
||||
const bundleRoot = options.bundles[subject];
|
||||
const report = auditClusterAlphaBundle({ bundleRoot });
|
||||
if (
|
||||
report.compatible !== true ||
|
||||
report.role !== role ||
|
||||
report.architecture !== architecture ||
|
||||
report.sourceRevision !== options.sourceRevision ||
|
||||
report.workflowRunId !== options.runId ||
|
||||
report.workflowRunAttempt !== options.runAttempt
|
||||
) {
|
||||
fail(`${subject} bundle is detached from the Cluster milestone run`);
|
||||
}
|
||||
return Object.freeze({
|
||||
artifactName: artifactName(options.sourceRevision, role, architecture),
|
||||
role,
|
||||
architecture,
|
||||
bundleManifest: fileRecord(
|
||||
path.join(bundleRoot, 'manifest.json'),
|
||||
'manifest.json',
|
||||
),
|
||||
archiveSha256: report.archiveSha256,
|
||||
imageId: report.imageId,
|
||||
verificationSha256: report.verificationSha256,
|
||||
});
|
||||
}
|
||||
|
||||
function validateArtifactRecord(record, subject, manifest) {
|
||||
const { role, architecture } = splitSubject(subject);
|
||||
if (
|
||||
!exactKeys(record, [
|
||||
'artifactName',
|
||||
'role',
|
||||
'architecture',
|
||||
'bundleManifest',
|
||||
'archiveSha256',
|
||||
'imageId',
|
||||
'verificationSha256',
|
||||
]) ||
|
||||
record.artifactName !==
|
||||
artifactName(manifest.sourceRevision, role, architecture) ||
|
||||
record.role !== role ||
|
||||
record.architecture !== architecture ||
|
||||
!exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) ||
|
||||
record.bundleManifest.file !== 'manifest.json' ||
|
||||
!SHA256_PATTERN.test(record.bundleManifest.sha256 || '') ||
|
||||
!Number.isSafeInteger(record.bundleManifest.bytes) ||
|
||||
record.bundleManifest.bytes < 2 ||
|
||||
!SHA256_PATTERN.test(record.archiveSha256 || '') ||
|
||||
!SHA256_PATTERN.test(record.imageId || '') ||
|
||||
!SHA256_PATTERN.test(record.verificationSha256 || '')
|
||||
) {
|
||||
fail(`${subject} milestone artifact record is incompatible`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateWorkflow(document, sourceRevision) {
|
||||
if (
|
||||
!exactKeys(document, [
|
||||
'repository',
|
||||
'workflowRef',
|
||||
'workflowSha',
|
||||
'event',
|
||||
'job',
|
||||
'runId',
|
||||
'runAttempt',
|
||||
]) ||
|
||||
document.repository !== WORKFLOW_IDENTITY.repository ||
|
||||
document.workflowRef !== WORKFLOW_IDENTITY.workflowRef ||
|
||||
document.workflowSha !== sourceRevision ||
|
||||
document.event !== WORKFLOW_IDENTITY.event ||
|
||||
document.job !== WORKFLOW_IDENTITY.job ||
|
||||
!DECIMAL_ID_PATTERN.test(document.runId || '') ||
|
||||
!ATTEMPT_PATTERN.test(document.runAttempt || '')
|
||||
) {
|
||||
fail('Cluster milestone manifest workflow identity is incompatible');
|
||||
}
|
||||
}
|
||||
|
||||
function auditClusterAlphaMilestone(options) {
|
||||
const milestoneRoot = fs.realpathSync(
|
||||
path.resolve(options.milestoneRoot || ''),
|
||||
);
|
||||
if (!fs.lstatSync(milestoneRoot).isDirectory()) {
|
||||
fail('Cluster milestone root must be a canonical directory');
|
||||
}
|
||||
const expectedFiles = Object.values(FILES).sort();
|
||||
const actualFiles = fs
|
||||
.readdirSync(milestoneRoot, { withFileTypes: true })
|
||||
.map((entry) => {
|
||||
if (!entry.isFile() || entry.isSymbolicLink()) {
|
||||
fail(`Cluster milestone contains a non-regular entry: ${entry.name}`);
|
||||
}
|
||||
return entry.name;
|
||||
})
|
||||
.sort();
|
||||
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
||||
fail('Cluster milestone file set is not closed');
|
||||
}
|
||||
const manifest = readBoundedJson(
|
||||
path.join(milestoneRoot, FILES.manifest),
|
||||
'Cluster milestone manifest',
|
||||
);
|
||||
if (
|
||||
!exactKeys(manifest, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'maturity',
|
||||
'product',
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'workflow',
|
||||
'artifacts',
|
||||
'readme',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 1 ||
|
||||
manifest.schema !== SCHEMA ||
|
||||
manifest.maturity !== 'cluster_integration_candidate_not_public_release' ||
|
||||
manifest.product !== 'cluster' ||
|
||||
typeof manifest.version !== 'string' ||
|
||||
manifest.version.length < 3 ||
|
||||
manifest.version.length > 64 ||
|
||||
!REVISION_PATTERN.test(manifest.sourceRevision || '') ||
|
||||
!exactKeys(manifest.artifacts, SUBJECTS) ||
|
||||
!exactKeys(manifest.readme, ['file', 'sha256', 'bytes']) ||
|
||||
manifest.readme.file !== FILES.readme ||
|
||||
!SHA256_PATTERN.test(manifest.readme.sha256 || '') ||
|
||||
!Number.isSafeInteger(manifest.readme.bytes) ||
|
||||
manifest.readme.bytes < 2
|
||||
) {
|
||||
fail('Cluster milestone manifest identity or shape is incompatible');
|
||||
}
|
||||
validateWorkflow(manifest.workflow, manifest.sourceRevision);
|
||||
for (const subject of SUBJECTS) {
|
||||
validateArtifactRecord(manifest.artifacts[subject], subject, manifest);
|
||||
}
|
||||
const records = SUBJECTS.map((subject) => manifest.artifacts[subject]);
|
||||
for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) {
|
||||
if (
|
||||
new Set(records.map((record) => record[field])).size !== SUBJECTS.length
|
||||
) {
|
||||
fail(`Cluster milestone ${field} subjects are not distinct`);
|
||||
}
|
||||
}
|
||||
const actualReadme = fileRecord(
|
||||
path.join(milestoneRoot, FILES.readme),
|
||||
FILES.readme,
|
||||
);
|
||||
if (
|
||||
actualReadme.sha256 !== manifest.readme.sha256 ||
|
||||
actualReadme.bytes !== manifest.readme.bytes
|
||||
) {
|
||||
fail('Cluster milestone README differs from manifest');
|
||||
}
|
||||
const expectedChecksums = checksumContents(milestoneRoot, [
|
||||
FILES.readme,
|
||||
FILES.manifest,
|
||||
]);
|
||||
if (
|
||||
fs.readFileSync(path.join(milestoneRoot, FILES.checksums), 'utf8') !==
|
||||
expectedChecksums
|
||||
) {
|
||||
fail('Cluster milestone SHA256SUMS differs from the closed file set');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/alpha-cluster-milestone-audit@v1',
|
||||
sourceRevision: manifest.sourceRevision,
|
||||
version: manifest.version,
|
||||
workflowRunId: manifest.workflow.runId,
|
||||
workflowRunAttempt: manifest.workflow.runAttempt,
|
||||
subjects: [...SUBJECTS],
|
||||
compatible: true,
|
||||
});
|
||||
}
|
||||
|
||||
function finalizeClusterAlphaMilestone(options) {
|
||||
const normalized = validateFinalizeOptions(options);
|
||||
const artifacts = Object.fromEntries(
|
||||
SUBJECTS.map((subject) => [subject, bundleRecord(normalized, subject)]),
|
||||
);
|
||||
const versions = new Set(
|
||||
SUBJECTS.map(
|
||||
(subject) =>
|
||||
readBoundedJson(
|
||||
path.join(normalized.bundles[subject], 'manifest.json'),
|
||||
`${subject} bundle manifest`,
|
||||
).version,
|
||||
),
|
||||
);
|
||||
const release = readReleaseIdentity(normalized.root);
|
||||
if (versions.size !== 1 || !versions.has(release.version)) {
|
||||
fail('Cluster milestone bundles must have one release version');
|
||||
}
|
||||
for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) {
|
||||
if (
|
||||
new Set(SUBJECTS.map((subject) => artifacts[subject][field])).size !==
|
||||
SUBJECTS.length
|
||||
) {
|
||||
fail(`Cluster milestone ${field} subjects must be distinct`);
|
||||
}
|
||||
}
|
||||
let created = false;
|
||||
try {
|
||||
fs.mkdirSync(normalized.outputRoot, { mode: 0o700 });
|
||||
created = true;
|
||||
copyExclusive(
|
||||
normalized.readme,
|
||||
path.join(normalized.outputRoot, FILES.readme),
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
schema: SCHEMA,
|
||||
maturity: 'cluster_integration_candidate_not_public_release',
|
||||
product: 'cluster',
|
||||
version: [...versions][0],
|
||||
sourceRevision: normalized.sourceRevision,
|
||||
workflow: {
|
||||
repository: normalized.repository,
|
||||
workflowRef: normalized.workflowRef,
|
||||
workflowSha: normalized.workflowSha,
|
||||
event: normalized.eventName,
|
||||
job: WORKFLOW_IDENTITY.job,
|
||||
runId: normalized.runId,
|
||||
runAttempt: normalized.runAttempt,
|
||||
},
|
||||
artifacts,
|
||||
readme: fileRecord(
|
||||
path.join(normalized.outputRoot, FILES.readme),
|
||||
FILES.readme,
|
||||
),
|
||||
};
|
||||
writeExclusive(
|
||||
path.join(normalized.outputRoot, FILES.manifest),
|
||||
`${JSON.stringify(manifest, null, 2)}\n`,
|
||||
);
|
||||
writeExclusive(
|
||||
path.join(normalized.outputRoot, FILES.checksums),
|
||||
checksumContents(normalized.outputRoot, [FILES.readme, FILES.manifest]),
|
||||
);
|
||||
auditClusterAlphaMilestone({ milestoneRoot: normalized.outputRoot });
|
||||
return Object.freeze(manifest);
|
||||
} catch (error) {
|
||||
if (created)
|
||||
fs.rmSync(normalized.outputRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function countOccurrences(contents, token) {
|
||||
return contents.split(token).length - 1;
|
||||
}
|
||||
|
||||
function jobBlock(workflow, jobName) {
|
||||
const header = `\n ${jobName}:\n`;
|
||||
const start = workflow.indexOf(header);
|
||||
if (start < 0) return '';
|
||||
const remaining = workflow.slice(start + header.length);
|
||||
const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining);
|
||||
const end = nextMatch
|
||||
? start + header.length + nextMatch.index
|
||||
: workflow.length;
|
||||
return workflow.slice(start, end);
|
||||
}
|
||||
|
||||
function auditClusterAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(
|
||||
fs.realpathSync(path.resolve(root)),
|
||||
'.github/workflows/ql3-ci.yml',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
const findings = [];
|
||||
const clusterCondition =
|
||||
"github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')";
|
||||
if (
|
||||
countOccurrences(workflow, clusterCondition) !== 3 ||
|
||||
!workflow.includes("github.run_id || 'validation'") ||
|
||||
!workflow.includes(
|
||||
"cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}",
|
||||
)
|
||||
) {
|
||||
findings.push('CLUSTER_MILESTONE_SCOPE_OR_CONCURRENCY_DRIFT');
|
||||
}
|
||||
const milestone = jobBlock(workflow, 'cluster-alpha-milestone');
|
||||
const tokens = [
|
||||
'name: Finalize the Cluster Alpha integration milestone',
|
||||
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c',
|
||||
'scripts/ql3-cluster-alpha-milestone.cjs',
|
||||
'--mode=finalize',
|
||||
'--mode=audit',
|
||||
`name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`,
|
||||
'retention-days: 30',
|
||||
'overwrite: false',
|
||||
];
|
||||
if (
|
||||
!milestone ||
|
||||
tokens.some((token) => !milestone.includes(token)) ||
|
||||
SUBJECTS.some(
|
||||
(subject) =>
|
||||
!milestone.includes(
|
||||
`name: ql3-alpha-${'${{ github.sha }}'}-${subject}`,
|
||||
),
|
||||
) ||
|
||||
REQUIRED_WORKFLOW_NEEDS.some(
|
||||
(job) => !milestone.includes(` - ${job}\n`),
|
||||
)
|
||||
) {
|
||||
findings.push('CLUSTER_MILESTONE_FINALIZER_CONTRACT_DRIFT');
|
||||
}
|
||||
const finalizerIndex = milestone.indexOf('--mode=finalize');
|
||||
const auditIndex = milestone.indexOf('--mode=audit');
|
||||
const uploadIndex = milestone.indexOf('actions/upload-artifact@');
|
||||
if (
|
||||
finalizerIndex < 0 ||
|
||||
auditIndex <= finalizerIndex ||
|
||||
uploadIndex <= auditIndex
|
||||
) {
|
||||
findings.push('CLUSTER_MILESTONE_GATE_ORDER_DRIFT');
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/alpha-cluster-milestone-workflow-audit@v1',
|
||||
requiredNeeds: [...REQUIRED_WORKFLOW_NEEDS],
|
||||
subjects: [...SUBJECTS],
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
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(['milestone', 'mode'])
|
||||
) {
|
||||
fail('audit arguments are invalid');
|
||||
}
|
||||
return { mode: values.mode, milestoneRoot: path.resolve(values.milestone) };
|
||||
}
|
||||
if (values.mode === 'audit-workflow') {
|
||||
if (
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(['mode', 'root'])
|
||||
) {
|
||||
fail('workflow audit arguments are invalid');
|
||||
}
|
||||
return { mode: values.mode, root: path.resolve(values.root) };
|
||||
}
|
||||
const bundleArguments = SUBJECTS.map((subject) => `${subject}-bundle`);
|
||||
const expected = [
|
||||
...bundleArguments,
|
||||
'event',
|
||||
'mode',
|
||||
'output',
|
||||
'readme',
|
||||
'repository',
|
||||
'run-attempt',
|
||||
'run-id',
|
||||
'source-revision',
|
||||
'workflow-ref',
|
||||
'workflow-sha',
|
||||
].sort();
|
||||
if (
|
||||
values.mode !== 'finalize' ||
|
||||
JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected)
|
||||
) {
|
||||
fail('finalize arguments are invalid');
|
||||
}
|
||||
return {
|
||||
mode: values.mode,
|
||||
outputRoot: path.resolve(values.output),
|
||||
bundles: Object.fromEntries(
|
||||
SUBJECTS.map((subject) => [
|
||||
subject,
|
||||
path.resolve(values[`${subject}-bundle`]),
|
||||
]),
|
||||
),
|
||||
readme: path.resolve(values.readme),
|
||||
sourceRevision: values['source-revision'],
|
||||
repository: values.repository,
|
||||
workflowRef: values['workflow-ref'],
|
||||
workflowSha: values['workflow-sha'],
|
||||
eventName: values.event,
|
||||
runId: values['run-id'],
|
||||
runAttempt: values['run-attempt'],
|
||||
};
|
||||
}
|
||||
|
||||
function runCli(argv) {
|
||||
const options = parseArguments(argv);
|
||||
let report;
|
||||
if (options.mode === 'finalize') {
|
||||
report = finalizeClusterAlphaMilestone(options);
|
||||
} else if (options.mode === 'audit-workflow') {
|
||||
report = auditClusterAlphaMilestoneWorkflow(options.root);
|
||||
if (!report.compatible) fail(JSON.stringify(report));
|
||||
} else {
|
||||
report = auditClusterAlphaMilestone(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
|
||||
: 'Cluster Alpha milestone failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
FILES,
|
||||
REQUIRED_WORKFLOW_NEEDS,
|
||||
SCHEMA,
|
||||
SUBJECTS,
|
||||
artifactName,
|
||||
auditClusterAlphaMilestone,
|
||||
auditClusterAlphaMilestoneWorkflow,
|
||||
finalizeClusterAlphaMilestone,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
@@ -465,6 +465,18 @@ function countOccurrences(contents, token) {
|
||||
return contents.split(token).length - 1;
|
||||
}
|
||||
|
||||
function jobBlock(workflow, jobName) {
|
||||
const header = `\n ${jobName}:\n`;
|
||||
const start = workflow.indexOf(header);
|
||||
if (start < 0) return '';
|
||||
const remaining = workflow.slice(start + header.length);
|
||||
const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining);
|
||||
const end = nextMatch
|
||||
? start + header.length + nextMatch.index
|
||||
: workflow.length;
|
||||
return workflow.slice(start, end);
|
||||
}
|
||||
|
||||
function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||
const workflowPath = path.join(
|
||||
fs.realpathSync(path.resolve(root)),
|
||||
@@ -473,7 +485,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||
const workflow = fs.readFileSync(workflowPath, 'utf8');
|
||||
const findings = [];
|
||||
const milestoneStart = workflow.indexOf('\n local-alpha-milestone:\n');
|
||||
const milestone = milestoneStart >= 0 ? workflow.slice(milestoneStart) : '';
|
||||
const milestone = jobBlock(workflow, 'local-alpha-milestone');
|
||||
const requiredWorkflowTokens = [
|
||||
'alpha_artifact_scope:',
|
||||
'default: local',
|
||||
@@ -492,7 +504,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) {
|
||||
"github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')";
|
||||
if (
|
||||
countOccurrences(workflow, localScopeCondition) !== 3 ||
|
||||
countOccurrences(workflow, clusterScopeCondition) !== 2
|
||||
countOccurrences(workflow, clusterScopeCondition) !== 3
|
||||
) {
|
||||
findings.push('MILESTONE_SCOPE_CONTRACT_DRIFT');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user