mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
546 lines
17 KiB
JavaScript
546 lines
17 KiB
JavaScript
'use strict';
|
|
|
|
const assert = require('node:assert/strict');
|
|
const crypto = require('node:crypto');
|
|
const { spawnSync } = require('node:child_process');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const test = require('node:test');
|
|
|
|
const {
|
|
ARTIFACT_TYPE,
|
|
FILE_MEDIA_TYPE,
|
|
OCI_EMPTY_CONFIG_DIGEST,
|
|
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
|
OCI_MANIFEST_MEDIA_TYPE,
|
|
createCatalogPlan,
|
|
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
|
const {
|
|
auditClusterImageCiWorkflow,
|
|
} = require('../../scripts/ql3-cluster-image-release-audit.cjs');
|
|
const {
|
|
privateReleaseEvidenceReceipts,
|
|
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
|
|
|
const ROOT = path.resolve(__dirname, '../..');
|
|
const SCRIPT = path.join(
|
|
ROOT,
|
|
'scripts/ql3-release-catalog-consumption-ceremony.cjs',
|
|
);
|
|
const VERSION = '3.0.0-alpha.0';
|
|
const REVISION = 'a'.repeat(40);
|
|
const SOURCE_REF = `refs/tags/v${VERSION}`;
|
|
const OWNER = 'example';
|
|
const SOURCE_REPOSITORY = `${OWNER}/qinglong`;
|
|
const TOKEN = 'github_pat_catalog_consumption_fixture';
|
|
|
|
function sha256(value) {
|
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
|
}
|
|
|
|
function selectedImages(scope) {
|
|
const cluster = [
|
|
['control', 'qinglong3-cluster-control'],
|
|
['control-ai', 'qinglong3-cluster-control-ai'],
|
|
['admin', 'qinglong3-cluster-admin'],
|
|
['worker', 'qinglong3-worker'],
|
|
];
|
|
const local = [['local', 'qinglong3-local-application']];
|
|
return scope === 'local'
|
|
? local
|
|
: scope === 'cluster'
|
|
? cluster
|
|
: [...cluster, ...local];
|
|
}
|
|
|
|
function releaseSet(scope) {
|
|
const selected = selectedImages(scope);
|
|
const release = {
|
|
version: VERSION,
|
|
sourceRevision: REVISION,
|
|
sourceRef: SOURCE_REF,
|
|
scope,
|
|
};
|
|
const images = selected.map(([name, repository], index) => {
|
|
const digest = `sha256:${(index + 1).toString(16).repeat(64)}`;
|
|
const reference = `ghcr.io/${OWNER}/${repository}`;
|
|
return {
|
|
name,
|
|
repository,
|
|
digest,
|
|
reference: `${reference}@${digest}`,
|
|
versionTag: `${reference}:${VERSION}`,
|
|
sourceTag: `${reference}:sha-${REVISION}`,
|
|
platforms: ['linux/amd64', 'linux/arm64'],
|
|
imageRecordDigest: `sha256:${(index + 6).toString(16).repeat(64)}`,
|
|
};
|
|
});
|
|
const names = images.map((entry) => entry.name);
|
|
const unsigned = {
|
|
schemaVersion: 1,
|
|
schema: 'qinglong/release-set@v3',
|
|
release,
|
|
candidate: {
|
|
schema: 'qinglong/release-candidate-contract@v1',
|
|
contractDigest: `sha256:${'b'.repeat(64)}`,
|
|
},
|
|
repositoryOwner: OWNER,
|
|
platforms: ['linux/amd64', 'linux/arm64'],
|
|
deploymentFamilies: {
|
|
local: {
|
|
selected: ['local', 'all'].includes(scope),
|
|
profiles: ['edge', 'standalone'],
|
|
images: names.filter((name) => name === 'local'),
|
|
},
|
|
cluster: {
|
|
selected: ['cluster', 'all'].includes(scope),
|
|
profiles: ['cluster', 'worker-edge', 'worker-node'],
|
|
images: names.filter((name) => name !== 'local'),
|
|
},
|
|
},
|
|
evidenceReceipts: privateReleaseEvidenceReceipts(release),
|
|
images,
|
|
promotion: {
|
|
authority: 'complete_verified_release_set',
|
|
versionTags: 'promote_after_complete_set_audit',
|
|
sourceTags: 'promote_after_complete_set_audit',
|
|
crossRepositoryAtomicity: false,
|
|
recovery: 'verify_exact_digest_then_continue',
|
|
},
|
|
requiredVerification: {
|
|
imageKeylessSignature: true,
|
|
imageAttestations: [
|
|
'github-provenance',
|
|
'cyclonedx-sbom',
|
|
'os-vulnerability',
|
|
'release-candidate-contract',
|
|
],
|
|
privateReleaseEvidenceReceipts:
|
|
scope === 'local'
|
|
? []
|
|
: ['worker-management', 'cloudnativepg-disaster-recovery'],
|
|
releaseSetBuildProvenance: true,
|
|
},
|
|
};
|
|
return { ...unsigned, releaseSetDigest: sha256(JSON.stringify(unsigned)) };
|
|
}
|
|
|
|
function catalogManifest(value, scope) {
|
|
const identity = {
|
|
version: VERSION,
|
|
sourceRevision: REVISION,
|
|
sourceRef: SOURCE_REF,
|
|
releaseScope: scope,
|
|
repositoryOwner: OWNER,
|
|
sourceRepository: SOURCE_REPOSITORY,
|
|
};
|
|
const plan = createCatalogPlan(value, identity);
|
|
return {
|
|
schemaVersion: 2,
|
|
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
|
artifactType: ARTIFACT_TYPE,
|
|
config: {
|
|
mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
|
digest: OCI_EMPTY_CONFIG_DIGEST,
|
|
size: 2,
|
|
},
|
|
layers: [
|
|
{
|
|
mediaType: FILE_MEDIA_TYPE,
|
|
digest: plan.releaseSet.contentDigest,
|
|
size: plan.releaseSet.bytes,
|
|
annotations: {
|
|
'org.opencontainers.image.title': plan.releaseSet.fileName,
|
|
},
|
|
},
|
|
],
|
|
annotations: { ...plan.catalog.annotations },
|
|
};
|
|
}
|
|
|
|
function fixture(t, options = {}) {
|
|
const directory = fs.realpathSync(
|
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-catalog-consumption-')),
|
|
);
|
|
fs.chmodSync(directory, 0o700);
|
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
|
const scope = options.scope || 'local';
|
|
const release = releaseSet(scope);
|
|
const releaseText = options.nonCanonicalRelease
|
|
? `${JSON.stringify(release, null, 2)}\n`
|
|
: `${JSON.stringify(release)}\n`;
|
|
const manifest = catalogManifest(release, scope);
|
|
const manifestText = JSON.stringify(manifest);
|
|
const manifestDigest = sha256(Buffer.from(manifestText, 'utf8'));
|
|
const changedDigest = `sha256:${'f'.repeat(64)}`;
|
|
const capture = path.join(directory, 'calls.jsonl');
|
|
const state = path.join(directory, 'state');
|
|
const tokenFile = path.join(directory, 'github-token');
|
|
const outputDirectory = path.join(directory, 'bundle');
|
|
fs.writeFileSync(tokenFile, TOKEN, { mode: 0o600 });
|
|
const tools = {};
|
|
for (const name of ['regctl', 'cosign', 'gh']) {
|
|
const tool = path.join(directory, name);
|
|
let behavior = '';
|
|
if (name === 'regctl') {
|
|
behavior = `
|
|
if (args[0] === 'image' && args[1] === 'digest') {
|
|
const count = fs.existsSync(${JSON.stringify(
|
|
state,
|
|
)}) ? Number(fs.readFileSync(${JSON.stringify(state)}, 'utf8')) : 0;
|
|
fs.writeFileSync(${JSON.stringify(state)}, String(count + 1));
|
|
const digest = count > 0 && ${JSON.stringify(
|
|
Boolean(options.changeDiscovery),
|
|
)} ? ${JSON.stringify(changedDigest)} : ${JSON.stringify(manifestDigest)};
|
|
process.stdout.write(digest + '\\n');
|
|
if (count === 0 && ${JSON.stringify(Boolean(options.mutateCosign))}) {
|
|
fs.appendFileSync(${JSON.stringify(path.join(directory, 'cosign'))}, '\\n');
|
|
}
|
|
} else if (args[0] === 'artifact' && args[1] === 'get') {
|
|
process.stdout.write(${JSON.stringify(releaseText)});
|
|
} else if (args[0] === 'manifest' && args[1] === 'get') {
|
|
process.stdout.write(${JSON.stringify(
|
|
options.manifestDrift ? `${manifestText} ` : manifestText,
|
|
)});
|
|
} else {
|
|
process.exitCode = 7;
|
|
}
|
|
`;
|
|
} else {
|
|
behavior = `
|
|
process.stdout.write(JSON.stringify({ verified: true, tool: name }) + '\\n');
|
|
if (name === 'cosign' && ${JSON.stringify(Boolean(options.cosignFailure))}) {
|
|
process.exitCode = 9;
|
|
}
|
|
`;
|
|
}
|
|
const source = `#!${process.execPath}
|
|
'use strict';
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const name = path.basename(process.argv[1]);
|
|
const args = process.argv.slice(2);
|
|
fs.appendFileSync(${JSON.stringify(
|
|
capture,
|
|
)}, JSON.stringify({ name, args, tokenPresent: process.env.GH_TOKEN === ${JSON.stringify(
|
|
TOKEN,
|
|
)} }) + '\\n');
|
|
${behavior}
|
|
`;
|
|
fs.writeFileSync(tool, source, { mode: 0o700 });
|
|
tools[name] = tool;
|
|
}
|
|
return {
|
|
capture,
|
|
directory,
|
|
manifestDigest,
|
|
outputDirectory,
|
|
release,
|
|
scope,
|
|
tokenFile,
|
|
tools,
|
|
};
|
|
}
|
|
|
|
function createArguments(value) {
|
|
return [
|
|
'--mode=create',
|
|
`--version=${VERSION}`,
|
|
`--source-revision=${REVISION}`,
|
|
`--source-ref=${SOURCE_REF}`,
|
|
`--release-scope=${value.scope}`,
|
|
`--repository-owner=${OWNER}`,
|
|
`--source-repository=${SOURCE_REPOSITORY}`,
|
|
`--regctl=${value.tools.regctl}`,
|
|
`--cosign=${value.tools.cosign}`,
|
|
`--gh=${value.tools.gh}`,
|
|
`--github-token-file=${value.tokenFile}`,
|
|
`--output-directory=${value.outputDirectory}`,
|
|
];
|
|
}
|
|
|
|
function auditArguments(value) {
|
|
return [
|
|
'--mode=audit',
|
|
`--version=${VERSION}`,
|
|
`--source-revision=${REVISION}`,
|
|
`--source-ref=${SOURCE_REF}`,
|
|
`--release-scope=${value.scope}`,
|
|
`--repository-owner=${OWNER}`,
|
|
`--source-repository=${SOURCE_REPOSITORY}`,
|
|
`--output-directory=${value.outputDirectory}`,
|
|
];
|
|
}
|
|
|
|
function invoke(arguments_) {
|
|
return spawnSync(process.execPath, [SCRIPT, ...arguments_], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
env: {},
|
|
});
|
|
}
|
|
|
|
function createAndAudit(value) {
|
|
const created = invoke(createArguments(value));
|
|
assert.equal(created.status, 0, created.stderr);
|
|
assert.equal(created.stderr, '');
|
|
const audited = invoke(auditArguments(value));
|
|
assert.equal(audited.status, 0, audited.stderr);
|
|
assert.equal(audited.stderr, '');
|
|
return {
|
|
created: JSON.parse(created.stdout),
|
|
audited: JSON.parse(audited.stdout),
|
|
};
|
|
}
|
|
|
|
test('consumes one immutable catalog into an auditable no-replace bundle', (t) => {
|
|
const value = fixture(t);
|
|
const result = createAndAudit(value);
|
|
assert.equal(result.created.compatible, true);
|
|
assert.equal(result.audited.compatible, true);
|
|
assert.equal(result.audited.releaseScope, 'local');
|
|
assert.equal(
|
|
result.created.immutableReference,
|
|
`ghcr.io/${OWNER}/qinglong3-release-catalog@${value.manifestDigest}`,
|
|
);
|
|
|
|
assert.equal(fs.statSync(value.outputDirectory).mode & 0o777, 0o700);
|
|
const names = fs.readdirSync(value.outputDirectory).sort();
|
|
assert.deepEqual(names, [
|
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
|
`qinglong3-release-catalog-manifest-${VERSION}-local.json`,
|
|
`qinglong3-release-set-${VERSION}-local.json`,
|
|
]);
|
|
for (const name of names) {
|
|
assert.equal(
|
|
fs.statSync(path.join(value.outputDirectory, name)).mode & 0o777,
|
|
0o600,
|
|
);
|
|
}
|
|
const report = fs.readFileSync(
|
|
path.join(
|
|
value.outputDirectory,
|
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
|
),
|
|
'utf8',
|
|
);
|
|
assert.equal(report.includes(TOKEN), false);
|
|
const parsed = JSON.parse(report);
|
|
assert.equal(
|
|
parsed.schema,
|
|
'qinglong/release-catalog-consumption-ceremony@v1',
|
|
);
|
|
assert.equal(parsed.verification.discoveryResolvedTwice, true);
|
|
assert.equal(parsed.verification.catalogReceiptReconstructed, true);
|
|
assert.equal(parsed.claims.deploymentMutation, false);
|
|
assert.equal(
|
|
parsed.claims.workstationFileWrites,
|
|
'private_temporary_only_plus_final_bundle',
|
|
);
|
|
assert.equal(parsed.steps.length, 6);
|
|
assert.equal(
|
|
fs
|
|
.readdirSync(value.directory)
|
|
.some((name) => name.startsWith('.ql3-release-catalog-consumption-')),
|
|
false,
|
|
);
|
|
|
|
const calls = fs
|
|
.readFileSync(value.capture, 'utf8')
|
|
.trim()
|
|
.split('\n')
|
|
.map((line) => JSON.parse(line));
|
|
assert.deepEqual(
|
|
calls.map((entry) => entry.name),
|
|
['regctl', 'cosign', 'gh', 'regctl', 'regctl', 'regctl'],
|
|
);
|
|
assert.deepEqual(
|
|
calls.map((entry) => entry.tokenPresent),
|
|
[false, false, true, false, false, false],
|
|
);
|
|
assert.deepEqual(calls[0].args, [
|
|
'image',
|
|
'digest',
|
|
`ghcr.io/${OWNER}/qinglong3-release-catalog:v${VERSION}-local`,
|
|
]);
|
|
assert.ok(calls[1].args.includes(parsed.workflowIdentity));
|
|
assert.ok(calls[2].args.includes('--deny-self-hosted-runners'));
|
|
assert.deepEqual(calls[3].args.slice(0, 4), [
|
|
'artifact',
|
|
'get',
|
|
'--file',
|
|
`qinglong3-release-set-${VERSION}-local.json`,
|
|
]);
|
|
});
|
|
|
|
test('supports the complete all-scope image family without runtime coupling', (t) => {
|
|
const value = fixture(t, { scope: 'all' });
|
|
createAndAudit(value);
|
|
const report = JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(
|
|
value.outputDirectory,
|
|
`qinglong3-release-catalog-consumption-${VERSION}-all.json`,
|
|
),
|
|
'utf8',
|
|
),
|
|
);
|
|
assert.deepEqual(report.releaseSet.images, [
|
|
'control',
|
|
'control-ai',
|
|
'admin',
|
|
'worker',
|
|
'local',
|
|
]);
|
|
assert.equal(report.releaseSet.imageCount, 5);
|
|
assert.equal(report.claims.deploymentMutation, false);
|
|
});
|
|
|
|
test('fails before publication when discovery changes during verification', (t) => {
|
|
const value = fixture(t, { changeDiscovery: true });
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /catalog discovery changed/);
|
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
|
});
|
|
|
|
test('rejects non-canonical release sets and mismatched raw manifests', async (t) => {
|
|
await t.test('release set', () => {
|
|
const value = fixture(t, { nonCanonicalRelease: true });
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /canonical JSON/);
|
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
|
});
|
|
await t.test('manifest', () => {
|
|
const value = fixture(t, { manifestDrift: true });
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /catalog manifest digest/);
|
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
|
});
|
|
});
|
|
|
|
test('rejects external verification failure and executable mutation', async (t) => {
|
|
await t.test('cosign failure', () => {
|
|
const value = fixture(t, { cosignFailure: true });
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /keyless_signature/);
|
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
|
});
|
|
await t.test('tool mutation', () => {
|
|
const value = fixture(t, { mutateCosign: true });
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /executable changed/);
|
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
|
});
|
|
});
|
|
|
|
test('offline audit detects every durable bundle drift surface', async (t) => {
|
|
for (const [name, mutate] of [
|
|
[
|
|
'release set',
|
|
(value) => {
|
|
const file = path.join(
|
|
value.outputDirectory,
|
|
`qinglong3-release-set-${VERSION}-local.json`,
|
|
);
|
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
parsed.images[0].digest = `sha256:${'e'.repeat(64)}`;
|
|
fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`);
|
|
},
|
|
],
|
|
[
|
|
'manifest',
|
|
(value) => {
|
|
fs.appendFileSync(
|
|
path.join(
|
|
value.outputDirectory,
|
|
`qinglong3-release-catalog-manifest-${VERSION}-local.json`,
|
|
),
|
|
' ',
|
|
);
|
|
},
|
|
],
|
|
[
|
|
'report',
|
|
(value) => {
|
|
const file = path.join(
|
|
value.outputDirectory,
|
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
|
);
|
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
parsed.claims.deploymentMutation = true;
|
|
fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`);
|
|
},
|
|
],
|
|
[
|
|
'extra file',
|
|
(value) => {
|
|
fs.writeFileSync(path.join(value.outputDirectory, 'unexpected'), 'x', {
|
|
mode: 0o600,
|
|
});
|
|
},
|
|
],
|
|
]) {
|
|
await t.test(name, () => {
|
|
const value = fixture(t);
|
|
createAndAudit(value);
|
|
mutate(value);
|
|
const audited = invoke(auditArguments(value));
|
|
assert.notEqual(audited.status, 0);
|
|
});
|
|
}
|
|
});
|
|
|
|
test('rejects output reuse, public credentials, symlinks and open CLI shapes', async (t) => {
|
|
await t.test('no replace', () => {
|
|
const value = fixture(t);
|
|
createAndAudit(value);
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /must not already exist/);
|
|
});
|
|
await t.test('public token', () => {
|
|
const value = fixture(t);
|
|
fs.chmodSync(value.tokenFile, 0o644);
|
|
const result = invoke(createArguments(value));
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /owner-private/);
|
|
});
|
|
await t.test('symlink tool', () => {
|
|
const value = fixture(t);
|
|
const link = path.join(value.directory, 'linked');
|
|
fs.symlinkSync(value.tools.regctl, link);
|
|
const arguments_ = createArguments(value).map((argument) =>
|
|
argument.startsWith('--regctl=') ? `--regctl=${link}` : argument,
|
|
);
|
|
const result = invoke(arguments_);
|
|
assert.notEqual(result.status, 0);
|
|
});
|
|
await t.test('open arguments', () => {
|
|
const value = fixture(t);
|
|
const result = invoke([...createArguments(value), '--unknown=true']);
|
|
assert.notEqual(result.status, 0);
|
|
assert.match(result.stderr, /arguments are invalid/);
|
|
});
|
|
});
|
|
|
|
test('supply-chain CI cannot silently remove the consumption ceremony tests', () => {
|
|
const source = fs.readFileSync(
|
|
path.join(ROOT, '.github/workflows/ql3-ci.yml'),
|
|
'utf8',
|
|
);
|
|
const mutated = source.replace(
|
|
'test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs',
|
|
'test/back/release-catalog-consumption-removed.test.cjs',
|
|
);
|
|
assert.throws(
|
|
() => auditClusterImageCiWorkflow(mutated),
|
|
/catalog consumption ceremony/,
|
|
);
|
|
});
|