diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index f6908ba0..3d1f6e43 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -838,51 +838,37 @@ jobs: if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all') env: IMAGE: ${{ matrix.repository }}:ci-${{ matrix.image_arch }} - IMAGE_ARCH: ${{ matrix.image_arch }} - IMAGE_ROLE: ${{ matrix.image }} - IMAGE_REPOSITORY: ${{ matrix.repository }} BUNDLE_ROOT: ${{ runner.temp }}/ql3-alpha-${{ matrix.image }}-${{ matrix.image_arch }} run: | set -euo pipefail umask 077 - mkdir "${BUNDLE_ROOT}" - archive="${BUNDLE_ROOT}/${IMAGE_REPOSITORY}-${IMAGE_ARCH}.docker.tar" - docker image save --output "${archive}" "${IMAGE}" - cp "${RUNNER_TEMP}/ql3-cluster-${IMAGE_ROLE}.cdx.json" \ - "${BUNDLE_ROOT}/${IMAGE_REPOSITORY}.cdx.json" - cp docs/operations/ql3-alpha-candidate.md "${BUNDLE_ROOT}/README.md" - archive_sha256="sha256:$(sha256sum "${archive}" | cut -d ' ' -f 1)" - ARCHIVE="${archive}" ARCHIVE_SHA256="${archive_sha256}" \ - MANIFEST="${BUNDLE_ROOT}/manifest.json" \ - node <<'NODE' - const fs = require('node:fs'); - const childProcess = require('node:child_process'); - const release = require('./ql3-release.json'); - const imageId = childProcess - .execFileSync('docker', ['image', 'inspect', '--format', '{{.Id}}', process.env.IMAGE], { encoding: 'utf8' }) - .trim(); - const manifest = { - schemaVersion: 1, - schema: 'qinglong/alpha-native-image@v1', - maturity: 'alpha_candidate_not_public_release', - product: 'cluster', - role: process.env.IMAGE_ROLE, - version: release.version, - sourceRevision: process.env.GITHUB_SHA, - architecture: process.env.IMAGE_ARCH, - image: process.env.IMAGE, - imageId, - archive: process.env.ARCHIVE.split('/').pop(), - archiveSha256: process.env.ARCHIVE_SHA256, - verification: { - osVulnerabilityPolicy: 'passed', - sbomInventoryReconciliation: 'passed', - nonRootRuntimeIdentity: 'passed', - clusterAdminProductFacade: process.env.IMAGE_ROLE === 'admin' ? 'passed' : 'not_applicable', - }, - }; - fs.writeFileSync(process.env.MANIFEST, `${JSON.stringify(manifest)}\n`, { mode: 0o600, flag: 'wx' }); - NODE + node scripts/ql3-cluster-alpha-bundle.cjs \ + --mode=record-verification \ + --role=${{ matrix.image }} \ + --architecture=${{ matrix.image_arch }} \ + --image="${IMAGE}" \ + --source-revision=${{ github.sha }} \ + --repository=${{ github.repository }} \ + --workflow-ref="${{ github.workflow_ref }}" \ + --workflow-sha=${{ github.workflow_sha }} \ + --event=${{ github.event_name }} \ + --job=cluster-image \ + --run-id=${{ github.run_id }} \ + --run-attempt=${{ github.run_attempt }} \ + --output="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}-${{ matrix.image_arch }}-verification.json" + node scripts/ql3-cluster-alpha-bundle.cjs \ + --mode=create \ + --role=${{ matrix.image }} \ + --architecture=${{ matrix.image_arch }} \ + --image="${IMAGE}" \ + --source-revision=${{ github.sha }} \ + --sbom="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}.cdx.json" \ + --verification-evidence="${RUNNER_TEMP}/ql3-cluster-${{ matrix.image }}-${{ matrix.image_arch }}-verification.json" \ + --readme=docs/operations/ql3-alpha-candidate.md \ + --output="${BUNDLE_ROOT}" + node scripts/ql3-cluster-alpha-bundle.cjs \ + --mode=offline-audit \ + --bundle="${BUNDLE_ROOT}" - name: Upload the tested native Cluster Alpha image if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -1643,6 +1629,112 @@ jobs: if-no-files-found: warn retention-days: 14 + cluster-alpha-milestone: + name: Finalize the Cluster Alpha integration milestone + if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all') + needs: + - backend + - service-manager-bridge + - linux-resource-envelopes + - linux-resource-release-evidence + - supply-chain + - local-image + - cluster-image + - cluster-console-capacity-release-evidence + - image-oci + - worker-runtime + - local-profiles + - cluster-postgres + - cluster-postgres-ha + - cluster-cloudnativepg-live + - cluster-provider-credential-test-kubernetes-live + - cluster-secret-binding-mounted-provider-kubernetes-live + - cluster-vault-kv-worker-secret-live + - cluster-plugin-package-kubernetes-live + - cluster-plugin-package-recovery-e2e + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + - name: Download the exact control amd64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-control-amd64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-amd64 + - name: Download the exact control arm64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-control-arm64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-arm64 + - name: Download the exact control-ai amd64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-control-ai-amd64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-ai-amd64 + - name: Download the exact control-ai arm64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-control-ai-arm64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/control-ai-arm64 + - name: Download the exact admin amd64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-admin-amd64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/admin-amd64 + - name: Download the exact admin arm64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-admin-arm64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/admin-arm64 + - name: Download the exact worker amd64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-worker-amd64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/worker-amd64 + - name: Download the exact worker arm64 Cluster Alpha bundle + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-alpha-${{ github.sha }}-worker-arm64 + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone-input/worker-arm64 + - name: Close all native Cluster bundles into one milestone index + run: | + set -euo pipefail + umask 077 + node scripts/ql3-cluster-alpha-milestone.cjs \ + --mode=finalize \ + --control-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-amd64" \ + --control-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-arm64" \ + --control-ai-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-ai-amd64" \ + --control-ai-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/control-ai-arm64" \ + --admin-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/admin-amd64" \ + --admin-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/admin-arm64" \ + --worker-amd64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/worker-amd64" \ + --worker-arm64-bundle="${RUNNER_TEMP}/ql3-alpha-cluster-milestone-input/worker-arm64" \ + --source-revision=${{ github.sha }} \ + --repository=${{ github.repository }} \ + --workflow-ref="${{ github.workflow_ref }}" \ + --workflow-sha=${{ github.workflow_sha }} \ + --event=${{ github.event_name }} \ + --run-id=${{ github.run_id }} \ + --run-attempt=${{ github.run_attempt }} \ + --readme=docs/operations/ql3-cluster-alpha-milestone.md \ + --output="${RUNNER_TEMP}/ql3-alpha-cluster-milestone" + node scripts/ql3-cluster-alpha-milestone.cjs \ + --mode=audit \ + --milestone="${RUNNER_TEMP}/ql3-alpha-cluster-milestone" + - name: Upload the closed Cluster Alpha milestone index + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-alpha-${{ github.sha }}-cluster-milestone + path: ${{ runner.temp }}/ql3-alpha-cluster-milestone + if-no-files-found: error + retention-days: 30 + compression-level: 0 + overwrite: false + include-hidden-files: false + local-alpha-milestone: name: Finalize the Local Alpha milestone if: github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'local' || inputs.alpha_artifact_scope == 'all') diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 607abf2a..b54349dc 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-27): +- D-414/ADR-0509(已实现,首份真实 Cluster milestone artifact 待授权):开发约二十天后,Cluster 阶段产物不再是八个由 workflow 内联 shell 自报 `passed` 的松散 archive。新增统一 `qinglong/alpha-cluster-image@v1` materializer/auditor,固定 control/control-ai/admin/worker 的 repository、OCI title、non-root user 和 amd64/arm64 identity;每个六文件 bundle 含单镜像 Docker archive、精确 SBOM、绑定 source/workflow SHA/ref/run/attempt/role/architecture/image ID 的 verification evidence、README、manifest 与 `SHA256SUMS`,下载后可无 Docker/网络离线复核闭合文件集和内容 digest。新增完整 CI 后置 `cluster-alpha-milestone` finalizer,重新下载八个 artifact,要求同一 version/source/run/attempt 且八个 image ID/archive digest/verification digest 互异,再上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引;没有该索引的部分 archive 只是失败运行中间件。maturity 明确为 `cluster_integration_candidate_not_public_release`,适用于隔离 registry/K3s/Kubernetes 集成,不冒充受保护 tag、GHCR digest、签名、catalog 或生产 HA。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS;首份实际产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。 + - D-413/ADR-0508(已实现,首份真实 milestone artifact 待授权):Local Alpha 不再把两个矩阵 job 各自上传的 amd64/arm64 大归档直接视为阶段版本。新增独立发布期 `local-alpha-milestone` finalizer,只有完整 `QingLong 3.0 CI` 的 19 个 job dependency 全部成功后,才重新下载并离线审计两套 v2 Trial Kit,要求同一 version/source/workflow SHA/run/attempt、精确 `amd64|arm64`、四个不同 Application/operator image ID、两个不同 archive digest 与 verification digest,再上传 `qinglong/alpha-local-milestone@v1` 三文件小型索引。没有 index 的孤立 artifact 明确是失败运行中间文件,不能交付用户。手动门新增 `alpha_artifact_scope=local|cluster|all` 且默认 Local,避免路由/NAS 试用无谓生成八套 Cluster archive;完整测试不因 scope 缩小。显式 milestone 以 run ID 隔离并发域且不被后续普通 push 取消,source 仍绑定触发时 exact SHA。该 finalizer 不新增 workspace package、镜像 layer、设备依赖、daemon、listener、timer、连接池或稳态 RSS;真实可下载产物仍需维护者明确触发 `produce_alpha_artifacts=true + alpha_artifact_scope=local` 后记录 index 与双 bundle digest。 - D-412/ADR-0507(已实现,真实公开发布待受保护 tag):Public Local Release Set 从“只签 Application、用户旅程却依赖另一个未发布 operator”的断层收敛为一对分别构建、扫描、签名和 multi-arch attestation 的镜像:`local` 是唯一常驻 Application,`local-operator` 只承担 setup/upgrade/recovery 等短生命周期 Owner authority。release candidate 的 Local scope 精确包含两者,并分别要求 Application Edge/Standalone rollout 与 operator `--version`/`setup --help` 门;CI OCI 证据、OS 漏洞策略、release-set、catalog consumption、final tag closure 和 Local selection 均按六镜像总闭包升级。`qinglong/release-set-image-record@v2`、`qinglong/release-set@v4`、`application/vnd.qinglong.release-set.v4+json`、`qinglong/local-compose-release-image@v3` 与 `qinglong/local-compose-image-selection@v3` 失败关闭旧孵化 schema。Compose revision 只保存 operator digest 作为管理 authority,不生成 operator service,所以路由设备稳态仍只有 Application,没有新增进程、listener、timer、端口或 RSS;管理动作才短暂下载/运行 operator。18-package clean build 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Local Owner CLI 为 301 total / 294 pass / 7 conditional skip / 0 fail,backend 为 1,610 total / 1,608 pass / 2 conditional skip / 0 fail,静态 release workflow 审计 100/100。该切片证明发布机制闭合,不冒充已经存在 GHCR tag 或真实用户可下载 Public Release Set;首份正式可交付物仍需受保护 release tag 的六镜像、签名、catalog-bound Local/Cluster 部署证据和终态 closure。 diff --git a/docs/adr/ADR-0509-cluster-alpha-bundle-and-milestone-closure.md b/docs/adr/ADR-0509-cluster-alpha-bundle-and-milestone-closure.md new file mode 100644 index 00000000..eddc65a2 --- /dev/null +++ b/docs/adr/ADR-0509-cluster-alpha-bundle-and-milestone-closure.md @@ -0,0 +1,52 @@ +# ADR-0509:Cluster Alpha Bundle 与跨架构里程碑闭合 + +- 状态:Accepted(首份实际 Cluster milestone artifact 待维护者授权) +- 日期:2026-08-28 +- 决策:D-414 +- 关联:ADR-0503、ADR-0506、ADR-0508 + +## 背景 + +QingLong 3.0 已开发约二十天。Local Alpha 已有双架构 Trial Kit 和完整 CI 后置 milestone index,但 Cluster 的 control、control-ai、admin、worker 仍由矩阵 job 内的 shell 各自生成 Docker archive 和一份 v1 manifest。 + +旧 Cluster archive 没有 `SHA256SUMS`、闭合文件集、独立 verification evidence 或可离线 auditor;manifest 直接把 gate 写成 `passed`,没有绑定 workflow ref/SHA、run ID/attempt 和 exact image ID。八个 artifact 也没有完整 CI 后置的唯一完成信号。部署者无法区分“某个矩阵 job 上传成功”与“本次阶段版本整体成立”。 + +## 决策 + +### 1. 单镜像 bundle 由仓库脚本统一物化 + +新增 `ql3-cluster-alpha-bundle.cjs`,固定四角色的 repository、OCI title、runtime user 和两种架构。bundle schema 为 `qinglong/alpha-cluster-image@v1`,文件集恰好包含:单镜像 Docker archive、对应 CycloneDX SBOM、`verification-evidence.json`、README、manifest 和 `SHA256SUMS`。 + +物化前必须重新检查 image reference、ID、OS、architecture、non-root user、source/version/title label,复用现有 SBOM closure audit,并验证 evidence 绑定 exact source、role、architecture、image ID、workflow ref/SHA、run/attempt 和 gate 集。离线 audit 不调用 Docker或网络,拒绝额外文件、symlink、长度/digest、SBOM/evidence 或 checksum 漂移。 + +### 2. 八个 bundle 只有在完整 CI 后才能闭合 + +新增 `cluster-alpha-milestone` finalizer,依赖现有完整 CI 的 19 个顶层 job。它下载同一 source 的四角色乘 amd64/arm64 八个 artifact,逐个离线复审,并要求 version/source/run/attempt 一致,image ID、archive digest 和 verification digest 八项均互不复用。 + +成功后上传 `qinglong/alpha-cluster-milestone@v1` 三文件索引。索引保存八个 artifact 名、bundle manifest digest、archive digest、image ID 和 verification digest。缺少索引的零散 archive 是中间文件,不是阶段版本。 + +### 3. 仍不把 Integration Candidate 冒充 Public Release + +Cluster milestone 的 maturity 固定为 `cluster_integration_candidate_not_public_release`。它可供隔离 registry/K3s/Kubernetes 节点做多组件集成,但没有受保护 tag、GHCR immutable digest、签名/attestation、release catalog 或生产 deployment lock。正式发布继续由 Public Release Set 独立裁决。 + +## 被拒绝的替代方案 + +- 保留 workflow 内联 shell:难以单测和离线复核,manifest 仍会自报通过。 +- 只依赖 GitHub job 绿色状态:下载物与 workflow 主体没有内容级绑定,失败运行也可能留下部分 archive。 +- 把八个 archive 合并为一个超大 artifact:会重复下载和存储,对只需要特定角色/架构的部署者不友好。 +- 将 Cluster candidate 直接提升为正式发布:缺少 registry、签名、catalog 和生产部署闭环。 + +## 影响 + +- 显式 `alpha_artifact_scope=cluster|all` 时增加 verification 文件、离线 audit 和一次八 artifact finalization;普通 push/PR 不生成大产物; +- 每个部署者只下载目标架构/角色,milestone 本身保持三文件小索引; +- 新实现属于 repository release tooling,不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或稳态 RSS; +- Local 的路由/NAS 稳态边界不变,Cluster 节点获得可裁决的阶段集成产物。 + +## 验证 + +- bundle 正向测试覆盖闭合六文件、image/SBOM/evidence identity 与离线 audit; +- 负向测试覆盖 root user、archive/SBOM/evidence 篡改、额外文件和 image subject 脱离; +- milestone 正向测试闭合八项,并覆盖跨 workflow run 混用、index mutation、额外文件和 workflow 门序; +- 静态 audit 固定 19 个完整 CI dependency、八 artifact 下载和 `finalize → audit → upload`; +- 首份真实产物仍需维护者显式授权 `produce_alpha_artifacts=true + alpha_artifact_scope=cluster`。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 584adc5f..9f274edc 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -512,6 +512,7 @@ | [ADR-0506](./ADR-0506-source-bound-local-alpha-verification-evidence.md) | 源码绑定的 Local Alpha 验证证据 | Accepted | | [ADR-0507](./ADR-0507-public-local-application-and-operator-release-pair.md) | Public Local Application 与 Operator 发布对 | Accepted(首份真实公开发布待受保护 tag) | | [ADR-0508](./ADR-0508-cross-architecture-local-alpha-milestone-closure.md) | 跨架构 Local Alpha 里程碑闭合 | Accepted(首份实际 milestone artifact 待维护者授权) | +| [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index bfdd122b..c75495cc 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -11,7 +11,7 @@ | Cluster Integration Candidate | amd64/arm64 集群测试节点 | OS 漏洞策略、SBOM 与镜像库存复核、non-root identity;Admin 额外通过产品 facade smoke | 导入隔离 registry/测试节点,进行多组件集成;不作为 production HA release | | Public Release Set | 生产用户 | 受保护 tag、六镜像 multi-arch digest(Local Application/operator + Cluster 四角色)、签名/attestation、私有发布证据、catalog、Local/Cluster 部署与回退闭环 | 尚未实际发布;只能由受保护 release workflow 生成 | -只有 `Local Alpha Trial Kit` 可以称为本阶段“用户可试运行产物”。单个 headless runtime 和 Cluster archive 都只是工程候选;后者还不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 +`Local Alpha Trial Kit + Local milestone index` 是本阶段的用户可试运行产物;`Cluster Integration Candidate + Cluster milestone index` 是集群部署者可下载、可离线验真的集成产物。单个 headless runtime、单个 Cluster archive 或没有 milestone index 的部分矩阵产物都只是工程中间件。Cluster milestone 仍不满足正式 Kubernetes deployment-lock 的 GHCR immutable digest 与 catalog provenance。 ## 当前阶段实物(2026-08-27) @@ -28,12 +28,14 @@ ADR-0506 现要求 `qinglong/alpha-local-trial-kit@v2` 额外包含 `verificatio ## 生成 -在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交并设置 `produce_alpha_artifacts=true`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。 +在 GitHub Actions 手动运行 `QingLong 3.0 CI`,选择目标 `next` 提交,设置 `produce_alpha_artifacts=true`,并明确选择 `alpha_artifact_scope=local|cluster|all`。普通 push/PR 不上传大镜像,避免每次开发提交都制造伪里程碑和额外存储成本。 成功后同一次 run 生成、保留 30 天: - `ql3-alpha--local-amd64` 与 `ql3-alpha--local-arm64`; +- `ql3-alpha--local-milestone`; - `ql3-alpha--control-`、`control-ai-`、`admin-`、`worker-`。 +- `ql3-alpha--cluster-milestone`。 Local artifact 含: @@ -43,7 +45,7 @@ Local artifact 含: - 与实际只读镜像 inventory 对账过的 CycloneDX SBOM; - 面向 Local 用户的 README 与覆盖全部内容文件的 `SHA256SUMS`。 -Cluster artifact 仍是每个角色一个 native Docker archive 和各自 manifest。 +Cluster artifact 是每角色/架构一个六文件闭包:native Docker archive、精确 CycloneDX SBOM、workflow-bound verification evidence、README、`qinglong/alpha-cluster-image@v1` manifest 和覆盖全部内容文件的 `SHA256SUMS`。完整 CI 成功后,八个 bundle 由 `qinglong/alpha-cluster-milestone@v1` 小型索引闭合;索引本身不重复存放大 archive。 任何 required job 失败时不上传对应产物。artifact 名和 archive 内的 `ci-*` tag 都表示 commit-bound candidate,不能改名后冒充 `v3.x` release。 @@ -82,4 +84,4 @@ Cluster candidate 必须先导入隔离 registry 并重新绑定该 registry 的 ## 里程碑判定 -一次用户阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构的 Application/operator、完整 CI run、artifact 名与 digest、fresh setup→首 Owner→active→stop 的目标 Profile smoke、已知限制和回退路径。仅有源码、`dist/`、单元测试数字、Dockerfile、单个 headless runtime 或“理论上可构建”都不算用户可用产物。 +一次 Local 用户阶段里程碑只有同时记录以下事实才成立:源码 commit、版本、两种 Tier-1 架构的 Application/operator、完整 CI run、artifact 名与 digest、fresh setup→首 Owner→active→stop 的目标 Profile smoke、已知限制和回退路径。一次 Cluster 集成里程碑还必须精确闭合四角色乘两架构、同一 run/attempt 和八个独立主体。仅有源码、`dist/`、单元测试数字、Dockerfile、单个 headless runtime 或“理论上可构建”都不算阶段可用产物。 diff --git a/docs/operations/ql3-cluster-alpha-milestone.md b/docs/operations/ql3-cluster-alpha-milestone.md new file mode 100644 index 00000000..2d656ef5 --- /dev/null +++ b/docs/operations/ql3-cluster-alpha-milestone.md @@ -0,0 +1,43 @@ +# QingLong 3.0 Cluster Alpha Integration Milestone + +本目录是一次完整 Cluster Alpha integration milestone 的闭合索引,不包含八个大体积 Docker archive。`manifest.json` 精确列出同一次 GitHub Actions run 生成并离线复核的 control、control-ai、admin、worker 在 amd64、arm64 上的八个 artifact。只有该索引和目标 artifact 同时存在,才可把这次运行称为阶段性 Cluster 集成产物。 + +## 成熟度边界 + +- `maturity` 固定为 `cluster_integration_candidate_not_public_release`; +- 适用于隔离 registry、临时 Kubernetes/K3s 节点和非生产数据库上的集成验证; +- 不提供公开 GHCR tag、不可变 catalog selection、Cosign 签名、GitHub attestation、生产 HA、升级或长期支持承诺; +- 低配路由器/NAS 应选择 Local Alpha Trial Kit,不能部署四个 Cluster 角色。 + +任何单角色或单架构 artifact 提前上传都只是中间文件。缺少 `ql3-alpha--cluster-milestone`、完整 CI 未成功、run/attempt 不一致或索引审计失败时,不得作为阶段交付物。 + +## 选择并验证下载物 + +1. 在本目录执行 `sha256sum --check SHA256SUMS`。 +2. 确认 `manifest.json`: + - schema 为 `qinglong/alpha-cluster-milestone@v1`; + - `sourceRevision` 为目标完整 40 位提交; + - workflow event/job 为 `workflow_dispatch` / `cluster-alpha-milestone`; + - 对应 run ID/attempt 的完整 `QingLong 3.0 CI` 成功; + - `artifacts` 恰好包含四角色乘两架构的八项。 +3. 按节点架构和需要的角色下载 artifact;一个可运行的完整 Cluster 测试部署通常需要 control、admin、worker,启用 AI 才增加 control-ai。 +4. 对每个 bundle 执行其 `SHA256SUMS`,并核对 bundle `manifest.json` 的 digest 等于 milestone `bundleManifest.sha256`。 +5. 使用隔离 registry 导入 Docker archive,再以导入后的 immutable digest 更新测试 deployment lock;不要直接依赖 archive 内的 `ci-*` tag。 + +持有同版本源码与 Node.js 24 时可离线复审: + +```sh +node scripts/ql3-cluster-alpha-milestone.cjs \ + --mode=audit \ + --milestone=/absolute/path/to/ql3-alpha-cluster-milestone + +node scripts/ql3-cluster-alpha-bundle.cjs \ + --mode=offline-audit \ + --bundle=/absolute/path/to/one-cluster-bundle +``` + +这两个命令不访问 Docker 或网络;它们验证闭合文件集、长度、SHA-256、SBOM identity、workflow evidence、角色/架构集合和八个主体互不复用。它们不会重新执行漏洞扫描或 Kubernetes live gate。 + +## 部署与回退 + +Cluster candidate 只应进入临时命名空间、隔离 registry 和可删除数据库。回退方式是删除测试 workload 与测试数据、撤销临时 credential,并回到原 deployment lock。生产 CloudNativePG、跨主机 STONITH/DR、CSI custody、外部 ingress TLS/IdP 和正式升级仍必须由 Public Release Set 的 catalog-bound deployment ceremony 证明。 diff --git a/package.json b/package.json index 4e6d9529..54032e6b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "test:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-live-contract.cjs", "bundle:local-alpha-trial-kit:ql3": "node scripts/ql3-local-alpha-trial-kit-bundle.cjs", "finalize:local-alpha-milestone:ql3": "node scripts/ql3-local-alpha-milestone.cjs", + "bundle:cluster-alpha:ql3": "node scripts/ql3-cluster-alpha-bundle.cjs", + "finalize:cluster-alpha-milestone:ql3": "node scripts/ql3-cluster-alpha-milestone.cjs", "audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs", "test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs", "audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs", diff --git a/scripts/ql3-cluster-alpha-bundle.cjs b/scripts/ql3-cluster-alpha-bundle.cjs new file mode 100644 index 00000000..7c432ebe --- /dev/null +++ b/scripts/ql3-cluster-alpha-bundle.cjs @@ -0,0 +1,745 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const childProcess = require('node:child_process'); +const fs = require('node:fs'); +const path = require('node:path'); +const { auditClusterImageSbom } = require('./ql3-cluster-image-sbom.cjs'); +const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); + +const DEFAULT_ROOT = path.resolve(__dirname, '..'); +const SCHEMA = 'qinglong/alpha-cluster-image@v1'; +const VERIFICATION_SCHEMA = 'qinglong/alpha-cluster-image-verification@v1'; +const ARCHITECTURES = Object.freeze(['amd64', 'arm64']); +const ROLES = Object.freeze({ + control: Object.freeze({ + repository: 'qinglong3-cluster-control', + title: 'QingLong 3.0 Cluster Control', + user: '10001:10001', + }), + 'control-ai': Object.freeze({ + repository: 'qinglong3-cluster-control-ai', + title: 'QingLong 3.0 Cluster Control AI', + user: '10001:10001', + }), + admin: Object.freeze({ + repository: 'qinglong3-cluster-admin', + title: 'QingLong 3.0 Cluster Admin', + user: '10001:10001', + }), + worker: Object.freeze({ + repository: 'qinglong3-worker', + title: 'QingLong 3.0 Worker', + user: '65532:65532', + }), +}); +const WORKFLOW_IDENTITY = Object.freeze({ + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + event: 'workflow_dispatch', + job: 'cluster-image', +}); +const ARCHIVE_MIN_BYTES = 1024; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_README_BYTES = 512 * 1024; +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; +const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u; +const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u; + +function fail(message) { + throw new Error(message); +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function roleConfig(role) { + const config = ROLES[role]; + if (!config) fail('Cluster image role is invalid'); + return config; +} + +function names(role, architecture) { + const repository = roleConfig(role).repository; + return Object.freeze({ + archive: `${repository}-${architecture}.docker.tar`, + sbom: `${repository}.cdx.json`, + verification: 'verification-evidence.json', + readme: 'README.md', + manifest: 'manifest.json', + checksums: 'SHA256SUMS', + }); +} + +function assertCanonicalFile(filePath, maximumBytes, label) { + const resolved = path.resolve(filePath); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return resolved; +} + +function readBoundedJson(filePath, label) { + const resolved = assertCanonicalFile(filePath, MAX_JSON_BYTES, label); + try { + return JSON.parse(fs.readFileSync(resolved, 'utf8')); + } catch { + fail(`${label} must contain valid JSON`); + } +} + +function sha256File(filePath) { + const descriptor = fs.openSync(filePath, 'r'); + const hash = crypto.createHash('sha256'); + const buffer = Buffer.allocUnsafe(1024 * 1024); + try { + let bytesRead; + do { + bytesRead = fs.readSync(descriptor, buffer, 0, buffer.length, null); + if (bytesRead > 0) hash.update(buffer.subarray(0, bytesRead)); + } while (bytesRead > 0); + } finally { + fs.closeSync(descriptor); + } + return `sha256:${hash.digest('hex')}`; +} + +function writeExclusive(filePath, contents, mode = 0o600) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + mode, + ); + try { + fs.writeFileSync(descriptor, contents); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function copyExclusive(source, destination) { + fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); + fs.chmodSync(destination, 0o600); +} + +function inspectDockerImage(image) { + const output = childProcess.execFileSync( + 'docker', + ['image', 'inspect', image], + { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + const parsed = JSON.parse(output); + if (!Array.isArray(parsed) || parsed.length !== 1) { + fail(`docker returned an invalid inspection for ${image}`); + } + return parsed[0]; +} + +function saveDockerImage(image, archivePath) { + childProcess.execFileSync( + 'docker', + ['image', 'save', '--output', archivePath, image], + { stdio: ['ignore', 'ignore', 'pipe'] }, + ); +} + +function validateImageReference(value, role, architecture) { + const expected = `${roleConfig(role).repository}:ci-${architecture}`; + if (value !== expected) fail('Cluster image reference is incompatible'); + return value; +} + +function normalizeImageInspection(inspection, options) { + const config = roleConfig(options.role); + const labels = inspection?.Config?.Labels; + if ( + !SHA256_PATTERN.test(inspection?.Id || '') || + inspection?.Os !== 'linux' || + inspection?.Architecture !== options.architecture || + inspection?.Config?.User !== config.user || + labels?.['org.opencontainers.image.title'] !== config.title || + labels?.['org.opencontainers.image.source'] !== + 'https://github.com/whyour/qinglong' || + labels?.['org.opencontainers.image.revision'] !== options.sourceRevision || + labels?.['org.opencontainers.image.version'] !== options.version + ) { + fail('Cluster image identity is incompatible'); + } + return Object.freeze({ + reference: options.reference, + id: inspection.Id, + os: 'linux', + architecture: options.architecture, + user: config.user, + }); +} + +function validateSbom(document, options, offline = false) { + if (!offline) { + auditClusterImageSbom(document, { + root: options.root, + image: options.role, + }); + } + const properties = Object.fromEntries( + (document?.metadata?.properties || []).map((entry) => [ + entry?.name, + entry?.value, + ]), + ); + if ( + document?.bomFormat !== 'CycloneDX' || + document?.specVersion !== '1.5' || + document?.version !== 1 || + !Array.isArray(document.components) || + !Array.isArray(document.dependencies) || + document.metadata?.component?.version !== options.version || + properties['qinglong:image-profile'] !== options.role + ) { + fail('Cluster image SBOM identity is incompatible'); + } +} + +function gates(role) { + return Object.freeze({ + osVulnerabilityPolicy: 'passed', + sbomInventoryReconciliation: 'passed', + nonRootRuntimeIdentity: 'passed', + clusterAdminProductFacade: role === 'admin' ? 'passed' : 'not_applicable', + }); +} + +function validateVerificationEvidence(document, expected) { + if ( + !exactKeys(document, [ + 'schemaVersion', + 'schema', + 'subject', + 'workflow', + 'gates', + ]) || + document.schemaVersion !== 1 || + document.schema !== VERIFICATION_SCHEMA || + !exactKeys(document.subject, [ + 'version', + 'sourceRevision', + 'role', + 'architecture', + 'imageId', + ]) || + document.subject.version !== expected.version || + document.subject.sourceRevision !== expected.sourceRevision || + document.subject.role !== expected.role || + document.subject.architecture !== expected.architecture || + document.subject.imageId !== expected.imageId || + !exactKeys(document.workflow, [ + 'repository', + 'workflowRef', + 'workflowSha', + 'event', + 'job', + 'runId', + 'runAttempt', + ]) || + document.workflow.repository !== WORKFLOW_IDENTITY.repository || + document.workflow.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + document.workflow.workflowSha !== expected.sourceRevision || + document.workflow.event !== WORKFLOW_IDENTITY.event || + document.workflow.job !== WORKFLOW_IDENTITY.job || + !DECIMAL_ID_PATTERN.test(document.workflow.runId || '') || + !ATTEMPT_PATTERN.test(document.workflow.runAttempt || '') || + !exactKeys(document.gates, Object.keys(gates(expected.role))) || + JSON.stringify(document.gates) !== JSON.stringify(gates(expected.role)) + ) { + fail('Cluster image verification evidence is incompatible'); + } + return document; +} + +function validateCommonIdentity(options) { + if ( + !ARCHITECTURES.includes(options.architecture) || + !Object.hasOwn(ROLES, options.role) || + !REVISION_PATTERN.test(options.sourceRevision || '') + ) { + fail('Cluster image identity is invalid'); + } +} + +function createClusterAlphaVerificationEvidence(options, adapters = {}) { + validateCommonIdentity(options); + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const output = path.resolve(options.output || ''); + const parent = path.dirname(output); + if ( + !path.isAbsolute(output) || + fs.existsSync(output) || + fs.realpathSync(parent) !== parent || + options.repository !== WORKFLOW_IDENTITY.repository || + options.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + options.workflowSha !== options.sourceRevision || + options.eventName !== WORKFLOW_IDENTITY.event || + options.job !== WORKFLOW_IDENTITY.job || + !DECIMAL_ID_PATTERN.test(options.runId || '') || + !ATTEMPT_PATTERN.test(options.runAttempt || '') + ) { + fail('Cluster verification workflow identity or output is invalid'); + } + const release = readReleaseIdentity(root); + const reference = validateImageReference( + options.image, + options.role, + options.architecture, + ); + const inspectImage = adapters.inspectImage || inspectDockerImage; + const image = normalizeImageInspection(inspectImage(reference), { + reference, + role: options.role, + architecture: options.architecture, + sourceRevision: options.sourceRevision, + version: release.version, + }); + const evidence = { + schemaVersion: 1, + schema: VERIFICATION_SCHEMA, + subject: { + version: release.version, + sourceRevision: options.sourceRevision, + role: options.role, + architecture: options.architecture, + imageId: image.id, + }, + workflow: { + repository: options.repository, + workflowRef: options.workflowRef, + workflowSha: options.workflowSha, + event: options.eventName, + job: options.job, + runId: options.runId, + runAttempt: options.runAttempt, + }, + gates: { ...gates(options.role) }, + }; + validateVerificationEvidence(evidence, evidence.subject); + writeExclusive(output, `${JSON.stringify(evidence, null, 2)}\n`); + return Object.freeze(evidence); +} + +function fileRecord(bundleRoot, name) { + const filePath = path.join(bundleRoot, name); + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) { + fail(`bundle file is invalid: ${name}`); + } + return Object.freeze({ + file: name, + sha256: sha256File(filePath), + bytes: stat.size, + }); +} + +function checksumContents(bundleRoot, checkedNames) { + return `${checkedNames + .map( + (name) => `${sha256File(path.join(bundleRoot, name)).slice(7)} ${name}`, + ) + .join('\n')}\n`; +} + +function createClusterAlphaBundle(options, adapters = {}) { + validateCommonIdentity(options); + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const outputRoot = path.resolve(options.outputRoot || ''); + const parent = path.dirname(outputRoot); + if ( + !path.isAbsolute(outputRoot) || + fs.existsSync(outputRoot) || + fs.realpathSync(parent) !== parent + ) { + fail('Cluster bundle output is invalid'); + } + const sbomPath = assertCanonicalFile( + options.sbom, + MAX_JSON_BYTES, + 'Cluster SBOM', + ); + const evidencePath = assertCanonicalFile( + options.verificationEvidence, + MAX_JSON_BYTES, + 'Cluster verification evidence', + ); + const readmePath = assertCanonicalFile( + options.readme, + MAX_README_BYTES, + 'Cluster README', + ); + const release = readReleaseIdentity(root); + const reference = validateImageReference( + options.image, + options.role, + options.architecture, + ); + const inspectImage = adapters.inspectImage || inspectDockerImage; + const saveImage = adapters.saveImage || saveDockerImage; + const image = normalizeImageInspection(inspectImage(reference), { + reference, + role: options.role, + architecture: options.architecture, + sourceRevision: options.sourceRevision, + version: release.version, + }); + const sbom = readBoundedJson(sbomPath, 'Cluster SBOM'); + validateSbom(sbom, { root, role: options.role, version: release.version }); + const evidence = readBoundedJson( + evidencePath, + 'Cluster verification evidence', + ); + validateVerificationEvidence(evidence, { + version: release.version, + sourceRevision: options.sourceRevision, + role: options.role, + architecture: options.architecture, + imageId: image.id, + }); + const bundleNames = names(options.role, options.architecture); + let created = false; + try { + fs.mkdirSync(outputRoot, { mode: 0o700 }); + created = true; + const archivePath = path.join(outputRoot, bundleNames.archive); + saveImage(reference, archivePath); + const archiveStat = fs.lstatSync(archivePath); + if ( + !archiveStat.isFile() || + archiveStat.isSymbolicLink() || + archiveStat.size < ARCHIVE_MIN_BYTES + ) { + fail('Cluster Docker archive is invalid or unexpectedly small'); + } + fs.chmodSync(archivePath, 0o600); + copyExclusive(sbomPath, path.join(outputRoot, bundleNames.sbom)); + copyExclusive( + evidencePath, + path.join(outputRoot, bundleNames.verification), + ); + copyExclusive(readmePath, path.join(outputRoot, bundleNames.readme)); + const manifest = { + schemaVersion: 1, + schema: SCHEMA, + maturity: 'cluster_integration_candidate_not_public_release', + product: 'cluster', + role: options.role, + version: release.version, + sourceRevision: options.sourceRevision, + architecture: options.architecture, + image, + archive: fileRecord(outputRoot, bundleNames.archive), + sbom: fileRecord(outputRoot, bundleNames.sbom), + readme: fileRecord(outputRoot, bundleNames.readme), + verification: fileRecord(outputRoot, bundleNames.verification), + }; + writeExclusive( + path.join(outputRoot, bundleNames.manifest), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + writeExclusive( + path.join(outputRoot, bundleNames.checksums), + checksumContents(outputRoot, [ + bundleNames.archive, + bundleNames.sbom, + bundleNames.verification, + bundleNames.readme, + bundleNames.manifest, + ]), + ); + auditClusterAlphaBundle({ bundleRoot: outputRoot }); + return Object.freeze(manifest); + } catch (error) { + if (created) fs.rmSync(outputRoot, { recursive: true, force: true }); + throw error; + } +} + +function validateFileRecord(record, expectedName, bundleRoot) { + if ( + !exactKeys(record, ['file', 'sha256', 'bytes']) || + record.file !== expectedName || + !SHA256_PATTERN.test(record.sha256 || '') || + !Number.isSafeInteger(record.bytes) || + record.bytes < 2 + ) { + fail(`manifest file record is invalid: ${expectedName}`); + } + const actual = fileRecord(bundleRoot, expectedName); + if (actual.sha256 !== record.sha256 || actual.bytes !== record.bytes) { + fail(`bundle file differs from manifest: ${expectedName}`); + } +} + +function auditClusterAlphaBundle(options) { + const bundleRoot = fs.realpathSync(path.resolve(options.bundleRoot || '')); + if (!fs.lstatSync(bundleRoot).isDirectory()) { + fail('Cluster bundle root must be a canonical directory'); + } + const manifest = readBoundedJson( + path.join(bundleRoot, 'manifest.json'), + 'manifest', + ); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'schema', + 'maturity', + 'product', + 'role', + 'version', + 'sourceRevision', + 'architecture', + 'image', + 'archive', + 'sbom', + 'readme', + 'verification', + ]) || + manifest.schemaVersion !== 1 || + manifest.schema !== SCHEMA || + manifest.maturity !== 'cluster_integration_candidate_not_public_release' || + manifest.product !== 'cluster' || + !Object.hasOwn(ROLES, manifest.role) || + !ARCHITECTURES.includes(manifest.architecture) || + typeof manifest.version !== 'string' || + manifest.version.length < 3 || + manifest.version.length > 64 || + !REVISION_PATTERN.test(manifest.sourceRevision || '') + ) { + fail('Cluster bundle manifest identity or shape is incompatible'); + } + const bundleNames = names(manifest.role, manifest.architecture); + const expectedFiles = Object.values(bundleNames).sort(); + const actualFiles = fs + .readdirSync(bundleRoot, { withFileTypes: true }) + .map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + fail(`Cluster bundle contains a non-regular entry: ${entry.name}`); + } + return entry.name; + }) + .sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail('Cluster bundle file set is not closed'); + } + if ( + !exactKeys(manifest.image, [ + 'reference', + 'id', + 'os', + 'architecture', + 'user', + ]) || + manifest.image.reference !== + `${roleConfig(manifest.role).repository}:ci-${manifest.architecture}` || + !SHA256_PATTERN.test(manifest.image.id || '') || + manifest.image.os !== 'linux' || + manifest.image.architecture !== manifest.architecture || + manifest.image.user !== roleConfig(manifest.role).user + ) { + fail('Cluster bundle image identity is incompatible'); + } + validateFileRecord(manifest.archive, bundleNames.archive, bundleRoot); + if (manifest.archive.bytes < ARCHIVE_MIN_BYTES) { + fail('Cluster bundle archive is unexpectedly small'); + } + validateFileRecord(manifest.sbom, bundleNames.sbom, bundleRoot); + validateFileRecord(manifest.readme, bundleNames.readme, bundleRoot); + validateFileRecord( + manifest.verification, + bundleNames.verification, + bundleRoot, + ); + const sbom = readBoundedJson(path.join(bundleRoot, bundleNames.sbom), 'SBOM'); + validateSbom(sbom, { role: manifest.role, version: manifest.version }, true); + const evidence = readBoundedJson( + path.join(bundleRoot, bundleNames.verification), + 'verification evidence', + ); + validateVerificationEvidence(evidence, { + version: manifest.version, + sourceRevision: manifest.sourceRevision, + role: manifest.role, + architecture: manifest.architecture, + imageId: manifest.image.id, + }); + const expectedChecksums = checksumContents(bundleRoot, [ + bundleNames.archive, + bundleNames.sbom, + bundleNames.verification, + bundleNames.readme, + bundleNames.manifest, + ]); + if ( + fs.readFileSync(path.join(bundleRoot, bundleNames.checksums), 'utf8') !== + expectedChecksums + ) { + fail('Cluster bundle SHA256SUMS differs from the closed file set'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-cluster-image-audit@v1', + role: manifest.role, + architecture: manifest.architecture, + sourceRevision: manifest.sourceRevision, + version: manifest.version, + workflowRunId: evidence.workflow.runId, + workflowRunAttempt: evidence.workflow.runAttempt, + imageId: manifest.image.id, + archiveSha256: manifest.archive.sha256, + verificationSha256: manifest.verification.sha256, + compatible: true, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z0-9-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + if (values.mode === 'audit' || values.mode === 'offline-audit') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['bundle', 'mode']) + ) { + fail('audit arguments are invalid'); + } + return { mode: values.mode, bundleRoot: path.resolve(values.bundle) }; + } + const common = { + architecture: values.architecture, + image: values.image, + role: values.role, + sourceRevision: values['source-revision'], + }; + if (values.mode === 'record-verification') { + const expected = [ + 'architecture', + 'event', + 'image', + 'job', + 'mode', + 'output', + 'repository', + 'role', + 'run-attempt', + 'run-id', + 'source-revision', + 'workflow-ref', + 'workflow-sha', + ].sort(); + if ( + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('verification arguments are invalid'); + } + return { + mode: values.mode, + ...common, + output: path.resolve(values.output), + repository: values.repository, + workflowRef: values['workflow-ref'], + workflowSha: values['workflow-sha'], + eventName: values.event, + job: values.job, + runId: values['run-id'], + runAttempt: values['run-attempt'], + }; + } + const expected = [ + 'architecture', + 'image', + 'mode', + 'output', + 'readme', + 'role', + 'sbom', + 'source-revision', + 'verification-evidence', + ].sort(); + if ( + values.mode !== 'create' || + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('create arguments are invalid'); + } + return { + mode: values.mode, + ...common, + outputRoot: path.resolve(values.output), + sbom: path.resolve(values.sbom), + verificationEvidence: path.resolve(values['verification-evidence']), + readme: path.resolve(values.readme), + }; +} + +function runCli(argv) { + const options = parseArguments(argv); + let report; + if (options.mode === 'record-verification') { + report = createClusterAlphaVerificationEvidence(options); + } else if (options.mode === 'create') { + report = createClusterAlphaBundle(options); + } else { + report = auditClusterAlphaBundle(options); + } + process.stdout.write(`${JSON.stringify(report)}\n`); + return report; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error ? error.message : 'Cluster Alpha bundle failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + ARCHITECTURES, + ROLES, + SCHEMA, + VERIFICATION_SCHEMA, + auditClusterAlphaBundle, + createClusterAlphaBundle, + createClusterAlphaVerificationEvidence, + names, + parseArguments, + runCli, + sha256File, +}); diff --git a/scripts/ql3-cluster-alpha-milestone.cjs b/scripts/ql3-cluster-alpha-milestone.cjs new file mode 100644 index 00000000..0d8c612a --- /dev/null +++ b/scripts/ql3-cluster-alpha-milestone.cjs @@ -0,0 +1,647 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { + ARCHITECTURES, + ROLES, + auditClusterAlphaBundle, + sha256File, +} = require('./ql3-cluster-alpha-bundle.cjs'); +const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs'); + +const DEFAULT_ROOT = path.resolve(__dirname, '..'); +const SCHEMA = 'qinglong/alpha-cluster-milestone@v1'; +const SUBJECTS = Object.freeze( + Object.keys(ROLES).flatMap((role) => + ARCHITECTURES.map((architecture) => `${role}-${architecture}`), + ), +); +const FILES = Object.freeze({ + readme: 'README.md', + manifest: 'manifest.json', + checksums: 'SHA256SUMS', +}); +const WORKFLOW_IDENTITY = Object.freeze({ + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + event: 'workflow_dispatch', + job: 'cluster-alpha-milestone', +}); +const REQUIRED_WORKFLOW_NEEDS = Object.freeze([ + 'backend', + 'service-manager-bridge', + 'linux-resource-envelopes', + 'linux-resource-release-evidence', + 'supply-chain', + 'local-image', + 'cluster-image', + 'cluster-console-capacity-release-evidence', + 'image-oci', + 'worker-runtime', + 'local-profiles', + 'cluster-postgres', + 'cluster-postgres-ha', + 'cluster-cloudnativepg-live', + 'cluster-provider-credential-test-kubernetes-live', + 'cluster-secret-binding-mounted-provider-kubernetes-live', + 'cluster-vault-kv-worker-secret-live', + 'cluster-plugin-package-kubernetes-live', + 'cluster-plugin-package-recovery-e2e', +]); +const SHA256_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; +const DECIMAL_ID_PATTERN = /^[1-9][0-9]{0,19}$/u; +const ATTEMPT_PATTERN = /^[1-9][0-9]{0,5}$/u; +const MAX_JSON_BYTES = 4 * 1024 * 1024; +const MAX_README_BYTES = 512 * 1024; + +function fail(message) { + throw new Error(message); +} + +function exactKeys(value, expected) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value)) === JSON.stringify(expected) + ); +} + +function assertCanonicalFile(filePath, maximumBytes, label) { + const resolved = path.resolve(filePath); + const stat = fs.lstatSync(resolved); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved + ) { + fail(`${label} must be one bounded canonical regular file`); + } + return resolved; +} + +function readBoundedJson(filePath, label) { + const resolved = assertCanonicalFile(filePath, MAX_JSON_BYTES, label); + try { + return JSON.parse(fs.readFileSync(resolved, 'utf8')); + } catch { + fail(`${label} must contain valid JSON`); + } +} + +function writeExclusive(filePath, contents, mode = 0o600) { + const descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, + mode, + ); + try { + fs.writeFileSync(descriptor, contents); + fs.fsyncSync(descriptor); + } finally { + fs.closeSync(descriptor); + } +} + +function copyExclusive(source, destination) { + fs.copyFileSync(source, destination, fs.constants.COPYFILE_EXCL); + fs.chmodSync(destination, 0o600); +} + +function fileRecord(filePath, name) { + const stat = fs.lstatSync(filePath); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size < 2) { + fail(`milestone file is invalid: ${name}`); + } + return Object.freeze({ + file: name, + sha256: sha256File(filePath), + bytes: stat.size, + }); +} + +function checksumContents(root, checkedNames) { + return `${checkedNames + .map((name) => `${sha256File(path.join(root, name)).slice(7)} ${name}`) + .join('\n')}\n`; +} + +function artifactName(sourceRevision, role, architecture) { + return `ql3-alpha-${sourceRevision}-${role}-${architecture}`; +} + +function splitSubject(subject) { + const architecture = ARCHITECTURES.find((value) => + subject.endsWith(`-${value}`), + ); + if (!architecture) fail(`milestone subject is invalid: ${subject}`); + return Object.freeze({ + role: subject.slice(0, -(architecture.length + 1)), + architecture, + }); +} + +function validateIdentity(options) { + if ( + !REVISION_PATTERN.test(options.sourceRevision || '') || + options.repository !== WORKFLOW_IDENTITY.repository || + options.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + options.workflowSha !== options.sourceRevision || + options.eventName !== WORKFLOW_IDENTITY.event || + !DECIMAL_ID_PATTERN.test(options.runId || '') || + !ATTEMPT_PATTERN.test(options.runAttempt || '') + ) { + fail('Cluster milestone workflow identity is invalid'); + } +} + +function validateFinalizeOptions(options) { + validateIdentity(options); + const root = fs.realpathSync(path.resolve(options.root || DEFAULT_ROOT)); + const outputRoot = path.resolve(options.outputRoot || ''); + const parent = path.dirname(outputRoot); + if ( + !path.isAbsolute(outputRoot) || + fs.existsSync(outputRoot) || + fs.realpathSync(parent) !== parent + ) { + fail('Cluster milestone output is invalid'); + } + const bundles = {}; + for (const subject of SUBJECTS) { + const bundleRoot = fs.realpathSync( + path.resolve(options.bundles?.[subject] || ''), + ); + if (!fs.lstatSync(bundleRoot).isDirectory()) { + fail(`${subject} bundle root is invalid`); + } + bundles[subject] = bundleRoot; + } + if (new Set(Object.values(bundles)).size !== SUBJECTS.length) { + fail('Cluster milestone subjects must use distinct bundles'); + } + return Object.freeze({ + root, + outputRoot, + bundles: Object.freeze(bundles), + readme: assertCanonicalFile( + options.readme, + MAX_README_BYTES, + 'Cluster milestone README', + ), + sourceRevision: options.sourceRevision, + repository: options.repository, + workflowRef: options.workflowRef, + workflowSha: options.workflowSha, + eventName: options.eventName, + runId: options.runId, + runAttempt: options.runAttempt, + }); +} + +function bundleRecord(options, subject) { + const { role, architecture } = splitSubject(subject); + const bundleRoot = options.bundles[subject]; + const report = auditClusterAlphaBundle({ bundleRoot }); + if ( + report.compatible !== true || + report.role !== role || + report.architecture !== architecture || + report.sourceRevision !== options.sourceRevision || + report.workflowRunId !== options.runId || + report.workflowRunAttempt !== options.runAttempt + ) { + fail(`${subject} bundle is detached from the Cluster milestone run`); + } + return Object.freeze({ + artifactName: artifactName(options.sourceRevision, role, architecture), + role, + architecture, + bundleManifest: fileRecord( + path.join(bundleRoot, 'manifest.json'), + 'manifest.json', + ), + archiveSha256: report.archiveSha256, + imageId: report.imageId, + verificationSha256: report.verificationSha256, + }); +} + +function validateArtifactRecord(record, subject, manifest) { + const { role, architecture } = splitSubject(subject); + if ( + !exactKeys(record, [ + 'artifactName', + 'role', + 'architecture', + 'bundleManifest', + 'archiveSha256', + 'imageId', + 'verificationSha256', + ]) || + record.artifactName !== + artifactName(manifest.sourceRevision, role, architecture) || + record.role !== role || + record.architecture !== architecture || + !exactKeys(record.bundleManifest, ['file', 'sha256', 'bytes']) || + record.bundleManifest.file !== 'manifest.json' || + !SHA256_PATTERN.test(record.bundleManifest.sha256 || '') || + !Number.isSafeInteger(record.bundleManifest.bytes) || + record.bundleManifest.bytes < 2 || + !SHA256_PATTERN.test(record.archiveSha256 || '') || + !SHA256_PATTERN.test(record.imageId || '') || + !SHA256_PATTERN.test(record.verificationSha256 || '') + ) { + fail(`${subject} milestone artifact record is incompatible`); + } +} + +function validateWorkflow(document, sourceRevision) { + if ( + !exactKeys(document, [ + 'repository', + 'workflowRef', + 'workflowSha', + 'event', + 'job', + 'runId', + 'runAttempt', + ]) || + document.repository !== WORKFLOW_IDENTITY.repository || + document.workflowRef !== WORKFLOW_IDENTITY.workflowRef || + document.workflowSha !== sourceRevision || + document.event !== WORKFLOW_IDENTITY.event || + document.job !== WORKFLOW_IDENTITY.job || + !DECIMAL_ID_PATTERN.test(document.runId || '') || + !ATTEMPT_PATTERN.test(document.runAttempt || '') + ) { + fail('Cluster milestone manifest workflow identity is incompatible'); + } +} + +function auditClusterAlphaMilestone(options) { + const milestoneRoot = fs.realpathSync( + path.resolve(options.milestoneRoot || ''), + ); + if (!fs.lstatSync(milestoneRoot).isDirectory()) { + fail('Cluster milestone root must be a canonical directory'); + } + const expectedFiles = Object.values(FILES).sort(); + const actualFiles = fs + .readdirSync(milestoneRoot, { withFileTypes: true }) + .map((entry) => { + if (!entry.isFile() || entry.isSymbolicLink()) { + fail(`Cluster milestone contains a non-regular entry: ${entry.name}`); + } + return entry.name; + }) + .sort(); + if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) { + fail('Cluster milestone file set is not closed'); + } + const manifest = readBoundedJson( + path.join(milestoneRoot, FILES.manifest), + 'Cluster milestone manifest', + ); + if ( + !exactKeys(manifest, [ + 'schemaVersion', + 'schema', + 'maturity', + 'product', + 'version', + 'sourceRevision', + 'workflow', + 'artifacts', + 'readme', + ]) || + manifest.schemaVersion !== 1 || + manifest.schema !== SCHEMA || + manifest.maturity !== 'cluster_integration_candidate_not_public_release' || + manifest.product !== 'cluster' || + typeof manifest.version !== 'string' || + manifest.version.length < 3 || + manifest.version.length > 64 || + !REVISION_PATTERN.test(manifest.sourceRevision || '') || + !exactKeys(manifest.artifacts, SUBJECTS) || + !exactKeys(manifest.readme, ['file', 'sha256', 'bytes']) || + manifest.readme.file !== FILES.readme || + !SHA256_PATTERN.test(manifest.readme.sha256 || '') || + !Number.isSafeInteger(manifest.readme.bytes) || + manifest.readme.bytes < 2 + ) { + fail('Cluster milestone manifest identity or shape is incompatible'); + } + validateWorkflow(manifest.workflow, manifest.sourceRevision); + for (const subject of SUBJECTS) { + validateArtifactRecord(manifest.artifacts[subject], subject, manifest); + } + const records = SUBJECTS.map((subject) => manifest.artifacts[subject]); + for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) { + if ( + new Set(records.map((record) => record[field])).size !== SUBJECTS.length + ) { + fail(`Cluster milestone ${field} subjects are not distinct`); + } + } + const actualReadme = fileRecord( + path.join(milestoneRoot, FILES.readme), + FILES.readme, + ); + if ( + actualReadme.sha256 !== manifest.readme.sha256 || + actualReadme.bytes !== manifest.readme.bytes + ) { + fail('Cluster milestone README differs from manifest'); + } + const expectedChecksums = checksumContents(milestoneRoot, [ + FILES.readme, + FILES.manifest, + ]); + if ( + fs.readFileSync(path.join(milestoneRoot, FILES.checksums), 'utf8') !== + expectedChecksums + ) { + fail('Cluster milestone SHA256SUMS differs from the closed file set'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-cluster-milestone-audit@v1', + sourceRevision: manifest.sourceRevision, + version: manifest.version, + workflowRunId: manifest.workflow.runId, + workflowRunAttempt: manifest.workflow.runAttempt, + subjects: [...SUBJECTS], + compatible: true, + }); +} + +function finalizeClusterAlphaMilestone(options) { + const normalized = validateFinalizeOptions(options); + const artifacts = Object.fromEntries( + SUBJECTS.map((subject) => [subject, bundleRecord(normalized, subject)]), + ); + const versions = new Set( + SUBJECTS.map( + (subject) => + readBoundedJson( + path.join(normalized.bundles[subject], 'manifest.json'), + `${subject} bundle manifest`, + ).version, + ), + ); + const release = readReleaseIdentity(normalized.root); + if (versions.size !== 1 || !versions.has(release.version)) { + fail('Cluster milestone bundles must have one release version'); + } + for (const field of ['imageId', 'archiveSha256', 'verificationSha256']) { + if ( + new Set(SUBJECTS.map((subject) => artifacts[subject][field])).size !== + SUBJECTS.length + ) { + fail(`Cluster milestone ${field} subjects must be distinct`); + } + } + let created = false; + try { + fs.mkdirSync(normalized.outputRoot, { mode: 0o700 }); + created = true; + copyExclusive( + normalized.readme, + path.join(normalized.outputRoot, FILES.readme), + ); + const manifest = { + schemaVersion: 1, + schema: SCHEMA, + maturity: 'cluster_integration_candidate_not_public_release', + product: 'cluster', + version: [...versions][0], + sourceRevision: normalized.sourceRevision, + workflow: { + repository: normalized.repository, + workflowRef: normalized.workflowRef, + workflowSha: normalized.workflowSha, + event: normalized.eventName, + job: WORKFLOW_IDENTITY.job, + runId: normalized.runId, + runAttempt: normalized.runAttempt, + }, + artifacts, + readme: fileRecord( + path.join(normalized.outputRoot, FILES.readme), + FILES.readme, + ), + }; + writeExclusive( + path.join(normalized.outputRoot, FILES.manifest), + `${JSON.stringify(manifest, null, 2)}\n`, + ); + writeExclusive( + path.join(normalized.outputRoot, FILES.checksums), + checksumContents(normalized.outputRoot, [FILES.readme, FILES.manifest]), + ); + auditClusterAlphaMilestone({ milestoneRoot: normalized.outputRoot }); + return Object.freeze(manifest); + } catch (error) { + if (created) + fs.rmSync(normalized.outputRoot, { recursive: true, force: true }); + throw error; + } +} + +function countOccurrences(contents, token) { + return contents.split(token).length - 1; +} + +function jobBlock(workflow, jobName) { + const header = `\n ${jobName}:\n`; + const start = workflow.indexOf(header); + if (start < 0) return ''; + const remaining = workflow.slice(start + header.length); + const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining); + const end = nextMatch + ? start + header.length + nextMatch.index + : workflow.length; + return workflow.slice(start, end); +} + +function auditClusterAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { + const workflow = fs.readFileSync( + path.join( + fs.realpathSync(path.resolve(root)), + '.github/workflows/ql3-ci.yml', + ), + 'utf8', + ); + const findings = []; + const clusterCondition = + "github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')"; + if ( + countOccurrences(workflow, clusterCondition) !== 3 || + !workflow.includes("github.run_id || 'validation'") || + !workflow.includes( + "cancel-in-progress: ${{ !(github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts) }}", + ) + ) { + findings.push('CLUSTER_MILESTONE_SCOPE_OR_CONCURRENCY_DRIFT'); + } + const milestone = jobBlock(workflow, 'cluster-alpha-milestone'); + const tokens = [ + 'name: Finalize the Cluster Alpha integration milestone', + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c', + 'scripts/ql3-cluster-alpha-milestone.cjs', + '--mode=finalize', + '--mode=audit', + `name: ql3-alpha-${'${{ github.sha }}'}-cluster-milestone`, + 'retention-days: 30', + 'overwrite: false', + ]; + if ( + !milestone || + tokens.some((token) => !milestone.includes(token)) || + SUBJECTS.some( + (subject) => + !milestone.includes( + `name: ql3-alpha-${'${{ github.sha }}'}-${subject}`, + ), + ) || + REQUIRED_WORKFLOW_NEEDS.some( + (job) => !milestone.includes(` - ${job}\n`), + ) + ) { + findings.push('CLUSTER_MILESTONE_FINALIZER_CONTRACT_DRIFT'); + } + const finalizerIndex = milestone.indexOf('--mode=finalize'); + const auditIndex = milestone.indexOf('--mode=audit'); + const uploadIndex = milestone.indexOf('actions/upload-artifact@'); + if ( + finalizerIndex < 0 || + auditIndex <= finalizerIndex || + uploadIndex <= auditIndex + ) { + findings.push('CLUSTER_MILESTONE_GATE_ORDER_DRIFT'); + } + return Object.freeze({ + schemaVersion: 1, + schema: 'qinglong/alpha-cluster-milestone-workflow-audit@v1', + requiredNeeds: [...REQUIRED_WORKFLOW_NEEDS], + subjects: [...SUBJECTS], + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z0-9-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + if (values.mode === 'audit') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['milestone', 'mode']) + ) { + fail('audit arguments are invalid'); + } + return { mode: values.mode, milestoneRoot: path.resolve(values.milestone) }; + } + if (values.mode === 'audit-workflow') { + if ( + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(['mode', 'root']) + ) { + fail('workflow audit arguments are invalid'); + } + return { mode: values.mode, root: path.resolve(values.root) }; + } + const bundleArguments = SUBJECTS.map((subject) => `${subject}-bundle`); + const expected = [ + ...bundleArguments, + 'event', + 'mode', + 'output', + 'readme', + 'repository', + 'run-attempt', + 'run-id', + 'source-revision', + 'workflow-ref', + 'workflow-sha', + ].sort(); + if ( + values.mode !== 'finalize' || + JSON.stringify(Object.keys(values).sort()) !== JSON.stringify(expected) + ) { + fail('finalize arguments are invalid'); + } + return { + mode: values.mode, + outputRoot: path.resolve(values.output), + bundles: Object.fromEntries( + SUBJECTS.map((subject) => [ + subject, + path.resolve(values[`${subject}-bundle`]), + ]), + ), + readme: path.resolve(values.readme), + sourceRevision: values['source-revision'], + repository: values.repository, + workflowRef: values['workflow-ref'], + workflowSha: values['workflow-sha'], + eventName: values.event, + runId: values['run-id'], + runAttempt: values['run-attempt'], + }; +} + +function runCli(argv) { + const options = parseArguments(argv); + let report; + if (options.mode === 'finalize') { + report = finalizeClusterAlphaMilestone(options); + } else if (options.mode === 'audit-workflow') { + report = auditClusterAlphaMilestoneWorkflow(options.root); + if (!report.compatible) fail(JSON.stringify(report)); + } else { + report = auditClusterAlphaMilestone(options); + } + process.stdout.write(`${JSON.stringify(report)}\n`); + return report; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${ + error instanceof Error + ? error.message + : 'Cluster Alpha milestone failed' + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + FILES, + REQUIRED_WORKFLOW_NEEDS, + SCHEMA, + SUBJECTS, + artifactName, + auditClusterAlphaMilestone, + auditClusterAlphaMilestoneWorkflow, + finalizeClusterAlphaMilestone, + parseArguments, + runCli, +}); diff --git a/scripts/ql3-local-alpha-milestone.cjs b/scripts/ql3-local-alpha-milestone.cjs index 777bf7a7..13818bab 100644 --- a/scripts/ql3-local-alpha-milestone.cjs +++ b/scripts/ql3-local-alpha-milestone.cjs @@ -465,6 +465,18 @@ function countOccurrences(contents, token) { return contents.split(token).length - 1; } +function jobBlock(workflow, jobName) { + const header = `\n ${jobName}:\n`; + const start = workflow.indexOf(header); + if (start < 0) return ''; + const remaining = workflow.slice(start + header.length); + const nextMatch = /\n [a-z0-9-]+:\n/u.exec(remaining); + const end = nextMatch + ? start + header.length + nextMatch.index + : workflow.length; + return workflow.slice(start, end); +} + function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { const workflowPath = path.join( fs.realpathSync(path.resolve(root)), @@ -473,7 +485,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { const workflow = fs.readFileSync(workflowPath, 'utf8'); const findings = []; const milestoneStart = workflow.indexOf('\n local-alpha-milestone:\n'); - const milestone = milestoneStart >= 0 ? workflow.slice(milestoneStart) : ''; + const milestone = jobBlock(workflow, 'local-alpha-milestone'); const requiredWorkflowTokens = [ 'alpha_artifact_scope:', 'default: local', @@ -492,7 +504,7 @@ function auditLocalAlphaMilestoneWorkflow(root = DEFAULT_ROOT) { "github.event_name == 'workflow_dispatch' && inputs.produce_alpha_artifacts && (inputs.alpha_artifact_scope == 'cluster' || inputs.alpha_artifact_scope == 'all')"; if ( countOccurrences(workflow, localScopeCondition) !== 3 || - countOccurrences(workflow, clusterScopeCondition) !== 2 + countOccurrences(workflow, clusterScopeCondition) !== 3 ) { findings.push('MILESTONE_SCOPE_CONTRACT_DRIFT'); } diff --git a/test/back/ql3ClusterAlphaBundle.test.cjs b/test/back/ql3ClusterAlphaBundle.test.cjs new file mode 100644 index 00000000..0b7571b4 --- /dev/null +++ b/test/back/ql3ClusterAlphaBundle.test.cjs @@ -0,0 +1,208 @@ +'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 { + ROLES, + auditClusterAlphaBundle, + createClusterAlphaBundle, + createClusterAlphaVerificationEvidence, +} = require('../../scripts/ql3-cluster-alpha-bundle.cjs'); +const { + createClusterImageSbom, +} = require('../../scripts/ql3-cluster-image-sbom.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const revision = 'a'.repeat(40); + +function inspection(role, architecture = 'arm64', idCharacter = '7') { + const config = ROLES[role]; + return { + Id: `sha256:${idCharacter.repeat(64)}`, + Os: 'linux', + Architecture: architecture, + Config: { + User: config.user, + Labels: { + 'org.opencontainers.image.title': config.title, + 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', + 'org.opencontainers.image.revision': revision, + 'org.opencontainers.image.version': version, + }, + }, + }; +} + +function fixture(t, role = 'admin') { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-alpha-bundle-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const paths = { + fixtureRoot, + sbom: path.join(fixtureRoot, 'source.cdx.json'), + evidence: path.join(fixtureRoot, 'source-verification.json'), + readme: path.join(fixtureRoot, 'source-README.md'), + outputRoot: path.join(fixtureRoot, 'bundle'), + }; + fs.writeFileSync( + paths.sbom, + `${JSON.stringify(createClusterImageSbom({ root, image: role }))}\n`, + ); + fs.writeFileSync(paths.readme, '# Cluster integration candidate\n'); + createClusterAlphaVerificationEvidence( + verificationOptions(paths, role), + adapters(role), + ); + return paths; +} + +function verificationOptions(paths, role = 'admin', overrides = {}) { + return { + root, + output: paths.evidence, + role, + architecture: 'arm64', + image: `${ROLES[role].repository}:ci-arm64`, + sourceRevision: revision, + repository: 'whyour/qinglong', + workflowRef: 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next', + workflowSha: revision, + eventName: 'workflow_dispatch', + job: 'cluster-image', + runId: '33073349397', + runAttempt: '2', + ...overrides, + }; +} + +function createOptions(paths, role = 'admin') { + return { + root, + outputRoot: paths.outputRoot, + role, + architecture: 'arm64', + image: `${ROLES[role].repository}:ci-arm64`, + sourceRevision: revision, + sbom: paths.sbom, + verificationEvidence: paths.evidence, + readme: paths.readme, + }; +} + +function adapters(role = 'admin', overrides = {}) { + return { + inspectImage() { + return inspection(role); + }, + saveImage(image, archivePath) { + assert.equal(image, `${ROLES[role].repository}:ci-arm64`); + fs.writeFileSync(archivePath, Buffer.alloc(2048, 9), { flag: 'wx' }); + }, + ...overrides, + }; +} + +test('materializes and offline-audits one closed Cluster image bundle', (t) => { + const paths = fixture(t); + const manifest = createClusterAlphaBundle(createOptions(paths), adapters()); + assert.equal(manifest.schema, 'qinglong/alpha-cluster-image@v1'); + assert.equal( + manifest.maturity, + 'cluster_integration_candidate_not_public_release', + ); + assert.equal(manifest.role, 'admin'); + assert.equal(manifest.image.user, '10001:10001'); + assert.equal(manifest.verification.file, 'verification-evidence.json'); + const report = auditClusterAlphaBundle({ bundleRoot: paths.outputRoot }); + assert.equal(report.compatible, true); + assert.equal(report.workflowRunId, '33073349397'); + assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ + 'README.md', + 'SHA256SUMS', + 'manifest.json', + 'qinglong3-cluster-admin-arm64.docker.tar', + 'qinglong3-cluster-admin.cdx.json', + 'verification-evidence.json', + ]); +}); + +test('binds verification evidence to role, architecture, image and exact CI run', (t) => { + const paths = fixture(t, 'worker'); + const evidence = JSON.parse(fs.readFileSync(paths.evidence, 'utf8')); + assert.deepEqual(evidence.subject, { + version, + sourceRevision: revision, + role: 'worker', + architecture: 'arm64', + imageId: `sha256:${'7'.repeat(64)}`, + }); + assert.equal(evidence.workflow.job, 'cluster-image'); + assert.equal(evidence.gates.clusterAdminProductFacade, 'not_applicable'); +}); + +test('fails closed before output on incompatible image identity', (t) => { + const paths = fixture(t); + assert.throws( + () => + createClusterAlphaBundle( + createOptions(paths), + adapters('admin', { + inspectImage() { + const value = inspection('admin'); + value.Config.User = '0:0'; + return value; + }, + }), + ), + /image identity is incompatible/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('offline audit rejects archive, file-set, SBOM and evidence mutations', (t) => { + for (const mutation of ['archive', 'extra', 'sbom', 'evidence']) { + const paths = fixture(t); + paths.outputRoot = path.join(paths.fixtureRoot, `bundle-${mutation}`); + createClusterAlphaBundle(createOptions(paths), adapters()); + if (mutation === 'archive') { + fs.appendFileSync( + path.join(paths.outputRoot, 'qinglong3-cluster-admin-arm64.docker.tar'), + 'tamper', + ); + } else if (mutation === 'extra') { + fs.writeFileSync(path.join(paths.outputRoot, 'credential.txt'), 'secret'); + } else if (mutation === 'sbom') { + fs.appendFileSync( + path.join(paths.outputRoot, 'qinglong3-cluster-admin.cdx.json'), + 'tamper', + ); + } else { + fs.appendFileSync( + path.join(paths.outputRoot, 'verification-evidence.json'), + 'tamper', + ); + } + assert.throws(() => + auditClusterAlphaBundle({ bundleRoot: paths.outputRoot }), + ); + } +}); + +test('rejects verification detached from the inspected image subject', (t) => { + const paths = fixture(t); + const evidence = JSON.parse(fs.readFileSync(paths.evidence, 'utf8')); + evidence.subject.imageId = `sha256:${'8'.repeat(64)}`; + fs.writeFileSync(paths.evidence, `${JSON.stringify(evidence)}\n`); + assert.throws( + () => createClusterAlphaBundle(createOptions(paths), adapters()), + /verification evidence is incompatible/, + ); +}); diff --git a/test/back/ql3ClusterAlphaMilestone.test.cjs b/test/back/ql3ClusterAlphaMilestone.test.cjs new file mode 100644 index 00000000..4fed389f --- /dev/null +++ b/test/back/ql3ClusterAlphaMilestone.test.cjs @@ -0,0 +1,218 @@ +'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 { + ROLES, + createClusterAlphaBundle, + createClusterAlphaVerificationEvidence, +} = require('../../scripts/ql3-cluster-alpha-bundle.cjs'); +const { + SUBJECTS, + auditClusterAlphaMilestone, + auditClusterAlphaMilestoneWorkflow, + finalizeClusterAlphaMilestone, +} = require('../../scripts/ql3-cluster-alpha-milestone.cjs'); +const { + createClusterImageSbom, +} = require('../../scripts/ql3-cluster-image-sbom.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const revision = 'b'.repeat(40); +const workflowRef = + 'whyour/qinglong/.github/workflows/ql3-ci.yml@refs/heads/next'; + +function splitSubject(subject) { + const architecture = subject.endsWith('-amd64') ? 'amd64' : 'arm64'; + return { role: subject.slice(0, -(architecture.length + 1)), architecture }; +} + +function inspection(role, architecture, idCharacter) { + const config = ROLES[role]; + return { + Id: `sha256:${idCharacter.repeat(64)}`, + Os: 'linux', + Architecture: architecture, + Config: { + User: config.user, + Labels: { + 'org.opencontainers.image.title': config.title, + 'org.opencontainers.image.source': 'https://github.com/whyour/qinglong', + 'org.opencontainers.image.revision': revision, + 'org.opencontainers.image.version': version, + }, + }, + }; +} + +function fixture(t, runId = '33073349397') { + const fixtureRoot = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-cluster-alpha-milestone-')), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const readme = path.join(fixtureRoot, 'README-source.md'); + fs.writeFileSync(readme, '# Cluster Alpha milestone\n'); + const sboms = {}; + for (const role of Object.keys(ROLES)) { + sboms[role] = path.join(fixtureRoot, `${role}.cdx.json`); + fs.writeFileSync( + sboms[role], + `${JSON.stringify(createClusterImageSbom({ root, image: role }))}\n`, + ); + } + const bundles = {}; + SUBJECTS.forEach((subject, index) => { + const { role, architecture } = splitSubject(subject); + const idCharacter = String(index + 1); + const evidence = path.join(fixtureRoot, `${subject}-verification.json`); + const image = `${ROLES[role].repository}:ci-${architecture}`; + const inspectImage = () => inspection(role, architecture, idCharacter); + createClusterAlphaVerificationEvidence( + { + root, + output: evidence, + role, + architecture, + image, + sourceRevision: revision, + repository: 'whyour/qinglong', + workflowRef, + workflowSha: revision, + eventName: 'workflow_dispatch', + job: 'cluster-image', + runId, + runAttempt: '2', + }, + { inspectImage }, + ); + bundles[subject] = path.join(fixtureRoot, subject); + createClusterAlphaBundle( + { + root, + outputRoot: bundles[subject], + role, + architecture, + image, + sourceRevision: revision, + sbom: sboms[role], + verificationEvidence: evidence, + readme, + }, + { + inspectImage, + saveImage(_reference, archivePath) { + fs.writeFileSync(archivePath, Buffer.alloc(2048, index + 1), { + flag: 'wx', + }); + }, + }, + ); + }); + return { + fixtureRoot, + bundles, + readme, + outputRoot: path.join(fixtureRoot, 'milestone'), + }; +} + +function finalizeOptions(paths, overrides = {}) { + return { + root, + outputRoot: paths.outputRoot, + bundles: paths.bundles, + readme: paths.readme, + sourceRevision: revision, + repository: 'whyour/qinglong', + workflowRef, + workflowSha: revision, + eventName: 'workflow_dispatch', + runId: '33073349397', + runAttempt: '2', + ...overrides, + }; +} + +test('closes eight role/architecture bundles into one offline-verifiable index', (t) => { + const paths = fixture(t); + const manifest = finalizeClusterAlphaMilestone(finalizeOptions(paths)); + assert.equal(manifest.schema, 'qinglong/alpha-cluster-milestone@v1'); + assert.equal(Object.keys(manifest.artifacts).length, 8); + assert.deepEqual(Object.keys(manifest.artifacts), SUBJECTS); + assert.equal( + new Set(Object.values(manifest.artifacts).map((entry) => entry.imageId)) + .size, + 8, + ); + const report = auditClusterAlphaMilestone({ + milestoneRoot: paths.outputRoot, + }); + assert.equal(report.compatible, true); + assert.deepEqual(report.subjects, SUBJECTS); + assert.deepEqual(fs.readdirSync(paths.outputRoot).sort(), [ + 'README.md', + 'SHA256SUMS', + 'manifest.json', + ]); +}); + +test('rejects one bundle detached from the authorized workflow run', (t) => { + const paths = fixture(t, '33073349398'); + assert.throws( + () => finalizeClusterAlphaMilestone(finalizeOptions(paths)), + /detached from the Cluster milestone run/, + ); + assert.equal(fs.existsSync(paths.outputRoot), false); +}); + +test('offline milestone audit rejects index and file-set mutation', (t) => { + for (const mutation of ['manifest', 'extra']) { + const paths = fixture(t); + paths.outputRoot = path.join(paths.fixtureRoot, `milestone-${mutation}`); + finalizeClusterAlphaMilestone(finalizeOptions(paths)); + if (mutation === 'manifest') { + fs.appendFileSync(path.join(paths.outputRoot, 'manifest.json'), 'tamper'); + } else { + fs.writeFileSync(path.join(paths.outputRoot, 'secret.txt'), 'secret'); + } + assert.throws(() => + auditClusterAlphaMilestone({ milestoneRoot: paths.outputRoot }), + ); + } +}); + +test('workflow audit proves full CI closure before milestone upload', () => { + const report = auditClusterAlphaMilestoneWorkflow(root); + assert.equal(report.compatible, true); + assert.deepEqual(report.findings, []); + assert.equal(report.requiredNeeds.length, 19); + assert.deepEqual(report.subjects, SUBJECTS); +}); + +test('workflow audit rejects missing final offline audit', (t) => { + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-cluster-alpha-workflow-'), + ); + t.after(() => fs.rmSync(fixtureRoot, { recursive: true, force: true })); + const workflowDirectory = path.join(fixtureRoot, '.github/workflows'); + fs.mkdirSync(workflowDirectory, { recursive: true }); + const source = fs + .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + .replace( + 'node scripts/ql3-cluster-alpha-milestone.cjs \\\n --mode=audit', + 'node scripts/ql3-cluster-alpha-milestone.cjs \\\n --mode=inspect', + ); + fs.writeFileSync(path.join(workflowDirectory, 'ql3-ci.yml'), source); + const report = auditClusterAlphaMilestoneWorkflow(fixtureRoot); + assert.equal(report.compatible, false); + assert.ok( + report.findings.includes('CLUSTER_MILESTONE_FINALIZER_CONTRACT_DRIFT'), + ); +}); diff --git a/test/back/ql3LocalAlphaMilestone.test.cjs b/test/back/ql3LocalAlphaMilestone.test.cjs index e4771d3e..a396b786 100644 --- a/test/back/ql3LocalAlphaMilestone.test.cjs +++ b/test/back/ql3LocalAlphaMilestone.test.cjs @@ -256,13 +256,19 @@ test('workflow audit rejects a partial milestone finalizer', (t) => { fs.mkdirSync(path.join(fixtureRoot, '.github/workflows'), { recursive: true, }); - const workflow = fs - .readFileSync(path.join(root, '.github/workflows/ql3-ci.yml'), 'utf8') + const source = fs.readFileSync( + path.join(root, '.github/workflows/ql3-ci.yml'), + 'utf8', + ); + const marker = '\n local-alpha-milestone:\n'; + const markerIndex = source.indexOf(marker); + const workflow = `${source.slice(0, markerIndex)}${source + .slice(markerIndex) .replace(' - cluster-postgres-ha\n', '') .replace( 'scripts/ql3-local-alpha-milestone.cjs', 'scripts/unreviewed-finalizer.cjs', - ); + )}`; fs.writeFileSync( path.join(fixtureRoot, '.github/workflows/ql3-ci.yml'), workflow,