From 7130d77a76770f38e25ba6131df30c1bd6564b05 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 08:54:51 +0800 Subject: [PATCH] feat(ql3): scope deployment-family release candidates --- .github/workflows/ql3-ci.yml | 43 +- .github/workflows/ql3-image-release.yml | 185 ++++++--- deploy/console/ql3-cluster-copilot/README.md | 5 +- .../ql3-cluster-copilot/verify-release.sh | 3 +- .../containers/ql3-cluster-admin/Dockerfile | 3 +- .../containers/ql3-cluster-control/Dockerfile | 3 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 3 +- ...age-attested-multiarch-release-contract.md | 1 + ...-management-evidence-image-release-gate.md | 1 + ...ster-admin-release-workstation-ceremony.md | 9 +- ...yment-family-release-candidate-contract.md | 120 ++++++ ...min-release-workstation-ceremony-audit.cjs | 4 +- ...ter-admin-release-workstation-ceremony.cjs | 5 + ...ter-copilot-console-distribution-audit.cjs | 23 +- scripts/ql3-cluster-image-release-audit.cjs | 268 ++++++------- scripts/ql3-cluster-image-sbom.cjs | 87 +++-- scripts/ql3-cluster-oci-layout-audit.cjs | 33 ++ scripts/ql3-image-os-vulnerability-policy.cjs | 47 ++- scripts/ql3-release-candidate-contract.cjs | 369 ++++++++++++++++++ .../ql3ClusterAdminReleaseVerifier.test.cjs | 22 +- ...erAdminReleaseWorkstationCeremony.test.cjs | 16 +- ...inReleaseWorkstationCeremonyAudit.test.cjs | 14 +- .../back/ql3ClusterImageReleaseAudit.test.cjs | 96 ++++- test/back/ql3ClusterImageSbom.test.cjs | 28 ++ test/back/ql3ClusterOciLayoutAudit.test.cjs | 33 +- .../ql3ImageOsVulnerabilityPolicy.test.cjs | 12 +- .../back/ql3ReleaseCandidateContract.test.cjs | 202 ++++++++++ 27 files changed, 1332 insertions(+), 303 deletions(-) create mode 100644 docs/adr/ADR-0425-deployment-family-release-candidate-contract.md create mode 100644 scripts/ql3-release-candidate-contract.cjs create mode 100644 test/back/ql3ReleaseCandidateContract.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index da602673..2dc108e5 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -462,38 +462,66 @@ jobs: 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 - runner: ubuntu-24.04-arm 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 - runner: ubuntu-24.04 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 - runner: ubuntu-24.04-arm 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 - runner: ubuntu-24.04 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 - runner: ubuntu-24.04-arm 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 steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 @@ -509,7 +537,7 @@ jobs: pnpm audit:image-release:ql3 - name: Build the native production image env: - IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }} + IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} run: >- docker build --file ${{ matrix.dockerfile }} @@ -526,7 +554,7 @@ jobs: uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: version: 'v0.70.0' - image-ref: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }} + image-ref: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} scanners: 'vuln' vuln-type: 'os' severity: 'HIGH,CRITICAL' @@ -539,8 +567,8 @@ jobs: trivyignores: ${{ runner.temp }}/ql3-${{ matrix.image }}-${{ matrix.image_arch }}.trivyignore.yaml - name: Verify architecture and non-root runtime identity env: - IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }} - EXPECTED: ${{ matrix.image_arch }} 10001:10001 + IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} + EXPECTED: ${{ matrix.image_arch }} ${{ matrix.runtime_user }} run: | set -euo pipefail actual="$(docker image inspect --format '{{.Architecture}} {{.Config.User}}' "${IMAGE}")" @@ -561,11 +589,11 @@ jobs: --output=${{ runner.temp }}/ql3-cluster-${{ matrix.image }}.cdx.json - name: Reconcile SBOM with the actual read-only image inventory env: - IMAGE: qinglong3-cluster-${{ matrix.image }}:ci-${{ matrix.image_arch }} + IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} run: >- docker run --rm --read-only --security-opt no-new-privileges - --user 10001:10001 + --user ${{ matrix.runtime_user }} --volume "${{ github.workspace }}:/audit:ro" --workdir /audit --entrypoint node @@ -593,6 +621,9 @@ jobs: - image: local dockerfile: deploy/containers/ql3-local-application/Dockerfile target: runtime + - image: worker + dockerfile: deploy/containers/ql3-worker/Dockerfile + target: runtime steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/.github/workflows/ql3-image-release.yml b/.github/workflows/ql3-image-release.yml index 0a453c43..5dd9d980 100644 --- a/.github/workflows/ql3-image-release.yml +++ b/.github/workflows/ql3-image-release.yml @@ -7,6 +7,15 @@ on: description: Exact QingLong 3 SemVer tag without the v prefix required: true type: string + release_scope: + description: Deployment family to publish + required: true + default: all + type: choice + options: + - local + - cluster + - all permissions: contents: read @@ -16,8 +25,56 @@ concurrency: cancel-in-progress: false jobs: + release-candidate: + name: Freeze the source-derived release candidate contract + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + outputs: + cluster-evidence-required: ${{ steps.contract.outputs.cluster-evidence-required }} + os-matrix: ${{ steps.contract.outputs.os-matrix }} + publish-matrix: ${{ steps.contract.outputs.publish-matrix }} + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: '24.18.0' + + - name: Derive the exact deployment-family release plan + id: contract + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + run: | + set -euo pipefail + umask 077 + report="${RUNNER_TEMP}/ql3-release-candidate-contract.json" + node scripts/ql3-release-candidate-contract.cjs \ + --mode=create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --output="${report}" + REPORT="${report}" node <<'NODE' + const fs = require('node:fs'); + const report = JSON.parse(fs.readFileSync(process.env.REPORT, 'utf8')); + const output = [ + `cluster-evidence-required=${report.releasePlan.clusterEvidenceRequired}`, + `os-matrix=${JSON.stringify(report.releasePlan.osMatrix)}`, + `publish-matrix=${JSON.stringify(report.releasePlan.publishMatrix)}`, + ]; + fs.appendFileSync(process.env.GITHUB_OUTPUT, `${output.join('\n')}\n`); + NODE + worker-management-release-evidence: name: Audit private Worker management release evidence + needs: release-candidate + if: needs.release-candidate.outputs.cluster-evidence-required == 'true' runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral] environment: ql3-production-release-evidence timeout-minutes: 10 @@ -62,6 +119,8 @@ jobs: cluster-dr-release-evidence: name: Audit private CloudNativePG disaster-recovery evidence + needs: release-candidate + if: needs.release-candidate.outputs.cluster-evidence-required == 'true' runs-on: [self-hosted, linux, ql3-release-evidence-ephemeral] environment: ql3-production-release-evidence timeout-minutes: 10 @@ -106,6 +165,7 @@ jobs: os-vulnerability: name: Scan ${{ matrix.image }} OS packages on ${{ matrix.image_arch }} + needs: release-candidate runs-on: ${{ matrix.runner }} timeout-minutes: 45 permissions: @@ -113,55 +173,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - 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 + include: ${{ fromJSON(needs.release-candidate.outputs.os-matrix) }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -244,9 +256,21 @@ jobs: publish: name: Publish immutable multi-architecture ${{ matrix.image }} image needs: + - release-candidate - worker-management-release-evidence - cluster-dr-release-evidence - os-vulnerability + if: >- + always() && + needs.release-candidate.result == 'success' && + needs.os-vulnerability.result == 'success' && + ( + needs.release-candidate.outputs.cluster-evidence-required != 'true' || + ( + needs.worker-management-release-evidence.result == 'success' && + needs.cluster-dr-release-evidence.result == 'success' + ) + ) runs-on: ubuntu-24.04 permissions: contents: read @@ -257,19 +281,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - 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 + include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }} steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 with: @@ -279,6 +291,30 @@ jobs: with: node-version: '24.18.0' + - name: Recreate and audit the source-derived release candidate contract + env: + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_SCOPE: ${{ inputs.release_scope }} + run: | + set -euo pipefail + umask 077 + contract="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-contract.json" + node scripts/ql3-release-candidate-contract.cjs \ + --mode=create \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --output="${contract}" + audit="${RUNNER_TEMP}/${{ matrix.repository }}-release-candidate-audit.json" + node scripts/ql3-release-candidate-contract.cjs \ + --mode=audit \ + --version="${RELEASE_VERSION}" \ + --source-revision="${GITHUB_SHA}" \ + --source-ref="${GITHUB_REF}" \ + --release-scope="${RELEASE_SCOPE}" \ + --report="${contract}" > "${audit}" + - name: Resolve and validate release identity id: identity env: @@ -416,6 +452,15 @@ jobs: predicate-path: ${{ runner.temp }}/${{ matrix.repository }}-os-vulnerability.json push-to-registry: true + - name: Attest the source-derived release candidate contract + uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4 + with: + subject-name: ${{ steps.identity.outputs.image }} + subject-digest: ${{ steps.push.outputs.digest }} + predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1 + predicate-path: ${{ runner.temp }}/${{ matrix.repository }}-release-candidate-contract.json + push-to-registry: true + - name: Verify the published manifest and attestation bindings env: IMAGE: ${{ steps.identity.outputs.image }} @@ -512,6 +557,22 @@ jobs: --deny-self-hosted-runners \ --bundle-from-oci + - name: Verify the release candidate contract from the OCI registry + env: + GH_TOKEN: ${{ github.token }} + IMAGE: ${{ steps.identity.outputs.image }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + gh attestation verify "oci://${IMAGE}@${DIGEST}" \ + --repo "${GITHUB_REPOSITORY}" \ + --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/ql3-image-release.yml" \ + --source-digest "${GITHUB_SHA}" \ + --source-ref "${GITHUB_REF}" \ + --predicate-type "https://qinglong.dev/attestations/release-candidate-contract/v1" \ + --deny-self-hosted-runners \ + --bundle-from-oci + - name: Promote only the verified digest to immutable release tags env: IMAGE: ${{ steps.identity.outputs.image }} diff --git a/deploy/console/ql3-cluster-copilot/README.md b/deploy/console/ql3-cluster-copilot/README.md index fdc629aa..b0f59365 100644 --- a/deploy/console/ql3-cluster-copilot/README.md +++ b/deploy/console/ql3-cluster-copilot/README.md @@ -37,7 +37,7 @@ deploy/console/ql3-cluster-copilot/verify-release.sh \ For a release acceptance ceremony, prefer the source-tag workstation runner. Create a current-owner `0700` report directory and a canonical `0600` file containing only a short-lived GitHub token. Pass canonical absolute executable -paths (resolve Homebrew symlinks first). The token is sent only to the three +paths (resolve Homebrew symlinks first). The token is sent only to the four `gh attestation verify` children, never in argv, the report or the `cosign` and `docker` environments. @@ -54,7 +54,8 @@ node scripts/ql3-cluster-admin-release-workstation-ceremony.cjs \ --output=/absolute/private/release/admin-ceremony.json ``` -In addition to the signature, provenance, CycloneDX and OS-vulnerability +In addition to the signature, provenance, CycloneDX, OS-vulnerability and +source-derived release-candidate contract checks, the runner pulls the same immutable digest, confirms its local `RepoDigests` binding, and runs the image-carried `evidence-verify` command on a fixed non-sensitive vector with network disabled, a read-only root, no diff --git a/deploy/console/ql3-cluster-copilot/verify-release.sh b/deploy/console/ql3-cluster-copilot/verify-release.sh index 09e9ddab..278b8afe 100755 --- a/deploy/console/ql3-cluster-copilot/verify-release.sh +++ b/deploy/console/ql3-cluster-copilot/verify-release.sh @@ -54,5 +54,6 @@ verify_attestation() { verify_attestation '' verify_attestation https://cyclonedx.org/bom verify_attestation https://qinglong.dev/attestations/image-os-vulnerability/v1 +verify_attestation https://qinglong.dev/attestations/release-candidate-contract/v1 -printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"compatible":true}' +printf '%s\n' '{"schemaVersion":1,"component":"qinglong3-cluster-admin-release-verifier","signature":true,"provenance":true,"sbom":true,"osVulnerabilityEvidence":true,"releaseCandidateContract":true,"compatible":true}' diff --git a/deploy/containers/ql3-cluster-admin/Dockerfile b/deploy/containers/ql3-cluster-admin/Dockerfile index 230180ca..4fc45dc7 100644 --- a/deploy/containers/ql3-cluster-admin/Dockerfile +++ b/deploy/containers/ql3-cluster-admin/Dockerfile @@ -96,7 +96,8 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Admin" \ org.opencontainers.image.description="QingLong 3.0 cluster operations and bounded Copilot surfaces" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ - org.opencontainers.image.licenses="Apache-2.0" + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="3.0.0-alpha.0" ENV NODE_ENV=production diff --git a/deploy/containers/ql3-cluster-control/Dockerfile b/deploy/containers/ql3-cluster-control/Dockerfile index c79c92dc..a0484542 100644 --- a/deploy/containers/ql3-cluster-control/Dockerfile +++ b/deploy/containers/ql3-cluster-control/Dockerfile @@ -97,7 +97,8 @@ LABEL org.opencontainers.image.title="QingLong 3.0 Cluster Control" \ org.opencontainers.image.description="QingLong 3.0 PostgreSQL-backed cluster control plane" \ org.opencontainers.image.source="https://github.com/whyour/qinglong" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \ - org.opencontainers.image.licenses="Apache-2.0" + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.version="3.0.0-alpha.0" ENV NODE_ENV=production diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 21e766c7..5ed7c705 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,7 +11,8 @@ 最新增量证据(2026-08-16): -- D-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io//qinglong3-cluster-admin@sha256:`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATH,token 只注入 3 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX 与 OS-vulnerability evidence,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identity;offline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed`、`reportAttestation=none`、`actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`,ADR-0424 必须保持 Proposed;stub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted。 +- D-333/ADR-0425(已接受;公开发布结果待实际 tag):3.0 发布入口不再把所有部署者绑成一个不可分割矩阵。唯一 `.github/workflows/ql3-image-release.yml` 增加 closed `local|cluster|all` deployment-family scope;根级 source-derived release-candidate contract 从 exact `v3` SemVer/tag/40-hex revision、18 个边界审计通过且非 single/shallow 的 workspace、Node 24.18.0 engine、容器 runtime manifest/Dockerfile version、双架构和部署 profile 推导唯一 OS/publish matrix,并以 canonical SHA-256 失败关闭版本或源码漂移。`local` 只发布 AI-excluded Local image、只要求 Edge/Standalone digest rollout,不再等待 Worker management/CloudNativePG 私有 HA evidence;`cluster` 才要求两个 ephemeral private evidence gate,并闭合此前遗漏的 `qinglong3-worker`,与 control/control-ai/admin 一同进入 native amd64/arm64 build-once、Trivy OS scan、CycloneDX、OCI merge、Cosign 与 GitHub attestation 链;`all` 同时保留两族门禁。legacy 根 `2.21.0-14` 被显式标记为不参与 3.0 release identity,而不是伪改旧产品版本。Worker 现在有 27-component(24 external/3 internal)、28-node 的 production SBOM,BSD-3-Clause 纳入受审 allowlist,Worker config 固定 `65532:65532`、`worker` profile、`edge,node` capacity labels 和 3.0 version;control/admin 也补齐同一 version label。candidate contract 作为第四类 digest-bound GitHub predicate 发布并远端回读,Cluster Admin verifier/外部 ceremony/offline audit 同步升级为四类 attestation/八步 transcript。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、listener、timer、watcher 或低配设备常驻资源。定向 105/105、backend 1,246 pass/2 条件 skip/0 fail、18-package clean build/test 均通过;package boundary 确认为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release、OS vulnerability policy、Console/distribution 审计均 compatible,四个 runtime dependency root 的离线缓存审计为 0 vulnerability。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin npm pack 仍为 250 files、271,238-byte tarball、1,690,196-byte unpacked。由于本 Gate 不改变 schema、migration、SQL、role、Pool 或连接/HA 拓扑,不重复执行 PostgreSQL 门,继续复用 D-331 的 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。公开 tag/digest 尚不存在,因此不宣称真实 GHCR/Cosign/attestation 发布成功,在线依赖漏洞新鲜度与五镜像远端门由实际 release workflow 重新取得。 +- D-332/ADR-0424(实现门完成、外部验收待公开 release):从 exact reviewed `v3.*` source tag 执行的 Cluster Admin release workstation ceremony 已实现为根级 runner + 独立 offline auditor,不新增 workspace package、生产依赖、产品命令、镜像内容或常驻组件。runner 只接受 owner-bound `ghcr.io//qinglong3-cluster-admin@sha256:`、40-hex source revision、完整 tag ref、canonical absolute `cosign|gh|docker`、current-owner `0600` 短期 GitHub token file 与 no-replace 私有 report;三个工具按绝对路径直接执行且前后复验 inode/size/SHA-256,不经 shell/ambient PATH,token 只注入 4 个 `gh attestation verify` 子进程。ceremony 精确验证 keyless workflow identity、provenance、CycloneDX、OS-vulnerability evidence 与 D-333 source-derived release-candidate contract,拉取并 inspect 同一 RepoDigest,再在 non-root/read-only/network-none/drop-ALL/no-new-privileges/128 MiB/0.25 CPU/32 PIDs 下运行 release image 内置 `evidence-verify` 检查固定非敏感 vector。成功报告只含 public release identity、tool/argv/stdout/stderr digest、字节数、isolation/limitation 与自身 canonical SHA-256,不含原始 transcript、token、路径或 workstation identity;offline auditor 只证明 canonical structure、digest 和 expected identity binding,明确 `externalResults=not_replayed`、`reportAttestation=none`、`actionAuthority=none`。定向正负门覆盖 token 隔离、mutable/source drift、tool/file authority drift、no-replace、结构重签和 report swapping;backend 1,233 pass/2 条件 skip、Cluster Admin 387 pass/3 条件 skip、18-package clean build/test 退出 0。workspace 保持 18 package、无 single/shallow package;npm pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/OS vulnerability/Console/distribution 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 与 MCP 也不变。本门无 schema/migration/SQL/role/Pool/连接拓扑变化,复用紧邻 D-331 的 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。由于当前没有公开 3.0 release digest,且工作站没有真实 `gh/cosign`,ADR-0424 必须保持 Proposed;stub 或本地 image 不能冒充最终外部 ceremony,公开 digest 可用后才记录真实 report/tool digest 并转 Accepted。 - D-331/ADR-0423(已接受):`@qinglong/cluster-admin` 在既有 `copilot-console/` 职责目录增加独立 TypeScript evidence verifier,并以第 11 个静态产品命令 `ql3-cluster-admin evidence-verify --bundle=/absolute/evidence.json` 交付。它只通过 no-follow/stable descriptor 读取一个最大 512 KiB 的 canonical absolute UTF-8 JSON,拒绝 BOM、CRLF、minified、duplicate-key、symlink、relative path 与读取中漂移;独立固定检查 exact bundle/request shape、13 operations、16-entry/8 MiB/64-item/depth/key ceiling、安全字段白名单和顺序 typed alias,再重算不含 `contentDigest` 的 canonical SHA-256。结果明确只证明 `bundleDigest=verified`;没有原始 fact 时逐条 digest 为 `not_recomputed_without_raw_facts`,server signature/attestation/durable audit 均未验证且 action authority 为 none。实现不读 stdin/environment/context,不联网、不写文件、不新增 package、依赖、route、listener、数据库、Kubernetes workload 或 Edge/Standalone closure。定向门 18/18,Cluster Admin 387 pass/3 条件 skip,18-package clean build/test 退出 0,backend 1,225 pass/2 条件 skip/0 fail。真实 arm64 Admin image `qinglong3-cluster-admin:d331-local` 为 344,567,527 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 11 个命令、有效 bundle、tamper rejection 与零 verifier file write。npm pack dry-run 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;结构/依赖/部署/发布/Console 审计零 finding,workspace 保持 18 package、无 single/shallow package,Cluster Admin 122 个源码中 121 个位于领域目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 仍为 2,589,890/2,589,968 bytes。因本门没有 schema/migration/SQL/role/Pool/连接拓扑变化,不重复冒充执行 HA,复用紧邻 D-330 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一门应完成公开 release digest 的外部工作站 ceremony,不得给 verifier 增加上传、签名或行动能力。 - D-330/ADR-0422(已接受):同一 loopback-only Cluster field ledger 现可由用户显式导出纯浏览器本地的脱敏 evidence bundle。导出只消费本页已逐次读取的最近 16 条、最多 8 MiB canonical fact,不调用 upstream/BFF、补读详情/分页、轮询、上传或持久化;固定 sanitizer 只保留 operation、非权威本机观察时间、安全枚举/boolean/有界 number、结构计数/分页事实、per-bundle typed alias 与原始 fact canonical byte count/SHA-256,自由文本、名称、路径/URL/command/input/output/environment、reason/error/message、credential/token/session/authorization、未知字段及 Copilot model text 一律省略。bundle 固定为 UTF-8 `qinglong/cluster-console-redacted-evidence-bundle@v1` JSON、最大 512 KiB,顶层 self-digest 明确不是 server signature/audit/action authority;生成器作为第 4 个 digest-bound asset 留在既有 `@qinglong/cluster-admin`,不增加 package、依赖、Cluster/BFF route、数据库、对象存储、Kubernetes workload 或 Edge/Standalone closure。定向门 24/24,Cluster Admin 382 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,224 pass/2 条件 skip/0 fail。真实浏览器以恶意 HTML、credential-like 值、私有路径和 Copilot model text 验证纯文本与零泄漏;3 次显式读取后导出 3,611-byte 可复算 JSON,upstream 计数仍为 3,390×844 无横向溢出且 0 console error/warning。真实 arm64 Admin image `qinglong3-cluster-admin:d330-local` 为 344,543,263 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console、第 4 个 asset 与内置分发。npm pack dry-run 为 246 files、267,731-byte tarball、1,665,996-byte unpacked;package/dependency/Edge import/Cluster deployment/image release/Console/distribution 审计零 finding。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,1,199 个源码中 1,181 个位于职责目录。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2`,报告 SHA-256 `c9feb83c98ad2269c7649bd0869921d9dee7cfd00c9bc1a8a7879d81630d37c7`,证据审计与 Docker 残留均为零;本 Gate 没有 schema、migration、SQL、role、Pool 或连接拓扑变化。下一独立 Gate 应交付公开 release digest 的外部工作站 ceremony,或提供独立、离线、无 authority 的 evidence bundle verifier;不得为导出增加服务端聚合、稳定跨包标识、自动抓取或上传能力。 - D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operation;browser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`,workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。 diff --git a/docs/adr/ADR-0196-local-image-attested-multiarch-release-contract.md b/docs/adr/ADR-0196-local-image-attested-multiarch-release-contract.md index 3640c6fb..6a865968 100644 --- a/docs/adr/ADR-0196-local-image-attested-multiarch-release-contract.md +++ b/docs/adr/ADR-0196-local-image-attested-multiarch-release-contract.md @@ -7,6 +7,7 @@ D-184、D-185、D-186 - 关联 ADR:ADR-0042、ADR-0090、ADR-0128、ADR-0185、ADR-0194、 ADR-0195 +- 后续修订:ADR-0425 已取代本 ADR 的固定三镜像矩阵;本 ADR 的 Local image identity、双架构、SBOM、签名与 digest rollout 约束继续有效。 ## 背景 diff --git a/docs/adr/ADR-0253-private-worker-management-evidence-image-release-gate.md b/docs/adr/ADR-0253-private-worker-management-evidence-image-release-gate.md index 0525ab72..c7dfdc37 100644 --- a/docs/adr/ADR-0253-private-worker-management-evidence-image-release-gate.md +++ b/docs/adr/ADR-0253-private-worker-management-evidence-image-release-gate.md @@ -4,6 +4,7 @@ - 日期:2026-08-01 - 关联 RFC:QL-RFC-0001 D-235、D-236 - 关联 ADR:ADR-0128、ADR-0196、ADR-0252 +- 后续修订:ADR-0425 已把本 ADR 的私有 Worker evidence gate 收窄到 `cluster|all` 发布族;`local` 发布不得等待该证据,Cluster 的 source/freshness/private-runner 约束继续有效。 ## 背景 diff --git a/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md b/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md index 2e809285..d669d50f 100644 --- a/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md +++ b/docs/adr/ADR-0424-external-cluster-admin-release-workstation-ceremony.md @@ -28,12 +28,13 @@ attestation service、transparency log、本地 Docker daemon 和短期 GitHub t `cosign`、`gh`、`docker`、短期 token file 和新 report path;mutable tag、branch ref、owner 漂移、symlink、group/other-writable executable 或已存在输出均失败关闭。 2. 三个外部工具按绝对路径直接执行,不经 shell 或 ambient `PATH`。GitHub token - 必须来自 current-owner `0600` bounded file,只注入三个 `gh attestation verify` + 必须来自 current-owner `0600` bounded file,只注入四个 `gh attestation verify` 子进程;不得进入 argv、`cosign`/`docker` 环境、报告或失败输出。工具在执行前后 复验 device/inode/size/SHA-256,降低 ceremony 中途替换风险。 3. ceremony 精确执行一次 keyless signature 验证,以及绑定 release workflow、 source digest、source tag、非 self-hosted runner 和 OCI bundle 的 provenance、 - CycloneDX、OS-vulnerability 三类 GitHub attestation 验证。随后拉取同一 digest, + CycloneDX、OS-vulnerability、source-derived release-candidate contract 四类 GitHub + attestation 验证。随后拉取同一 digest, 要求本地 image inspection 的 Linux `amd64|arm64` `RepoDigests` 包含精确输入。 4. ceremony 使用固定、非敏感、单条 `run_read` redacted evidence vector 检验最终 release image 内的第 11 个 `evidence-verify` 产品命令。该容器使用 non-root @@ -41,12 +42,12 @@ attestation service、transparency log、本地 Docker daemon 和短期 GitHub t 128 MiB、0.25 CPU 与 32 PIDs,只读挂载 vector;输出必须与独立 verifier 的 exact no-authority result 一致,vector inode/size/mtime/digest 前后不变。 5. 成功只新建一个 current-owner `0600`、two-space canonical JSON 报告。报告保留 - public release identity、工具 SHA-256/size、七步 argv/stdout/stderr digest 与字节数、 + public release identity、工具 SHA-256/size、八步 argv/stdout/stderr digest 与字节数、 verification/isolation 结果和自身 canonical SHA-256,不保留原始工具输出、token、 executable path 或 workstation identity。它明确声明 `reportAttestation=none`、`actionAuthority=none`。 6. 独立 offline audit 使用 no-follow stable read 校验报告 canonical encoding、exact - shape、expected release identity、工具与七步 transcript digest、一致的 isolation/ + shape、expected release identity、工具与八步 transcript digest、一致的 isolation/ limitation 以及顶层 digest。其结果固定为 `externalResults=not_replayed`;离线审计 不能证明外部命令确实运行,也不能重放某一历史时点的 registry、GitHub 或 transparency-log 状态。 diff --git a/docs/adr/ADR-0425-deployment-family-release-candidate-contract.md b/docs/adr/ADR-0425-deployment-family-release-candidate-contract.md new file mode 100644 index 00000000..db2009fa --- /dev/null +++ b/docs/adr/ADR-0425-deployment-family-release-candidate-contract.md @@ -0,0 +1,120 @@ +# ADR-0425:按部署族冻结 3.0 Release Candidate,并闭合 Worker 发布集合 + +- 状态:Accepted(实现与静态/变异门已完成;公开 tag、GHCR digest 和远端证明结果待实际发布) +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-01、D-03、D-05、D-14、D-42、D-61、D-186、D-257、D-333 +- 关联 ADR:ADR-0088、ADR-0128、ADR-0196、ADR-0253、ADR-0254、ADR-0255、ADR-0281、ADR-0420、ADR-0424 + +## 背景 + +原唯一 image release workflow 有两项结构问题。第一,version 只由 dispatch input 与 tag 字符串相互校验, +没有把 18 个 QL3 workspace manifest、容器 runtime manifest、Node ABI、Dockerfile version、部署 Profile、 +镜像集合与双架构矩阵冻结为同一份可证明契约。第二,Local、Control、Control AI 与 Admin 四个 image 被一个 +固定矩阵发布,且无条件等待 Worker management 和 CloudNativePG 两个私有集群证据。这让只使用 +Edge/Standalone 的路由器/NAS 发布也依赖集群 HA 基础设施;反过来,真实 Cluster 部署需要的 +`qinglong3-worker` 已有 Dockerfile、锁文件、Kubernetes manifests 和 live rollout,却完全不在发布矩阵中。 + +这不是测试数量问题,而是产品集合和发布 authority 不一致:轻量用户被过度阻塞,集群用户又拿不到完整制品。 + +## 决策 + +### 1. 唯一 workflow 支持三个封闭部署族 + +`.github/workflows/ql3-image-release.yml` 继续是唯一 image publication authority,并只接受显式 +`workflow_dispatch` 到 exact protected `v3` tag。新增必选 `release_scope`: + +- `local`:仅 `qinglong3-local-application`,服务 Edge/Standalone; +- `cluster`:`control`、可选 AI control、Admin、Worker; +- `all`:同时发布两族,但不得弱化任一族的门禁。 + +scope 不接受自由文本、额外 repository 或运行时拼接。matrix 只能来自下一节的 source-derived contract, +workflow 内不再维护第二份 image 清单。 + +### 2. Source-derived release-candidate contract 是矩阵唯一来源 + +根级 `ql3-release-candidate-contract.cjs` 接受 exact QingLong 3 SemVer、40-hex commit、匹配的完整 tag ref 和 +closed scope,随后从受审源码推导 no-replace canonical JSON: + +- 18 个 workspace 必须全部通过 package-boundary audit,hard cap 仍为 18,且无 single/shallow package; +- 每个 workspace version 必须等于 tag version,Node engine 必须为 `>=24.18.0 <25`; +- 每个所选 image 的 production manifest、Dockerfile Node 24.18.0 与 OCI version label 必须相同; +- 平台固定 `linux/amd64`、`linux/arm64`; +- Local profile 固定 `edge|standalone` 且不要求 Cluster private evidence; +- Cluster profile 固定 `cluster|worker-edge|worker-node`,必须要求 Worker management 与 CloudNativePG evidence; +- legacy 根 package 的 2.x version 只作为兼容事实记录,并明确排除出 3.0 release identity。 + +报告携带对自身 unsigned exact JSON 的 SHA-256。publisher 从同一 checkout 重新生成并独立 exact-audit,不能直接 +信任 job output 中的任意 repository/path;job output 只传递 contract 派生的有界 matrix 和 cluster evidence bit。 + +### 3. Local 不再被 Cluster HA 证据阻塞 + +Local scope 的两个私有 evidence job 必须为 skipped;publisher 仍无条件依赖 release-candidate 与 native OS scan, +并继续对 pushed Local digest 执行 Edge/Standalone 两个真实 compose rollout。Cluster/all scope 才能把 private +evidence bit 置为 true;publisher 使用显式 `always()` 条件,只在 candidate/OS 成功,且 cluster scope 的两个 +私有 job 均成功时取得写权限。skipped 不能被当作 Cluster success,failed/cancelled 也不能通过条件表达式旁路。 + +### 4. Cluster 发布集合必须包含 Worker + +Worker 加入与其他 image 相同的 native amd64/arm64 build-once、Trivy 0.70.0 OS-only HIGH/CRITICAL、扫描证据、 +OCI merge、production dependency audit、CycloneDX、Cosign keyless、GitHub attestation、远端 manifest 回读和 +验证后 tag promotion。Worker production SBOM 当前为 27 components(24 external、3 internal)和 28 dependency +nodes;唯一新增 license allowlist 项是锁中 `asn1js` 的 BSD-3-Clause。OCI config 固定 non-root `65532:65532`、 +唯一 Worker process entrypoint、`io.qinglong.profile=worker`、`edge,node` capacity profiles 与 exact 3.0 version。 + +Control/Admin Dockerfile 同步补 exact 3.0 version label,使 tag、workspace、runtime manifest 与所有 OCI config +首次共享同一 release identity。 + +### 5. Candidate contract 必须成为 digest-bound 第四类证明 + +每个发布 digest 除 SLSA、CycloneDX、OS-vulnerability 外,再以 +`https://qinglong.dev/attestations/release-candidate-contract/v1` 附加 candidate predicate,并以 repository、 +workflow、source digest、source ref、非 self-hosted builder 和 OCI bundle 远端回读。Admin image 内的 +`verify-release.sh`、外部 workstation ceremony 与 offline report auditor 同步从三类/七步升级为四类/八步; +否则“生成了 contract”不能算发布者或部署者实际验证过。 + +## 资源与权限边界 + +- 不新增 workspace package、npm production dependency、数据库、migration、SQL、role、Pool、connection; +- contract 与 audit 只在显式 release job 短生命周期运行,不进入 Local/Worker/Control/Admin runtime filesystem; +- 不增加 Edge/Standalone timer、watcher、listener、queue、cache 或常驻进程; +- Local scope 不接触 self-hosted private evidence runner;Cluster scope 不得把 skipped 私有证据解释为成功; +- release tag 仍只在所有 digest verification 完成后 promotion,tag 本身不成为部署 authority。 + +## 失败与恢复 + +- tag/version/workspace/container version 任一漂移:修正源码并重新创建 tag,不手改报告; +- package boundary 不兼容或出现第 19 个 package:先独立评审边界,不扩大 candidate hard cap; +- Worker SBOM/license/config 漂移:更新锁与供应链 ADR 后重跑,不能从 Cluster scope 静默删除 Worker; +- Local scope 意外等待 Cluster evidence:视为发布拓扑回归; +- Cluster scope 的 private job skipped/failed:publisher 不启动; +- candidate attestation 缺失或远端 source binding 不匹配:不得 promotion version/source tag; +- 公开发布尚不存在:只报告 implementation-ready,不用 fixture、stub 或本机 tag 冒充 GHCR 成功。 + +## 被拒绝的替代方案 + +### 为 Local 和 Cluster 复制两套 workflow + +拒绝。它会复制 OIDC identity、action pins、scanner、copier、签名和 tag promotion 逻辑,形成安全策略漂移。 + +### 继续发布固定 all matrix + +拒绝。低配用户会被无关 HA 证据阻塞,同时无法表达独立修补 Local image 的发布意图。 + +### Cluster 不发布 Worker,让运维现场自行 build + +拒绝。部署 manifest 已把 Worker 作为产品制品;现场 build 绕过统一 SBOM、OS scan、签名与 provenance。 + +### 只校验 tag,不持久化 candidate predicate + +拒绝。tag 不能证明 workspace、容器、Profile、平台和 gate 集合,也不能让部署端在 digest 上独立回读。 + +## 验证 + +- release candidate create/audit、scope/version/source/report mutation:7 项; +- Worker SBOM/OCI、OS policy、共享 release workflow、Admin verifier/ceremony/distribution 定向总计 105/105; +- backend 1,246 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0; +- package boundary 返回 18 packages、hard cap 18、single/shallow 均为空;dependency、Edge import、Cluster/Worker deployment、image release、OS policy、Console/distribution 均 compatible; +- 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes; +- Cluster Admin npm pack 为 250 files、271,238-byte tarball、1,690,196-byte unpacked;四个 runtime dependency root 的离线缓存审计为 0 vulnerability; +- 本 Gate 无 schema、migration、SQL、role、Pool 或连接/HA 拓扑变化,因此不重复 PostgreSQL 门,复用 D-331 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线; +- 公开 tag 后再记录 GHCR 五镜像 digest、四类 attestation 与外部 Admin ceremony,不提前宣称完成。 diff --git a/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs b/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs index 0c631c0e..e2a118aa 100644 --- a/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs +++ b/scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs @@ -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 || diff --git a/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs b/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs index 4297f670..7c5478da 100644 --- a/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs +++ b/scripts/ql3-cluster-admin-release-workstation-ceremony.cjs @@ -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, diff --git a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs index 96cf1acc..0afad637 100644 --- a/scripts/ql3-cluster-copilot-console-distribution-audit.cjs +++ b/scripts/ql3-cluster-copilot-console-distribution-audit.cjs @@ -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', diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 16fd6d57..06ee471b 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -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', ], }; diff --git a/scripts/ql3-cluster-image-sbom.cjs b/scripts/ql3-cluster-image-sbom.cjs index f295d9f3..a66498c4 100644 --- a/scripts/ql3-cluster-image-sbom.cjs +++ b/scripts/ql3-cluster-image-sbom.cjs @@ -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}`); } } diff --git a/scripts/ql3-cluster-oci-layout-audit.cjs b/scripts/ql3-cluster-oci-layout-audit.cjs index aded4723..fe2c2ea9 100644 --- a/scripts/ql3-cluster-oci-layout-audit.cjs +++ b/scripts/ql3-cluster-oci-layout-audit.cjs @@ -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', }, }, }; diff --git a/scripts/ql3-image-os-vulnerability-policy.cjs b/scripts/ql3-image-os-vulnerability-policy.cjs index 01a1c87d..081901f4 100644 --- a/scripts/ql3-image-os-vulnerability-policy.cjs +++ b/scripts/ql3-image-os-vulnerability-policy.cjs @@ -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 }) { diff --git a/scripts/ql3-release-candidate-contract.cjs b/scripts/ql3-release-candidate-contract.cjs new file mode 100644 index 00000000..efabdf01 --- /dev/null +++ b/scripts/ql3-release-candidate-contract.cjs @@ -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, +}); diff --git a/test/back/ql3ClusterAdminReleaseVerifier.test.cjs b/test/back/ql3ClusterAdminReleaseVerifier.test.cjs index 5fbfc908..e4ef2951 100644 --- a/test/back/ql3ClusterAdminReleaseVerifier.test.cjs +++ b/test/back/ql3ClusterAdminReleaseVerifier.test.cjs @@ -10,7 +10,9 @@ const verifier = path.join( ROOT, 'deploy/console/ql3-cluster-copilot/verify-release.sh', ); -const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat(64)}`; +const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'b'.repeat( + 64, +)}`; const revision = 'c'.repeat(40); function fixture(t) { @@ -46,7 +48,7 @@ function invoke(args, env) { }); } -test('verifies one signature and three digest-bound GitHub attestations', (t) => { +test('verifies one signature and four digest-bound GitHub attestations', (t) => { assert.equal(fs.statSync(verifier).mode & 0o777, 0o755); const value = fixture(t); const result = invoke( @@ -61,11 +63,12 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) => provenance: true, sbom: true, osVulnerabilityEvidence: true, + releaseCandidateContract: true, compatible: true, }); const calls = fs.readFileSync(value.capture, 'utf8'); assert.equal((calls.match(/^cosign$/gmu) ?? []).length, 1); - assert.equal((calls.match(/^gh$/gmu) ?? []).length, 3); + assert.equal((calls.match(/^gh$/gmu) ?? []).length, 4); for (const required of [ 'arg=--certificate-identity', 'arg=https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.1', @@ -83,17 +86,26 @@ test('verifies one signature and three digest-bound GitHub attestations', (t) => 'arg=refs/tags/v3.0.0-alpha.1', 'arg=https://cyclonedx.org/bom', 'arg=https://qinglong.dev/attestations/image-os-vulnerability/v1', + 'arg=https://qinglong.dev/attestations/release-candidate-contract/v1', 'arg=--deny-self-hosted-runners', 'arg=--bundle-from-oci', ]) { - assert.match(calls, new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu')); + assert.match( + calls, + new RegExp(`^${required.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'mu'), + ); } }); test('rejects mutable or source-unbound inputs before invoking trust tools', (t) => { const value = fixture(t); for (const args of [ - ['ghcr.io/example/qinglong3-cluster-admin:latest', 'example/qinglong', revision, 'refs/tags/v3.0.0'], + [ + 'ghcr.io/example/qinglong3-cluster-admin:latest', + 'example/qinglong', + revision, + 'refs/tags/v3.0.0', + ], [image, 'other/qinglong', revision, 'refs/tags/v3.0.0'], [image, 'example/qinglong', 'short', 'refs/tags/v3.0.0'], [image, 'example/qinglong', revision, 'refs/heads/next'], diff --git a/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs b/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs index 8b76265d..ec3be06e 100644 --- a/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs +++ b/test/back/ql3ClusterAdminReleaseWorkstationCeremony.test.cjs @@ -118,7 +118,8 @@ test('runs the immutable release ceremony and writes only digest-level evidence' assert.equal(report.release.sourceRevision, revision); assert.equal(report.verification.embeddedEvidenceVerifier, true); assert.equal(report.evidenceVector.classification, 'synthetic_non_sensitive'); - assert.equal(report.steps.length, 7); + assert.equal(report.steps.length, 8); + assert.equal(report.verification.releaseCandidateContract, true); assert.equal(report.claims.reportAttestation, 'none'); assert.equal(reportText.includes(token), false); assert.equal(fs.statSync(value.output).mode & 0o777, 0o600); @@ -136,11 +137,11 @@ test('runs the immutable release ceremony and writes only digest-level evidence' .map((line) => JSON.parse(line)); assert.deepEqual( calls.map(({ name }) => name), - ['cosign', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'], + ['cosign', 'gh', 'gh', 'gh', 'gh', 'docker', 'docker', 'docker'], ); assert.deepEqual( calls.map(({ tokenPresent }) => tokenPresent), - [false, true, true, true, false, false, false], + [false, true, true, true, true, false, false, false], ); assert.deepEqual(calls[0].args, [ 'verify', @@ -150,7 +151,7 @@ test('runs the immutable release ceremony and writes only digest-level evidence' 'https://token.actions.githubusercontent.com', image, ]); - for (const call of calls.slice(1, 4)) { + for (const call of calls.slice(1, 5)) { for (const required of [ 'attestation', 'verify', @@ -176,7 +177,12 @@ test('runs the immutable release ceremony and writes only digest-level evidence' 'https://qinglong.dev/attestations/image-os-vulnerability/v1', ), ); - const dockerRun = calls[6].args; + assert.ok( + calls[4].args.includes( + 'https://qinglong.dev/attestations/release-candidate-contract/v1', + ), + ); + const dockerRun = calls[7].args; for (const required of [ '--read-only', 'none', diff --git a/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs b/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs index 68f78321..10797194 100644 --- a/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs +++ b/test/back/ql3ClusterAdminReleaseWorkstationCeremonyAudit.test.cjs @@ -64,6 +64,7 @@ function validReport() { ['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'], @@ -89,6 +90,7 @@ function validReport() { provenance: true, cyclonedxSbom: true, osVulnerabilityEvidence: true, + releaseCandidateContract: true, imagePulled: true, localRepoDigestBound: true, embeddedEvidenceVerifier: true, @@ -116,9 +118,9 @@ function validReport() { name, tool, executableSha256: toolDigest[tool], - argvSha256: `sha256:${'56789ab'[index].repeat(64)}`, - stdoutBytes: index === 6 ? 400 : 0, - stdoutSha256: index === 6 ? `sha256:${'d'.repeat(64)}` : emptyDigest, + argvSha256: `sha256:${'56789abc'[index].repeat(64)}`, + stdoutBytes: index === 7 ? 400 : 0, + stdoutSha256: index === 7 ? `sha256:${'d'.repeat(64)}` : emptyDigest, stderrBytes: 0, stderrSha256: emptyDigest, exitCode: 0, @@ -166,7 +168,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', ( compatible: true, reportContentDigest: report.contentDigest, releaseImage: image, - verificationSteps: 7, + verificationSteps: 8, externalResults: 'not_replayed', actionAuthority: 'none', }); @@ -187,7 +189,7 @@ test('accepts a canonical digest-bound ceremony report without replay claims', ( compatible: true, reportContentDigest: report.contentDigest, releaseImage: image, - verificationSteps: 7, + verificationSteps: 8, externalResults: 'not_replayed', actionAuthority: 'none', }); @@ -205,7 +207,7 @@ test('rejects structural claim widening even after the report is re-digested', ( report.isolation.network = 'default'; }, (report) => { - report.steps[6].stderrBytes = 1; + report.steps[7].stderrBytes = 1; }, (report) => { report.secret = 'must-not-be-accepted'; diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 1abf209b..e44d0d45 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -23,7 +23,7 @@ const releaseSource = fs.readFileSync( test('accepts the reviewed native CI and digest release contracts', () => { assert.deepEqual(auditClusterImageRelease(root), { ci: { - images: ['control', 'control-ai', 'admin', 'local'], + images: ['control', 'control-ai', 'admin', 'local', 'worker'], nativeArchitectures: ['amd64', 'arm64'], runtimeInventory: true, clusterAdminProductFacade: true, @@ -40,6 +40,14 @@ test('accepts the reviewed native CI and digest release contracts', () => { }, release: { 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, @@ -65,11 +73,16 @@ test('accepts the reviewed native CI and digest release contracts', () => { 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: @@ -85,6 +98,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { 'provenance', 'cyclonedx', 'os-vulnerability', + 'release-candidate', 'release-tags', ], }, @@ -175,6 +189,17 @@ test('rejects removal of the native cluster-admin image gate', () => { ); }); +test('rejects removal of the native Worker image gate', () => { + const mutated = ciSource.replace( + ' image: worker\n repository: qinglong3-worker\n runtime_user: 65532:65532', + ' image: worker-disabled\n repository: qinglong3-worker\n runtime_user: 65532:65532', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /matrices must contain only exact/, + ); +}); + test('rejects an additional unreviewed CI image authority', () => { const mutated = ciSource.replace( ' target: runtime\n steps:', @@ -245,7 +270,7 @@ test('rejects release publication without the private evidence dependency', () = ); assert.throws( () => auditReleaseWorkflow(mutated), - /depend on both protected ephemeral private evidence jobs/, + /always require candidate and OS gates/, ); }); @@ -256,7 +281,7 @@ test('rejects release publication without the OS vulnerability dependency', () = ); assert.throws( () => auditReleaseWorkflow(mutated), - /depend on both protected ephemeral private evidence jobs/, + /always require candidate and OS gates/, ); }); @@ -267,7 +292,7 @@ test('rejects release publication without current disaster-recovery evidence', ( ); assert.throws( () => auditReleaseWorkflow(mutated), - /depend on both protected ephemeral private evidence jobs/, + /always require candidate and OS gates/, ); }); @@ -289,12 +314,12 @@ test('rejects disaster-recovery evidence detached from the release source', () = test('rejects an incomplete native OS vulnerability architecture matrix', () => { const mutated = releaseSource.replace( - ' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: arm64\n dockerfile: deploy/containers/ql3-local-application/Dockerfile', - ' - image: local\n runner: ubuntu-24.04-arm\n node_arch: arm64\n image_arch: disabled\n dockerfile: deploy/containers/ql3-local-application/Dockerfile', + 'include: ${{ fromJSON(needs.release-candidate.outputs.os-matrix) }}', + 'include: []', ); assert.throws( () => auditReleaseWorkflow(mutated), - /OS vulnerability matrix must scan exact/, + /OS vulnerability matrix must come from/, ); }); @@ -349,7 +374,7 @@ test('rejects a reusable private evidence runner', () => { ); assert.throws( () => auditReleaseWorkflow(mutated), - /protected ephemeral private evidence job/, + /always require candidate and OS gates/, ); }); @@ -404,34 +429,34 @@ test('rejects a release missing application SBOM attestation', () => { test('rejects a release missing the independent admin image', () => { const mutated = releaseSource.replace( - '- image: admin\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies', - '- image: admin-disabled\n repository: qinglong3-cluster-admin\n runtime_root: deploy/containers/ql3-cluster-admin/runtime-dependencies', + 'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}', + 'include: []', ); assert.throws( () => auditReleaseWorkflow(mutated), - /matrix must contain only exact/, + /matrix must come only from/, ); }); test('rejects a release missing the AI-excluded local image', () => { const mutated = releaseSource.replace( - '- image: local\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies', - '- image: local-disabled\n repository: qinglong3-local-application\n runtime_root: deploy/containers/ql3-local-application/runtime-dependencies', + ' - local\n - cluster\n - all', + ' - cluster\n - all', ); assert.throws( () => auditReleaseWorkflow(mutated), - /matrix must contain only exact/, + /closed local, cluster or all/, ); }); test('rejects an additional repository in the privileged release matrix', () => { const mutated = releaseSource.replace( - ' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n steps:', - ' runtime_root: deploy/containers/ql3-local-application/runtime-dependencies\n - image: unreviewed\n repository: unreviewed\n dockerfile: Dockerfile\n runtime_root: unreviewed\n steps:', + 'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}', + 'include:\n - image: unreviewed\n repository: unreviewed\n runtime_root: unreviewed', ); assert.throws( () => auditReleaseWorkflow(mutated), - /matrix must contain only exact/, + /matrix must come only from/, ); }); @@ -639,3 +664,36 @@ test('rejects removal of the digest-bound OS vulnerability attestation', () => { /digest-bound OS vulnerability evidence/, ); }); + +test('rejects removal of the digest-bound release candidate attestation', () => { + const mutated = releaseSource.replace( + 'predicate-type: https://qinglong.dev/attestations/release-candidate-contract/v1', + 'predicate-type: https://example.invalid/not-a-candidate-contract', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /source-derived release candidate contract/, + ); +}); + +test('rejects treating skipped private evidence as cluster success', () => { + const mutated = releaseSource.replace( + "needs.release-candidate.outputs.cluster-evidence-required != 'true' ||", + "needs.release-candidate.outputs.cluster-evidence-required == 'true' ||", + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /requiring private HA evidence only for a cluster family/, + ); +}); + +test('rejects a publisher matrix detached from the source-derived contract', () => { + const mutated = releaseSource.replace( + 'include: ${{ fromJSON(needs.release-candidate.outputs.publish-matrix) }}', + 'include: ${{ fromJSON(inputs.publish_matrix) }}', + ); + assert.throws( + () => auditReleaseWorkflow(mutated), + /matrix must come only from/, + ); +}); diff --git a/test/back/ql3ClusterImageSbom.test.cjs b/test/back/ql3ClusterImageSbom.test.cjs index 832e7685..c132731e 100644 --- a/test/back/ql3ClusterImageSbom.test.cjs +++ b/test/back/ql3ClusterImageSbom.test.cjs @@ -124,6 +124,34 @@ test('generates the AI-excluded local application image closure', () => { ); }); +test('generates the headless Worker image runtime closure', () => { + const document = createClusterImageSbom({ root, image: 'worker' }); + const report = auditClusterImageSbom(document, { + root, + image: 'worker', + }); + + assert.deepEqual(report, { + image: 'worker', + root: 'pkg:npm/%40qinglong/worker-image-dependencies@3.0.0-alpha.0', + components: 27, + externalComponents: 24, + internalComponents: 3, + dependencyNodes: 28, + inventoryVerified: false, + }); + assert.equal( + document.components.some( + (component) => component.name === '@qinglong/worker-runtime', + ), + true, + ); + assert.equal( + document.components.some((component) => component.name === '@qinglong/ai'), + false, + ); +}); + test('rejects a control SBOM presented as cluster-admin evidence', () => { const document = createClusterImageSbom({ root }); assert.throws( diff --git a/test/back/ql3ClusterOciLayoutAudit.test.cjs b/test/back/ql3ClusterOciLayoutAudit.test.cjs index 412f8f7e..4c892a06 100644 --- a/test/back/ql3ClusterOciLayoutAudit.test.cjs +++ b/test/back/ql3ClusterOciLayoutAudit.test.cjs @@ -25,6 +25,7 @@ function createFixture(t, options = {}) { const isControl = image === 'control' || image === 'control-ai'; const isControlAi = image === 'control-ai'; const isLocal = image === 'local'; + const isWorker = image === 'worker'; const layoutRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-oci-layout-')); t.after(() => fs.rmSync(layoutRoot, { recursive: true, force: true })); const blobDirectory = path.join(layoutRoot, 'blobs', 'sha256'); @@ -96,6 +97,8 @@ function createFixture(t, options = {}) { User: options.rootArm64 && architecture === 'arm64' ? '0:0' + : isWorker + ? '65532:65532' : isLocal ? '65532:65532' : '10001:10001', @@ -108,7 +111,9 @@ function createFixture(t, options = {}) { ], Entrypoint: [ 'node', - isLocal + isWorker + ? '/opt/qinglong/node_modules/@qinglong/worker-runtime/dist/process/workerProcessCli.js' + : isLocal ? '/opt/qinglong/node_modules/@qinglong/local-application/dist/cli.js' : isControl ? isControlAi @@ -118,6 +123,12 @@ function createFixture(t, options = {}) { ], WorkingDir: '/opt/qinglong', Labels: { + ...(isWorker + ? { + 'io.qinglong.profile': 'worker', + 'io.qinglong.worker.capacity-profiles': 'edge,node', + } + : {}), ...(isLocal ? { 'io.qinglong.ai': 'excluded', @@ -131,6 +142,8 @@ function createFixture(t, options = {}) { : {}), 'org.opencontainers.image.description': isLocal ? 'QingLong 3.0 AI-excluded Edge and Standalone runtime' + : isWorker + ? 'QingLong 3.0 headless Remote Worker runtime' : isControl ? isControlAi ? 'Optional QingLong 3.0 AI-enabled cluster control plane' @@ -142,12 +155,14 @@ function createFixture(t, options = {}) { 'https://github.com/whyour/qinglong', 'org.opencontainers.image.title': isLocal ? 'QingLong 3.0 Local Application' + : isWorker + ? 'QingLong 3.0 Worker' : isControl ? isControlAi ? 'QingLong 3.0 Cluster Control AI' : 'QingLong 3.0 Cluster Control' : 'QingLong 3.0 Cluster Admin', - ...(isLocal + ...(isLocal || isWorker || isControl || image === 'admin' ? { 'org.opencontainers.image.version': '3.0.0-alpha.0', } @@ -349,6 +364,20 @@ test('accepts the AI-excluded local image and attestation closure', (t) => { ); }); +test('accepts the headless Worker image and attestation closure', (t) => { + const report = auditClusterOciLayout({ + root, + layoutRoot: createFixture(t, { image: 'worker' }), + expectedRevision: revision, + image: 'worker', + }); + assert.equal(report.image, 'worker'); + assert.deepEqual( + report.platforms.map((platform) => platform.platform), + ['linux/amd64', 'linux/arm64'], + ); +}); + test('rejects cluster-control config presented as cluster-admin evidence', (t) => { assert.throws( () => diff --git a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs index f739b77d..83bcd8a7 100644 --- a/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs +++ b/test/back/ql3ImageOsVulnerabilityPolicy.test.cjs @@ -25,7 +25,8 @@ function exception(overrides = {}) { owner: 'security/platform', ticket: 'QLSEC-123', expiresOn: '2026-08-15', - rationale: 'Temporary exposure accepted while the fixed base image is qualified.', + rationale: + 'Temporary exposure accepted while the fixed base image is qualified.', ...overrides, }; } @@ -55,6 +56,7 @@ test('accepts the empty fail-closed production exception policy', () => { control: 0, 'control-ai': 0, local: 0, + worker: 0, }, }); assert.equal( @@ -132,10 +134,7 @@ test('rejects unscoped images and non-OS package purls', () => { test('rejects duplicate, unsorted and extensible exception identities', () => { for (const exceptions of [ [exception(), exception()], - [ - exception({ id: 'CVE-2026-99999' }), - exception({ id: 'CVE-2026-12345' }), - ], + [exception({ id: 'CVE-2026-99999' }), exception({ id: 'CVE-2026-12345' })], [{ ...exception(), extra: true }], ]) { const audit = auditImageOsVulnerabilityPolicy(policy(exceptions), { @@ -144,8 +143,7 @@ test('rejects duplicate, unsorted and extensible exception identities', () => { assert.equal(audit.compatible, false); assert.equal( audit.findings.some( - (finding) => - finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID', + (finding) => finding.code === 'QL3_IMAGE_OS_VULNERABILITY_EXCEPTION_ID', ), true, ); diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs new file mode 100644 index 00000000..da2f5243 --- /dev/null +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -0,0 +1,202 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const { + auditReleaseCandidateContract, + createReleaseCandidateContract, + parseArguments, + runCli, +} = require('../../scripts/ql3-release-candidate-contract.cjs'); + +const root = path.resolve(__dirname, '../..'); +const identity = Object.freeze({ + version: '3.0.0-alpha.0', + sourceRevision: 'a'.repeat(40), + sourceRef: 'refs/tags/v3.0.0-alpha.0', +}); + +test('freezes an independent low-resource local release family', () => { + const contract = createReleaseCandidateContract({ + root, + ...identity, + releaseScope: 'local', + }); + assert.deepEqual( + contract.images.map((entry) => entry.image), + ['local'], + ); + assert.equal(contract.releasePlan.clusterEvidenceRequired, false); + assert.deepEqual(contract.deploymentFamilies.local.profiles, [ + 'edge', + 'standalone', + ]); + assert.equal(contract.workspace.packageCount, 18); + assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u); + assert.deepEqual( + auditReleaseCandidateContract(contract, { + root, + ...identity, + releaseScope: 'local', + }), + { + compatible: true, + contractDigest: contract.contractDigest, + releaseScope: 'local', + workspacePackageCount: 18, + images: ['local'], + clusterEvidenceRequired: false, + }, + ); +}); + +test('closes the cluster release family with the Worker image', () => { + const contract = createReleaseCandidateContract({ + root, + ...identity, + releaseScope: 'cluster', + }); + assert.deepEqual( + contract.images.map((entry) => entry.image), + ['control', 'control-ai', 'admin', 'worker'], + ); + assert.equal(contract.releasePlan.clusterEvidenceRequired, true); + assert.equal(contract.releasePlan.osMatrix.length, 8); + assert.equal( + contract.requiredGates.includes('edge-and-standalone-rollout'), + false, + ); + assert.equal( + contract.requiredGates.includes('cloudnativepg-disaster-recovery-evidence'), + true, + ); +}); + +test('combines local and cluster families without weakening either gate', () => { + const contract = createReleaseCandidateContract({ + root, + ...identity, + releaseScope: 'all', + }); + assert.deepEqual( + contract.images.map((entry) => entry.image), + ['control', 'control-ai', 'admin', 'worker', 'local'], + ); + assert.equal( + contract.requiredGates.includes('edge-and-standalone-rollout'), + true, + ); + assert.equal( + contract.requiredGates.includes('worker-management-production-evidence'), + true, + ); +}); + +test('rejects tag, version and source identity drift', () => { + assert.throws( + () => + createReleaseCandidateContract({ + root, + ...identity, + sourceRef: 'refs/heads/next', + releaseScope: 'local', + }), + /exact version tag/, + ); + assert.throws( + () => + createReleaseCandidateContract({ + root, + ...identity, + version: '2.21.0', + releaseScope: 'local', + }), + /QingLong 3 SemVer/, + ); + assert.throws( + () => + createReleaseCandidateContract({ + root, + ...identity, + sourceRevision: 'movable', + releaseScope: 'local', + }), + /Git SHA-1/, + ); +}); + +test('rejects a source-derived report mutated after creation', () => { + const contract = createReleaseCandidateContract({ + root, + ...identity, + releaseScope: 'local', + }); + contract.releasePlan.publishMatrix[0].repository = 'unreviewed'; + assert.throws( + () => + auditReleaseCandidateContract(contract, { + root, + ...identity, + releaseScope: 'local', + }), + /differs from the source-derived contract/, + ); +}); + +test('writes once and independently audits the exact report through the CLI', (t) => { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-candidate-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const report = path.join(directory, 'contract.json'); + const common = [ + '--version=3.0.0-alpha.0', + `--source-revision=${identity.sourceRevision}`, + '--source-ref=refs/tags/v3.0.0-alpha.0', + '--release-scope=local', + ]; + const output = { write() {} }; + runCli(['--mode=create', ...common, `--output=${report}`], root, output); + assert.equal(fs.statSync(report).mode & 0o777, 0o600); + assert.equal( + runCli(['--mode=audit', ...common, `--report=${report}`], root, output) + .compatible, + true, + ); + assert.throws( + () => + runCli( + ['--mode=create', ...common, `--output=${report}`], + root, + output, + ), + /output must be unused/, + ); +}); + +test('parses only exact closed create and audit modes', () => { + const common = [ + '--version=3.0.0-alpha.0', + `--source-revision=${identity.sourceRevision}`, + '--source-ref=refs/tags/v3.0.0-alpha.0', + '--release-scope=local', + ]; + assert.equal( + parseArguments(['--mode=create', ...common, '--output=/tmp/report.json']) + .mode, + 'create', + ); + assert.throws( + () => + parseArguments([ + '--mode=create', + ...common, + '--output=/tmp/report.json', + '--extra=true', + ]), + /arguments are invalid/, + ); +});