mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate release tags on deployment readiness
This commit is contained in:
@@ -73,7 +73,7 @@ test('rejects verifier, embedded artifact and release workflow drift', () => {
|
||||
'.github/workflows/ql3-image-release.yml',
|
||||
(source) =>
|
||||
source.replace(
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote final tags only after every required deployment gate',
|
||||
'Promote mutable release tags',
|
||||
),
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_WORKFLOW_DRIFT',
|
||||
|
||||
@@ -95,6 +95,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
tagAfterVerification: true,
|
||||
tagAfterCompleteReleaseSet: true,
|
||||
tagAfterVerifiedCatalog: true,
|
||||
tagAfterRequiredDeploymentGates: true,
|
||||
boundedRepositoryTagInventory: true,
|
||||
allTagConflictsCheckedBeforeMutation: true,
|
||||
responseLossRecovery: 'reuse_exact_digest_only',
|
||||
@@ -107,7 +108,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
privateEvidenceFreshnessRevalidatedAtClosure: true,
|
||||
exactScopeClosure: true,
|
||||
standaloneInspection: true,
|
||||
tagPromotionAuthority: 'verified_immutable_catalog',
|
||||
tagPromotionAuthority: 'verified_catalog_bound_deployments',
|
||||
fileProvenanceAttested: true,
|
||||
artifactRetentionDays: 90,
|
||||
crossRepositoryAtomicity: false,
|
||||
@@ -135,10 +136,11 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
receiptAttested: true,
|
||||
},
|
||||
finalPublicationClosure: {
|
||||
planSchema: 'qinglong/release-publication-plan@v1',
|
||||
planSchema: 'qinglong/release-publication-plan@v2',
|
||||
tagObservationSchema: 'qinglong/release-publication-tag-observation@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v1',
|
||||
receiptSchema: 'qinglong/release-publication-closure-receipt@v2',
|
||||
catalogReadyBeforeTagMutation: true,
|
||||
deploymentReadyBeforeTagMutation: true,
|
||||
allTagsExactDigest: true,
|
||||
tagsPerImage: 2,
|
||||
conflictPolicy: 'fail_closed_before_any_tag_mutation',
|
||||
@@ -147,6 +149,16 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
registryTagCas: false,
|
||||
receiptAttested: true,
|
||||
},
|
||||
deploymentReadiness: {
|
||||
receiptSchema: 'qinglong/release-deployment-readiness-receipt@v1',
|
||||
scopes: ['local', 'cluster', 'all'],
|
||||
localProfiles: ['edge', 'standalone'],
|
||||
clusterNodes: 3,
|
||||
independentFinalizerCatalogConsumption: true,
|
||||
exactEvidenceBytesAudited: true,
|
||||
jobResultOnlyAuthority: false,
|
||||
receiptAttestedBeforeTagMutation: true,
|
||||
},
|
||||
catalogDeploymentGate: {
|
||||
scopes: ['cluster', 'all'],
|
||||
catalogAuthority: 'immutable_digest_after_public_consumption',
|
||||
@@ -155,6 +167,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
installReceiptAudited: true,
|
||||
fencedRetirementReceiptAudited: true,
|
||||
publicationAuthority: false,
|
||||
requiredForFinalization: true,
|
||||
},
|
||||
localCatalogDeploymentGate: {
|
||||
scopes: ['local', 'all'],
|
||||
@@ -164,6 +177,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
rolloutReceiptAudited: true,
|
||||
gracefulCleanup: true,
|
||||
publicationAuthority: false,
|
||||
requiredForFinalization: true,
|
||||
},
|
||||
localRolloutPreflight: true,
|
||||
localRolloutApply: true,
|
||||
@@ -179,6 +193,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
||||
'catalog-consumption',
|
||||
'catalog-bound-local-compose-deployment',
|
||||
'catalog-bound-k3s-deployment',
|
||||
'deployment-readiness',
|
||||
'release-tags',
|
||||
'release-publication-closure',
|
||||
],
|
||||
@@ -219,6 +234,17 @@ test('rejects removal of the final publication closure contract tests', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the deployment readiness contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3ReleaseDeploymentReadinessContract.test.cjs',
|
||||
'test/back/deployment-readiness-tests-removed.test.cjs',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditClusterImageCiWorkflow(mutated),
|
||||
/durable catalog, deployment-lock and workflow negative tests/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the private release evidence receipt contract tests', () => {
|
||||
const mutated = ciSource.replace(
|
||||
'test/back/ql3PrivateReleaseEvidenceReceiptContract.test.cjs',
|
||||
@@ -641,7 +667,7 @@ test('rejects a movable action tag in the privileged release job', () => {
|
||||
)}actions/checkout@v6${releaseSource.slice(offset + pinned.length)}`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/privileged publisher|immutable checkout action|release-set job|cluster release must read/,
|
||||
/privileged publisher|immutable checkout action|release-set job|cluster release must read|release finalization/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -995,7 +1021,7 @@ test('rejects a deployment lock without standalone inspection', () => {
|
||||
)}`;
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1006,7 +1032,7 @@ test('rejects a catalog title that leaks the runner temporary path', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1017,7 +1043,7 @@ test('rejects a catalog publication without byte-exact round trip', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1028,7 +1054,7 @@ test('rejects direct overwrite publication to the catalog discovery tag', () =>
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1039,7 +1065,7 @@ test('rejects catalog publication without a conflict-aware decision', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1050,7 +1076,7 @@ test('rejects catalog publication without bounded tag inventory classification',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1061,7 +1087,7 @@ test('rejects response-loss recovery that cannot reuse an exact catalog digest',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1072,7 +1098,7 @@ test('rejects using the mutable catalog discovery tag as deployment authority',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1083,7 +1109,7 @@ test('rejects durable catalog provenance detached from its manifest digest', ()
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1094,30 +1120,62 @@ test('rejects a release-catalog receipt without file provenance', () => {
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/independently inspect, durably publish/,
|
||||
/independently inspect and durably publish/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects final tag publication before catalog receipt attestation', () => {
|
||||
test('rejects final tag publication before deployment readiness', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'Promote final tags only after the catalog receipt is attested',
|
||||
'Promote tags after release-set audit only',
|
||||
'Promote final tags only after every required deployment gate',
|
||||
'Promote tags before deployment readiness',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release tags and the final closure receipt|release-set job must download/,
|
||||
/release finalization|release tags and the final closure receipt/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects finalization that ignores a required deployment job result', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
"needs.release-catalog-local-deployment-live.result == 'success'",
|
||||
"needs.release-catalog-local-deployment-live.result != 'failure'",
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects deployment readiness without exact downloaded evidence bundles', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' path: ${{ runner.temp }}/ql3-release-catalog-deployment',
|
||||
' path: ${{ runner.temp }}/ql3-release-catalog-deployment/report.json',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/cluster release must read the newly published immutable catalog/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a publication plan detached from deployment readiness', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' --deployment-readiness="${readiness}" \\',
|
||||
' --deployment-readiness="${catalog_receipt}" \\',
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects deployment readiness without its own pre-promotion attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' subject-path: ${{ steps.final-publication.outputs.readiness }}',
|
||||
' subject-path: ${{ steps.final-publication.outputs.plan }}',
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects final tag promotion without bounded repository inventory', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
" 'tag',\n 'ls',\n image.registryRepository,",
|
||||
" 'image',\n 'digest',\n image.registryRepository,",
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects omission of the final publication closure audit', () => {
|
||||
@@ -1125,39 +1183,33 @@ test('rejects omission of the final publication closure audit', () => {
|
||||
' --mode=close \\',
|
||||
' --mode=audit \\',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects omitting tag observations from the durable closure bundle', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
'observations="${BUNDLE}/qinglong3-release-publication-tag-observation-',
|
||||
'observations="${FINALIZATION_ROOT}/qinglong3-release-publication-tag-observation-',
|
||||
'observations="${RUNNER_TEMP}/qinglong3-release-publication-tag-observation-',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects a final closure receipt without its own attestation', () => {
|
||||
const mutated = releaseSource.replace(
|
||||
' subject-path: ${{ steps.final-publication.outputs.receipt }}',
|
||||
' subject-path: ${{ steps.catalog-receipt.outputs.receipt }}',
|
||||
);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job must download only same-run records/,
|
||||
' subject-path: ${{ steps.final-publication.outputs.closure }}',
|
||||
' subject-path: ${{ steps.final-publication.outputs.readiness }}',
|
||||
);
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /release finalization/);
|
||||
});
|
||||
|
||||
test('rejects a short-lived deployment digest lock', () => {
|
||||
const marker = ' retention-days: 90';
|
||||
assert.equal(releaseSource.includes(marker), true);
|
||||
const mutated = releaseSource.replace(marker, ' retention-days: 1');
|
||||
assert.throws(() => auditReleaseWorkflow(mutated), /deployment lock/);
|
||||
assert.throws(
|
||||
() => auditReleaseWorkflow(mutated),
|
||||
/release-set job|release finalization/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects removal of the digest-bound OS vulnerability attestation', () => {
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
const test = require('node:test');
|
||||
const {
|
||||
SCHEMA,
|
||||
auditDeploymentReadinessReceipt,
|
||||
createDeploymentReadinessReceipt,
|
||||
parseArguments,
|
||||
validateDeploymentReadinessReceipt,
|
||||
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
|
||||
|
||||
const VERSION = '3.0.0-alpha.0';
|
||||
const REVISION = 'd'.repeat(40);
|
||||
const OWNER = 'qinglong-release';
|
||||
const SOURCE_REPOSITORY = `${OWNER}/qinglong`;
|
||||
const MANIFEST_DIGEST = `sha256:${'a'.repeat(64)}`;
|
||||
const RELEASE_SET_DIGEST = `sha256:${'b'.repeat(64)}`;
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function identity(scope) {
|
||||
return {
|
||||
version: VERSION,
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: `refs/tags/v${VERSION}`,
|
||||
releaseScope: scope,
|
||||
repositoryOwner: OWNER,
|
||||
sourceRepository: SOURCE_REPOSITORY,
|
||||
};
|
||||
}
|
||||
|
||||
function consumption(scope, marker) {
|
||||
return {
|
||||
compatible: true,
|
||||
releaseScope: scope,
|
||||
sourceRepository: SOURCE_REPOSITORY,
|
||||
workflowIdentity: `https://github.com/${SOURCE_REPOSITORY}/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
|
||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||
catalogManifestDigest: MANIFEST_DIGEST,
|
||||
immutableReference: `ghcr.io/${OWNER}/qinglong3-release-catalog@${MANIFEST_DIGEST}`,
|
||||
imageCount: scope === 'all' ? 5 : scope === 'local' ? 1 : 4,
|
||||
discoveryTagAuthority: 'none',
|
||||
externalToolResultsReplayed: false,
|
||||
deploymentMutation: false,
|
||||
contentDigest: sha256(marker),
|
||||
releaseSet: {
|
||||
release: {
|
||||
version: VERSION,
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: `refs/tags/v${VERSION}`,
|
||||
scope,
|
||||
},
|
||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function reportEntry(value) {
|
||||
return {
|
||||
value,
|
||||
digest: sha256(`${JSON.stringify(value)}\n`),
|
||||
};
|
||||
}
|
||||
|
||||
function localReport(scope, catalog, profile) {
|
||||
return reportEntry({
|
||||
schemaVersion: 1,
|
||||
profile,
|
||||
generation: 2,
|
||||
exactRepoDigest: true,
|
||||
composeMerge: true,
|
||||
rolloutActive: true,
|
||||
durableReceipt: true,
|
||||
sqliteWriteContract: 37,
|
||||
sqliteBackup: true,
|
||||
sqliteWriteObservation: { committed: true },
|
||||
sqliteRestorePrepared: true,
|
||||
sqliteRestoreCommitted: true,
|
||||
sqliteRestoreRolloutRecovered: true,
|
||||
sqliteRestoreReplayUnchanged: true,
|
||||
sqliteEvidenceCollected: true,
|
||||
sqliteCollectedRolloutReplayUnchanged: true,
|
||||
gracefulCleanup: true,
|
||||
releaseAuthority: {
|
||||
mode: 'verified_release_catalog',
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: `refs/tags/v${VERSION}`,
|
||||
scope,
|
||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||
catalogManifestDigest: MANIFEST_DIGEST,
|
||||
catalogConsumptionDigest: catalog.contentDigest,
|
||||
selectionDigest: sha256('selection'),
|
||||
},
|
||||
compatible: true,
|
||||
});
|
||||
}
|
||||
|
||||
function clusterReport(scope, catalog) {
|
||||
return reportEntry({
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
|
||||
kubernetes: {
|
||||
serverVersion: 'v1.34.3+k3s1',
|
||||
architecture: 'linux/amd64',
|
||||
nodeCount: 3,
|
||||
clusterUid: 'fixture-cluster',
|
||||
},
|
||||
deployment: {
|
||||
namespace: 'qinglong3-system',
|
||||
resourceCount: 7,
|
||||
deploymentCount: 4,
|
||||
replicas: 0,
|
||||
fieldManager: 'qinglong3-catalog-lock',
|
||||
immutableImages: true,
|
||||
headName: 'ql3-deployment-head',
|
||||
headPhase: 'committed',
|
||||
headGeneration: 2,
|
||||
deploymentDigest: sha256('deployment'),
|
||||
resourceInventoryCount: 6,
|
||||
},
|
||||
releaseAuthority: {
|
||||
mode: 'verified_release_catalog',
|
||||
version: VERSION,
|
||||
sourceRevision: REVISION,
|
||||
sourceRef: `refs/tags/v${VERSION}`,
|
||||
scope,
|
||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||
catalogManifestDigest: MANIFEST_DIGEST,
|
||||
catalogConsumptionDigest: catalog.contentDigest,
|
||||
immutableReference: catalog.immutableReference,
|
||||
},
|
||||
preflightDigest: sha256('preflight'),
|
||||
receiptDigest: sha256('receipt'),
|
||||
receiptAuditCompatible: true,
|
||||
retirement: {
|
||||
preflightDigest: sha256('retirement-preflight'),
|
||||
receiptDigest: sha256('retirement-receipt'),
|
||||
receiptAuditCompatible: true,
|
||||
targetCount: 1,
|
||||
targetAbsent: true,
|
||||
uidResourceVersionDeletePreconditions: true,
|
||||
deploymentHeadCas: true,
|
||||
inventoryCount: 6,
|
||||
unixSocketProxy: true,
|
||||
},
|
||||
serverSideDryRun: true,
|
||||
serverSideApply: true,
|
||||
convergenceRead: true,
|
||||
deploymentHeadCas: true,
|
||||
resourceInventoryClosed: true,
|
||||
crossResourceAtomicity: false,
|
||||
cleanupComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
function fixture(scope = 'all') {
|
||||
const finalizerConsumption = consumption(scope, 'finalizer');
|
||||
const input = {
|
||||
identity: identity(scope),
|
||||
finalizerConsumption,
|
||||
};
|
||||
if (scope !== 'cluster') {
|
||||
const localConsumption = consumption(scope, 'local');
|
||||
input.local = {
|
||||
consumption: localConsumption,
|
||||
edge: localReport(scope, localConsumption, 'edge'),
|
||||
standalone: localReport(scope, localConsumption, 'standalone'),
|
||||
};
|
||||
}
|
||||
if (scope !== 'local') {
|
||||
const clusterConsumption = consumption(scope, 'cluster');
|
||||
input.cluster = {
|
||||
consumption: clusterConsumption,
|
||||
report: clusterReport(scope, clusterConsumption),
|
||||
};
|
||||
}
|
||||
return { input, receipt: createDeploymentReadinessReceipt(input) };
|
||||
}
|
||||
|
||||
test('requires the exact deployment families for Local, Cluster and All', () => {
|
||||
for (const [scope, families, reportCount] of [
|
||||
['local', ['local'], 2],
|
||||
['cluster', ['cluster'], 1],
|
||||
['all', ['local', 'cluster'], 3],
|
||||
]) {
|
||||
const { input, receipt } = fixture(scope);
|
||||
assert.equal(receipt.schema, SCHEMA);
|
||||
assert.deepEqual(receipt.requiredDeploymentFamilies, families);
|
||||
assert.equal(
|
||||
auditDeploymentReadinessReceipt(receipt, input).reportCount,
|
||||
reportCount,
|
||||
);
|
||||
assert.equal(
|
||||
validateDeploymentReadinessReceipt(receipt, {
|
||||
release: receipt.release,
|
||||
sourceRepository: SOURCE_REPOSITORY,
|
||||
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||
catalogManifestDigest: MANIFEST_DIGEST,
|
||||
immutableReference: receipt.catalog.immutableReference,
|
||||
}),
|
||||
receipt,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects missing, extra and cross-scope deployment evidence', () => {
|
||||
const local = fixture('local').input;
|
||||
assert.throws(
|
||||
() =>
|
||||
createDeploymentReadinessReceipt({
|
||||
...local,
|
||||
local: undefined,
|
||||
}),
|
||||
/local deployment catalog consumption is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
createDeploymentReadinessReceipt({
|
||||
...local,
|
||||
cluster: fixture('cluster').input.cluster,
|
||||
}),
|
||||
/cluster deployment evidence is forbidden/,
|
||||
);
|
||||
const all = fixture('all').input;
|
||||
assert.throws(
|
||||
() =>
|
||||
createDeploymentReadinessReceipt({
|
||||
...all,
|
||||
cluster: undefined,
|
||||
}),
|
||||
/cluster deployment catalog consumption is invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects reports detached from the independently consumed catalog', () => {
|
||||
const current = fixture('all').input;
|
||||
const drifted = structuredClone(current);
|
||||
drifted.local.consumption.catalogManifestDigest = sha256('other-manifest');
|
||||
drifted.local.consumption.immutableReference = `ghcr.io/${OWNER}/qinglong3-release-catalog@${drifted.local.consumption.catalogManifestDigest}`;
|
||||
drifted.local.edge.value.releaseAuthority.catalogManifestDigest =
|
||||
drifted.local.consumption.catalogManifestDigest;
|
||||
drifted.local.standalone.value.releaseAuthority.catalogManifestDigest =
|
||||
drifted.local.consumption.catalogManifestDigest;
|
||||
assert.throws(
|
||||
() => createDeploymentReadinessReceipt(drifted),
|
||||
/detached from the finalizer catalog/,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects synthetic, incomplete and unclean deployment reports', () => {
|
||||
for (const mutate of [
|
||||
(input) => {
|
||||
input.local.edge.value.releaseAuthority.mode = 'synthetic_live_fixture';
|
||||
},
|
||||
(input) => {
|
||||
input.local.standalone.value.gracefulCleanup = false;
|
||||
},
|
||||
(input) => {
|
||||
input.cluster.report.value.receiptAuditCompatible = false;
|
||||
},
|
||||
(input) => {
|
||||
input.cluster.report.value.cleanupComplete = false;
|
||||
},
|
||||
]) {
|
||||
const input = structuredClone(fixture('all').input);
|
||||
mutate(input);
|
||||
assert.throws(
|
||||
() => createDeploymentReadinessReceipt(input),
|
||||
/deployment evidence is invalid/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects tampered, recomputed or authority-detached receipts', () => {
|
||||
const { receipt } = fixture('all');
|
||||
const tampered = structuredClone(receipt);
|
||||
tampered.receiptDigest = sha256('tampered');
|
||||
assert.throws(
|
||||
() => validateDeploymentReadinessReceipt(tampered),
|
||||
/receipt digest is invalid/,
|
||||
);
|
||||
const recomputed = structuredClone(receipt);
|
||||
recomputed.evidence[0].unexpected = true;
|
||||
delete recomputed.receiptDigest;
|
||||
recomputed.receiptDigest = sha256(JSON.stringify(recomputed));
|
||||
assert.throws(
|
||||
() => validateDeploymentReadinessReceipt(recomputed),
|
||||
/evidence summary is invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateDeploymentReadinessReceipt(receipt, {
|
||||
releaseSetDigest: sha256('another-release-set'),
|
||||
}),
|
||||
/authority is detached/,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts only closed scope-specific CLI argument sets', () => {
|
||||
const common = [
|
||||
'--mode=create',
|
||||
`--version=${VERSION}`,
|
||||
`--source-revision=${REVISION}`,
|
||||
`--source-ref=refs/tags/v${VERSION}`,
|
||||
'--release-scope=local',
|
||||
`--repository-owner=${OWNER}`,
|
||||
`--source-repository=${SOURCE_REPOSITORY}`,
|
||||
'--finalizer-consumption-bundle=/tmp/finalizer',
|
||||
'--local-consumption-bundle=/tmp/local',
|
||||
'--edge-report=/tmp/edge',
|
||||
'--standalone-report=/tmp/standalone',
|
||||
'--output=/tmp/receipt',
|
||||
];
|
||||
assert.equal(parseArguments(common).releaseScope, 'local');
|
||||
assert.throws(
|
||||
() => parseArguments([...common, '--cluster-report=/tmp/cluster']),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArguments(common.filter((entry) => !entry.startsWith('--edge'))),
|
||||
/arguments are invalid/,
|
||||
);
|
||||
});
|
||||
@@ -35,6 +35,9 @@ const {
|
||||
const {
|
||||
readReleaseIdentity,
|
||||
} = require('../../scripts/lib/ql3-release-identity.cjs');
|
||||
const {
|
||||
createDeploymentReadinessReceipt,
|
||||
} = require('../../scripts/ql3-release-deployment-readiness-contract.cjs');
|
||||
const {
|
||||
privateReleaseEvidenceReceipts,
|
||||
} = require('./ql3ReleaseEvidenceFixture.cjs');
|
||||
@@ -106,6 +109,145 @@ function manifestFor(plan) {
|
||||
});
|
||||
}
|
||||
|
||||
function catalogConsumption(set, manifestDigest, marker) {
|
||||
return {
|
||||
compatible: true,
|
||||
releaseScope: set.release.scope,
|
||||
sourceRepository: identity.sourceRepository,
|
||||
workflowIdentity: `https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}`,
|
||||
releaseSetDigest: set.releaseSetDigest,
|
||||
catalogManifestDigest: manifestDigest,
|
||||
immutableReference: `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${manifestDigest}`,
|
||||
imageCount: set.images.length,
|
||||
discoveryTagAuthority: 'none',
|
||||
externalToolResultsReplayed: false,
|
||||
deploymentMutation: false,
|
||||
contentDigest: sha256(marker),
|
||||
releaseSet: set,
|
||||
};
|
||||
}
|
||||
|
||||
function localReport(profile, consumption, marker) {
|
||||
return {
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
profile,
|
||||
generation: 2,
|
||||
exactRepoDigest: true,
|
||||
composeMerge: true,
|
||||
rolloutActive: true,
|
||||
durableReceipt: true,
|
||||
sqliteWriteContract: 37,
|
||||
sqliteBackup: true,
|
||||
sqliteWriteObservation: { rows: 1 },
|
||||
sqliteRestorePrepared: true,
|
||||
sqliteRestoreCommitted: true,
|
||||
sqliteRestoreRolloutRecovered: true,
|
||||
sqliteRestoreReplayUnchanged: true,
|
||||
sqliteEvidenceCollected: true,
|
||||
sqliteCollectedRolloutReplayUnchanged: true,
|
||||
gracefulCleanup: true,
|
||||
releaseAuthority: {
|
||||
mode: 'verified_release_catalog',
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
scope: consumption.releaseScope,
|
||||
releaseSetDigest: consumption.releaseSetDigest,
|
||||
catalogManifestDigest: consumption.catalogManifestDigest,
|
||||
catalogConsumptionDigest: consumption.contentDigest,
|
||||
selectionDigest: sha256('local-selection'),
|
||||
},
|
||||
compatible: true,
|
||||
},
|
||||
digest: sha256(marker),
|
||||
};
|
||||
}
|
||||
|
||||
function clusterReport(consumption) {
|
||||
return {
|
||||
value: {
|
||||
schemaVersion: 1,
|
||||
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
|
||||
kubernetes: {
|
||||
serverVersion: 'v1.34.3+k3s1',
|
||||
architecture: 'linux/amd64',
|
||||
nodeCount: 3,
|
||||
clusterUid: 'cluster-fixture',
|
||||
},
|
||||
deployment: {
|
||||
replicas: 0,
|
||||
immutableImages: true,
|
||||
headPhase: 'committed',
|
||||
headGeneration: 2,
|
||||
deploymentDigest: sha256('deployment'),
|
||||
},
|
||||
releaseAuthority: {
|
||||
mode: 'verified_release_catalog',
|
||||
version,
|
||||
sourceRevision: identity.sourceRevision,
|
||||
sourceRef: identity.sourceRef,
|
||||
scope: consumption.releaseScope,
|
||||
releaseSetDigest: consumption.releaseSetDigest,
|
||||
catalogManifestDigest: consumption.catalogManifestDigest,
|
||||
catalogConsumptionDigest: consumption.contentDigest,
|
||||
immutableReference: consumption.immutableReference,
|
||||
},
|
||||
preflightDigest: sha256('preflight'),
|
||||
receiptDigest: sha256('deployment-receipt'),
|
||||
receiptAuditCompatible: true,
|
||||
retirement: {
|
||||
receiptAuditCompatible: true,
|
||||
targetAbsent: true,
|
||||
uidResourceVersionDeletePreconditions: true,
|
||||
deploymentHeadCas: true,
|
||||
unixSocketProxy: true,
|
||||
},
|
||||
serverSideDryRun: true,
|
||||
serverSideApply: true,
|
||||
convergenceRead: true,
|
||||
deploymentHeadCas: true,
|
||||
resourceInventoryClosed: true,
|
||||
crossResourceAtomicity: false,
|
||||
cleanupComplete: true,
|
||||
},
|
||||
digest: sha256('cluster-report'),
|
||||
};
|
||||
}
|
||||
|
||||
function deploymentReadiness(set, manifestDigest) {
|
||||
const scope = set.release.scope;
|
||||
const finalizerConsumption = catalogConsumption(
|
||||
set,
|
||||
manifestDigest,
|
||||
'finalizer-consumption',
|
||||
);
|
||||
const input = {
|
||||
identity: { ...identity, releaseScope: scope },
|
||||
finalizerConsumption,
|
||||
};
|
||||
if (scope !== 'cluster') {
|
||||
const consumption = catalogConsumption(
|
||||
set,
|
||||
manifestDigest,
|
||||
'local-consumption',
|
||||
);
|
||||
input.local = {
|
||||
consumption,
|
||||
edge: localReport('edge', consumption, 'edge-report'),
|
||||
standalone: localReport('standalone', consumption, 'standalone-report'),
|
||||
};
|
||||
}
|
||||
if (scope !== 'local') {
|
||||
const consumption = catalogConsumption(
|
||||
set,
|
||||
manifestDigest,
|
||||
'cluster-consumption',
|
||||
);
|
||||
input.cluster = { consumption, report: clusterReport(consumption) };
|
||||
}
|
||||
return createDeploymentReadinessReceipt(input);
|
||||
}
|
||||
|
||||
function fixture(scope = 'all') {
|
||||
const set = releaseSet(scope);
|
||||
const options = { ...identity, releaseScope: scope };
|
||||
@@ -117,10 +259,12 @@ function fixture(scope = 'all') {
|
||||
manifest,
|
||||
manifestDigest,
|
||||
);
|
||||
const readiness = deploymentReadiness(set, manifestDigest);
|
||||
const publicationPlan = createPublicationPlan(
|
||||
set,
|
||||
catalogPlan,
|
||||
catalogReceipt,
|
||||
readiness,
|
||||
manifest,
|
||||
manifestDigest,
|
||||
options,
|
||||
@@ -141,6 +285,7 @@ function fixture(scope = 'all') {
|
||||
manifest,
|
||||
manifestDigest,
|
||||
catalogReceipt,
|
||||
readiness,
|
||||
publicationPlan,
|
||||
observation,
|
||||
};
|
||||
@@ -164,11 +309,11 @@ test('plans tag publication only from a verified immutable catalog', () => {
|
||||
assert.equal(publicationPlan.schema, PUBLICATION_PLAN_SCHEMA);
|
||||
assert.equal(
|
||||
publicationPlan.promotionPolicy.authority,
|
||||
'verified_immutable_catalog',
|
||||
'verified_catalog_bound_deployments',
|
||||
);
|
||||
assert.equal(
|
||||
publicationPlan.requiredPrerequisites.catalogReceipt,
|
||||
'attested_before_tag_promotion',
|
||||
publicationPlan.requiredPrerequisites.deploymentReadiness,
|
||||
'scope_exact_receipt_attested_before_tag_promotion',
|
||||
);
|
||||
assert.equal(publicationPlan.promotionPolicy.registryTagCas, false);
|
||||
assert.equal(publicationPlan.images.length, set.images.length);
|
||||
@@ -188,6 +333,7 @@ test('creates a deterministic final closure receipt for every exact tag', () =>
|
||||
assert.deepEqual(first, replay);
|
||||
assert.equal(first.publishedTags.length, publicationPlan.images.length * 2);
|
||||
assert.equal(first.verification.catalogReadyBeforeTagMutation, true);
|
||||
assert.equal(first.verification.deploymentReadyBeforeTagMutation, true);
|
||||
assert.equal(first.verification.allTagsExactDigest, true);
|
||||
assert.equal(first.verification.registryTagCas, false);
|
||||
assert.equal(
|
||||
@@ -223,6 +369,7 @@ test('rejects a publication plan detached from release-set or catalog evidence',
|
||||
current.set,
|
||||
other.catalogPlan,
|
||||
other.catalogReceipt,
|
||||
current.readiness,
|
||||
other.manifest,
|
||||
other.manifestDigest,
|
||||
current.options,
|
||||
@@ -237,6 +384,7 @@ test('rejects a publication plan detached from release-set or catalog evidence',
|
||||
current.set,
|
||||
current.catalogPlan,
|
||||
weakenedReceipt,
|
||||
current.readiness,
|
||||
current.manifest,
|
||||
current.manifestDigest,
|
||||
current.options,
|
||||
@@ -286,6 +434,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
|
||||
releaseSet: path.join(directory, 'release-set.json'),
|
||||
catalogPlan: path.join(directory, 'catalog-plan.json'),
|
||||
catalogReceipt: path.join(directory, 'catalog-receipt.json'),
|
||||
readiness: path.join(directory, 'deployment-readiness.json'),
|
||||
manifest: path.join(directory, 'manifest.json'),
|
||||
publicationPlan: path.join(directory, 'publication-plan.json'),
|
||||
observation: path.join(directory, 'observation.json'),
|
||||
@@ -294,6 +443,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
|
||||
writeCanonical(files.releaseSet, value.set);
|
||||
writeCanonical(files.catalogPlan, value.catalogPlan);
|
||||
writeCanonical(files.catalogReceipt, value.catalogReceipt);
|
||||
writeCanonical(files.readiness, value.readiness);
|
||||
fs.writeFileSync(files.manifest, value.manifest, { mode: 0o600 });
|
||||
writeCanonical(files.observation, value.observation);
|
||||
const planArgs = [
|
||||
@@ -307,6 +457,7 @@ test('runs plan, close and audit as canonical no-replace CLI stages', (t) => {
|
||||
`--release-set=${files.releaseSet}`,
|
||||
`--catalog-plan=${files.catalogPlan}`,
|
||||
`--catalog-receipt=${files.catalogReceipt}`,
|
||||
`--deployment-readiness=${files.readiness}`,
|
||||
`--catalog-manifest=${files.manifest}`,
|
||||
`--catalog-manifest-digest=${value.manifestDigest}`,
|
||||
`--output=${files.publicationPlan}`,
|
||||
|
||||
Reference in New Issue
Block a user