mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): publish durable release catalog
This commit is contained in:
@@ -254,6 +254,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
'--bundle-from-oci',
|
||||
'Promote tags only after the complete set is verified',
|
||||
'Attest the complete release-set file provenance',
|
||||
'Publish and round-trip the durable OCI release catalog',
|
||||
'Attest durable release-catalog provenance',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
);
|
||||
|
||||
@@ -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\/ql3ReleaseSetContract\.test\.cjs/,
|
||||
'cluster image CI must run SBOM, candidate, release-set and workflow negative tests',
|
||||
/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\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs/,
|
||||
'cluster image CI must run SBOM, candidate, release-set, durable catalog and workflow negative tests',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -517,7 +517,7 @@ function auditReleaseWorkflow(source) {
|
||||
JSON.stringify(releaseSetJob?.needs) !==
|
||||
JSON.stringify(['release-candidate', 'publish']) ||
|
||||
releaseSetJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||
releaseSetJob?.['timeout-minutes'] !== 15 ||
|
||||
releaseSetJob?.['timeout-minutes'] !== 20 ||
|
||||
typeof releaseSetJob?.if !== 'string' ||
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*publish\.result == 'success'/.test(
|
||||
releaseSetJob.if,
|
||||
@@ -685,7 +685,7 @@ function auditReleaseWorkflow(source) {
|
||||
const releaseSetSteps = releaseSetJob?.steps;
|
||||
if (
|
||||
!Array.isArray(releaseSetSteps) ||
|
||||
releaseSetSteps.length !== 9 ||
|
||||
releaseSetSteps.length !== 15 ||
|
||||
releaseSetSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
releaseSetSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
@@ -702,29 +702,56 @@ function auditReleaseWorkflow(source) {
|
||||
'merge-multiple': true,
|
||||
}) ||
|
||||
releaseSetSteps[3]?.id !== 'release-set' ||
|
||||
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
!/ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=aggregate[\s\S]*--records="\$\{RUNNER_TEMP\}\/release-records"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{report\}"[\s\S]*ql3-release-set-contract\.cjs[\s\S]*--mode=inspect[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=plan[\s\S]*--source-repository="\$\{source_repository\}"[\s\S]*--release-set="\$\{report\}"[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[3]?.run ?? '',
|
||||
) ||
|
||||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/.test(
|
||||
releaseSetSteps[4]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[5]?.uses !==
|
||||
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
|
||||
releaseSetSteps[6]?.uses !==
|
||||
'docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c' ||
|
||||
!/for \(const image of report\.images\)[\s\S]*image\.reference[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*release tag already points at another digest[\s\S]*\['image', 'copy', state\.image\.reference, state\.tag\][\s\S]*promoted tag does not resolve to the release-set digest/.test(
|
||||
releaseSetSteps[6]?.run ?? '',
|
||||
releaseSetSteps[7]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[7]?.uses !==
|
||||
releaseSetSteps[8]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[7]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.release-set.outputs.report }}',
|
||||
}) ||
|
||||
releaseSetSteps[8]?.uses !==
|
||||
releaseSetSteps[9]?.id !== 'catalog' ||
|
||||
!/artifact put[\s\S]*--artifact-type "\$\{artifact_type\}"[\s\S]*--file-media-type "\$\{file_media_type\}"[\s\S]*--file "\$\{RELEASE_SET\}"[\s\S]*--file-title[\s\S]*--strip-dirs[\s\S]*dev\.qinglong\.release\.scope[\s\S]*org\.opencontainers\.image\.revision[\s\S]*org\.opencontainers\.image\.source[\s\S]*org\.opencontainers\.image\.version[\s\S]*image digest "\$\{discovery_tag\}"[\s\S]*artifact get --file "\$\{file_name\}" "\$\{immutable_reference\}"[\s\S]*cmp --silent "\$\{RELEASE_SET\}" "\$\{roundtrip\}"[\s\S]*manifest get "\$\{immutable_reference\}" --format raw-body[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[9]?.run ?? '',
|
||||
) ||
|
||||
!/cosign sign --yes "\$\{CATALOG\}@\$\{DIGEST\}"/.test(
|
||||
releaseSetSteps[10]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[11]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[11]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-name': '${{ steps.catalog.outputs.repository }}',
|
||||
'subject-digest': '${{ steps.catalog.outputs.digest }}',
|
||||
'push-to-registry': true,
|
||||
}) ||
|
||||
releaseSetSteps[12]?.id !== 'catalog-receipt' ||
|
||||
!/cosign verify[\s\S]*--certificate-identity "\$\{certificate_identity\}"[\s\S]*--certificate-oidc-issuer "https:\/\/token\.actions\.githubusercontent\.com"[\s\S]*"\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*gh attestation verify "oci:\/\/\$\{CATALOG\}@\$\{DIGEST\}"[\s\S]*--source-digest "\$\{GITHUB_SHA\}"[\s\S]*--source-ref "\$\{GITHUB_REF\}"[\s\S]*--deny-self-hosted-runners[\s\S]*--bundle-from-oci[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=receipt[\s\S]*--manifest-digest="\$\{DIGEST\}"[\s\S]*ql3-release-catalog-contract\.cjs[\s\S]*--mode=audit[\s\S]*GITHUB_OUTPUT/.test(
|
||||
releaseSetSteps[12]?.run ?? '',
|
||||
) ||
|
||||
releaseSetSteps[13]?.uses !==
|
||||
'actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6' ||
|
||||
JSON.stringify(releaseSetSteps[13]?.with) !==
|
||||
JSON.stringify({
|
||||
'subject-path': '${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
}) ||
|
||||
releaseSetSteps[14]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(releaseSetSteps[8]?.with) !==
|
||||
JSON.stringify(releaseSetSteps[14]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-set-${{ inputs.version }}-${{ inputs.release_scope }}',
|
||||
path: '${{ steps.release-set.outputs.report }}',
|
||||
path: '${{ steps.release-set.outputs.bundle }}',
|
||||
'if-no-files-found': 'error',
|
||||
'retention-days': 90,
|
||||
'compression-level': 0,
|
||||
@@ -733,7 +760,7 @@ function auditReleaseWorkflow(source) {
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'release-set job must download only same-run records, independently attest and publish one no-overwrite deployment lock',
|
||||
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
|
||||
);
|
||||
}
|
||||
if (
|
||||
@@ -832,6 +859,12 @@ function auditReleaseWorkflow(source) {
|
||||
6,
|
||||
'all release jobs must pin the reviewed immutable Node setup action',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
|
||||
2,
|
||||
'image and release-catalog publishers must both pin the reviewed Cosign installer',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/name: Recreate and audit the source-derived release candidate contract[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=create[\s\S]*--version="\$\{RELEASE_VERSION\}"[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--release-scope="\$\{RELEASE_SCOPE\}"[\s\S]*ql3-release-candidate-contract\.cjs[\s\S]*--mode=audit[\s\S]*--report="\$\{contract\}"/,
|
||||
@@ -891,14 +924,14 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
|
||||
5,
|
||||
'release workflow must create four image attestations and one complete release-set provenance attestation',
|
||||
7,
|
||||
'release workflow must create four image attestations plus release-set, durable catalog and receipt provenance',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/push-to-registry: true/g,
|
||||
4,
|
||||
'all GitHub attestations must be pushed beside the OCI image',
|
||||
5,
|
||||
'all image and durable-catalog OCI attestations must be pushed beside their subject',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -967,7 +1000,7 @@ function auditReleaseWorkflow(source) {
|
||||
'GitHub attestation verification must read the published OCI bundle',
|
||||
],
|
||||
]) {
|
||||
requireOccurrences(source, pattern, 4, finding);
|
||||
requireOccurrences(source, pattern, 5, finding);
|
||||
}
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -986,8 +1019,8 @@ function auditReleaseWorkflow(source) {
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish the deployment digest lock/,
|
||||
'release tags and the deployment lock must be published only after every selected digest record is complete',
|
||||
/release-set:\s+name: Close and publish the complete deployment release set[\s\S]*needs:[\s\S]*- publish[\s\S]*name: Promote tags only after the complete set is verified[\s\S]*for \(const image of report\.images\)[\s\S]*image\.versionTag, image\.sourceTag[\s\S]*image', 'copy'[\s\S]*name: Attest the complete release-set file provenance[\s\S]*name: Publish and round-trip the durable OCI release catalog[\s\S]*name: Keylessly sign the immutable release-catalog digest[\s\S]*name: Attest durable release-catalog provenance[\s\S]*name: Verify the durable catalog and create its immutable receipt[\s\S]*name: Attest the immutable release-catalog receipt[\s\S]*name: Publish the deployment digest lock/,
|
||||
'release tags, durable OCI catalog and deployment bundle must be published only after every selected digest record is complete',
|
||||
);
|
||||
return {
|
||||
trigger: 'explicit protected v3 tag dispatch',
|
||||
@@ -1046,11 +1079,24 @@ function auditReleaseWorkflow(source) {
|
||||
sourceDerived: true,
|
||||
sameRunRecords: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'complete_verified_release_set',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
},
|
||||
durableCatalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
artifactType: 'application/vnd.qinglong.release-set.v1+json',
|
||||
basenameOnly: true,
|
||||
crossRunnerDeterministic: true,
|
||||
byteExactRoundTrip: true,
|
||||
keylessSignatureVerified: true,
|
||||
githubProvenanceVerified: true,
|
||||
discoveryTagAuthority: 'none',
|
||||
immutableDigestAuthority: 'verified',
|
||||
receiptAttested: true,
|
||||
},
|
||||
localRolloutPreflight: true,
|
||||
localRolloutApply: true,
|
||||
postPublishVerification: [
|
||||
@@ -1061,6 +1107,7 @@ function auditReleaseWorkflow(source) {
|
||||
'os-vulnerability',
|
||||
'release-candidate',
|
||||
'release-set',
|
||||
'durable-catalog',
|
||||
'release-tags',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -253,6 +253,7 @@ function createReleaseCandidateContract(options) {
|
||||
'production-dependency-audit',
|
||||
'digest-signature-and-attestations',
|
||||
'cross-image-release-set',
|
||||
'durable-oci-release-catalog',
|
||||
...(options.releaseScope !== 'cluster'
|
||||
? ['edge-and-standalone-rollout']
|
||||
: []),
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
RELEASE_SET_SCHEMA,
|
||||
inspectReleaseSet,
|
||||
} = require('./ql3-release-set-contract.cjs');
|
||||
|
||||
const CATALOG_PLAN_SCHEMA = 'qinglong/release-catalog-plan@v1';
|
||||
const CATALOG_RECEIPT_SCHEMA = 'qinglong/release-catalog-receipt@v1';
|
||||
const ARTIFACT_TYPE = 'application/vnd.qinglong.release-set.v1+json';
|
||||
const FILE_MEDIA_TYPE = ARTIFACT_TYPE;
|
||||
const OCI_MANIFEST_MEDIA_TYPE = 'application/vnd.oci.image.manifest.v1+json';
|
||||
const OCI_EMPTY_CONFIG_MEDIA_TYPE = 'application/vnd.oci.empty.v1+json';
|
||||
const OCI_EMPTY_CONFIG_DIGEST =
|
||||
'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a';
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const SOURCE_REPOSITORY_PATTERN =
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?\/[a-z0-9._-]{1,100}$/u;
|
||||
const MAX_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
class QingLong3ReleaseCatalogError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong 3 release catalog failed: ${message}`);
|
||||
this.name = 'QingLong3ReleaseCatalogError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new QingLong3ReleaseCatalogError(message);
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function canonicalJson(value) {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value)) === JSON.stringify(expected)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveCanonicalAbsolute(input, label) {
|
||||
if (typeof input !== 'string' || !path.isAbsolute(input)) {
|
||||
fail(`${label} path must be absolute`);
|
||||
}
|
||||
const resolved = path.resolve(input);
|
||||
if (resolved !== input) fail(`${label} path must be normalized`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function readBoundedFile(filePath, label) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, label);
|
||||
const stat = fs.lstatSync(resolved);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_FILE_BYTES ||
|
||||
fs.realpathSync(resolved) !== resolved ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail(`${label} must be one bounded canonical regular file`);
|
||||
}
|
||||
return Object.freeze({
|
||||
path: resolved,
|
||||
contents: fs.readFileSync(resolved, 'utf8'),
|
||||
bytes: stat.size,
|
||||
});
|
||||
}
|
||||
|
||||
function parseJson(contents, label, requireCanonical) {
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(contents);
|
||||
} catch {
|
||||
fail(`${label} must contain valid JSON`);
|
||||
}
|
||||
if (requireCanonical && canonicalJson(value) !== contents) {
|
||||
fail(`${label} must use exact canonical JSON encoding`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCanonicalJson(filePath, label) {
|
||||
const file = readBoundedFile(filePath, label);
|
||||
return Object.freeze({
|
||||
...file,
|
||||
value: parseJson(file.contents, label, true),
|
||||
});
|
||||
}
|
||||
|
||||
function writeNoReplace(filePath, value) {
|
||||
const resolved = resolveCanonicalAbsolute(filePath, 'output');
|
||||
if (
|
||||
fs.existsSync(resolved) ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail('output must be unused in one canonical directory');
|
||||
}
|
||||
fs.writeFileSync(resolved, canonicalJson(value), {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeSourceRepository(value, repositoryOwner) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!SOURCE_REPOSITORY_PATTERN.test(value) ||
|
||||
!value.startsWith(`${repositoryOwner}/`)
|
||||
) {
|
||||
fail(
|
||||
'source repository must be one lowercase repository owned by the publisher',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function expectedFileName(version, releaseScope) {
|
||||
return `qinglong3-release-set-${version}-${releaseScope}.json`;
|
||||
}
|
||||
|
||||
function createCatalogPlan(releaseSet, options) {
|
||||
inspectReleaseSet(releaseSet, options);
|
||||
const sourceRepository = normalizeSourceRepository(
|
||||
options.sourceRepository,
|
||||
releaseSet.repositoryOwner,
|
||||
);
|
||||
const contents = canonicalJson(releaseSet);
|
||||
const fileName = expectedFileName(options.version, options.releaseScope);
|
||||
const registryRepository = `ghcr.io/${releaseSet.repositoryOwner}/qinglong3-release-catalog`;
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: CATALOG_PLAN_SCHEMA,
|
||||
release: { ...releaseSet.release },
|
||||
sourceRepository,
|
||||
releaseSet: {
|
||||
schema: RELEASE_SET_SCHEMA,
|
||||
releaseSetDigest: releaseSet.releaseSetDigest,
|
||||
contentDigest: sha256(contents),
|
||||
bytes: Buffer.byteLength(contents),
|
||||
fileName,
|
||||
},
|
||||
catalog: {
|
||||
repository: 'qinglong3-release-catalog',
|
||||
registryRepository,
|
||||
discoveryTag: `${registryRepository}:v${options.version}-${options.releaseScope}`,
|
||||
artifactType: ARTIFACT_TYPE,
|
||||
fileMediaType: FILE_MEDIA_TYPE,
|
||||
annotations: {
|
||||
'dev.qinglong.release.scope': options.releaseScope,
|
||||
'org.opencontainers.image.revision': options.sourceRevision,
|
||||
'org.opencontainers.image.source': `https://github.com/${sourceRepository}`,
|
||||
'org.opencontainers.image.version': options.version,
|
||||
},
|
||||
},
|
||||
publicationPolicy: {
|
||||
title: 'basename_only',
|
||||
crossRunnerDeterministic: true,
|
||||
discoveryTagAuthority: 'none',
|
||||
immutableDigestAuthority: 'required',
|
||||
roundTrip: 'byte_exact',
|
||||
recovery: 'republish_same_content_then_verify_digest',
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
planDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function auditCatalogPlan(actual, releaseSet, options) {
|
||||
const expected = createCatalogPlan(releaseSet, options);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
fail('catalog plan differs from the standalone release set');
|
||||
}
|
||||
return expected;
|
||||
}
|
||||
|
||||
function validatePlanShape(plan) {
|
||||
if (
|
||||
!exactKeys(plan, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'release',
|
||||
'sourceRepository',
|
||||
'releaseSet',
|
||||
'catalog',
|
||||
'publicationPolicy',
|
||||
'planDigest',
|
||||
]) ||
|
||||
plan.schemaVersion !== 1 ||
|
||||
plan.schema !== CATALOG_PLAN_SCHEMA ||
|
||||
!DIGEST_PATTERN.test(plan.planDigest || '')
|
||||
) {
|
||||
fail('catalog plan shape is invalid');
|
||||
}
|
||||
const { planDigest, ...unsigned } = plan;
|
||||
if (planDigest !== sha256(JSON.stringify(unsigned))) {
|
||||
fail('catalog plan digest is invalid');
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
function validateManifest(plan, manifestContents, manifestDigest) {
|
||||
if (
|
||||
!DIGEST_PATTERN.test(manifestDigest || '') ||
|
||||
sha256(manifestContents) !== manifestDigest
|
||||
) {
|
||||
fail('catalog manifest digest is invalid');
|
||||
}
|
||||
const manifest = parseJson(manifestContents, 'catalog manifest', false);
|
||||
if (
|
||||
!exactKeys(manifest, [
|
||||
'schemaVersion',
|
||||
'mediaType',
|
||||
'artifactType',
|
||||
'config',
|
||||
'layers',
|
||||
'annotations',
|
||||
]) ||
|
||||
manifest.schemaVersion !== 2 ||
|
||||
manifest.mediaType !== OCI_MANIFEST_MEDIA_TYPE ||
|
||||
manifest.artifactType !== plan.catalog.artifactType ||
|
||||
!exactKeys(manifest.config, ['mediaType', 'digest', 'size']) ||
|
||||
manifest.config.mediaType !== OCI_EMPTY_CONFIG_MEDIA_TYPE ||
|
||||
manifest.config.digest !== OCI_EMPTY_CONFIG_DIGEST ||
|
||||
manifest.config.size !== 2 ||
|
||||
!Array.isArray(manifest.layers) ||
|
||||
manifest.layers.length !== 1 ||
|
||||
!exactKeys(manifest.layers[0], [
|
||||
'mediaType',
|
||||
'digest',
|
||||
'size',
|
||||
'annotations',
|
||||
]) ||
|
||||
manifest.layers[0].mediaType !== plan.catalog.fileMediaType ||
|
||||
manifest.layers[0].digest !== plan.releaseSet.contentDigest ||
|
||||
manifest.layers[0].size !== plan.releaseSet.bytes ||
|
||||
JSON.stringify(manifest.layers[0].annotations) !==
|
||||
JSON.stringify({
|
||||
'org.opencontainers.image.title': plan.releaseSet.fileName,
|
||||
}) ||
|
||||
JSON.stringify(manifest.annotations) !==
|
||||
JSON.stringify(plan.catalog.annotations)
|
||||
) {
|
||||
fail('catalog manifest differs from the exact publication plan');
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function createCatalogReceipt(plan, manifestContents, manifestDigest) {
|
||||
validatePlanShape(plan);
|
||||
validateManifest(plan, manifestContents, manifestDigest);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: CATALOG_RECEIPT_SCHEMA,
|
||||
release: { ...plan.release },
|
||||
planDigest: plan.planDigest,
|
||||
releaseSet: { ...plan.releaseSet },
|
||||
catalog: {
|
||||
repository: plan.catalog.repository,
|
||||
discoveryTag: plan.catalog.discoveryTag,
|
||||
manifestDigest,
|
||||
immutableReference: `${plan.catalog.registryRepository}@${manifestDigest}`,
|
||||
artifactType: plan.catalog.artifactType,
|
||||
},
|
||||
verification: {
|
||||
remoteManifestStructure: 'exact',
|
||||
releaseSetRoundTrip: 'byte_exact',
|
||||
keylessSignature: 'exact_workflow_identity',
|
||||
githubProvenance: 'source_tag_and_revision_bound',
|
||||
discoveryTagAuthority: 'none',
|
||||
immutableDigestAuthority: 'verified',
|
||||
},
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
receiptDigest: sha256(JSON.stringify(unsigned)),
|
||||
});
|
||||
}
|
||||
|
||||
function auditCatalogReceipt(actual, plan, manifestContents, manifestDigest) {
|
||||
const expected = createCatalogReceipt(plan, manifestContents, manifestDigest);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
fail('catalog receipt differs from the verified OCI manifest');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
releaseSetDigest: actual.releaseSet.releaseSetDigest,
|
||||
releaseScope: actual.release.scope,
|
||||
catalogManifestDigest: actual.catalog.manifestDigest,
|
||||
immutableReference: actual.catalog.immutableReference,
|
||||
discoveryTagAuthority: actual.verification.discoveryTagAuthority,
|
||||
});
|
||||
}
|
||||
|
||||
function parseArguments(argv) {
|
||||
const values = {};
|
||||
for (const argument of argv) {
|
||||
const match = /^--([a-z-]+)=(.+)$/u.exec(argument);
|
||||
if (!match || Object.hasOwn(values, match[1]))
|
||||
fail('arguments are invalid');
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
const identity = [
|
||||
'mode',
|
||||
'release-scope',
|
||||
'repository-owner',
|
||||
'source-ref',
|
||||
'source-repository',
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const expected =
|
||||
values.mode === 'plan'
|
||||
? [...identity, 'output', 'release-set']
|
||||
: values.mode === 'receipt'
|
||||
? ['manifest', 'manifest-digest', 'mode', 'output', 'plan']
|
||||
: values.mode === 'audit'
|
||||
? ['manifest', 'manifest-digest', 'mode', 'plan', 'receipt']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
...(values.version ? { version: values.version } : {}),
|
||||
...(values['source-revision']
|
||||
? { sourceRevision: values['source-revision'] }
|
||||
: {}),
|
||||
...(values['source-ref'] ? { sourceRef: values['source-ref'] } : {}),
|
||||
...(values['release-scope']
|
||||
? { releaseScope: values['release-scope'] }
|
||||
: {}),
|
||||
...(values['repository-owner']
|
||||
? { repositoryOwner: values['repository-owner'] }
|
||||
: {}),
|
||||
...(values['source-repository']
|
||||
? { sourceRepository: values['source-repository'] }
|
||||
: {}),
|
||||
...(values['release-set'] ? { releaseSet: values['release-set'] } : {}),
|
||||
...(values.plan ? { plan: values.plan } : {}),
|
||||
...(values.manifest ? { manifest: values.manifest } : {}),
|
||||
...(values['manifest-digest']
|
||||
? { manifestDigest: values['manifest-digest'] }
|
||||
: {}),
|
||||
...(values.receipt ? { receipt: values.receipt } : {}),
|
||||
...(values.output ? { output: values.output } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'plan') {
|
||||
const releaseSetFile = readCanonicalJson(options.releaseSet, 'release set');
|
||||
if (
|
||||
path.basename(releaseSetFile.path) !==
|
||||
expectedFileName(options.version, options.releaseScope)
|
||||
) {
|
||||
fail('release set filename must be deterministic');
|
||||
}
|
||||
const plan = createCatalogPlan(releaseSetFile.value, options);
|
||||
writeNoReplace(options.output, plan);
|
||||
output.write(canonicalJson(plan));
|
||||
return plan;
|
||||
}
|
||||
const plan = readCanonicalJson(options.plan, 'catalog plan').value;
|
||||
const manifest = readBoundedFile(options.manifest, 'catalog manifest');
|
||||
if (options.mode === 'receipt') {
|
||||
const receipt = createCatalogReceipt(
|
||||
plan,
|
||||
manifest.contents,
|
||||
options.manifestDigest,
|
||||
);
|
||||
writeNoReplace(options.output, receipt);
|
||||
output.write(canonicalJson(receipt));
|
||||
return receipt;
|
||||
}
|
||||
const receipt = readCanonicalJson(options.receipt, 'catalog receipt').value;
|
||||
const audit = auditCatalogReceipt(
|
||||
receipt,
|
||||
plan,
|
||||
manifest.contents,
|
||||
options.manifestDigest,
|
||||
);
|
||||
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 catalog failed'}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
ARTIFACT_TYPE,
|
||||
CATALOG_PLAN_SCHEMA,
|
||||
CATALOG_RECEIPT_SCHEMA,
|
||||
FILE_MEDIA_TYPE,
|
||||
OCI_EMPTY_CONFIG_DIGEST,
|
||||
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||
OCI_MANIFEST_MEDIA_TYPE,
|
||||
QingLong3ReleaseCatalogError,
|
||||
auditCatalogPlan,
|
||||
auditCatalogReceipt,
|
||||
createCatalogPlan,
|
||||
createCatalogReceipt,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
@@ -6,8 +6,13 @@ const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const {
|
||||
CLUSTER_IMAGES,
|
||||
LOCAL_IMAGES,
|
||||
RELEASE_SCOPES,
|
||||
SCHEMA: RELEASE_CANDIDATE_SCHEMA,
|
||||
auditReleaseCandidateContract,
|
||||
} = require('./ql3-release-candidate-contract.cjs');
|
||||
const { VERSION_PATTERN } = require('./lib/ql3-release-identity.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const IMAGE_RECORD_SCHEMA = 'qinglong/release-set-image-record@v1';
|
||||
@@ -312,6 +317,156 @@ function auditReleaseSet(actual, options) {
|
||||
});
|
||||
}
|
||||
|
||||
function inspectReleaseSet(actual, options) {
|
||||
const repositoryOwner = normalizeRepositoryOwner(options.repositoryOwner);
|
||||
if (
|
||||
!RELEASE_SCOPES.includes(options.releaseScope) ||
|
||||
typeof options.version !== 'string' ||
|
||||
!VERSION_PATTERN.test(options.version) ||
|
||||
!/^[a-f0-9]{40}$/u.test(options.sourceRevision || '') ||
|
||||
options.sourceRef !== `refs/tags/v${options.version}`
|
||||
) {
|
||||
fail('expected release identity is invalid');
|
||||
}
|
||||
if (
|
||||
!exactKeys(actual, [
|
||||
'schemaVersion',
|
||||
'schema',
|
||||
'release',
|
||||
'candidate',
|
||||
'repositoryOwner',
|
||||
'platforms',
|
||||
'deploymentFamilies',
|
||||
'images',
|
||||
'promotion',
|
||||
'requiredVerification',
|
||||
'releaseSetDigest',
|
||||
]) ||
|
||||
actual.schemaVersion !== 1 ||
|
||||
actual.schema !== RELEASE_SET_SCHEMA ||
|
||||
!exactKeys(actual.release, [
|
||||
'version',
|
||||
'sourceRevision',
|
||||
'sourceRef',
|
||||
'scope',
|
||||
]) ||
|
||||
actual.release.version !== options.version ||
|
||||
actual.release.sourceRevision !== options.sourceRevision ||
|
||||
actual.release.sourceRef !== options.sourceRef ||
|
||||
actual.release.scope !== options.releaseScope ||
|
||||
!exactKeys(actual.candidate, ['schema', 'contractDigest']) ||
|
||||
actual.candidate.schema !== RELEASE_CANDIDATE_SCHEMA ||
|
||||
!DIGEST_PATTERN.test(actual.candidate.contractDigest || '') ||
|
||||
actual.repositoryOwner !== repositoryOwner ||
|
||||
JSON.stringify(actual.platforms) !==
|
||||
JSON.stringify(['linux/amd64', 'linux/arm64']) ||
|
||||
!exactKeys(actual.deploymentFamilies, ['local', 'cluster']) ||
|
||||
!Array.isArray(actual.images) ||
|
||||
!exactKeys(actual.promotion, [
|
||||
'authority',
|
||||
'versionTags',
|
||||
'sourceTags',
|
||||
'crossRepositoryAtomicity',
|
||||
'recovery',
|
||||
]) ||
|
||||
JSON.stringify(actual.promotion) !==
|
||||
JSON.stringify({
|
||||
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',
|
||||
}) ||
|
||||
!exactKeys(actual.requiredVerification, [
|
||||
'imageKeylessSignature',
|
||||
'imageAttestations',
|
||||
'releaseSetBuildProvenance',
|
||||
]) ||
|
||||
JSON.stringify(actual.requiredVerification) !==
|
||||
JSON.stringify({
|
||||
imageKeylessSignature: true,
|
||||
imageAttestations: [...REQUIRED_IMAGE_ATTESTATIONS],
|
||||
releaseSetBuildProvenance: true,
|
||||
})
|
||||
) {
|
||||
fail('standalone release set shape or identity is invalid');
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.releaseScope === 'local'
|
||||
? [...LOCAL_IMAGES]
|
||||
: options.releaseScope === 'cluster'
|
||||
? [...CLUSTER_IMAGES]
|
||||
: [...CLUSTER_IMAGES, ...LOCAL_IMAGES];
|
||||
const expectedNames = selected.map((entry) => entry.image);
|
||||
if (actual.images.length !== selected.length) {
|
||||
fail('standalone release set image count is invalid');
|
||||
}
|
||||
for (let index = 0; index < selected.length; index += 1) {
|
||||
const image = actual.images[index];
|
||||
const expected = selected[index];
|
||||
const repository = `ghcr.io/${repositoryOwner}/${expected.repository}`;
|
||||
if (
|
||||
!exactKeys(image, [
|
||||
'name',
|
||||
'repository',
|
||||
'digest',
|
||||
'reference',
|
||||
'versionTag',
|
||||
'sourceTag',
|
||||
'platforms',
|
||||
'imageRecordDigest',
|
||||
]) ||
|
||||
image.name !== expected.image ||
|
||||
image.repository !== expected.repository ||
|
||||
!DIGEST_PATTERN.test(image.digest || '') ||
|
||||
image.reference !== `${repository}@${image.digest}` ||
|
||||
image.versionTag !== `${repository}:${options.version}` ||
|
||||
image.sourceTag !== `${repository}:sha-${options.sourceRevision}` ||
|
||||
JSON.stringify(image.platforms) !== JSON.stringify(actual.platforms) ||
|
||||
!DIGEST_PATTERN.test(image.imageRecordDigest || '')
|
||||
) {
|
||||
fail(`standalone release set image is invalid: ${expected.image}`);
|
||||
}
|
||||
}
|
||||
|
||||
const expectedFamilies = {
|
||||
local: {
|
||||
selected: ['local', 'all'].includes(options.releaseScope),
|
||||
profiles: ['edge', 'standalone'],
|
||||
images: expectedNames.filter((name) => name === 'local'),
|
||||
},
|
||||
cluster: {
|
||||
selected: ['cluster', 'all'].includes(options.releaseScope),
|
||||
profiles: ['cluster', 'worker-edge', 'worker-node'],
|
||||
images: expectedNames.filter((name) => name !== 'local'),
|
||||
},
|
||||
};
|
||||
if (
|
||||
JSON.stringify(actual.deploymentFamilies) !==
|
||||
JSON.stringify(expectedFamilies)
|
||||
) {
|
||||
fail('standalone release set deployment families are invalid');
|
||||
}
|
||||
const { releaseSetDigest, ...unsigned } = actual;
|
||||
if (
|
||||
!DIGEST_PATTERN.test(releaseSetDigest || '') ||
|
||||
releaseSetDigest !== sha256(JSON.stringify(unsigned))
|
||||
) {
|
||||
fail('standalone release set digest is invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
releaseSetDigest,
|
||||
releaseScope: actual.release.scope,
|
||||
imageCount: actual.images.length,
|
||||
images: Object.freeze([...expectedNames]),
|
||||
references: Object.freeze(actual.images.map((entry) => entry.reference)),
|
||||
verification: 'standalone_structure_identity_and_self_digest',
|
||||
sourceRecordsReplayed: false,
|
||||
});
|
||||
}
|
||||
|
||||
function readRecordDirectory(directoryPath, candidate) {
|
||||
const resolved = resolveCanonicalAbsolute(directoryPath, 'records');
|
||||
const stat = fs.lstatSync(resolved);
|
||||
@@ -345,8 +500,7 @@ function parseArguments(argv) {
|
||||
fail('arguments are invalid');
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
const common = [
|
||||
'candidate',
|
||||
const identity = [
|
||||
'mode',
|
||||
'release-scope',
|
||||
'repository-owner',
|
||||
@@ -354,6 +508,7 @@ function parseArguments(argv) {
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const common = ['candidate', ...identity];
|
||||
const expected =
|
||||
values.mode === 'record-image'
|
||||
? [...common, 'digest', 'image', 'output']
|
||||
@@ -361,6 +516,8 @@ function parseArguments(argv) {
|
||||
? [...common, 'output', 'records']
|
||||
: values.mode === 'audit'
|
||||
? [...common, 'records', 'report']
|
||||
: values.mode === 'inspect'
|
||||
? [...identity, 'report']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
@@ -371,7 +528,7 @@ function parseArguments(argv) {
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
candidate: values.candidate,
|
||||
...(values.candidate ? { candidate: values.candidate } : {}),
|
||||
version: values.version,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
@@ -387,6 +544,12 @@ function parseArguments(argv) {
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'inspect') {
|
||||
const report = readCanonicalJson(options.report, 'release set');
|
||||
const inspection = inspectReleaseSet(report, options);
|
||||
output.write(canonicalJson(inspection));
|
||||
return inspection;
|
||||
}
|
||||
const candidate = readCanonicalJson(options.candidate, 'release candidate');
|
||||
if (options.mode === 'record-image') {
|
||||
const record = createVerifiedImageRecord({ ...options, candidate, root });
|
||||
@@ -435,6 +598,7 @@ module.exports = Object.freeze({
|
||||
auditReleaseSet,
|
||||
createReleaseSet,
|
||||
createVerifiedImageRecord,
|
||||
inspectReleaseSet,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user