mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): scope deployment-family release candidates
This commit is contained in:
@@ -284,6 +284,7 @@ function auditReport(value, expected) {
|
||||
provenance: true,
|
||||
cyclonedxSbom: true,
|
||||
osVulnerabilityEvidence: true,
|
||||
releaseCandidateContract: true,
|
||||
imagePulled: true,
|
||||
localRepoDigestBound: true,
|
||||
embeddedEvidenceVerifier: true,
|
||||
@@ -333,6 +334,7 @@ function auditReport(value, expected) {
|
||||
['provenance_attestation', 'gh'],
|
||||
['cyclonedx_sbom_attestation', 'gh'],
|
||||
['os_vulnerability_attestation', 'gh'],
|
||||
['release_candidate_attestation', 'gh'],
|
||||
['immutable_image_pull', 'docker'],
|
||||
['local_digest_inspection', 'docker'],
|
||||
['embedded_evidence_verifier', 'docker'],
|
||||
@@ -383,7 +385,7 @@ function auditReport(value, expected) {
|
||||
fail('step evidence is invalid');
|
||||
}
|
||||
});
|
||||
const verifierStep = value.steps[6];
|
||||
const verifierStep = value.steps[7];
|
||||
if (
|
||||
verifierStep.stdoutBytes < 2 ||
|
||||
verifierStep.stderrBytes !== 0 ||
|
||||
|
||||
@@ -481,6 +481,10 @@ async function runCeremony(options) {
|
||||
'os_vulnerability_attestation',
|
||||
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
);
|
||||
attestation(
|
||||
'release_candidate_attestation',
|
||||
'https://qinglong.dev/attestations/release-candidate-contract/v1',
|
||||
);
|
||||
discard(
|
||||
runStep(
|
||||
tools.docker,
|
||||
@@ -583,6 +587,7 @@ async function runCeremony(options) {
|
||||
provenance: true,
|
||||
cyclonedxSbom: true,
|
||||
osVulnerabilityEvidence: true,
|
||||
releaseCandidateContract: true,
|
||||
imagePulled: true,
|
||||
localRepoDigestBound: true,
|
||||
embeddedEvidenceVerifier: true,
|
||||
|
||||
@@ -13,6 +13,7 @@ const FILES = Object.freeze({
|
||||
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
workflow: '.github/workflows/ql3-image-release.yml',
|
||||
candidate: 'scripts/ql3-release-candidate-contract.cjs',
|
||||
cli: 'packages/ql3-cluster-admin/src/copilot-console/cli.ts',
|
||||
server: 'packages/ql3-cluster-admin/src/copilot-console/server.ts',
|
||||
});
|
||||
@@ -105,6 +106,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
'--bundle-from-oci',
|
||||
'https://cyclonedx.org/bom',
|
||||
'https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
'https://qinglong.dev/attestations/release-candidate-contract/v1',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_VERIFIER_DRIFT',
|
||||
);
|
||||
@@ -134,6 +136,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
"'--bundle-from-oci'",
|
||||
"'https://cyclonedx.org/bom'",
|
||||
"'https://qinglong.dev/attestations/image-os-vulnerability/v1'",
|
||||
"'https://qinglong.dev/attestations/release-candidate-contract/v1'",
|
||||
"'immutable_image_pull'",
|
||||
"'local_digest_inspection'",
|
||||
"'embedded_evidence_verifier'",
|
||||
@@ -223,16 +226,30 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
||||
],
|
||||
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'candidate',
|
||||
[
|
||||
"image: 'admin'",
|
||||
"repository: 'qinglong3-cluster-admin'",
|
||||
"image: 'worker'",
|
||||
"repository: 'qinglong3-worker'",
|
||||
"profiles: ['edge', 'standalone']",
|
||||
'requiresClusterPrivateEvidence: false',
|
||||
'requiresClusterPrivateEvidence: true',
|
||||
],
|
||||
'QL3_CLUSTER_ADMIN_RELEASE_CANDIDATE_DRIFT',
|
||||
);
|
||||
requireFragments(
|
||||
'workflow',
|
||||
[
|
||||
'image: admin',
|
||||
'image_arch: amd64',
|
||||
'image_arch: arm64',
|
||||
'fromJSON(needs.release-candidate.outputs.publish-matrix)',
|
||||
'fromJSON(needs.release-candidate.outputs.os-matrix)',
|
||||
'cosign sign --yes "${IMAGE}@${DIGEST}"',
|
||||
'predicate-type: https://qinglong.dev/attestations/image-os-vulnerability/v1',
|
||||
'predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1',
|
||||
'gh attestation verify "oci://${IMAGE}@${DIGEST}"',
|
||||
'--predicate-type "https://cyclonedx.org/bom"',
|
||||
'--predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1"',
|
||||
'--deny-self-hosted-runners',
|
||||
'--bundle-from-oci',
|
||||
'Promote only the verified digest to immutable release tags',
|
||||
|
||||
@@ -57,6 +57,8 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
image: 'control',
|
||||
repository: 'qinglong3-cluster-control',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
@@ -65,6 +67,8 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
image: 'control',
|
||||
repository: 'qinglong3-cluster-control',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
@@ -73,6 +77,8 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
image: 'control-ai',
|
||||
repository: 'qinglong3-cluster-control-ai',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime-ai',
|
||||
},
|
||||
@@ -81,6 +87,8 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
image: 'control-ai',
|
||||
repository: 'qinglong3-cluster-control-ai',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime-ai',
|
||||
},
|
||||
@@ -89,6 +97,8 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
image: 'admin',
|
||||
repository: 'qinglong3-cluster-admin',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
@@ -97,9 +107,31 @@ function auditClusterImageCiWorkflow(
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
image: 'admin',
|
||||
repository: 'qinglong3-cluster-admin',
|
||||
runtime_user: '10001:10001',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
image: 'worker',
|
||||
repository: 'qinglong3-worker',
|
||||
runtime_user: '65532:65532',
|
||||
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
image: 'worker',
|
||||
repository: 'qinglong3-worker',
|
||||
runtime_user: '65532:65532',
|
||||
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
];
|
||||
const expectedLocalNativeMatrix = [
|
||||
{
|
||||
@@ -134,6 +166,11 @@ function auditClusterImageCiWorkflow(
|
||||
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'worker',
|
||||
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
];
|
||||
if (
|
||||
JSON.stringify(
|
||||
@@ -146,7 +183,7 @@ function auditClusterImageCiWorkflow(
|
||||
JSON.stringify(expectedOciMatrix)
|
||||
) {
|
||||
throw new Error(
|
||||
'image CI matrices must contain only exact control/control-ai/admin/local amd64/arm64 evidence targets',
|
||||
'image CI matrices must contain only exact control/control-ai/admin/local/worker amd64/arm64 evidence targets',
|
||||
);
|
||||
}
|
||||
const expectedTrivyInputs = {
|
||||
@@ -170,7 +207,7 @@ function auditClusterImageCiWorkflow(
|
||||
],
|
||||
[
|
||||
clusterImageJob,
|
||||
'qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }}',
|
||||
'${{ matrix.repository }}:ci-${{ matrix.image_arch }}',
|
||||
'${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml',
|
||||
'cluster',
|
||||
],
|
||||
@@ -257,6 +294,8 @@ function auditClusterImageCiWorkflow(
|
||||
['control-ai', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
|
||||
['admin', 'ubuntu-24\\.04', 'x64', 'amd64'],
|
||||
['admin', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
|
||||
['worker', 'ubuntu-24\\.04', 'x64', 'amd64'],
|
||||
['worker', 'ubuntu-24\\.04-arm', 'arm64', 'arm64'],
|
||||
]) {
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -274,7 +313,7 @@ function auditClusterImageCiWorkflow(
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/--read-only[\s\S]*--user 10001:10001[\s\S]*--inventory-root=\/opt\/qinglong\/node_modules/,
|
||||
/--read-only[\s\S]*--user \$\{\{ matrix\.runtime_user \}\}[\s\S]*--inventory-root=\/opt\/qinglong\/node_modules/,
|
||||
'cluster image CI must reconcile the SBOM with a read-only non-root image inventory',
|
||||
);
|
||||
requirePattern(
|
||||
@@ -299,8 +338,8 @@ function auditClusterImageCiWorkflow(
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime/,
|
||||
'OCI evidence CI must build independent control, control-ai, admin and local images',
|
||||
/- image: control\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime\s+- image: control-ai\s+dockerfile: deploy\/containers\/ql3-cluster-control\/Dockerfile\s+target: runtime-ai\s+- image: admin\s+dockerfile: deploy\/containers\/ql3-cluster-admin\/Dockerfile\s+target: runtime\s+- image: local\s+dockerfile: deploy\/containers\/ql3-local-application\/Dockerfile\s+target: runtime\s+- image: worker\s+dockerfile: deploy\/containers\/ql3-worker\/Dockerfile\s+target: runtime/,
|
||||
'OCI evidence CI must build independent control, control-ai, admin, local and worker images',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -329,7 +368,7 @@ function auditClusterImageCiWorkflow(
|
||||
'local image CI must generate and inventory-check the exact local SBOM profile',
|
||||
);
|
||||
return {
|
||||
images: ['control', 'control-ai', 'admin', 'local'],
|
||||
images: ['control', 'control-ai', 'admin', 'local', 'worker'],
|
||||
nativeArchitectures: ['amd64', 'arm64'],
|
||||
runtimeInventory: true,
|
||||
clusterAdminProductFacade: true,
|
||||
@@ -348,112 +387,23 @@ function auditClusterImageCiWorkflow(
|
||||
|
||||
function auditReleaseWorkflow(source) {
|
||||
const workflow = yaml.load(source);
|
||||
const candidateJob = workflow?.jobs?.['release-candidate'];
|
||||
const evidenceJob = workflow?.jobs?.['worker-management-release-evidence'];
|
||||
const drEvidenceJob = workflow?.jobs?.['cluster-dr-release-evidence'];
|
||||
const osVulnerabilityJob = workflow?.jobs?.['os-vulnerability'];
|
||||
const publishJob = workflow?.jobs?.publish;
|
||||
const expectedReleaseMatrix = [
|
||||
{
|
||||
image: 'control',
|
||||
repository: 'qinglong3-cluster-control',
|
||||
runtime_root:
|
||||
'deploy/containers/ql3-cluster-control/runtime-dependencies',
|
||||
},
|
||||
{
|
||||
image: 'control-ai',
|
||||
repository: 'qinglong3-cluster-control-ai',
|
||||
runtime_root:
|
||||
'deploy/containers/ql3-cluster-control/runtime-dependencies',
|
||||
},
|
||||
{
|
||||
image: 'admin',
|
||||
repository: 'qinglong3-cluster-admin',
|
||||
runtime_root: 'deploy/containers/ql3-cluster-admin/runtime-dependencies',
|
||||
},
|
||||
{
|
||||
image: 'local',
|
||||
repository: 'qinglong3-local-application',
|
||||
runtime_root:
|
||||
'deploy/containers/ql3-local-application/runtime-dependencies',
|
||||
},
|
||||
];
|
||||
const expectedOsVulnerabilityMatrix = [
|
||||
{
|
||||
image: 'control',
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'control',
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'control-ai',
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime-ai',
|
||||
},
|
||||
{
|
||||
image: 'control-ai',
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime-ai',
|
||||
},
|
||||
{
|
||||
image: 'admin',
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'admin',
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'local',
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
{
|
||||
image: 'local',
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
|
||||
target: 'runtime',
|
||||
},
|
||||
];
|
||||
if (
|
||||
JSON.stringify(publishJob?.strategy?.matrix?.include) !==
|
||||
JSON.stringify(expectedReleaseMatrix)
|
||||
publishJob?.strategy?.matrix?.include !==
|
||||
'${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}'
|
||||
) {
|
||||
throw new Error(
|
||||
'release workflow matrix must contain only exact control, control-ai, admin and local image authorities',
|
||||
'publisher matrix must come only from the source-derived release candidate contract',
|
||||
);
|
||||
}
|
||||
if (
|
||||
JSON.stringify(osVulnerabilityJob?.strategy?.matrix?.include) !==
|
||||
JSON.stringify(expectedOsVulnerabilityMatrix) ||
|
||||
osVulnerabilityJob?.strategy?.matrix?.include !==
|
||||
'${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}' ||
|
||||
osVulnerabilityJob?.needs !== 'release-candidate' ||
|
||||
osVulnerabilityJob?.strategy?.['fail-fast'] !== false ||
|
||||
osVulnerabilityJob?.['runs-on'] !== '${{ matrix.runner }}' ||
|
||||
osVulnerabilityJob?.['timeout-minutes'] !== 45 ||
|
||||
@@ -461,7 +411,7 @@ function auditReleaseWorkflow(source) {
|
||||
JSON.stringify({ contents: 'read' })
|
||||
) {
|
||||
throw new Error(
|
||||
'release OS vulnerability matrix must scan exact control, control-ai, admin and local amd64/arm64 candidates with read-only authority',
|
||||
'release OS vulnerability matrix must come from the source-derived deployment-family contract with read-only authority',
|
||||
);
|
||||
}
|
||||
requirePattern(
|
||||
@@ -469,6 +419,11 @@ function auditReleaseWorkflow(source) {
|
||||
/workflow_dispatch:\s*\n\s+inputs:\s*\n\s+version:/,
|
||||
'release workflow must support an explicit version input',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/release_scope:\s+description: Deployment family to publish\s+required: true\s+default: all\s+type: choice\s+options:\s+- local\s+- cluster\s+- all/,
|
||||
'release workflow must select one closed local, cluster or all deployment family',
|
||||
);
|
||||
if (/^ (?:push|pull_request|schedule):/m.test(source)) {
|
||||
throw new Error(
|
||||
'release workflow must require an explicit protected-tag dispatch',
|
||||
@@ -477,6 +432,8 @@ function auditReleaseWorkflow(source) {
|
||||
if (
|
||||
JSON.stringify(workflow?.permissions) !==
|
||||
JSON.stringify({ contents: 'read' }) ||
|
||||
JSON.stringify(candidateJob?.permissions) !==
|
||||
JSON.stringify({ contents: 'read' }) ||
|
||||
JSON.stringify(evidenceJob?.permissions) !==
|
||||
JSON.stringify({ contents: 'read' }) ||
|
||||
JSON.stringify(drEvidenceJob?.permissions) !==
|
||||
@@ -495,6 +452,15 @@ function auditReleaseWorkflow(source) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
candidateJob?.['runs-on'] !== 'ubuntu-24.04' ||
|
||||
candidateJob?.['timeout-minutes'] !== 5 ||
|
||||
JSON.stringify(candidateJob?.outputs) !==
|
||||
JSON.stringify({
|
||||
'cluster-evidence-required':
|
||||
'${{ steps.contract.outputs.cluster-evidence-required }}',
|
||||
'os-matrix': '${{ steps.contract.outputs.os-matrix }}',
|
||||
'publish-matrix': '${{ steps.contract.outputs.publish-matrix }}',
|
||||
}) ||
|
||||
JSON.stringify(evidenceJob?.['runs-on']) !==
|
||||
JSON.stringify([
|
||||
'self-hosted',
|
||||
@@ -511,16 +477,45 @@ function auditReleaseWorkflow(source) {
|
||||
]) ||
|
||||
drEvidenceJob?.environment !== 'ql3-production-release-evidence' ||
|
||||
drEvidenceJob?.['timeout-minutes'] !== 10 ||
|
||||
evidenceJob?.needs !== 'release-candidate' ||
|
||||
drEvidenceJob?.needs !== 'release-candidate' ||
|
||||
evidenceJob?.if !==
|
||||
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
|
||||
drEvidenceJob?.if !==
|
||||
"needs.release-candidate.outputs.cluster-evidence-required == 'true'" ||
|
||||
JSON.stringify(publishJob?.needs) !==
|
||||
JSON.stringify([
|
||||
'release-candidate',
|
||||
'worker-management-release-evidence',
|
||||
'cluster-dr-release-evidence',
|
||||
'os-vulnerability',
|
||||
]) ||
|
||||
publishJob?.if !== undefined
|
||||
typeof publishJob?.if !== 'string' ||
|
||||
!/always\(\)[\s\S]*release-candidate\.result == 'success'[\s\S]*os-vulnerability\.result == 'success'[\s\S]*cluster-evidence-required != 'true'[\s\S]*worker-management-release-evidence\.result == 'success'[\s\S]*cluster-dr-release-evidence\.result == 'success'/.test(
|
||||
publishJob.if,
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'release publisher must depend on both protected ephemeral private evidence jobs',
|
||||
'release publisher must always require candidate and OS gates while requiring private HA evidence only for a cluster family',
|
||||
);
|
||||
}
|
||||
const candidateSteps = candidateJob?.steps;
|
||||
if (
|
||||
!Array.isArray(candidateSteps) ||
|
||||
candidateSteps.length !== 3 ||
|
||||
candidateSteps[0]?.uses !==
|
||||
'actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803' ||
|
||||
candidateSteps[0]?.with?.['persist-credentials'] !== false ||
|
||||
candidateSteps[1]?.uses !==
|
||||
'actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38' ||
|
||||
candidateSteps[1]?.with?.['node-version'] !== '24.18.0' ||
|
||||
candidateSteps[2]?.id !== 'contract' ||
|
||||
!/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]*GITHUB_OUTPUT/.test(
|
||||
candidateSteps[2]?.run ?? '',
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
'release candidate job must derive bounded matrices from the exact tag, revision, version and deployment family',
|
||||
);
|
||||
}
|
||||
const osSteps = osVulnerabilityJob?.steps;
|
||||
@@ -721,34 +716,19 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6/g,
|
||||
4,
|
||||
5,
|
||||
'all release jobs must pin the reviewed immutable checkout action',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6/g,
|
||||
4,
|
||||
5,
|
||||
'all release jobs must pin the reviewed immutable Node setup action',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/- image: control\s+repository: qinglong3-cluster-control\s+runtime_root: deploy\/containers\/ql3-cluster-control\/runtime-dependencies/,
|
||||
'release workflow must publish the exact control image authority',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/- image: control-ai\s+repository: qinglong3-cluster-control-ai\s+runtime_root: deploy\/containers\/ql3-cluster-control\/runtime-dependencies/,
|
||||
'release workflow must publish the exact optional control-ai image authority',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/- image: admin\s+repository: qinglong3-cluster-admin\s+runtime_root: deploy\/containers\/ql3-cluster-admin\/runtime-dependencies/,
|
||||
'release workflow must publish the exact admin image authority',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/- image: local\s+repository: qinglong3-local-application\s+runtime_root: deploy\/containers\/ql3-local-application\/runtime-dependencies/,
|
||||
'release workflow must publish the exact local image authority',
|
||||
/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\}"/,
|
||||
'publisher must recreate and independently audit its exact source-derived candidate contract',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -798,13 +778,13 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/uses: actions\/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4/g,
|
||||
3,
|
||||
'release workflow must create provenance, SBOM and OS vulnerability attestations',
|
||||
4,
|
||||
'release workflow must create provenance, SBOM, OS vulnerability and release candidate attestations',
|
||||
);
|
||||
requireOccurrences(
|
||||
source,
|
||||
/push-to-registry: true/g,
|
||||
3,
|
||||
4,
|
||||
'all GitHub attestations must be pushed beside the OCI image',
|
||||
);
|
||||
requirePattern(
|
||||
@@ -817,6 +797,11 @@ function auditReleaseWorkflow(source) {
|
||||
/predicate-type: https:\/\/qinglong\.dev\/attestations\/image-os-vulnerability\/v1\s+predicate-path: \$\{\{ runner\.temp \}\}\/\$\{\{ matrix\.repository \}\}-os-vulnerability\.json/,
|
||||
'release workflow must attest the digest-bound OS vulnerability evidence',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/predicate-type: https:\/\/qinglong\.dev\/attestations\/release-candidate-contract\/v1\s+predicate-path: \$\{\{ runner\.temp \}\}\/\$\{\{ matrix\.repository \}\}-release-candidate-contract\.json/,
|
||||
'release workflow must attest the source-derived release candidate contract',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/subject-digest: \$\{\{ steps\.push\.outputs\.digest \}\}/,
|
||||
@@ -840,8 +825,8 @@ function auditReleaseWorkflow(source) {
|
||||
requireOccurrences(
|
||||
source,
|
||||
/gh attestation verify "oci:\/\/\$\{IMAGE\}@\$\{DIGEST\}"/g,
|
||||
3,
|
||||
'release workflow must independently verify provenance, CycloneDX and OS vulnerability attestations',
|
||||
4,
|
||||
'release workflow must independently verify provenance, CycloneDX, OS vulnerability and candidate attestations',
|
||||
);
|
||||
for (const [pattern, finding] of [
|
||||
[
|
||||
@@ -869,7 +854,7 @@ function auditReleaseWorkflow(source) {
|
||||
'GitHub attestation verification must read the published OCI bundle',
|
||||
],
|
||||
]) {
|
||||
requireOccurrences(source, pattern, 3, finding);
|
||||
requireOccurrences(source, pattern, 4, finding);
|
||||
}
|
||||
requirePattern(
|
||||
source,
|
||||
@@ -881,6 +866,11 @@ function auditReleaseWorkflow(source) {
|
||||
/--predicate-type "https:\/\/qinglong\.dev\/attestations\/image-os-vulnerability\/v1"/,
|
||||
'release workflow must verify the OS vulnerability predicate type explicitly',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/--predicate-type "https:\/\/qinglong\.dev\/attestations\/release-candidate-contract\/v1"/,
|
||||
'release workflow must verify the release candidate predicate type explicitly',
|
||||
);
|
||||
requirePattern(
|
||||
source,
|
||||
/name: Promote only the verified digest to immutable release tags[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image copy "\$\{IMAGE\}@\$\{DIGEST\}" "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"[\s\S]*image digest "\$\{IMAGE\}:\$\{VERSION\}"[\s\S]*image digest "\$\{IMAGE\}:sha-\$\{GITHUB_SHA\}"/,
|
||||
@@ -888,6 +878,14 @@ function auditReleaseWorkflow(source) {
|
||||
);
|
||||
return {
|
||||
trigger: 'explicit protected v3 tag dispatch',
|
||||
releaseCandidateContract: {
|
||||
scopes: ['local', 'cluster', 'all'],
|
||||
workspacePackages: 18,
|
||||
sourceDerived: true,
|
||||
digestAttested: true,
|
||||
localClusterEvidenceRequired: false,
|
||||
clusterPrivateEvidenceRequired: true,
|
||||
},
|
||||
workerManagementEvidence: {
|
||||
sourceAware: true,
|
||||
privateEphemeralRunner: true,
|
||||
@@ -913,11 +911,16 @@ function auditReleaseWorkflow(source) {
|
||||
immutableArtifactRetentionDays: 1,
|
||||
attestedToPublishedDigest: true,
|
||||
},
|
||||
images: ['control', 'control-ai', 'admin', 'local'],
|
||||
images: ['control', 'control-ai', 'admin', 'worker', 'local'],
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
keylessSignature: true,
|
||||
buildkitAttestations: ['sbom', 'provenance'],
|
||||
githubAttestations: ['provenance', 'sbom', 'os-vulnerability'],
|
||||
githubAttestations: [
|
||||
'provenance',
|
||||
'sbom',
|
||||
'os-vulnerability',
|
||||
'release-candidate',
|
||||
],
|
||||
publication: {
|
||||
copier: 'regctl@0.11.5',
|
||||
copierSha256:
|
||||
@@ -933,6 +936,7 @@ function auditReleaseWorkflow(source) {
|
||||
'provenance',
|
||||
'cyclonedx',
|
||||
'os-vulnerability',
|
||||
'release-candidate',
|
||||
'release-tags',
|
||||
],
|
||||
};
|
||||
|
||||
@@ -9,10 +9,8 @@ const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const IMAGE_PROFILES = Object.freeze({
|
||||
control: Object.freeze({
|
||||
id: 'control',
|
||||
buildManifestPath:
|
||||
'deploy/containers/ql3-cluster-control/package.json',
|
||||
buildLockPath:
|
||||
'deploy/containers/ql3-cluster-control/package-lock.json',
|
||||
buildManifestPath: 'deploy/containers/ql3-cluster-control/package.json',
|
||||
buildLockPath: 'deploy/containers/ql3-cluster-control/package-lock.json',
|
||||
imageManifestPath:
|
||||
'deploy/containers/ql3-cluster-control/runtime-dependencies/package.json',
|
||||
imageLockPath:
|
||||
@@ -26,10 +24,8 @@ const IMAGE_PROFILES = Object.freeze({
|
||||
}),
|
||||
'control-ai': Object.freeze({
|
||||
id: 'control-ai',
|
||||
buildManifestPath:
|
||||
'deploy/containers/ql3-cluster-control/package.json',
|
||||
buildLockPath:
|
||||
'deploy/containers/ql3-cluster-control/package-lock.json',
|
||||
buildManifestPath: 'deploy/containers/ql3-cluster-control/package.json',
|
||||
buildLockPath: 'deploy/containers/ql3-cluster-control/package-lock.json',
|
||||
imageManifestPath:
|
||||
'deploy/containers/ql3-cluster-control/runtime-dependencies/package.json',
|
||||
imageLockPath:
|
||||
@@ -44,10 +40,8 @@ const IMAGE_PROFILES = Object.freeze({
|
||||
}),
|
||||
admin: Object.freeze({
|
||||
id: 'admin',
|
||||
buildManifestPath:
|
||||
'deploy/containers/ql3-cluster-admin/package.json',
|
||||
buildLockPath:
|
||||
'deploy/containers/ql3-cluster-admin/package-lock.json',
|
||||
buildManifestPath: 'deploy/containers/ql3-cluster-admin/package.json',
|
||||
buildLockPath: 'deploy/containers/ql3-cluster-admin/package-lock.json',
|
||||
imageManifestPath:
|
||||
'deploy/containers/ql3-cluster-admin/runtime-dependencies/package.json',
|
||||
imageLockPath:
|
||||
@@ -62,10 +56,8 @@ const IMAGE_PROFILES = Object.freeze({
|
||||
}),
|
||||
local: Object.freeze({
|
||||
id: 'local',
|
||||
buildManifestPath:
|
||||
'deploy/containers/ql3-local-application/package.json',
|
||||
buildLockPath:
|
||||
'deploy/containers/ql3-local-application/package-lock.json',
|
||||
buildManifestPath: 'deploy/containers/ql3-local-application/package.json',
|
||||
buildLockPath: 'deploy/containers/ql3-local-application/package-lock.json',
|
||||
imageManifestPath:
|
||||
'deploy/containers/ql3-local-application/runtime-dependencies/package.json',
|
||||
imageLockPath:
|
||||
@@ -84,12 +76,28 @@ const IMAGE_PROFILES = Object.freeze({
|
||||
'drizzle-orm': '1.0.0-rc.4',
|
||||
}),
|
||||
}),
|
||||
worker: Object.freeze({
|
||||
id: 'worker',
|
||||
buildManifestPath: 'deploy/containers/ql3-worker/package.json',
|
||||
buildLockPath: 'deploy/containers/ql3-worker/package-lock.json',
|
||||
imageManifestPath:
|
||||
'deploy/containers/ql3-worker/runtime-dependencies/package.json',
|
||||
imageLockPath:
|
||||
'deploy/containers/ql3-worker/runtime-dependencies/package-lock.json',
|
||||
internalManifestPaths: Object.freeze([
|
||||
'packages/ql3-runtime-core/package.json',
|
||||
'packages/ql3-local-process/package.json',
|
||||
'packages/ql3-worker-runtime/package.json',
|
||||
]),
|
||||
buildOnlyDependencies: Object.freeze({}),
|
||||
}),
|
||||
});
|
||||
const INTERNAL_MANIFEST_PATHS = IMAGE_PROFILES.control.internalManifestPaths;
|
||||
const ALLOWED_LICENSE_IDS = Object.freeze([
|
||||
'0BSD',
|
||||
'Apache-2.0',
|
||||
'BSD-2-Clause',
|
||||
'BSD-3-Clause',
|
||||
'ISC',
|
||||
'MIT',
|
||||
'Python-2.0',
|
||||
@@ -102,7 +110,7 @@ function resolveImageProfile(value = 'control') {
|
||||
const profile = IMAGE_PROFILES[value];
|
||||
if (!profile) {
|
||||
throw new Error(
|
||||
'image profile must be exactly control, control-ai, admin or local',
|
||||
'image profile must be exactly control, control-ai, admin, local or worker',
|
||||
);
|
||||
}
|
||||
return profile;
|
||||
@@ -167,7 +175,9 @@ function lockPackageName(location, lockPackage) {
|
||||
const marker = 'node_modules/';
|
||||
const index = location.lastIndexOf(marker);
|
||||
if (index === -1) {
|
||||
throw new Error(`cannot derive package name from lock location: ${location}`);
|
||||
throw new Error(
|
||||
`cannot derive package name from lock location: ${location}`,
|
||||
);
|
||||
}
|
||||
const tail = location.slice(index + marker.length);
|
||||
const parts = tail.split('/');
|
||||
@@ -300,12 +310,8 @@ function exactDependencyRef(
|
||||
function createClusterImageSbom(options = {}) {
|
||||
const root = path.resolve(options.root || DEFAULT_ROOT);
|
||||
const profile = resolveImageProfile(options.image);
|
||||
const imageManifest = readJson(
|
||||
path.join(root, profile.imageManifestPath),
|
||||
);
|
||||
const buildManifest = readJson(
|
||||
path.join(root, profile.buildManifestPath),
|
||||
);
|
||||
const imageManifest = readJson(path.join(root, profile.imageManifestPath));
|
||||
const buildManifest = readJson(path.join(root, profile.buildManifestPath));
|
||||
const buildLock = readJson(path.join(root, profile.buildLockPath));
|
||||
const lock = readJson(path.join(root, profile.imageLockPath));
|
||||
for (const field of ['name', 'version']) {
|
||||
@@ -319,9 +325,7 @@ function createClusterImageSbom(options = {}) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
JSON.stringify(
|
||||
Object.entries(buildManifest.dependencies || {}).sort(),
|
||||
) !==
|
||||
JSON.stringify(Object.entries(buildManifest.dependencies || {}).sort()) !==
|
||||
JSON.stringify(
|
||||
Object.entries({
|
||||
...(imageManifest.dependencies || {}),
|
||||
@@ -334,7 +338,9 @@ function createClusterImageSbom(options = {}) {
|
||||
);
|
||||
}
|
||||
if (imageManifest.devDependencies !== undefined) {
|
||||
throw new Error('production image manifest must not declare devDependencies');
|
||||
throw new Error(
|
||||
'production image manifest must not declare devDependencies',
|
||||
);
|
||||
}
|
||||
const lockRoot = lock.packages?.[''];
|
||||
if (
|
||||
@@ -458,14 +464,14 @@ function createClusterImageSbom(options = {}) {
|
||||
|
||||
const components = [
|
||||
...externalComponents,
|
||||
...internalManifests.map((manifest) =>
|
||||
componentFromManifest(manifest),
|
||||
),
|
||||
...internalManifests.map((manifest) => componentFromManifest(manifest)),
|
||||
].sort((left, right) =>
|
||||
left['bom-ref'].localeCompare(right['bom-ref'], 'en'),
|
||||
);
|
||||
|
||||
const componentRefs = new Set(components.map((component) => component['bom-ref']));
|
||||
const componentRefs = new Set(
|
||||
components.map((component) => component['bom-ref']),
|
||||
);
|
||||
if (componentRefs.size !== components.length) {
|
||||
throw new Error('runtime component name and version pairs must be unique');
|
||||
}
|
||||
@@ -524,7 +530,9 @@ function componentMap(document) {
|
||||
throw new Error('SBOM component references must be unique strings');
|
||||
}
|
||||
if (component.name === 'typescript') {
|
||||
throw new Error(`development component leaked into SBOM: ${component.name}`);
|
||||
throw new Error(
|
||||
`development component leaked into SBOM: ${component.name}`,
|
||||
);
|
||||
}
|
||||
const licenseIds = (component.licenses || []).map(
|
||||
(entry) => entry?.license?.id,
|
||||
@@ -599,9 +607,13 @@ function collectRuntimeInventory(nodeModulesRoot) {
|
||||
visitNodeModules(root);
|
||||
const refs = inventory.map((entry) => entry.ref).sort();
|
||||
if (new Set(refs).size !== refs.length) {
|
||||
throw new Error('runtime inventory contains duplicate name/version packages');
|
||||
throw new Error(
|
||||
'runtime inventory contains duplicate name/version packages',
|
||||
);
|
||||
}
|
||||
return inventory.sort((left, right) => left.ref.localeCompare(right.ref, 'en'));
|
||||
return inventory.sort((left, right) =>
|
||||
left.ref.localeCompare(right.ref, 'en'),
|
||||
);
|
||||
}
|
||||
|
||||
function auditClusterImageSbom(document, options = {}) {
|
||||
@@ -652,7 +664,10 @@ function auditClusterImageSbom(document, options = {}) {
|
||||
'SBOM component set',
|
||||
);
|
||||
for (const [ref, expectedComponent] of expectedComponents) {
|
||||
if (JSON.stringify(actualComponents.get(ref)) !== JSON.stringify(expectedComponent)) {
|
||||
if (
|
||||
JSON.stringify(actualComponents.get(ref)) !==
|
||||
JSON.stringify(expectedComponent)
|
||||
) {
|
||||
throw new Error(`SBOM component metadata differs for ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,38 @@ function createBlobReader(layoutRoot) {
|
||||
}
|
||||
|
||||
function expectedImageConfig(architecture, revision, image) {
|
||||
if (image === 'worker') {
|
||||
return {
|
||||
architecture,
|
||||
os: 'linux',
|
||||
config: {
|
||||
User: '65532:65532',
|
||||
Env: [
|
||||
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
|
||||
'NODE_VERSION=24.18.0',
|
||||
'YARN_VERSION=1.22.22',
|
||||
'NODE_ENV=production',
|
||||
],
|
||||
Entrypoint: [
|
||||
'node',
|
||||
'/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js',
|
||||
],
|
||||
WorkingDir: '/opt/qinglong',
|
||||
Labels: {
|
||||
'io.qinglong.profile': 'worker',
|
||||
'io.qinglong.worker.capacity-profiles': 'edge,node',
|
||||
'org.opencontainers.image.description':
|
||||
'QingLong 3.0 headless Remote Worker runtime',
|
||||
'org.opencontainers.image.licenses': 'Apache-2.0',
|
||||
'org.opencontainers.image.revision': revision,
|
||||
'org.opencontainers.image.source':
|
||||
'https://github.com/whyour/qinglong',
|
||||
'org.opencontainers.image.title': 'QingLong 3.0 Worker',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (image === 'local') {
|
||||
return {
|
||||
architecture,
|
||||
@@ -249,6 +281,7 @@ function expectedImageConfig(architecture, revision, image) {
|
||||
? 'QingLong 3.0 Cluster Control AI'
|
||||
: 'QingLong 3.0 Cluster Control'
|
||||
: 'QingLong 3.0 Cluster Admin',
|
||||
'org.opencontainers.image.version': '3.0.0-alpha.0',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,7 +7,13 @@ const path = require('node:path');
|
||||
const { TextDecoder } = require('node:util');
|
||||
|
||||
const FIXTURE = 'qinglong/image-os-vulnerability-exceptions@v1';
|
||||
const IMAGES = Object.freeze(['admin', 'control', 'control-ai', 'local']);
|
||||
const IMAGES = Object.freeze([
|
||||
'admin',
|
||||
'control',
|
||||
'control-ai',
|
||||
'local',
|
||||
'worker',
|
||||
]);
|
||||
const MAX_POLICY_BYTES = 256 * 1024;
|
||||
const MAX_EXCEPTIONS = 128;
|
||||
const MAX_EXCEPTION_DAYS = 30;
|
||||
@@ -15,7 +21,8 @@ const CONTROL_PATTERN = /[\u0000-\u001f\u007f]/;
|
||||
const CVE_PATTERN = /^CVE-[0-9]{4}-[0-9]{4,}$/;
|
||||
const OWNER_PATTERN = /^[a-z0-9][a-z0-9._/-]{1,127}$/;
|
||||
const TICKET_PATTERN = /^[A-Z][A-Z0-9]{1,15}-[1-9][0-9]{0,9}$/;
|
||||
const PURL_PATTERN = /^pkg:(?:apk|deb|rpm)\/[A-Za-z0-9._~%+-]+\/[A-Za-z0-9._~%+-]+@[A-Za-z0-9._~%+:-]+$/;
|
||||
const PURL_PATTERN =
|
||||
/^pkg:(?:apk|deb|rpm)\/[A-Za-z0-9._~%+-]+\/[A-Za-z0-9._~%+-]+@[A-Za-z0-9._~%+:-]+$/;
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const POLICY_PATH = 'deploy/containers/ql3-os-vulnerability-exceptions.json';
|
||||
|
||||
@@ -69,8 +76,14 @@ function utcDay(value) {
|
||||
: null;
|
||||
}
|
||||
|
||||
function auditImageOsVulnerabilityPolicy(policy, dependencies = { now: Date.now }) {
|
||||
if (!exactKeys(dependencies, ['now']) || typeof dependencies.now !== 'function') {
|
||||
function auditImageOsVulnerabilityPolicy(
|
||||
policy,
|
||||
dependencies = { now: Date.now },
|
||||
) {
|
||||
if (
|
||||
!exactKeys(dependencies, ['now']) ||
|
||||
typeof dependencies.now !== 'function'
|
||||
) {
|
||||
fail('clock is invalid');
|
||||
}
|
||||
const nowMs = dependencies.now();
|
||||
@@ -95,11 +108,18 @@ function auditImageOsVulnerabilityPolicy(policy, dependencies = { now: Date.now
|
||||
control: 0,
|
||||
'control-ai': 0,
|
||||
local: 0,
|
||||
worker: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const counts = { admin: 0, control: 0, 'control-ai': 0, local: 0 };
|
||||
const counts = {
|
||||
admin: 0,
|
||||
control: 0,
|
||||
'control-ai': 0,
|
||||
local: 0,
|
||||
worker: 0,
|
||||
};
|
||||
const seen = new Set();
|
||||
let previousId = '';
|
||||
for (const exception of policy.exceptions) {
|
||||
@@ -192,7 +212,9 @@ function renderTrivyIgnore(policy, image, dependencies = { now: Date.now }) {
|
||||
if (!IMAGES.includes(image)) fail('image is invalid');
|
||||
const audit = auditImageOsVulnerabilityPolicy(policy, dependencies);
|
||||
if (!audit.compatible) fail('policy is incompatible');
|
||||
const selected = policy.exceptions.filter((entry) => entry.images.includes(image));
|
||||
const selected = policy.exceptions.filter((entry) =>
|
||||
entry.images.includes(image),
|
||||
);
|
||||
const lines = ['vulnerabilities:'];
|
||||
if (selected.length === 0) lines.push(' []');
|
||||
for (const exception of selected) {
|
||||
@@ -203,7 +225,9 @@ function renderTrivyIgnore(policy, image, dependencies = { now: Date.now }) {
|
||||
}
|
||||
lines.push(` expired_at: ${exception.expiresOn}`);
|
||||
lines.push(
|
||||
` statement: ${JSON.stringify(`owner=${exception.owner}; ticket=${exception.ticket}; rationale=${exception.rationale}`)}`,
|
||||
` statement: ${JSON.stringify(
|
||||
`owner=${exception.owner}; ticket=${exception.ticket}; rationale=${exception.rationale}`,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
@@ -241,7 +265,8 @@ function parseArguments(argv) {
|
||||
for (const argument of argv) {
|
||||
if (argument === '--') continue;
|
||||
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
|
||||
if (!match || Object.hasOwn(values, match[1])) fail('arguments are invalid');
|
||||
if (!match || Object.hasOwn(values, match[1]))
|
||||
fail('arguments are invalid');
|
||||
values[match[1]] = match[2];
|
||||
}
|
||||
if (
|
||||
@@ -250,7 +275,11 @@ function parseArguments(argv) {
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({ mode: 'render', image: values.image, output: values.output });
|
||||
return Object.freeze({
|
||||
mode: 'render',
|
||||
image: values.image,
|
||||
output: values.output,
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, dependencies = { now: Date.now }) {
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('node:crypto');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { auditPackageBoundaries } = require('./ql3-package-boundary-audit.cjs');
|
||||
|
||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||
const SCHEMA = 'qinglong/release-candidate-contract@v1';
|
||||
const PREDICATE_TYPE =
|
||||
'https://qinglong.dev/attestations/release-candidate-contract/v1';
|
||||
const MAX_REPORT_BYTES = 1024 * 1024;
|
||||
const RELEASE_SCOPES = Object.freeze(['all', 'cluster', 'local']);
|
||||
const NODE_ENGINE = '>=24.18.0 <25';
|
||||
const NODE_VERSION = '24.18.0';
|
||||
const LOCAL_IMAGES = Object.freeze([
|
||||
Object.freeze({
|
||||
image: 'local',
|
||||
repository: 'qinglong3-local-application',
|
||||
dockerfile: 'deploy/containers/ql3-local-application/Dockerfile',
|
||||
target: 'runtime',
|
||||
runtime_root:
|
||||
'deploy/containers/ql3-local-application/runtime-dependencies',
|
||||
}),
|
||||
]);
|
||||
const CLUSTER_IMAGES = Object.freeze([
|
||||
Object.freeze({
|
||||
image: 'control',
|
||||
repository: 'qinglong3-cluster-control',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime',
|
||||
runtime_root: 'deploy/containers/ql3-cluster-control/runtime-dependencies',
|
||||
}),
|
||||
Object.freeze({
|
||||
image: 'control-ai',
|
||||
repository: 'qinglong3-cluster-control-ai',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-control/Dockerfile',
|
||||
target: 'runtime-ai',
|
||||
runtime_root: 'deploy/containers/ql3-cluster-control/runtime-dependencies',
|
||||
}),
|
||||
Object.freeze({
|
||||
image: 'admin',
|
||||
repository: 'qinglong3-cluster-admin',
|
||||
dockerfile: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||
target: 'runtime',
|
||||
runtime_root: 'deploy/containers/ql3-cluster-admin/runtime-dependencies',
|
||||
}),
|
||||
Object.freeze({
|
||||
image: 'worker',
|
||||
repository: 'qinglong3-worker',
|
||||
dockerfile: 'deploy/containers/ql3-worker/Dockerfile',
|
||||
target: 'runtime',
|
||||
runtime_root: 'deploy/containers/ql3-worker/runtime-dependencies',
|
||||
}),
|
||||
]);
|
||||
|
||||
class ReleaseCandidateContractError extends Error {
|
||||
constructor(message) {
|
||||
super(`QingLong release candidate contract failed: ${message}`);
|
||||
this.name = 'ReleaseCandidateContractError';
|
||||
}
|
||||
}
|
||||
|
||||
function fail(message) {
|
||||
throw new ReleaseCandidateContractError(message);
|
||||
}
|
||||
|
||||
function readJson(filePath, maximumBytes = MAX_REPORT_BYTES) {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (
|
||||
!stat.isFile() ||
|
||||
stat.isSymbolicLink() ||
|
||||
stat.size < 2 ||
|
||||
stat.size > maximumBytes
|
||||
) {
|
||||
fail(`invalid bounded JSON file: ${filePath}`);
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||
}
|
||||
|
||||
function selectedImages(scope) {
|
||||
if (scope === 'local') return [...LOCAL_IMAGES];
|
||||
if (scope === 'cluster') return [...CLUSTER_IMAGES];
|
||||
return [...CLUSTER_IMAGES, ...LOCAL_IMAGES];
|
||||
}
|
||||
|
||||
function validateIdentity(options) {
|
||||
if (
|
||||
typeof options.version !== 'string' ||
|
||||
!/^3\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$/u.test(
|
||||
options.version,
|
||||
)
|
||||
) {
|
||||
fail('version must be an exact QingLong 3 SemVer');
|
||||
}
|
||||
if (!/^[a-f0-9]{40}$/u.test(options.sourceRevision || '')) {
|
||||
fail('source revision must be an exact Git SHA-1 commit');
|
||||
}
|
||||
if (options.sourceRef !== `refs/tags/v${options.version}`) {
|
||||
fail('source ref must be the exact version tag');
|
||||
}
|
||||
if (!RELEASE_SCOPES.includes(options.releaseScope)) {
|
||||
fail('release scope must be all, cluster or local');
|
||||
}
|
||||
}
|
||||
|
||||
function createReleaseCandidateContract(options) {
|
||||
const root = path.resolve(options.root || DEFAULT_ROOT);
|
||||
validateIdentity(options);
|
||||
const boundaries = auditPackageBoundaries(root);
|
||||
if (
|
||||
!boundaries.compatible ||
|
||||
boundaries.workspacePackageCount !== 18 ||
|
||||
boundaries.workspacePackageHardCap !== 18 ||
|
||||
boundaries.singleSourcePackages.length !== 0 ||
|
||||
boundaries.shallowSourcePackages.length !== 0
|
||||
) {
|
||||
fail('workspace package boundary is incompatible');
|
||||
}
|
||||
const workspacePackages = boundaries.packages
|
||||
.map((entry) => {
|
||||
const manifest = readJson(path.join(root, entry.path, 'package.json'));
|
||||
if (
|
||||
manifest.name !== entry.name ||
|
||||
manifest.version !== options.version ||
|
||||
manifest.engines?.node !== NODE_ENGINE
|
||||
) {
|
||||
fail(`workspace release identity differs: ${entry.path}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
name: manifest.name,
|
||||
path: entry.path,
|
||||
version: manifest.version,
|
||||
});
|
||||
})
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
||||
const images = selectedImages(options.releaseScope);
|
||||
const imageManifests = images.map((image) => {
|
||||
const manifest = readJson(
|
||||
path.join(root, image.runtime_root, 'package.json'),
|
||||
);
|
||||
if (
|
||||
manifest.version !== options.version ||
|
||||
manifest.engines?.node !== NODE_ENGINE
|
||||
) {
|
||||
fail(`image release identity differs: ${image.runtime_root}`);
|
||||
}
|
||||
const dockerfile = fs.readFileSync(
|
||||
path.join(root, image.dockerfile),
|
||||
'utf8',
|
||||
);
|
||||
if (
|
||||
!dockerfile.includes(`node:${NODE_VERSION}-bookworm-slim@sha256:`) ||
|
||||
!dockerfile.includes(
|
||||
`org.opencontainers.image.version=\"${options.version}\"`,
|
||||
)
|
||||
) {
|
||||
fail(`image Dockerfile release identity differs: ${image.dockerfile}`);
|
||||
}
|
||||
return Object.freeze({
|
||||
image: image.image,
|
||||
repository: image.repository,
|
||||
dockerfile: image.dockerfile,
|
||||
target: image.target,
|
||||
runtimeRoot: image.runtime_root,
|
||||
manifestName: manifest.name,
|
||||
version: manifest.version,
|
||||
});
|
||||
});
|
||||
const publishMatrix = images.map(({ dockerfile, target, ...image }) => image);
|
||||
const osMatrix = images.flatMap((image) => [
|
||||
{
|
||||
image: image.image,
|
||||
runner: 'ubuntu-24.04',
|
||||
node_arch: 'x64',
|
||||
image_arch: 'amd64',
|
||||
dockerfile: image.dockerfile,
|
||||
target: image.target,
|
||||
},
|
||||
{
|
||||
image: image.image,
|
||||
runner: 'ubuntu-24.04-arm',
|
||||
node_arch: 'arm64',
|
||||
image_arch: 'arm64',
|
||||
dockerfile: image.dockerfile,
|
||||
target: image.target,
|
||||
},
|
||||
]);
|
||||
const unsigned = {
|
||||
schemaVersion: 1,
|
||||
schema: SCHEMA,
|
||||
release: {
|
||||
version: options.version,
|
||||
sourceRevision: options.sourceRevision,
|
||||
sourceRef: options.sourceRef,
|
||||
scope: options.releaseScope,
|
||||
},
|
||||
compatibility: {
|
||||
legacyRootPackageVersion: readJson(path.join(root, 'package.json'))
|
||||
.version,
|
||||
legacyRootExcludedFromReleaseIdentity: true,
|
||||
nodeVersion: NODE_VERSION,
|
||||
nodeEngine: NODE_ENGINE,
|
||||
platforms: ['linux/amd64', 'linux/arm64'],
|
||||
},
|
||||
workspace: {
|
||||
packageCount: workspacePackages.length,
|
||||
packageHardCap: boundaries.workspacePackageHardCap,
|
||||
packages: workspacePackages,
|
||||
},
|
||||
deploymentFamilies: {
|
||||
local: {
|
||||
selected: options.releaseScope !== 'cluster',
|
||||
profiles: ['edge', 'standalone'],
|
||||
requiresClusterPrivateEvidence: false,
|
||||
},
|
||||
cluster: {
|
||||
selected: options.releaseScope !== 'local',
|
||||
profiles: ['cluster', 'worker-edge', 'worker-node'],
|
||||
requiresClusterPrivateEvidence: true,
|
||||
},
|
||||
},
|
||||
images: imageManifests,
|
||||
releasePlan: {
|
||||
clusterEvidenceRequired: options.releaseScope !== 'local',
|
||||
osMatrix,
|
||||
publishMatrix,
|
||||
},
|
||||
requiredGates: [
|
||||
'package-boundary',
|
||||
'source-tag-version-identity',
|
||||
'native-os-vulnerability',
|
||||
'multiarch-oci-layout',
|
||||
'production-dependency-audit',
|
||||
'digest-signature-and-attestations',
|
||||
...(options.releaseScope !== 'cluster'
|
||||
? ['edge-and-standalone-rollout']
|
||||
: []),
|
||||
...(options.releaseScope !== 'local'
|
||||
? [
|
||||
'worker-management-production-evidence',
|
||||
'cloudnativepg-disaster-recovery-evidence',
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
return Object.freeze({
|
||||
...unsigned,
|
||||
contractDigest: sha256(Buffer.from(JSON.stringify(unsigned))),
|
||||
});
|
||||
}
|
||||
|
||||
function auditReleaseCandidateContract(actual, options) {
|
||||
const expected = createReleaseCandidateContract(options);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
fail('report differs from the source-derived contract');
|
||||
}
|
||||
return Object.freeze({
|
||||
compatible: true,
|
||||
contractDigest: actual.contractDigest,
|
||||
releaseScope: actual.release.scope,
|
||||
workspacePackageCount: actual.workspace.packageCount,
|
||||
images: Object.freeze(actual.images.map((entry) => entry.image)),
|
||||
clusterEvidenceRequired: actual.releasePlan.clusterEvidenceRequired,
|
||||
});
|
||||
}
|
||||
|
||||
function writeNoReplace(filePath, value) {
|
||||
const resolved = path.resolve(filePath || '');
|
||||
if (
|
||||
!path.isAbsolute(filePath || '') ||
|
||||
fs.existsSync(resolved) ||
|
||||
fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved)
|
||||
) {
|
||||
fail('output must be unused in one canonical directory');
|
||||
}
|
||||
fs.writeFileSync(resolved, `${JSON.stringify(value)}\n`, {
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
flag: 'wx',
|
||||
});
|
||||
}
|
||||
|
||||
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 common = [
|
||||
'mode',
|
||||
'release-scope',
|
||||
'source-ref',
|
||||
'source-revision',
|
||||
'version',
|
||||
];
|
||||
const expected =
|
||||
values.mode === 'create'
|
||||
? [...common, 'output']
|
||||
: values.mode === 'audit'
|
||||
? [...common, 'report']
|
||||
: [];
|
||||
if (
|
||||
expected.length === 0 ||
|
||||
JSON.stringify(Object.keys(values).sort()) !==
|
||||
JSON.stringify(expected.sort())
|
||||
) {
|
||||
fail('arguments are invalid');
|
||||
}
|
||||
return Object.freeze({
|
||||
mode: values.mode,
|
||||
version: values.version,
|
||||
sourceRevision: values['source-revision'],
|
||||
sourceRef: values['source-ref'],
|
||||
releaseScope: values['release-scope'],
|
||||
...(values.output ? { output: values.output } : {}),
|
||||
...(values.report ? { report: values.report } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
||||
const options = parseArguments(argv);
|
||||
if (options.mode === 'create') {
|
||||
const report = createReleaseCandidateContract({ ...options, root });
|
||||
writeNoReplace(options.output, report);
|
||||
output.write(`${JSON.stringify(report)}\n`);
|
||||
return report;
|
||||
}
|
||||
const report = readJson(path.resolve(options.report));
|
||||
const audit = auditReleaseCandidateContract(report, { ...options, root });
|
||||
output.write(`${JSON.stringify(audit)}\n`);
|
||||
return audit;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
runCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`${
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'release candidate contract failed'
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Object.freeze({
|
||||
CLUSTER_IMAGES,
|
||||
LOCAL_IMAGES,
|
||||
PREDICATE_TYPE,
|
||||
RELEASE_SCOPES,
|
||||
SCHEMA,
|
||||
ReleaseCandidateContractError,
|
||||
auditReleaseCandidateContract,
|
||||
createReleaseCandidateContract,
|
||||
parseArguments,
|
||||
runCli,
|
||||
});
|
||||
Reference in New Issue
Block a user