mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): gate local releases through published catalogs
This commit is contained in:
@@ -410,6 +410,8 @@ function auditReleaseWorkflow(source) {
|
||||
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
|
||||
const publishJob = workflow?.jobs?.publish;
|
||||
const releaseSetJob = workflow?.jobs?.['release-set'];
|
||||
const localCatalogDeploymentJob =
|
||||
workflow?.jobs?.['release-catalog-local-deployment-live'];
|
||||
const catalogDeploymentJob =
|
||||
workflow?.jobs?.['release-catalog-deployment-live'];
|
||||
if (
|
||||
@@ -474,6 +476,12 @@ function auditReleaseWorkflow(source) {
|
||||
attestations: 'write',
|
||||
'artifact-metadata': 'write',
|
||||
}) ||
|
||||
JSON.stringify(localCatalogDeploymentJob?.permissions) !==
|
||||
JSON.stringify({
|
||||
contents: 'read',
|
||||
packages: 'read',
|
||||
attestations: 'read',
|
||||
}) ||
|
||||
JSON.stringify(catalogDeploymentJob?.permissions) !==
|
||||
JSON.stringify({
|
||||
contents: 'read',
|
||||
@@ -777,6 +785,68 @@ function auditReleaseWorkflow(source) {
|
||||
'release-set job must download only same-run records, independently inspect, durably publish and attest one no-overwrite deployment lock bundle',
|
||||
);
|
||||
}
|
||||
const localCatalogDeploymentSteps = localCatalogDeploymentJob?.steps;
|
||||
if (
|
||||
localCatalogDeploymentJob?.needs !== 'release-set' ||
|
||||
localCatalogDeploymentJob?.if !==
|
||||
"always() && needs.release-set.result == 'success' && inputs.release_scope != 'cluster'" ||
|
||||
localCatalogDeploymentJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||
localCatalogDeploymentJob?.['timeout-minutes'] !== 30 ||
|
||||
!Array.isArray(localCatalogDeploymentSteps) ||
|
||||
localCatalogDeploymentSteps.length !== 10 ||
|
||||
localCatalogDeploymentSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
localCatalogDeploymentSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
localCatalogDeploymentSteps[1]?.uses !==
|
||||
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||
localCatalogDeploymentSteps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||
!/corepack prepare pnpm@8\.3\.1 --activate[\s\S]*pnpm install --frozen-lockfile --ignore-scripts[\s\S]*pnpm --filter @qinglong\/local-owner-cli check/u.test(
|
||||
localCatalogDeploymentSteps[2]?.run ?? '',
|
||||
) ||
|
||||
!/v0\.11\.5\/regctl-linux-amd64[\s\S]*c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467[\s\S]*sha256sum --check --strict/u.test(
|
||||
localCatalogDeploymentSteps[3]?.run ?? '',
|
||||
) ||
|
||||
localCatalogDeploymentSteps[4]?.uses !==
|
||||
'sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6' ||
|
||||
localCatalogDeploymentSteps[5]?.id !== 'local-catalog-consumption' ||
|
||||
!/install -d -m 0700[\s\S]*install -m 0600 \/dev\/null[\s\S]*printf '%s' "\$\{GH_TOKEN\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=create[\s\S]*--source-revision="\$\{GITHUB_SHA\}"[\s\S]*--source-ref="\$\{GITHUB_REF\}"[\s\S]*--output-directory="\$\{bundle\}"[\s\S]*--github-token-file="\$\{token\}"[\s\S]*ql3-release-catalog-consumption-ceremony\.cjs[\s\S]*--mode=audit[\s\S]*rm -f "\$\{token\}"/u.test(
|
||||
localCatalogDeploymentSteps[5]?.run ?? '',
|
||||
) ||
|
||||
localCatalogDeploymentSteps[6]?.id !== 'local-selection' ||
|
||||
!/ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-create[\s\S]*--consumption-bundle="\$\{CONSUMPTION_BUNDLE\}"[\s\S]*--allow-root-service=false[\s\S]*--output="\$\{selection\}"[\s\S]*ql3-deployment-lock-contract\.cjs[\s\S]*--mode=local-audit[\s\S]*--selection="\$\{selection\}"[\s\S]*selection-digest=\$\{selection\.selectionDigest\}[\s\S]*image=\$\{selection\.service\.image\}/u.test(
|
||||
localCatalogDeploymentSteps[6]?.run ?? '',
|
||||
) ||
|
||||
!/docker pull "\$\{IMAGE\}"[\s\S]*for profile in edge standalone[\s\S]*ql3-local-compose-rollout-live-contract\.cjs[\s\S]*--image="\$\{IMAGE\}"[\s\S]*--profile="\$\{profile\}"[\s\S]*--release-selection="\$\{RELEASE_SELECTION\}"[\s\S]*--expected-selection-digest="\$\{SELECTION_DIGEST\}"[\s\S]*verified_release_catalog[\s\S]*catalogConsumptionDigest/u.test(
|
||||
localCatalogDeploymentSteps[7]?.run ?? '',
|
||||
) ||
|
||||
localCatalogDeploymentSteps[8]?.if !== 'always()' ||
|
||||
!/docker ps -aq --filter ancestor="\$\{IMAGE\}"/u.test(
|
||||
localCatalogDeploymentSteps[8]?.run ?? '',
|
||||
) ||
|
||||
localCatalogDeploymentSteps[9]?.if !== 'always()' ||
|
||||
localCatalogDeploymentSteps[9]?.uses !==
|
||||
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' ||
|
||||
JSON.stringify(localCatalogDeploymentSteps[9]?.with) !==
|
||||
JSON.stringify({
|
||||
name: 'ql3-release-catalog-local-deployment-${{ github.run_id }}-${{ github.run_attempt }}',
|
||||
path: '${{ runner.temp }}/ql3-release-catalog-local-deployment',
|
||||
'if-no-files-found': 'warn',
|
||||
'retention-days': 90,
|
||||
'compression-level': 9,
|
||||
overwrite: false,
|
||||
'include-hidden-files': false,
|
||||
}) ||
|
||||
localCatalogDeploymentSteps.some(
|
||||
(step) =>
|
||||
/(?:artifact put|\bimage copy\b|cosign sign)/u.test(step.run ?? '') ||
|
||||
step.uses?.startsWith('actions/attest@') ||
|
||||
step.uses?.startsWith('docker/login-action@'),
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'Local release must independently consume the published catalog, reconstruct its exact selection and prove both Compose profiles without publication authority',
|
||||
);
|
||||
}
|
||||
const catalogDeploymentSteps = catalogDeploymentJob?.steps;
|
||||
if (
|
||||
catalogDeploymentJob?.needs !== 'release-set' ||
|
||||
@@ -943,20 +1013,20 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
|
||||
7,
|
||||
8,
|
||||
'all release jobs must pin the reviewed immutable checkout action',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
|
||||
7,
|
||||
8,
|
||||
'all release jobs must pin the reviewed immutable Node setup action',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: sigstore\/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4\.1\.2/g,
|
||||
3,
|
||||
'image publisher, catalog publisher and catalog consumer must pin the reviewed Cosign installer',
|
||||
4,
|
||||
'image publisher, catalog publisher and both catalog consumers must pin the reviewed Cosign installer',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -996,8 +1066,8 @@ function auditReleaseWorkflow(source) {
|
||||
requireExactOccurrences(
|
||||
source,
|
||||
/c93aa7638749f5aaac1a8e01787321889c78f0101809bb2880343478d0ba0467/g,
|
||||
3,
|
||||
'image publisher, release-set publisher and catalog consumer must checksum-pin the exact regctl OCI copier and reader',
|
||||
4,
|
||||
'image publisher, release-set publisher and both catalog consumers must checksum-pin the exact regctl OCI copier and reader',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -1199,6 +1269,15 @@ function auditReleaseWorkflow(source) {
|
||||
fencedRetirementReceiptAudited: true,
|
||||
publicationAuthority: false,
|
||||
},
|
||||
localCatalogDeploymentGate: {
|
||||
scopes: ['local', 'all'],
|
||||
catalogAuthority: 'immutable_digest_after_public_consumption',
|
||||
selectionReconstructed: true,
|
||||
profiles: ['edge', 'standalone'],
|
||||
rolloutReceiptAudited: true,
|
||||
gracefulCleanup: true,
|
||||
publicationAuthority: false,
|
||||
},
|
||||
localRolloutPreflight: true,
|
||||
localRolloutApply: true,
|
||||
postPublishVerification: [
|
||||
@@ -1211,6 +1290,7 @@ function auditReleaseWorkflow(source) {
|
||||
'release-set',
|
||||
'durable-catalog',
|
||||
'catalog-consumption',
|
||||
'catalog-bound-local-compose-deployment',
|
||||
'catalog-bound-k3s-deployment',
|
||||
'release-tags',
|
||||
],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
@@ -13,6 +14,8 @@ const {
|
||||
|
||||
const IMAGE_PATTERN =
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/;
|
||||
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
||||
const MAX_RELEASE_SELECTION_BYTES = 1024 * 1024;
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`QingLong local Compose rollout failed: ${message}`);
|
||||
@@ -28,15 +31,30 @@ function argumentsValue(argv) {
|
||||
return [argument.slice(2, separator), argument.slice(separator + 1)];
|
||||
}),
|
||||
);
|
||||
const keys = Object.keys(values).sort();
|
||||
const baseKeys = [
|
||||
'docker-executable',
|
||||
'docker-socket',
|
||||
'image',
|
||||
'profile',
|
||||
].sort();
|
||||
const catalogKeys = [
|
||||
...baseKeys,
|
||||
'expected-selection-digest',
|
||||
'release-selection',
|
||||
].sort();
|
||||
const syntheticInput = JSON.stringify(keys) === JSON.stringify(baseKeys);
|
||||
const catalogInput = JSON.stringify(keys) === JSON.stringify(catalogKeys);
|
||||
if (
|
||||
Object.keys(values).length !== argv.length ||
|
||||
(!syntheticInput && !catalogInput) ||
|
||||
!IMAGE_PATTERN.test(values.image ?? '') ||
|
||||
(values.profile !== 'edge' && values.profile !== 'standalone') ||
|
||||
typeof values['docker-executable'] !== 'string' ||
|
||||
typeof values['docker-socket'] !== 'string'
|
||||
) {
|
||||
fail(
|
||||
'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone',
|
||||
'usage: --image=repository@sha256:digest --docker-executable=/absolute/docker --docker-socket=/absolute/docker.sock --profile=edge|standalone [--release-selection=/canonical/selection.json --expected-selection-digest=sha256:digest]',
|
||||
);
|
||||
}
|
||||
const dockerExecutable = fs.realpathSync(values['docker-executable']);
|
||||
@@ -47,11 +65,114 @@ function argumentsValue(argv) {
|
||||
) {
|
||||
fail('Docker executable and socket must be canonical paths');
|
||||
}
|
||||
let releaseSelection;
|
||||
if (catalogInput) {
|
||||
if (
|
||||
!path.isAbsolute(values['release-selection']) ||
|
||||
!DIGEST_PATTERN.test(values['expected-selection-digest'])
|
||||
) {
|
||||
fail('catalog selection path and digest are invalid');
|
||||
}
|
||||
const selectionPath = fs.realpathSync(values['release-selection']);
|
||||
if (selectionPath !== values['release-selection']) {
|
||||
fail('catalog selection path must be canonical');
|
||||
}
|
||||
releaseSelection = Object.freeze({
|
||||
path: selectionPath,
|
||||
expectedSelectionDigest: values['expected-selection-digest'],
|
||||
});
|
||||
}
|
||||
return Object.freeze({
|
||||
image: values.image,
|
||||
profile: values.profile,
|
||||
dockerExecutable,
|
||||
dockerSocket,
|
||||
...(releaseSelection ? { releaseSelection } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function resolveReleaseSelection(input, temporaryRoot, uid) {
|
||||
if (!input.releaseSelection) {
|
||||
const releaseSelection = writeSyntheticLocalReleaseSelection({
|
||||
directory: temporaryRoot,
|
||||
image: input.image,
|
||||
allowRootService: uid === 0,
|
||||
sourceRevision: process.env.GITHUB_SHA,
|
||||
});
|
||||
return Object.freeze({
|
||||
releaseSelection,
|
||||
authority: Object.freeze({ mode: 'synthetic_live_fixture' }),
|
||||
});
|
||||
}
|
||||
const selectionPath = input.releaseSelection.path;
|
||||
const stat = fs.lstatSync(selectionPath);
|
||||
const contents = fs.readFileSync(selectionPath, 'utf8');
|
||||
let selection;
|
||||
try {
|
||||
selection = JSON.parse(contents);
|
||||
} catch {
|
||||
fail('catalog selection must be canonical JSON');
|
||||
}
|
||||
const unsigned = { ...selection };
|
||||
delete unsigned.selectionDigest;
|
||||
const computedDigest = sha256(JSON.stringify(unsigned));
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.nlink !== 1 ||
|
||||
stat.uid !== uid ||
|
||||
(stat.mode & 0o777) !== 0o600 ||
|
||||
stat.size < 2 ||
|
||||
stat.size > MAX_RELEASE_SELECTION_BYTES ||
|
||||
fs.realpathSync(selectionPath) !== selectionPath ||
|
||||
fs.realpathSync(path.dirname(selectionPath)) !==
|
||||
path.dirname(selectionPath) ||
|
||||
contents !== `${JSON.stringify(selection)}\n` ||
|
||||
computedDigest !== input.releaseSelection.expectedSelectionDigest ||
|
||||
selection.selectionDigest !== computedDigest ||
|
||||
selection.schemaVersion !== 1 ||
|
||||
selection.schema !== 'qinglong/local-compose-release-image@v2' ||
|
||||
!/^3\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-[0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?$/u.test(
|
||||
selection.release?.version ?? '',
|
||||
) ||
|
||||
!/^[a-f0-9]{40}$/u.test(selection.release?.sourceRevision ?? '') ||
|
||||
selection.release?.sourceRef !==
|
||||
`refs/tags/v${selection.release?.version}` ||
|
||||
!['local', 'all'].includes(selection.release?.scope) ||
|
||||
!DIGEST_PATTERN.test(selection.releaseSetDigest ?? '') ||
|
||||
selection.deploymentFamily !== 'local' ||
|
||||
selection.service?.kind !== 'compose' ||
|
||||
selection.service?.image !== input.image ||
|
||||
selection.service?.allowRootService !== (uid === 0) ||
|
||||
selection.catalog?.schema !==
|
||||
'qinglong/release-catalog-consumption-ceremony@v1' ||
|
||||
!DIGEST_PATTERN.test(selection.catalog?.manifestDigest ?? '') ||
|
||||
!DIGEST_PATTERN.test(selection.catalog?.consumptionReportDigest ?? '') ||
|
||||
selection.catalog?.releaseSetDigest !== selection.releaseSetDigest ||
|
||||
selection.catalog?.discoveryTagAuthority !== 'none' ||
|
||||
selection.verification?.catalogConsumption !== 'offline_reconstructed' ||
|
||||
selection.verification?.externalToolResultsReplayed !== false ||
|
||||
selection.verification?.networkAccess !== false ||
|
||||
selection.verification?.deploymentMutation !== false
|
||||
) {
|
||||
fail('catalog-bound release selection is incompatible');
|
||||
}
|
||||
return Object.freeze({
|
||||
releaseSelection: input.releaseSelection,
|
||||
authority: Object.freeze({
|
||||
mode: 'verified_release_catalog',
|
||||
sourceRevision: selection.release.sourceRevision,
|
||||
sourceRef: selection.release.sourceRef,
|
||||
scope: selection.release.scope,
|
||||
releaseSetDigest: selection.releaseSetDigest,
|
||||
catalogManifestDigest: selection.catalog.manifestDigest,
|
||||
catalogConsumptionDigest: selection.catalog.consumptionReportDigest,
|
||||
selectionDigest: selection.selectionDigest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -181,12 +302,8 @@ async function main() {
|
||||
const deploymentRoot = path.join(temporaryRoot, 'deployment');
|
||||
const commandPath = path.join(temporaryRoot, 'rollout.json');
|
||||
const uid = process.getuid();
|
||||
const releaseSelection = writeSyntheticLocalReleaseSelection({
|
||||
directory: temporaryRoot,
|
||||
image: input.image,
|
||||
allowRootService: uid === 0,
|
||||
sourceRevision: process.env.GITHUB_SHA,
|
||||
});
|
||||
const selection = resolveReleaseSelection(input, temporaryRoot, uid);
|
||||
const releaseSelection = selection.releaseSelection;
|
||||
let composeResourcesPresent = false;
|
||||
|
||||
try {
|
||||
@@ -852,6 +969,7 @@ async function main() {
|
||||
sqliteEvidenceCollected: true,
|
||||
sqliteCollectedRolloutReplayUnchanged: true,
|
||||
gracefulCleanup: true,
|
||||
releaseAuthority: selection.authority,
|
||||
compatible: true,
|
||||
})}\n`,
|
||||
);
|
||||
@@ -867,9 +985,16 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
argumentsValue,
|
||||
resolveReleaseSelection,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user