mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:32:44 +08:00
feat(ql3): verify release catalog consumption
This commit is contained in:
@@ -302,6 +302,7 @@ jobs:
|
|||||||
test/back/ql3ReleaseCandidateContract.test.cjs
|
test/back/ql3ReleaseCandidateContract.test.cjs
|
||||||
test/back/ql3ReleaseSetContract.test.cjs
|
test/back/ql3ReleaseSetContract.test.cjs
|
||||||
test/back/ql3ReleaseCatalogContract.test.cjs
|
test/back/ql3ReleaseCatalogContract.test.cjs
|
||||||
|
test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs
|
||||||
test/back/ql3DeploymentLockContract.test.cjs
|
test/back/ql3DeploymentLockContract.test.cjs
|
||||||
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
||||||
test/back/ql3LocalImageAudit.test.cjs
|
test/back/ql3LocalImageAudit.test.cjs
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(2026-08-16):
|
||||||
|
|
||||||
|
- D-338/ADR-0430(已接受;真实公开 catalog 运行待实际 release tag):发布端的 durable catalog 不再由部署者通过松散 shell 重定向手工消费。可信工作站上的 `ql3-release-catalog-consumption-ceremony.cjs` 从 exact source version/revision/tag、closed `local|cluster|all` scope 与 owner/source repository 推导唯一 discovery ref,前后两次解析必须得到同一 digest,后续只使用 catalog `@sha256:` immutable reference。ceremony 以绝对路径、dev/inode/size/SHA-256 固定 `regctl|cosign|gh`,owner-private token 只进入单个 GitHub provenance verifier;环境、cache/config/tmp 与最终写入均有封闭边界。下载的 canonical release set 经过 standalone identity/family/self-digest inspection,raw OCI manifest 同时按 digest、media type、empty config、单 layer、basename、size/content digest 与四项 annotation 重建 publication plan/receipt。成功后才以 `0700` no-replace 目录和三项 `0600` 文件发布 release set、raw manifest 与 self-digest report;offline audit 要求 exact-three-file,并完全重建结构/manifest/report,同时诚实声明网络签名结果未离线 replay。该 ceremony 无 registry/GitHub mutation、deployment action authority、Compose/Kubernetes apply 或数据库访问;D337 继续只消费审计后的 release set。Local/低配设备不安装任何工作站工具,Cluster 也不新增 controller/CRD/RBAC。独立 ceremony 20/20、完整定向发布链 121/121 已通过;backend 共 1,315 项,1,313 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 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 pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 仍未实际产生,因此本门不宣称已取得真实 Cosign/GitHub/registry 成功证据。
|
||||||
- D-337/ADR-0429(已接受):D-336 的 durable release set 现在可以离线物化为最终部署 authority,而不是由运维者手工复制 digest。可信工作站上的 `ql3-deployment-lock-contract.cjs` 不联网、不连接 Kubernetes API、不执行 rollout:Local/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selection;Cluster/All 先消费 `kubectl kustomize` 的最终多文档 YAML,再只改写封闭的 Pod/Deployment/StatefulSet/DaemonSet/ReplicaSet/Job/CronJob container 字段和 exact Plugin Package admission ConfigMap,生成带输入/输出 digest、各 role occurrence、release annotation 与 self digest 的 locked manifest/report。调用方必须显式声明 required role,未知位置的完整 role authority、畸形 container image、缺失角色、YAML alias/cycle/非 mapping、超限或覆盖输出全部失败关闭;audit 从原 release set 与原 render byte-exact 重建。采用 post-render 是因为真实原型证明外层 Kustomize component 不能可靠覆盖内层已选 repository/digest,且 `images` transformer 不处理 ConfigMap `data.image`。本机 kubectl 1.36.1/Kustomize 5.8.1 已真实渲染 CloudNativePG Core、Cluster AI、Worker node 与 Plugin Package Executor 四类清单,内层零 digest 均被同一 release set 的精确引用替换;定向 deployment-lock 11/11、发布链路联动 101/101,静态审计冻结 224 个 YAML、31 个直接 role image 引用与两个 admission authority。工具只在工作站运行,低配路由器只消费 Local selection,不新增 Node/YAML/Kubernetes/registry 工具、package、依赖、常驻进程或资源;Cluster 也不新增 controller/webhook/CRD/RBAC。完整 backend 共 1,295 项,1,293 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,10 项架构/部署审计与 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 pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。
|
- D-337/ADR-0429(已接受):D-336 的 durable release set 现在可以离线物化为最终部署 authority,而不是由运维者手工复制 digest。可信工作站上的 `ql3-deployment-lock-contract.cjs` 不联网、不连接 Kubernetes API、不执行 rollout:Local/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selection;Cluster/All 先消费 `kubectl kustomize` 的最终多文档 YAML,再只改写封闭的 Pod/Deployment/StatefulSet/DaemonSet/ReplicaSet/Job/CronJob container 字段和 exact Plugin Package admission ConfigMap,生成带输入/输出 digest、各 role occurrence、release annotation 与 self digest 的 locked manifest/report。调用方必须显式声明 required role,未知位置的完整 role authority、畸形 container image、缺失角色、YAML alias/cycle/非 mapping、超限或覆盖输出全部失败关闭;audit 从原 release set 与原 render byte-exact 重建。采用 post-render 是因为真实原型证明外层 Kustomize component 不能可靠覆盖内层已选 repository/digest,且 `images` transformer 不处理 ConfigMap `data.image`。本机 kubectl 1.36.1/Kustomize 5.8.1 已真实渲染 CloudNativePG Core、Cluster AI、Worker node 与 Plugin Package Executor 四类清单,内层零 digest 均被同一 release set 的精确引用替换;定向 deployment-lock 11/11、发布链路联动 101/101,静态审计冻结 224 个 YAML、31 个直接 role image 引用与两个 admission authority。工具只在工作站运行,低配路由器只消费 Local selection,不新增 Node/YAML/Kubernetes/registry 工具、package、依赖、常驻进程或资源;Cluster 也不新增 controller/webhook/CRD/RBAC。完整 backend 共 1,295 项,1,293 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,10 项架构/部署审计与 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 pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。
|
||||||
- D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io/<owner>/qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v<version>-<scope>` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。
|
- D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io/<owner>/qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v<version>-<scope>` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。
|
||||||
- D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。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 pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。
|
- D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。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 pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# ADR-0430:可审计的 Release Catalog 消费工作站 Ceremony
|
||||||
|
|
||||||
|
- 状态:Accepted(实现与本地契约门完成;真实公开 catalog 结果待实际 release tag)
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-335、D-336、D-337、D-338
|
||||||
|
- 关联 ADR:ADR-0424、ADR-0425、ADR-0427、ADR-0428、ADR-0429
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0428 已让发布 workflow 生成持久 OCI release catalog,ADR-0429 已能把验证后的 release set 离线物化为 Local
|
||||||
|
selection 或 Kubernetes locked manifest。但部署者侧仍依赖手工 shell 串联 discovery tag 解析、Cosign、GitHub
|
||||||
|
attestation、`regctl artifact get`、raw manifest 读取和 standalone inspection。该流程会把文件写入安全性留给调用者的
|
||||||
|
umask/重定向语义,也不检查 discovery tag 是否在 ceremony 中途移动,不保留工具身份、命令边界或可离线复验的 manifest。
|
||||||
|
|
||||||
|
因此,发布端已经有机器化证据,并不等于部署端消费同一 immutable catalog 的过程已经机器化。直接在 release workflow
|
||||||
|
内自动部署又会把发布写权限与生产部署权限合并,破坏显式运维授权边界。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 新增根级 `ql3-release-catalog-consumption-ceremony.cjs`,只在可信维护工作站显式运行。`create` 模式从
|
||||||
|
source-derived version、40-hex revision、exact tag ref、closed `local|cluster|all` scope、owner/source repository 和
|
||||||
|
三个 canonical absolute executable 推导唯一 catalog discovery reference,不接受任意 registry/repository/ref。
|
||||||
|
2. ceremony 对 discovery tag 解析两次;两次都必须返回同一 lowercase SHA-256 digest。所有后续命令只使用第一次得到的
|
||||||
|
`ghcr.io/<owner>/qinglong3-release-catalog@sha256:...`。tag 在验证窗口中移动时失败且不发布 bundle;tag 永远没有部署
|
||||||
|
authority。
|
||||||
|
3. immutable catalog 必须通过 exact workflow identity 的 keyless Cosign verification,以及绑定 source repository、
|
||||||
|
workflow、tag、revision、非 self-hosted runner 和 OCI bundle 的 GitHub provenance verification。owner-private token 只注入
|
||||||
|
单个 `gh attestation verify` 子进程;`regctl` 与 `cosign` 不接收该 token,也不继承 ambient environment。
|
||||||
|
4. ceremony 直接捕获而不是 shell 重定向 release-set 与 raw manifest bytes。release set 必须是 bounded canonical UTF-8 JSON,
|
||||||
|
通过 standalone structure/identity/family/self-digest inspection;raw manifest 必须与 immutable digest、单层 media type、
|
||||||
|
empty config、basename、size/content digest 和四项 annotation 完全一致。工具在每一步前及 ceremony 结束前按
|
||||||
|
path/dev/inode/size/SHA-256 复验。
|
||||||
|
5. 成功后才创建一个此前不存在的 owner-private `0700` bundle directory,并以 `0600`、no-replace、fsync 写入三项精确文件:
|
||||||
|
原始 canonical release set、原始 raw catalog manifest、canonical consumption report。report 绑定 source/release/catalog、
|
||||||
|
reconstructed plan/receipt digest、文件 digest、镜像闭包、工具 digest、六步 argv/stdout/stderr digest 和自身 digest;不保存
|
||||||
|
token、原始 transcript、本机路径或 workstation identity。
|
||||||
|
6. `audit` 模式完全离线,不调用外部工具。它要求 bundle 只有上述三项文件,重新执行 release-set inspection、catalog plan、
|
||||||
|
raw manifest 与 receipt reconstruction,并精确重建 report;外部 Cosign/GitHub 结果只作为 digest transcript 记录,明确
|
||||||
|
`externalToolResultsReplayed=false`,不能冒充离线重验了网络证明。
|
||||||
|
7. 外部工具的 cache/config/tmp 只能写入 bundle parent 下的 ceremony 私有临时目录,成功或失败后 best-effort 清理。ceremony
|
||||||
|
不执行 registry/GitHub mutation、不运行 Compose/Kubernetes apply、不连接数据库,也不取得部署 action authority。D337
|
||||||
|
materializer 只消费 audit 成功 bundle 中的 release set,部署仍是后续独立人工授权步骤。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- 不新增 workspace package、生产 dependency、镜像层、数据库、migration、SQL、role、Pool、Pod、controller、listener、timer
|
||||||
|
或 watcher;18-package 边界保持不变。
|
||||||
|
- Edge/Standalone/低配路由设备不安装 Node、regctl、Cosign、GitHub CLI 或 ceremony。可信工作站只把审计后的 Local release set、
|
||||||
|
selection 和 immutable image 交给设备。
|
||||||
|
- Cluster 工作站可消费 `cluster|all` 的四角色闭包,再把 release set 交给离线 Kubernetes post-render materializer;ceremony
|
||||||
|
本身没有 Kubernetes client 或 API authority。
|
||||||
|
- 公开 GHCR、Fulcio/Rekor 与 GitHub Attestations 的实际可用性只由真实 release/consumption run 证明。本地 fake tool 测试只证明
|
||||||
|
命令、隔离、输入输出和失败关闭契约。
|
||||||
|
|
||||||
|
## 失败与恢复
|
||||||
|
|
||||||
|
- discovery 两次解析不同:丢弃本次结果,以新的未使用输出目录重新开始;不能把任一 tag 值当成部署 authority。
|
||||||
|
- Cosign/GitHub/registry 失败:不发布 bundle,不降级为仅检查 JSON 或 tag。
|
||||||
|
- tool inode/bytes、token file、output parent 在运行中漂移:失败关闭;不从 ambient PATH 换用另一个 executable。
|
||||||
|
- 创建最终目录后发生进程/文件系统失败:保留不完整目录供调查,但该目录无法通过 exact-three-file audit;恢复必须使用新目录,
|
||||||
|
不覆盖或补写旧 bundle。
|
||||||
|
- offline audit 失败:重新执行在线 ceremony;不得手工修复 report/digest 声称通过。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 保留文档中的 shell 重定向即可
|
||||||
|
|
||||||
|
拒绝。它无法统一 no-replace、mode、symlink、输出上限、tool mutation、tag 中途移动和持久 manifest 证据,且容易把调用者的
|
||||||
|
ambient credential/environment 带入工具。
|
||||||
|
|
||||||
|
### 只保存下载后的 release-set JSON
|
||||||
|
|
||||||
|
拒绝。release-set self digest 不能证明 OCI manifest 的 media type、layer、title、annotation 或 immutable digest;raw manifest
|
||||||
|
是离线重建 publication plan/receipt 的必要证据。
|
||||||
|
|
||||||
|
### 在 release workflow 成功后自动部署
|
||||||
|
|
||||||
|
拒绝。发布 OIDC/package 写权限与生产集群/路由设备 authority 必须分离;验证成功只生成无行动权的部署输入。
|
||||||
|
|
||||||
|
### 在路由设备上运行 ceremony
|
||||||
|
|
||||||
|
拒绝。该工具依赖 Node、regctl、Cosign 与 GitHub CLI,并执行网络验真;这些成本和凭据面不应进入低资源运行设备。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- ceremony 正负门覆盖 Local/All scope、六步工具与 token 隔离、双次 discovery、canonical release set、raw manifest、工具
|
||||||
|
mutation、external failure、no-replace、private mode、symlink/open CLI,以及 release-set/manifest/report/目录漂移;独立
|
||||||
|
ceremony 测试 20/20;
|
||||||
|
- 与 deployment lock、release candidate、release set、release catalog 和 image release workflow 的定向发布链联动 121/121;共享
|
||||||
|
supply-chain CI 显式运行新测试,删除该测试会失败关闭;
|
||||||
|
- 完整 backend 共 1,315 项,1,313 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 仍为
|
||||||
|
18 packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;
|
||||||
|
- release version、deployment lock surfaces、package boundary、cluster dependency、Edge import、Cluster/Worker deployment、image
|
||||||
|
release、Local image 与 Console distribution 共 10 项审计全部 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 pack dry-run 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked;
|
||||||
|
- 本 Gate 不改变数据库或 HA 拓扑,除非完整回归发现相关漂移,否则复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA
|
||||||
|
基线,不把未重跑结果声明为本阶段新证据。
|
||||||
@@ -433,6 +433,7 @@
|
|||||||
| [ADR-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted |
|
| [ADR-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted |
|
||||||
| [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted |
|
| [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted |
|
||||||
| [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted |
|
| [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted |
|
||||||
|
| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ image 的证明替代任一角色镜像;尤其 Worker 与短生命周期 Admin
|
|||||||
|
|
||||||
## 工作站验真
|
## 工作站验真
|
||||||
|
|
||||||
以下命令应在可信维护工作站运行;先设置目标发布的显式值:
|
以下命令应在可信维护工作站运行;先设置目标发布的显式值、三个经过 `realpath` 解析的工具路径,以及一个
|
||||||
|
current-owner、无 group/other 权限、无换行的短期 GitHub token file。`output_parent` 必须是已有的 owner-private
|
||||||
|
目录,`bundle` 必须尚不存在:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
owner='<lowercase-owner>'
|
owner='<lowercase-owner>'
|
||||||
@@ -35,58 +37,53 @@ version='<source-derived-version>'
|
|||||||
scope='local' # 或 cluster/all
|
scope='local' # 或 cluster/all
|
||||||
source_ref='refs/tags/v<version>'
|
source_ref='refs/tags/v<version>'
|
||||||
source_revision='<40-hex-git-revision>'
|
source_revision='<40-hex-git-revision>'
|
||||||
catalog="ghcr.io/${owner}/qinglong3-release-catalog"
|
regctl_path='<canonical-absolute-path>/regctl'
|
||||||
discovery="${catalog}:v${version}-${scope}"
|
cosign_path='<canonical-absolute-path>/cosign'
|
||||||
digest="$(regctl image digest "${discovery}")"
|
gh_path='<canonical-absolute-path>/gh'
|
||||||
immutable="${catalog}@${digest}"
|
token_file='<canonical-absolute-owner-private-token-file>'
|
||||||
release_set="$(pwd)/qinglong3-release-set-${version}-${scope}.json"
|
output_parent='<canonical-absolute-owner-private-directory>'
|
||||||
|
bundle="${output_parent}/qinglong3-release-catalog-consumption-${version}-${scope}"
|
||||||
```
|
```
|
||||||
|
|
||||||
要求 `digest` 精确匹配 `sha256:<64 lowercase hex>`,然后按 immutable reference 下载并验证:
|
使用机器化 ceremony 完成 discovery 双次解析、immutable Cosign/GitHub provenance 验证、release-set 下载/inspection、raw
|
||||||
|
manifest/plan/receipt reconstruction 和 no-replace bundle 发布:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
regctl artifact get \
|
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
|
||||||
--file "qinglong3-release-set-${version}-${scope}.json" \
|
--mode=create \
|
||||||
"${immutable}" > "${release_set}"
|
|
||||||
|
|
||||||
cosign verify \
|
|
||||||
--certificate-identity \
|
|
||||||
"https://github.com/${repository}/.github/workflows/ql3-image-release.yml@${source_ref}" \
|
|
||||||
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
|
|
||||||
"${immutable}"
|
|
||||||
|
|
||||||
gh attestation verify "oci://${immutable}" \
|
|
||||||
--repo "${repository}" \
|
|
||||||
--signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \
|
|
||||||
--source-digest "${source_revision}" \
|
|
||||||
--source-ref "${source_ref}" \
|
|
||||||
--deny-self-hosted-runners \
|
|
||||||
--bundle-from-oci
|
|
||||||
|
|
||||||
node scripts/ql3-release-set-contract.cjs \
|
|
||||||
--mode=inspect \
|
|
||||||
--version="${version}" \
|
--version="${version}" \
|
||||||
--source-revision="${source_revision}" \
|
--source-revision="${source_revision}" \
|
||||||
--source-ref="${source_ref}" \
|
--source-ref="${source_ref}" \
|
||||||
--release-scope="${scope}" \
|
--release-scope="${scope}" \
|
||||||
--repository-owner="${owner}" \
|
--repository-owner="${owner}" \
|
||||||
--report="${release_set}"
|
--source-repository="${repository}" \
|
||||||
|
--regctl="${regctl_path}" \
|
||||||
|
--cosign="${cosign_path}" \
|
||||||
|
--gh="${gh_path}" \
|
||||||
|
--github-token-file="${token_file}" \
|
||||||
|
--output-directory="${bundle}"
|
||||||
|
|
||||||
|
node scripts/ql3-release-catalog-consumption-ceremony.cjs \
|
||||||
|
--mode=audit \
|
||||||
|
--version="${version}" \
|
||||||
|
--source-revision="${source_revision}" \
|
||||||
|
--source-ref="${source_ref}" \
|
||||||
|
--release-scope="${scope}" \
|
||||||
|
--repository-owner="${owner}" \
|
||||||
|
--source-repository="${repository}" \
|
||||||
|
--output-directory="${bundle}"
|
||||||
|
|
||||||
|
release_set="${bundle}/qinglong3-release-set-${version}-${scope}.json"
|
||||||
|
catalog_manifest="${bundle}/qinglong3-release-catalog-manifest-${version}-${scope}.json"
|
||||||
|
consumption_report="${bundle}/qinglong3-release-catalog-consumption-${version}-${scope}.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
若使用 90 天 bundle 中的文件,还应验证文件 provenance;它是 OCI catalog 的补充证据,不替代上述 immutable
|
bundle 只能包含上面三项 `0600` 文件,目录自身为 `0700`。ceremony 不使用 shell 重定向、不覆盖文件,并在每一步前与
|
||||||
catalog 验证:
|
结束前复验三个 executable;GitHub token 只进入一个 `gh attestation verify` 子进程。raw manifest 让离线 audit 能重建
|
||||||
|
catalog plan/receipt,但它不会离线重放网络签名,因此 audit 输出必须保持 `externalToolResultsReplayed:false`。
|
||||||
|
|
||||||
```sh
|
若改用 90 天 Actions bundle 的 release-set 文件,仍需另行验证该文件的 provenance;它不能替代上述 immutable catalog
|
||||||
gh attestation verify "${release_set}" \
|
ceremony,也不能与在线 ceremony 下载的文件混合后伪造成同一 three-file bundle。
|
||||||
--repo "${repository}" \
|
|
||||||
--signer-workflow "${repository}/.github/workflows/ql3-image-release.yml" \
|
|
||||||
--source-digest "${source_revision}" \
|
|
||||||
--source-ref "${source_ref}" \
|
|
||||||
--deny-self-hosted-runners
|
|
||||||
```
|
|
||||||
|
|
||||||
`inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image
|
|
||||||
records;其输出必须保持 `sourceRecordsReplayed:false`。
|
|
||||||
|
|
||||||
## 生成离线 deployment lock
|
## 生成离线 deployment lock
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@
|
|||||||
"audit:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-evidence-audit.cjs",
|
"audit:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-evidence-audit.cjs",
|
||||||
"gate:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-gate.cjs",
|
"gate:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-gate.cjs",
|
||||||
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
||||||
|
"release-catalog-consumption:ql3": "node scripts/ql3-release-catalog-consumption-ceremony.cjs",
|
||||||
"deployment-lock:ql3": "node scripts/ql3-deployment-lock-contract.cjs",
|
"deployment-lock:ql3": "node scripts/ql3-deployment-lock-contract.cjs",
|
||||||
"audit:deployment-lock-surfaces:ql3": "node scripts/ql3-deployment-lock-contract.cjs --mode=surfaces-audit",
|
"audit:deployment-lock-surfaces:ql3": "node scripts/ql3-deployment-lock-contract.cjs --mode=surfaces-audit",
|
||||||
"audit:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs",
|
"audit:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs",
|
||||||
|
|||||||
@@ -274,8 +274,8 @@ function auditClusterImageCiWorkflow(
|
|||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
|
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogConsumptionCeremony\.test\.cjs[\s\S]*test\/back\/ql3DeploymentLockContract\.test\.cjs/,
|
||||||
'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests',
|
'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests; catalog consumption ceremony is mandatory',
|
||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,536 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
ARTIFACT_TYPE,
|
||||||
|
FILE_MEDIA_TYPE,
|
||||||
|
OCI_EMPTY_CONFIG_DIGEST,
|
||||||
|
OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||||
|
OCI_MANIFEST_MEDIA_TYPE,
|
||||||
|
createCatalogPlan,
|
||||||
|
} = require('../../scripts/ql3-release-catalog-contract.cjs');
|
||||||
|
const {
|
||||||
|
auditClusterImageCiWorkflow,
|
||||||
|
} = require('../../scripts/ql3-cluster-image-release-audit.cjs');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '../..');
|
||||||
|
const SCRIPT = path.join(
|
||||||
|
ROOT,
|
||||||
|
'scripts/ql3-release-catalog-consumption-ceremony.cjs',
|
||||||
|
);
|
||||||
|
const VERSION = '3.0.0-alpha.0';
|
||||||
|
const REVISION = 'a'.repeat(40);
|
||||||
|
const SOURCE_REF = `refs/tags/v${VERSION}`;
|
||||||
|
const OWNER = 'example';
|
||||||
|
const SOURCE_REPOSITORY = `${OWNER}/qinglong`;
|
||||||
|
const TOKEN = 'github_pat_catalog_consumption_fixture';
|
||||||
|
|
||||||
|
function sha256(value) {
|
||||||
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedImages(scope) {
|
||||||
|
const cluster = [
|
||||||
|
['control', 'qinglong3-cluster-control'],
|
||||||
|
['control-ai', 'qinglong3-cluster-control-ai'],
|
||||||
|
['admin', 'qinglong3-cluster-admin'],
|
||||||
|
['worker', 'qinglong3-worker'],
|
||||||
|
];
|
||||||
|
const local = [['local', 'qinglong3-local-application']];
|
||||||
|
return scope === 'local'
|
||||||
|
? local
|
||||||
|
: scope === 'cluster'
|
||||||
|
? cluster
|
||||||
|
: [...cluster, ...local];
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseSet(scope) {
|
||||||
|
const selected = selectedImages(scope);
|
||||||
|
const images = selected.map(([name, repository], index) => {
|
||||||
|
const digest = `sha256:${(index + 1).toString(16).repeat(64)}`;
|
||||||
|
const reference = `ghcr.io/${OWNER}/${repository}`;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
repository,
|
||||||
|
digest,
|
||||||
|
reference: `${reference}@${digest}`,
|
||||||
|
versionTag: `${reference}:${VERSION}`,
|
||||||
|
sourceTag: `${reference}:sha-${REVISION}`,
|
||||||
|
platforms: ['linux/amd64', 'linux/arm64'],
|
||||||
|
imageRecordDigest: `sha256:${(index + 6).toString(16).repeat(64)}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const names = images.map((entry) => entry.name);
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/release-set@v1',
|
||||||
|
release: {
|
||||||
|
version: VERSION,
|
||||||
|
sourceRevision: REVISION,
|
||||||
|
sourceRef: SOURCE_REF,
|
||||||
|
scope,
|
||||||
|
},
|
||||||
|
candidate: {
|
||||||
|
schema: 'qinglong/release-candidate-contract@v1',
|
||||||
|
contractDigest: `sha256:${'b'.repeat(64)}`,
|
||||||
|
},
|
||||||
|
repositoryOwner: OWNER,
|
||||||
|
platforms: ['linux/amd64', 'linux/arm64'],
|
||||||
|
deploymentFamilies: {
|
||||||
|
local: {
|
||||||
|
selected: ['local', 'all'].includes(scope),
|
||||||
|
profiles: ['edge', 'standalone'],
|
||||||
|
images: names.filter((name) => name === 'local'),
|
||||||
|
},
|
||||||
|
cluster: {
|
||||||
|
selected: ['cluster', 'all'].includes(scope),
|
||||||
|
profiles: ['cluster', 'worker-edge', 'worker-node'],
|
||||||
|
images: names.filter((name) => name !== 'local'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
images,
|
||||||
|
promotion: {
|
||||||
|
authority: 'complete_verified_release_set',
|
||||||
|
versionTags: 'promote_after_complete_set_audit',
|
||||||
|
sourceTags: 'promote_after_complete_set_audit',
|
||||||
|
crossRepositoryAtomicity: false,
|
||||||
|
recovery: 'verify_exact_digest_then_continue',
|
||||||
|
},
|
||||||
|
requiredVerification: {
|
||||||
|
imageKeylessSignature: true,
|
||||||
|
imageAttestations: [
|
||||||
|
'github-provenance',
|
||||||
|
'cyclonedx-sbom',
|
||||||
|
'os-vulnerability',
|
||||||
|
'release-candidate-contract',
|
||||||
|
],
|
||||||
|
releaseSetBuildProvenance: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { ...unsigned, releaseSetDigest: sha256(JSON.stringify(unsigned)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogManifest(value, scope) {
|
||||||
|
const identity = {
|
||||||
|
version: VERSION,
|
||||||
|
sourceRevision: REVISION,
|
||||||
|
sourceRef: SOURCE_REF,
|
||||||
|
releaseScope: scope,
|
||||||
|
repositoryOwner: OWNER,
|
||||||
|
sourceRepository: SOURCE_REPOSITORY,
|
||||||
|
};
|
||||||
|
const plan = createCatalogPlan(value, identity);
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
mediaType: OCI_MANIFEST_MEDIA_TYPE,
|
||||||
|
artifactType: ARTIFACT_TYPE,
|
||||||
|
config: {
|
||||||
|
mediaType: OCI_EMPTY_CONFIG_MEDIA_TYPE,
|
||||||
|
digest: OCI_EMPTY_CONFIG_DIGEST,
|
||||||
|
size: 2,
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
mediaType: FILE_MEDIA_TYPE,
|
||||||
|
digest: plan.releaseSet.contentDigest,
|
||||||
|
size: plan.releaseSet.bytes,
|
||||||
|
annotations: {
|
||||||
|
'org.opencontainers.image.title': plan.releaseSet.fileName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
annotations: { ...plan.catalog.annotations },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t, options = {}) {
|
||||||
|
const directory = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-catalog-consumption-')),
|
||||||
|
);
|
||||||
|
fs.chmodSync(directory, 0o700);
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const scope = options.scope || 'local';
|
||||||
|
const release = releaseSet(scope);
|
||||||
|
const releaseText = options.nonCanonicalRelease
|
||||||
|
? `${JSON.stringify(release, null, 2)}\n`
|
||||||
|
: `${JSON.stringify(release)}\n`;
|
||||||
|
const manifest = catalogManifest(release, scope);
|
||||||
|
const manifestText = JSON.stringify(manifest);
|
||||||
|
const manifestDigest = sha256(Buffer.from(manifestText, 'utf8'));
|
||||||
|
const changedDigest = `sha256:${'f'.repeat(64)}`;
|
||||||
|
const capture = path.join(directory, 'calls.jsonl');
|
||||||
|
const state = path.join(directory, 'state');
|
||||||
|
const tokenFile = path.join(directory, 'github-token');
|
||||||
|
const outputDirectory = path.join(directory, 'bundle');
|
||||||
|
fs.writeFileSync(tokenFile, TOKEN, { mode: 0o600 });
|
||||||
|
const tools = {};
|
||||||
|
for (const name of ['regctl', 'cosign', 'gh']) {
|
||||||
|
const tool = path.join(directory, name);
|
||||||
|
let behavior = '';
|
||||||
|
if (name === 'regctl') {
|
||||||
|
behavior = `
|
||||||
|
if (args[0] === 'image' && args[1] === 'digest') {
|
||||||
|
const count = fs.existsSync(${JSON.stringify(
|
||||||
|
state,
|
||||||
|
)}) ? Number(fs.readFileSync(${JSON.stringify(state)}, 'utf8')) : 0;
|
||||||
|
fs.writeFileSync(${JSON.stringify(state)}, String(count + 1));
|
||||||
|
const digest = count > 0 && ${JSON.stringify(
|
||||||
|
Boolean(options.changeDiscovery),
|
||||||
|
)} ? ${JSON.stringify(changedDigest)} : ${JSON.stringify(manifestDigest)};
|
||||||
|
process.stdout.write(digest + '\\n');
|
||||||
|
if (count === 0 && ${JSON.stringify(Boolean(options.mutateCosign))}) {
|
||||||
|
fs.appendFileSync(${JSON.stringify(path.join(directory, 'cosign'))}, '\\n');
|
||||||
|
}
|
||||||
|
} else if (args[0] === 'artifact' && args[1] === 'get') {
|
||||||
|
process.stdout.write(${JSON.stringify(releaseText)});
|
||||||
|
} else if (args[0] === 'manifest' && args[1] === 'get') {
|
||||||
|
process.stdout.write(${JSON.stringify(
|
||||||
|
options.manifestDrift ? `${manifestText} ` : manifestText,
|
||||||
|
)});
|
||||||
|
} else {
|
||||||
|
process.exitCode = 7;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
behavior = `
|
||||||
|
process.stdout.write(JSON.stringify({ verified: true, tool: name }) + '\\n');
|
||||||
|
if (name === 'cosign' && ${JSON.stringify(Boolean(options.cosignFailure))}) {
|
||||||
|
process.exitCode = 9;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
const source = `#!${process.execPath}
|
||||||
|
'use strict';
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const name = path.basename(process.argv[1]);
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
fs.appendFileSync(${JSON.stringify(
|
||||||
|
capture,
|
||||||
|
)}, JSON.stringify({ name, args, tokenPresent: process.env.GH_TOKEN === ${JSON.stringify(
|
||||||
|
TOKEN,
|
||||||
|
)} }) + '\\n');
|
||||||
|
${behavior}
|
||||||
|
`;
|
||||||
|
fs.writeFileSync(tool, source, { mode: 0o700 });
|
||||||
|
tools[name] = tool;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
capture,
|
||||||
|
directory,
|
||||||
|
manifestDigest,
|
||||||
|
outputDirectory,
|
||||||
|
release,
|
||||||
|
scope,
|
||||||
|
tokenFile,
|
||||||
|
tools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArguments(value) {
|
||||||
|
return [
|
||||||
|
'--mode=create',
|
||||||
|
`--version=${VERSION}`,
|
||||||
|
`--source-revision=${REVISION}`,
|
||||||
|
`--source-ref=${SOURCE_REF}`,
|
||||||
|
`--release-scope=${value.scope}`,
|
||||||
|
`--repository-owner=${OWNER}`,
|
||||||
|
`--source-repository=${SOURCE_REPOSITORY}`,
|
||||||
|
`--regctl=${value.tools.regctl}`,
|
||||||
|
`--cosign=${value.tools.cosign}`,
|
||||||
|
`--gh=${value.tools.gh}`,
|
||||||
|
`--github-token-file=${value.tokenFile}`,
|
||||||
|
`--output-directory=${value.outputDirectory}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditArguments(value) {
|
||||||
|
return [
|
||||||
|
'--mode=audit',
|
||||||
|
`--version=${VERSION}`,
|
||||||
|
`--source-revision=${REVISION}`,
|
||||||
|
`--source-ref=${SOURCE_REF}`,
|
||||||
|
`--release-scope=${value.scope}`,
|
||||||
|
`--repository-owner=${OWNER}`,
|
||||||
|
`--source-repository=${SOURCE_REPOSITORY}`,
|
||||||
|
`--output-directory=${value.outputDirectory}`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke(arguments_) {
|
||||||
|
return spawnSync(process.execPath, [SCRIPT, ...arguments_], {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAndAudit(value) {
|
||||||
|
const created = invoke(createArguments(value));
|
||||||
|
assert.equal(created.status, 0, created.stderr);
|
||||||
|
assert.equal(created.stderr, '');
|
||||||
|
const audited = invoke(auditArguments(value));
|
||||||
|
assert.equal(audited.status, 0, audited.stderr);
|
||||||
|
assert.equal(audited.stderr, '');
|
||||||
|
return {
|
||||||
|
created: JSON.parse(created.stdout),
|
||||||
|
audited: JSON.parse(audited.stdout),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('consumes one immutable catalog into an auditable no-replace bundle', (t) => {
|
||||||
|
const value = fixture(t);
|
||||||
|
const result = createAndAudit(value);
|
||||||
|
assert.equal(result.created.compatible, true);
|
||||||
|
assert.equal(result.audited.compatible, true);
|
||||||
|
assert.equal(result.audited.releaseScope, 'local');
|
||||||
|
assert.equal(
|
||||||
|
result.created.immutableReference,
|
||||||
|
`ghcr.io/${OWNER}/qinglong3-release-catalog@${value.manifestDigest}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(fs.statSync(value.outputDirectory).mode & 0o777, 0o700);
|
||||||
|
const names = fs.readdirSync(value.outputDirectory).sort();
|
||||||
|
assert.deepEqual(names, [
|
||||||
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
||||||
|
`qinglong3-release-catalog-manifest-${VERSION}-local.json`,
|
||||||
|
`qinglong3-release-set-${VERSION}-local.json`,
|
||||||
|
]);
|
||||||
|
for (const name of names) {
|
||||||
|
assert.equal(
|
||||||
|
fs.statSync(path.join(value.outputDirectory, name)).mode & 0o777,
|
||||||
|
0o600,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const report = fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
value.outputDirectory,
|
||||||
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.equal(report.includes(TOKEN), false);
|
||||||
|
const parsed = JSON.parse(report);
|
||||||
|
assert.equal(
|
||||||
|
parsed.schema,
|
||||||
|
'qinglong/release-catalog-consumption-ceremony@v1',
|
||||||
|
);
|
||||||
|
assert.equal(parsed.verification.discoveryResolvedTwice, true);
|
||||||
|
assert.equal(parsed.verification.catalogReceiptReconstructed, true);
|
||||||
|
assert.equal(parsed.claims.deploymentMutation, false);
|
||||||
|
assert.equal(
|
||||||
|
parsed.claims.workstationFileWrites,
|
||||||
|
'private_temporary_only_plus_final_bundle',
|
||||||
|
);
|
||||||
|
assert.equal(parsed.steps.length, 6);
|
||||||
|
assert.equal(
|
||||||
|
fs
|
||||||
|
.readdirSync(value.directory)
|
||||||
|
.some((name) => name.startsWith('.ql3-release-catalog-consumption-')),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = fs
|
||||||
|
.readFileSync(value.capture, 'utf8')
|
||||||
|
.trim()
|
||||||
|
.split('\n')
|
||||||
|
.map((line) => JSON.parse(line));
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((entry) => entry.name),
|
||||||
|
['regctl', 'cosign', 'gh', 'regctl', 'regctl', 'regctl'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
calls.map((entry) => entry.tokenPresent),
|
||||||
|
[false, false, true, false, false, false],
|
||||||
|
);
|
||||||
|
assert.deepEqual(calls[0].args, [
|
||||||
|
'image',
|
||||||
|
'digest',
|
||||||
|
`ghcr.io/${OWNER}/qinglong3-release-catalog:v${VERSION}-local`,
|
||||||
|
]);
|
||||||
|
assert.ok(calls[1].args.includes(parsed.workflowIdentity));
|
||||||
|
assert.ok(calls[2].args.includes('--deny-self-hosted-runners'));
|
||||||
|
assert.deepEqual(calls[3].args.slice(0, 4), [
|
||||||
|
'artifact',
|
||||||
|
'get',
|
||||||
|
'--file',
|
||||||
|
`qinglong3-release-set-${VERSION}-local.json`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports the complete all-scope image family without runtime coupling', (t) => {
|
||||||
|
const value = fixture(t, { scope: 'all' });
|
||||||
|
createAndAudit(value);
|
||||||
|
const report = JSON.parse(
|
||||||
|
fs.readFileSync(
|
||||||
|
path.join(
|
||||||
|
value.outputDirectory,
|
||||||
|
`qinglong3-release-catalog-consumption-${VERSION}-all.json`,
|
||||||
|
),
|
||||||
|
'utf8',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(report.releaseSet.images, [
|
||||||
|
'control',
|
||||||
|
'control-ai',
|
||||||
|
'admin',
|
||||||
|
'worker',
|
||||||
|
'local',
|
||||||
|
]);
|
||||||
|
assert.equal(report.releaseSet.imageCount, 5);
|
||||||
|
assert.equal(report.claims.deploymentMutation, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails before publication when discovery changes during verification', (t) => {
|
||||||
|
const value = fixture(t, { changeDiscovery: true });
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /catalog discovery changed/);
|
||||||
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects non-canonical release sets and mismatched raw manifests', async (t) => {
|
||||||
|
await t.test('release set', () => {
|
||||||
|
const value = fixture(t, { nonCanonicalRelease: true });
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /canonical JSON/);
|
||||||
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
||||||
|
});
|
||||||
|
await t.test('manifest', () => {
|
||||||
|
const value = fixture(t, { manifestDrift: true });
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /catalog manifest digest/);
|
||||||
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects external verification failure and executable mutation', async (t) => {
|
||||||
|
await t.test('cosign failure', () => {
|
||||||
|
const value = fixture(t, { cosignFailure: true });
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /keyless_signature/);
|
||||||
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
||||||
|
});
|
||||||
|
await t.test('tool mutation', () => {
|
||||||
|
const value = fixture(t, { mutateCosign: true });
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /executable changed/);
|
||||||
|
assert.equal(fs.existsSync(value.outputDirectory), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offline audit detects every durable bundle drift surface', async (t) => {
|
||||||
|
for (const [name, mutate] of [
|
||||||
|
[
|
||||||
|
'release set',
|
||||||
|
(value) => {
|
||||||
|
const file = path.join(
|
||||||
|
value.outputDirectory,
|
||||||
|
`qinglong3-release-set-${VERSION}-local.json`,
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
parsed.images[0].digest = `sha256:${'e'.repeat(64)}`;
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'manifest',
|
||||||
|
(value) => {
|
||||||
|
fs.appendFileSync(
|
||||||
|
path.join(
|
||||||
|
value.outputDirectory,
|
||||||
|
`qinglong3-release-catalog-manifest-${VERSION}-local.json`,
|
||||||
|
),
|
||||||
|
' ',
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'report',
|
||||||
|
(value) => {
|
||||||
|
const file = path.join(
|
||||||
|
value.outputDirectory,
|
||||||
|
`qinglong3-release-catalog-consumption-${VERSION}-local.json`,
|
||||||
|
);
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
parsed.claims.deploymentMutation = true;
|
||||||
|
fs.writeFileSync(file, `${JSON.stringify(parsed)}\n`);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extra file',
|
||||||
|
(value) => {
|
||||||
|
fs.writeFileSync(path.join(value.outputDirectory, 'unexpected'), 'x', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]) {
|
||||||
|
await t.test(name, () => {
|
||||||
|
const value = fixture(t);
|
||||||
|
createAndAudit(value);
|
||||||
|
mutate(value);
|
||||||
|
const audited = invoke(auditArguments(value));
|
||||||
|
assert.notEqual(audited.status, 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects output reuse, public credentials, symlinks and open CLI shapes', async (t) => {
|
||||||
|
await t.test('no replace', () => {
|
||||||
|
const value = fixture(t);
|
||||||
|
createAndAudit(value);
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /must not already exist/);
|
||||||
|
});
|
||||||
|
await t.test('public token', () => {
|
||||||
|
const value = fixture(t);
|
||||||
|
fs.chmodSync(value.tokenFile, 0o644);
|
||||||
|
const result = invoke(createArguments(value));
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /owner-private/);
|
||||||
|
});
|
||||||
|
await t.test('symlink tool', () => {
|
||||||
|
const value = fixture(t);
|
||||||
|
const link = path.join(value.directory, 'linked');
|
||||||
|
fs.symlinkSync(value.tools.regctl, link);
|
||||||
|
const arguments_ = createArguments(value).map((argument) =>
|
||||||
|
argument.startsWith('--regctl=') ? `--regctl=${link}` : argument,
|
||||||
|
);
|
||||||
|
const result = invoke(arguments_);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
});
|
||||||
|
await t.test('open arguments', () => {
|
||||||
|
const value = fixture(t);
|
||||||
|
const result = invoke([...createArguments(value), '--unknown=true']);
|
||||||
|
assert.notEqual(result.status, 0);
|
||||||
|
assert.match(result.stderr, /arguments are invalid/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supply-chain CI cannot silently remove the consumption ceremony tests', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(ROOT, '.github/workflows/ql3-ci.yml'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const mutated = source.replace(
|
||||||
|
'test/back/ql3ReleaseCatalogConsumptionCeremony.test.cjs',
|
||||||
|
'test/back/release-catalog-consumption-removed.test.cjs',
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => auditClusterImageCiWorkflow(mutated),
|
||||||
|
/catalog consumption ceremony/,
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user