mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): bind deployment locks to release catalogs
This commit is contained in:
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(2026-08-16):
|
||||||
|
|
||||||
|
- D-339/ADR-0431(已接受;真实公开 catalog 运行待实际 release tag):D337 的 deployment-lock CLI 不再接受一份无法证明来源的松散 `--release-set`;Local/Kubernetes create/audit 必须同时接收 exact source repository 和 D338 生成的 owner-private three-file `--consumption-bundle`,先完整离线重建 release-set、raw OCI manifest、catalog plan/receipt、六步 argv/transcript digest 与 self-digest report,再把同一次 audit 读取的 release-set 对象交给 materializer,避免验真后重新按裸路径读取。Local selection 与 Kubernetes lock schema 升为 v2,显式绑定 consumption schema、source repository、exact workflow identity、catalog immutable reference、manifest digest、consumption report digest、release-set digest 和 `discoveryTagAuthority=none`;Cluster 被改写资源与 Pod template 也新增 catalog manifest/report digest annotations。旧 `--release-set`、bundle symlink/open shape、identity/scope/owner/image-count/digest 漂移均在创建任何输出前失败关闭。offline audit 诚实保持 `externalToolResultsReplayed=false`;本 Gate 不联网、不访问 Kubernetes API、不执行 Compose rollout/`kubectl apply`、不修改数据库,也不新增 package、生产依赖或运行期组件。供应链工作仍留在可信工作站,低配设备只接收 catalog-bound Local v2 selection 与一个 immutable image reference;Cluster 复用既有 post-render 流程。完整定向发布链 123/123;backend 共 1,317 项,1,315 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 未实际产生,因此不宣称真实线上验签成功。
|
||||||
- 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-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 或已完成线上发布。
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# ADR-0431:Catalog-bound Deployment Lock 证据链
|
||||||
|
|
||||||
|
- 状态:Accepted(实现与定向契约门完成;真实公开 catalog 运行待实际 release tag)
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-337、D-338、D-339
|
||||||
|
- 关联 ADR:ADR-0427、ADR-0428、ADR-0429、ADR-0430
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0430 已把 discovery、签名、provenance、release-set 与 OCI manifest 收敛到一个可离线复验的 three-file
|
||||||
|
consumption bundle;ADR-0429 的 deployment-lock materializer 却仍接受任意独立 `--release-set` 文件。两份文件即使内容相同,
|
||||||
|
materializer 也无法证明它读取的是刚刚通过 catalog ceremony 验真的那份 byte authority,输出中也没有 catalog immutable
|
||||||
|
reference、manifest digest 或 consumption report digest。文档约定不能替代机器约束。
|
||||||
|
|
||||||
|
直接让 release workflow 部署,或让 deployment lock 重新联网验签,都会混合发布、部署与网络 authority。把完整 Node、Cosign、
|
||||||
|
GitHub CLI、regctl 或 Kustomize 下沉到低配路由器同样不成立。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. `ql3-deployment-lock-contract.cjs` 的 Local/Kubernetes create/audit CLI 删除松散 `--release-set` 输入,改为同时要求
|
||||||
|
`--consumption-bundle` 与 exact `--source-repository`。旧参数是失败关闭的非法开放输入,不提供兼容旁路。
|
||||||
|
2. materializer 首先调用 ADR-0430 的完整 offline bundle audit:目录必须是 canonical、current-owner、`0700`,且只能包含三项
|
||||||
|
canonical/current-owner `0600` 文件;release-set、raw OCI manifest、reconstructed plan/receipt、六步 argv/transcript digest 与
|
||||||
|
self-digest report 必须重新闭合。只有同一次 audit 实际读取的 release-set 对象可以继续进入 lock materialization,避免审计后
|
||||||
|
再从松散路径读取造成新的 TOCTOU 窗口。
|
||||||
|
3. Local selection 与 Kubernetes lock schema 升为 v2,并写入同一 catalog authority:consumption schema、source repository、
|
||||||
|
exact workflow identity、immutable catalog reference、manifest digest、consumption report digest、release-set digest 和
|
||||||
|
`discoveryTagAuthority=none`。任一 identity、scope、owner、image count 或 digest 不一致均失败关闭。
|
||||||
|
4. Kubernetes 被改写的 workload/Pod template 与固定 Plugin Package admission ConfigMap 除 release-set/version/revision 外,还写入
|
||||||
|
catalog manifest 与 consumption report digest annotation。最终 apply 的 YAML 因而不能脱离其验真来源;lock report 继续绑定
|
||||||
|
输入/输出 manifest digest、角色闭包与自身 digest。
|
||||||
|
5. offline audit 明确记录 `catalogConsumption=offline_reconstructed` 和 `externalToolResultsReplayed=false`。它验证已保存证据的一致性,
|
||||||
|
不谎称重新执行过网络签名或 provenance verifier。
|
||||||
|
6. 本 Gate 仍没有部署 action authority:不访问 registry/GitHub/Kubernetes API,不执行 Compose rollout 或 `kubectl apply`,不修改
|
||||||
|
数据库。Local/Cluster 的最终行动继续由独立、显式授权步骤完成。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- 不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、controller、CRD、RBAC、listener、timer 或 watcher。
|
||||||
|
- Node、regctl、Cosign、GitHub CLI 和 Kustomize 只存在于可信维护工作站。低配设备只接收 catalog-bound Local v2 selection 与
|
||||||
|
immutable Local image reference,不下载 Cluster 镜像或加载集群依赖。
|
||||||
|
- Cluster 复用既有离线 post-render 路径;新增字段只增加少量 JSON/YAML 字节,不增加运行期 CPU、内存或网络开销。
|
||||||
|
|
||||||
|
## 失败与恢复
|
||||||
|
|
||||||
|
- bundle 缺项、多项、symlink、权限/owner/路径漂移,release-set/manifest/report 任一字节漂移,以及 source identity 或 catalog
|
||||||
|
binding 不一致,都会在任何 output 创建前失败。
|
||||||
|
- create 输出保持 `0600`、no-replace;结果未知时使用同一 bundle、identity、原 render 和原输出路径执行 audit,不改用裸
|
||||||
|
release-set,也不手工删除或覆盖证据。
|
||||||
|
- catalog discovery tag 移动不改变已绑定 immutable reference;若需要发布另一 digest,必须执行新的 online ceremony 并生成新的
|
||||||
|
bundle/selection/lock,而不是修改旧报告。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 继续依赖文档规定同一 release-set 路径
|
||||||
|
|
||||||
|
拒绝。路径约定无法证明文件属于已验真的 three-file bundle,也无法被 CI 或 offline audit 强制执行。
|
||||||
|
|
||||||
|
### 在 materializer 中重新联网验签
|
||||||
|
|
||||||
|
拒绝。会扩大网络、凭据和工具面,并让离线重放不可用;ADR-0430 已提供职责单一的在线 ceremony。
|
||||||
|
|
||||||
|
### 把 catalog 工具安装到路由设备
|
||||||
|
|
||||||
|
拒绝。低资源目标不应承担供应链工作站成本;设备只消费由工作站生成的最小、digest-bound 结果。
|
||||||
|
|
||||||
|
### 验真成功后自动 apply
|
||||||
|
|
||||||
|
拒绝。可信输入不等于部署授权,发布/验真身份不能自动获得生产 Local 或 Kubernetes mutation authority。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- deployment-lock 与 catalog-consumption 联合正负契约覆盖 Local/Cluster、真实 four-overlay Kustomize render、bundle symlink/open
|
||||||
|
arguments、旧 `--release-set` 旁路、catalog/release-set mismatch、no-replace 与 output alias;完整定向发布链 123/123;
|
||||||
|
- 共享 supply-chain CI 已固定运行两个测试文件,删除任一门都会使 image-release 静态审计失败;
|
||||||
|
- 完整 backend 共 1,317 项,1,315 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 基线,不把未重跑结果声明为本阶段
|
||||||
|
新证据。
|
||||||
@@ -434,6 +434,7 @@
|
|||||||
| [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) |
|
| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) |
|
||||||
|
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag) |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ node scripts/ql3-release-catalog-consumption-ceremony.cjs \
|
|||||||
--source-repository="${repository}" \
|
--source-repository="${repository}" \
|
||||||
--output-directory="${bundle}"
|
--output-directory="${bundle}"
|
||||||
|
|
||||||
release_set="${bundle}/qinglong3-release-set-${version}-${scope}.json"
|
|
||||||
catalog_manifest="${bundle}/qinglong3-release-catalog-manifest-${version}-${scope}.json"
|
catalog_manifest="${bundle}/qinglong3-release-catalog-manifest-${version}-${scope}.json"
|
||||||
consumption_report="${bundle}/qinglong3-release-catalog-consumption-${version}-${scope}.json"
|
consumption_report="${bundle}/qinglong3-release-catalog-consumption-${version}-${scope}.json"
|
||||||
```
|
```
|
||||||
@@ -87,9 +86,9 @@ ceremony,也不能与在线 ceremony 下载的文件混合后伪造成同一 t
|
|||||||
|
|
||||||
## 生成离线 deployment lock
|
## 生成离线 deployment lock
|
||||||
|
|
||||||
`inspect` 成功后,不要手工复制 digest,也不要直接修改仓库中的 Kustomize 占位符。deployment-lock
|
`audit` 成功后,不要从 bundle 中抽出 release-set 再作为独立输入,不要手工复制 digest,也不要直接修改仓库中的
|
||||||
materializer 在可信工作站离线运行,只读取已经验证的 release set 与本地清单;它不访问 registry、不连接 Kubernetes
|
Kustomize 占位符。deployment-lock materializer 在可信工作站离线运行,必须重新审计完整 three-file bundle,并让同一次审计
|
||||||
API,也不会执行 `kubectl apply`。
|
读取的 release set 直接进入物化;它不访问 registry、不连接 Kubernetes API,也不会执行 `kubectl apply`。
|
||||||
|
|
||||||
### Local / Compose
|
### Local / Compose
|
||||||
|
|
||||||
@@ -105,7 +104,8 @@ node scripts/ql3-deployment-lock-contract.cjs \
|
|||||||
--source-ref="${source_ref}" \
|
--source-ref="${source_ref}" \
|
||||||
--release-scope="${scope}" \
|
--release-scope="${scope}" \
|
||||||
--repository-owner="${owner}" \
|
--repository-owner="${owner}" \
|
||||||
--release-set="${release_set}" \
|
--source-repository="${repository}" \
|
||||||
|
--consumption-bundle="${bundle}" \
|
||||||
--allow-root-service=false \
|
--allow-root-service=false \
|
||||||
--output="${selection}"
|
--output="${selection}"
|
||||||
|
|
||||||
@@ -116,13 +116,15 @@ node scripts/ql3-deployment-lock-contract.cjs \
|
|||||||
--source-ref="${source_ref}" \
|
--source-ref="${source_ref}" \
|
||||||
--release-scope="${scope}" \
|
--release-scope="${scope}" \
|
||||||
--repository-owner="${owner}" \
|
--repository-owner="${owner}" \
|
||||||
--release-set="${release_set}" \
|
--source-repository="${repository}" \
|
||||||
|
--consumption-bundle="${bundle}" \
|
||||||
--allow-root-service=false \
|
--allow-root-service=false \
|
||||||
--selection="${selection}"
|
--selection="${selection}"
|
||||||
```
|
```
|
||||||
|
|
||||||
把已审计的 `service.image` 和 `service.allowRootService` 交给现有 Local private prepare/rollout 入口。selection
|
v2 selection 同时绑定 catalog immutable reference、manifest digest、consumption report digest 与 release-set digest。把已审计的
|
||||||
本身不修改 Compose 文件,也不启动容器。是否允许 root service 必须显式给出,不能由设备默认值推断。
|
`service.image` 和 `service.allowRootService` 交给现有 Local private prepare/rollout 入口。selection 本身不修改 Compose 文件,
|
||||||
|
也不启动容器。是否允许 root service 必须显式给出,不能由设备默认值推断。
|
||||||
|
|
||||||
### Kubernetes / Cluster / Worker
|
### Kubernetes / Cluster / Worker
|
||||||
|
|
||||||
@@ -144,7 +146,8 @@ node scripts/ql3-deployment-lock-contract.cjs \
|
|||||||
--source-ref="${source_ref}" \
|
--source-ref="${source_ref}" \
|
||||||
--release-scope="${scope}" \
|
--release-scope="${scope}" \
|
||||||
--repository-owner="${owner}" \
|
--repository-owner="${owner}" \
|
||||||
--release-set="${release_set}" \
|
--source-repository="${repository}" \
|
||||||
|
--consumption-bundle="${bundle}" \
|
||||||
--manifest="${rendered}" \
|
--manifest="${rendered}" \
|
||||||
--required-images=control \
|
--required-images=control \
|
||||||
--output-manifest="${locked}" \
|
--output-manifest="${locked}" \
|
||||||
@@ -157,7 +160,8 @@ node scripts/ql3-deployment-lock-contract.cjs \
|
|||||||
--source-ref="${source_ref}" \
|
--source-ref="${source_ref}" \
|
||||||
--release-scope="${scope}" \
|
--release-scope="${scope}" \
|
||||||
--repository-owner="${owner}" \
|
--repository-owner="${owner}" \
|
||||||
--release-set="${release_set}" \
|
--source-repository="${repository}" \
|
||||||
|
--consumption-bundle="${bundle}" \
|
||||||
--manifest="${rendered}" \
|
--manifest="${rendered}" \
|
||||||
--locked-manifest="${locked}" \
|
--locked-manifest="${locked}" \
|
||||||
--report="${lock_report}" \
|
--report="${lock_report}" \
|
||||||
@@ -166,9 +170,9 @@ node scripts/ql3-deployment-lock-contract.cjs \
|
|||||||
|
|
||||||
materializer 只改写 Pod、Deployment、StatefulSet、DaemonSet、ReplicaSet、Job、CronJob 的
|
materializer 只改写 Pod、Deployment、StatefulSet、DaemonSet、ReplicaSet、Job、CronJob 的
|
||||||
`containers`/`initContainers`/`ephemeralContainers`,以及固定名称
|
`containers`/`initContainers`/`ephemeralContainers`,以及固定名称
|
||||||
`ql3-plugin-package-secret-action-admission` ConfigMap 的 `data.image`。每个改写资源及其 Pod template 都绑定
|
`ql3-plugin-package-secret-action-admission` ConfigMap 的 `data.image`。每个改写资源及其 Pod template 都绑定 release-set、catalog
|
||||||
release-set digest、source revision 与 version annotation;未知位置的完整 QingLong role image authority、畸形已知
|
manifest、consumption report digest、source revision 与 version annotation;未知位置的完整 QingLong role image authority、畸形
|
||||||
container image、缺少 required role、YAML alias/cycle/非 mapping、超限输入或已有输出文件都会失败关闭。
|
已知 container image、缺少 required role、YAML alias/cycle/非 mapping、超限输入或已有输出文件都会失败关闭。
|
||||||
|
|
||||||
审计成功并完成人工差异检查后,才由有权限的独立步骤执行 `kubectl apply -f "${locked}"`。不要直接 apply
|
审计成功并完成人工差异检查后,才由有权限的独立步骤执行 `kubectl apply -f "${locked}"`。不要直接 apply
|
||||||
`${rendered}`,也不要使用 `kubectl apply -k` 绕过 deployment lock。
|
`${rendered}`,也不要使用 `kubectl apply -k` 绕过 deployment lock。
|
||||||
@@ -177,20 +181,20 @@ container image、缺少 required role、YAML alias/cycle/非 mapping、超限
|
|||||||
|
|
||||||
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable
|
||||||
reference;discovery tag 无 authority。
|
reference;discovery tag 无 authority。
|
||||||
2. `schema` 必须为 `qinglong/release-set@v1`;`release.version`、`release.sourceRef`、
|
2. materializer 只能接受完整 `qinglong/release-catalog-consumption-ceremony@v1` bundle,不能接受旧的松散 `--release-set`;其中
|
||||||
`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
`qinglong/release-set@v1` 的 `release.version`、`release.sourceRef`、`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||||
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||||
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 locked manifest;嵌套 overlay 的
|
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的
|
||||||
`newName`/digest 不是最终 authority。Local 必须生成并审计 service selection。两族最终都只能消费 release set 中的
|
`newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、
|
||||||
`@sha256:` reference。
|
consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。
|
||||||
5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于
|
5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于
|
||||||
发现;部署始终以 release set 中的镜像 digest 为准。
|
发现;部署始终以 release set 中的镜像 digest 为准。
|
||||||
|
|
||||||
## 低资源设备
|
## 低资源设备
|
||||||
|
|
||||||
路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站
|
路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站
|
||||||
完成上述 ceremony 和 Local selection 审计,再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable
|
完成上述 ceremony 和 Local v2 selection 审计,再向设备传输已检查的 catalog-bound canonical JSON,并只把 `local` family 的
|
||||||
image reference 写入 compose/rollout。
|
immutable image reference 写入 compose/rollout。
|
||||||
设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。
|
设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。
|
||||||
|
|
||||||
如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与
|
如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ const crypto = require('node:crypto');
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const yaml = require('js-yaml');
|
const yaml = require('js-yaml');
|
||||||
|
const {
|
||||||
|
SCHEMA: CATALOG_CONSUMPTION_SCHEMA,
|
||||||
|
auditCeremonyBundle,
|
||||||
|
catalogIdentity,
|
||||||
|
} = require('./ql3-release-catalog-consumption-ceremony.cjs');
|
||||||
const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs');
|
const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs');
|
||||||
|
|
||||||
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
const DEFAULT_ROOT = path.resolve(__dirname, '..');
|
||||||
const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v1';
|
const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2';
|
||||||
const KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v1';
|
const KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v2';
|
||||||
const MAX_RELEASE_SET_BYTES = 1024 * 1024;
|
const MAX_RELEASE_SET_BYTES = 1024 * 1024;
|
||||||
const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
|
const MAX_MANIFEST_BYTES = 8 * 1024 * 1024;
|
||||||
const MAX_RESOURCE_COUNT = 2048;
|
const MAX_RESOURCE_COUNT = 2048;
|
||||||
@@ -137,6 +142,41 @@ function inspectInputReleaseSet(releaseSet, options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function catalogAuthority(releaseSet, options) {
|
||||||
|
const consumption = options.consumption;
|
||||||
|
const manifestDigest = consumption?.catalogManifestDigest;
|
||||||
|
const expectedCatalog = catalogIdentity(options);
|
||||||
|
if (
|
||||||
|
consumption === null ||
|
||||||
|
typeof consumption !== 'object' ||
|
||||||
|
consumption.compatible !== true ||
|
||||||
|
consumption.releaseScope !== options.releaseScope ||
|
||||||
|
consumption.sourceRepository !== options.sourceRepository ||
|
||||||
|
consumption.workflowIdentity !== expectedCatalog.workflowIdentity ||
|
||||||
|
consumption.releaseSetDigest !== releaseSet.releaseSetDigest ||
|
||||||
|
!/^sha256:[a-f0-9]{64}$/u.test(manifestDigest || '') ||
|
||||||
|
consumption.immutableReference !==
|
||||||
|
`${expectedCatalog.repository}@${manifestDigest}` ||
|
||||||
|
consumption.imageCount !== releaseSet.images.length ||
|
||||||
|
consumption.discoveryTagAuthority !== 'none' ||
|
||||||
|
consumption.externalToolResultsReplayed !== false ||
|
||||||
|
consumption.deploymentMutation !== false ||
|
||||||
|
!/^sha256:[a-f0-9]{64}$/u.test(consumption.contentDigest || '')
|
||||||
|
) {
|
||||||
|
fail('release catalog consumption authority is invalid');
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
schema: CATALOG_CONSUMPTION_SCHEMA,
|
||||||
|
sourceRepository: consumption.sourceRepository,
|
||||||
|
workflowIdentity: consumption.workflowIdentity,
|
||||||
|
immutableReference: consumption.immutableReference,
|
||||||
|
manifestDigest,
|
||||||
|
consumptionReportDigest: consumption.contentDigest,
|
||||||
|
releaseSetDigest: consumption.releaseSetDigest,
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function imageByName(releaseSet, name) {
|
function imageByName(releaseSet, name) {
|
||||||
const matches = releaseSet.images.filter((entry) => entry.name === name);
|
const matches = releaseSet.images.filter((entry) => entry.name === name);
|
||||||
if (matches.length !== 1) fail(`release image is unavailable: ${name}`);
|
if (matches.length !== 1) fail(`release image is unavailable: ${name}`);
|
||||||
@@ -145,6 +185,7 @@ function imageByName(releaseSet, name) {
|
|||||||
|
|
||||||
function createLocalSelection(releaseSet, options) {
|
function createLocalSelection(releaseSet, options) {
|
||||||
const inspection = inspectInputReleaseSet(releaseSet, options);
|
const inspection = inspectInputReleaseSet(releaseSet, options);
|
||||||
|
const catalog = catalogAuthority(releaseSet, options);
|
||||||
if (!['local', 'all'].includes(options.releaseScope)) {
|
if (!['local', 'all'].includes(options.releaseScope)) {
|
||||||
fail('local selection requires a local or all release set');
|
fail('local selection requires a local or all release set');
|
||||||
}
|
}
|
||||||
@@ -157,6 +198,7 @@ function createLocalSelection(releaseSet, options) {
|
|||||||
schema: LOCAL_SELECTION_SCHEMA,
|
schema: LOCAL_SELECTION_SCHEMA,
|
||||||
release: { ...releaseSet.release },
|
release: { ...releaseSet.release },
|
||||||
releaseSetDigest: releaseSet.releaseSetDigest,
|
releaseSetDigest: releaseSet.releaseSetDigest,
|
||||||
|
catalog,
|
||||||
deploymentFamily: 'local',
|
deploymentFamily: 'local',
|
||||||
service: {
|
service: {
|
||||||
kind: 'compose',
|
kind: 'compose',
|
||||||
@@ -166,6 +208,8 @@ function createLocalSelection(releaseSet, options) {
|
|||||||
verification: {
|
verification: {
|
||||||
releaseSet: inspection.verification,
|
releaseSet: inspection.verification,
|
||||||
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
|
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
|
||||||
|
catalogConsumption: 'offline_reconstructed',
|
||||||
|
externalToolResultsReplayed: false,
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
deploymentMutation: false,
|
deploymentMutation: false,
|
||||||
},
|
},
|
||||||
@@ -186,6 +230,8 @@ function auditLocalSelection(actual, releaseSet, options) {
|
|||||||
deploymentFamily: 'local',
|
deploymentFamily: 'local',
|
||||||
releaseSetDigest: actual.releaseSetDigest,
|
releaseSetDigest: actual.releaseSetDigest,
|
||||||
selectionDigest: actual.selectionDigest,
|
selectionDigest: actual.selectionDigest,
|
||||||
|
catalogManifestDigest: actual.catalog.manifestDigest,
|
||||||
|
immutableReference: actual.catalog.immutableReference,
|
||||||
image: actual.service.image,
|
image: actual.service.image,
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
deploymentMutation: false,
|
deploymentMutation: false,
|
||||||
@@ -310,7 +356,7 @@ function podSpecFor(resource) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureAnnotations(target, releaseSet) {
|
function ensureAnnotations(target, releaseSet, catalog) {
|
||||||
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
if (target === null || typeof target !== 'object' || Array.isArray(target)) {
|
||||||
fail('rewritten resource metadata is invalid');
|
fail('rewritten resource metadata is invalid');
|
||||||
}
|
}
|
||||||
@@ -329,6 +375,9 @@ function ensureAnnotations(target, releaseSet) {
|
|||||||
metadata.annotations = {
|
metadata.annotations = {
|
||||||
...(metadata.annotations ?? {}),
|
...(metadata.annotations ?? {}),
|
||||||
'qinglong.io/release-set-digest': releaseSet.releaseSetDigest,
|
'qinglong.io/release-set-digest': releaseSet.releaseSetDigest,
|
||||||
|
'qinglong.io/release-catalog-manifest-digest': catalog.manifestDigest,
|
||||||
|
'qinglong.io/release-catalog-report-digest':
|
||||||
|
catalog.consumptionReportDigest,
|
||||||
'qinglong.io/release-source-revision': releaseSet.release.sourceRevision,
|
'qinglong.io/release-source-revision': releaseSet.release.sourceRevision,
|
||||||
'qinglong.io/release-version': releaseSet.release.version,
|
'qinglong.io/release-version': releaseSet.release.version,
|
||||||
};
|
};
|
||||||
@@ -407,7 +456,12 @@ function scanForUnhandledRoleImages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rewriteKubernetesResources(resources, releaseSet, requiredImages) {
|
function rewriteKubernetesResources(
|
||||||
|
resources,
|
||||||
|
releaseSet,
|
||||||
|
requiredImages,
|
||||||
|
catalog,
|
||||||
|
) {
|
||||||
const references = Object.fromEntries(
|
const references = Object.fromEntries(
|
||||||
ROLE_ORDER.map((role) => [role, imageByName(releaseSet, role).reference]),
|
ROLE_ORDER.map((role) => [role, imageByName(releaseSet, role).reference]),
|
||||||
);
|
);
|
||||||
@@ -459,7 +513,7 @@ function rewriteKubernetesResources(resources, releaseSet, requiredImages) {
|
|||||||
changed += 1;
|
changed += 1;
|
||||||
}
|
}
|
||||||
if (changed > 0) {
|
if (changed > 0) {
|
||||||
ensureAnnotations(resource, releaseSet);
|
ensureAnnotations(resource, releaseSet, catalog);
|
||||||
const template =
|
const template =
|
||||||
resource.kind === 'CronJob'
|
resource.kind === 'CronJob'
|
||||||
? resource.spec?.jobTemplate?.spec?.template
|
? resource.spec?.jobTemplate?.spec?.template
|
||||||
@@ -470,7 +524,7 @@ function rewriteKubernetesResources(resources, releaseSet, requiredImages) {
|
|||||||
)
|
)
|
||||||
? resource.spec?.template
|
? resource.spec?.template
|
||||||
: undefined;
|
: undefined;
|
||||||
if (template) ensureAnnotations(template, releaseSet);
|
if (template) ensureAnnotations(template, releaseSet, catalog);
|
||||||
changedResources += 1;
|
changedResources += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -493,6 +547,7 @@ function rewriteKubernetesResources(resources, releaseSet, requiredImages) {
|
|||||||
|
|
||||||
function createKubernetesLock(releaseSet, manifestContents, options) {
|
function createKubernetesLock(releaseSet, manifestContents, options) {
|
||||||
const inspection = inspectInputReleaseSet(releaseSet, options);
|
const inspection = inspectInputReleaseSet(releaseSet, options);
|
||||||
|
const catalog = catalogAuthority(releaseSet, options);
|
||||||
if (!['cluster', 'all'].includes(options.releaseScope)) {
|
if (!['cluster', 'all'].includes(options.releaseScope)) {
|
||||||
fail('Kubernetes materialization requires a cluster or all release set');
|
fail('Kubernetes materialization requires a cluster or all release set');
|
||||||
}
|
}
|
||||||
@@ -502,6 +557,7 @@ function createKubernetesLock(releaseSet, manifestContents, options) {
|
|||||||
parsed,
|
parsed,
|
||||||
releaseSet,
|
releaseSet,
|
||||||
requiredImages,
|
requiredImages,
|
||||||
|
catalog,
|
||||||
);
|
);
|
||||||
const outputManifest = renderManifest(rewritten.resources);
|
const outputManifest = renderManifest(rewritten.resources);
|
||||||
if (Buffer.byteLength(outputManifest, 'utf8') > MAX_MANIFEST_BYTES) {
|
if (Buffer.byteLength(outputManifest, 'utf8') > MAX_MANIFEST_BYTES) {
|
||||||
@@ -512,6 +568,7 @@ function createKubernetesLock(releaseSet, manifestContents, options) {
|
|||||||
schema: KUBERNETES_LOCK_SCHEMA,
|
schema: KUBERNETES_LOCK_SCHEMA,
|
||||||
release: { ...releaseSet.release },
|
release: { ...releaseSet.release },
|
||||||
releaseSetDigest: releaseSet.releaseSetDigest,
|
releaseSetDigest: releaseSet.releaseSetDigest,
|
||||||
|
catalog,
|
||||||
deploymentFamily: 'cluster',
|
deploymentFamily: 'cluster',
|
||||||
requiredImages: [...requiredImages],
|
requiredImages: [...requiredImages],
|
||||||
imageOccurrences: ROLE_ORDER.map((name) => ({
|
imageOccurrences: ROLE_ORDER.map((name) => ({
|
||||||
@@ -529,6 +586,8 @@ function createKubernetesLock(releaseSet, manifestContents, options) {
|
|||||||
verification: {
|
verification: {
|
||||||
releaseSet: inspection.verification,
|
releaseSet: inspection.verification,
|
||||||
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
|
sourceRecordsReplayed: inspection.sourceRecordsReplayed,
|
||||||
|
catalogConsumption: 'offline_reconstructed',
|
||||||
|
externalToolResultsReplayed: false,
|
||||||
unknownImageAuthorities: 0,
|
unknownImageAuthorities: 0,
|
||||||
mutableQingLongImages: 0,
|
mutableQingLongImages: 0,
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
@@ -561,6 +620,8 @@ function auditKubernetesLock(
|
|||||||
deploymentFamily: 'cluster',
|
deploymentFamily: 'cluster',
|
||||||
releaseSetDigest: actualReport.releaseSetDigest,
|
releaseSetDigest: actualReport.releaseSetDigest,
|
||||||
lockDigest: actualReport.lockDigest,
|
lockDigest: actualReport.lockDigest,
|
||||||
|
catalogManifestDigest: actualReport.catalog.manifestDigest,
|
||||||
|
immutableReference: actualReport.catalog.immutableReference,
|
||||||
outputManifestDigest: actualReport.manifest.outputDigest,
|
outputManifestDigest: actualReport.manifest.outputDigest,
|
||||||
requiredImages: Object.freeze([...actualReport.requiredImages]),
|
requiredImages: Object.freeze([...actualReport.requiredImages]),
|
||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
@@ -642,12 +703,13 @@ function parseArguments(argv) {
|
|||||||
}
|
}
|
||||||
const identity = [
|
const identity = [
|
||||||
'mode',
|
'mode',
|
||||||
|
'consumption-bundle',
|
||||||
'release-scope',
|
'release-scope',
|
||||||
'repository-owner',
|
'repository-owner',
|
||||||
|
'source-repository',
|
||||||
'source-ref',
|
'source-ref',
|
||||||
'source-revision',
|
'source-revision',
|
||||||
'version',
|
'version',
|
||||||
'release-set',
|
|
||||||
];
|
];
|
||||||
const expected =
|
const expected =
|
||||||
values.mode === 'local-create'
|
values.mode === 'local-create'
|
||||||
@@ -685,7 +747,8 @@ function parseArguments(argv) {
|
|||||||
sourceRef: values['source-ref'],
|
sourceRef: values['source-ref'],
|
||||||
releaseScope: values['release-scope'],
|
releaseScope: values['release-scope'],
|
||||||
repositoryOwner: values['repository-owner'],
|
repositoryOwner: values['repository-owner'],
|
||||||
releaseSet: values['release-set'],
|
sourceRepository: values['source-repository'],
|
||||||
|
consumptionBundle: values['consumption-bundle'],
|
||||||
...(values['allow-root-service']
|
...(values['allow-root-service']
|
||||||
? {
|
? {
|
||||||
allowRootService:
|
allowRootService:
|
||||||
@@ -722,13 +785,14 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
|||||||
output.write(canonicalJson(audit));
|
output.write(canonicalJson(audit));
|
||||||
return audit;
|
return audit;
|
||||||
}
|
}
|
||||||
const releaseSet = readCanonicalJson(
|
const consumption = auditCeremonyBundle({
|
||||||
options.releaseSet,
|
...options,
|
||||||
'release set',
|
outputDirectory: options.consumptionBundle,
|
||||||
MAX_RELEASE_SET_BYTES,
|
});
|
||||||
).value;
|
const releaseSet = consumption.releaseSet;
|
||||||
|
const boundOptions = Object.freeze({ ...options, consumption });
|
||||||
if (options.mode === 'local-create') {
|
if (options.mode === 'local-create') {
|
||||||
const selection = createLocalSelection(releaseSet, options);
|
const selection = createLocalSelection(releaseSet, boundOptions);
|
||||||
const target = preflightOutput(options.output, 'output');
|
const target = preflightOutput(options.output, 'output');
|
||||||
writeNoReplace(target, canonicalJson(selection));
|
writeNoReplace(target, canonicalJson(selection));
|
||||||
output.write(canonicalJson(selection));
|
output.write(canonicalJson(selection));
|
||||||
@@ -740,7 +804,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
|||||||
'local selection',
|
'local selection',
|
||||||
MAX_RELEASE_SET_BYTES,
|
MAX_RELEASE_SET_BYTES,
|
||||||
).value;
|
).value;
|
||||||
const audit = auditLocalSelection(selection, releaseSet, options);
|
const audit = auditLocalSelection(selection, releaseSet, boundOptions);
|
||||||
output.write(canonicalJson(audit));
|
output.write(canonicalJson(audit));
|
||||||
return audit;
|
return audit;
|
||||||
}
|
}
|
||||||
@@ -758,7 +822,11 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
|||||||
if (manifestTarget === reportTarget) {
|
if (manifestTarget === reportTarget) {
|
||||||
fail('output manifest and report paths must differ');
|
fail('output manifest and report paths must differ');
|
||||||
}
|
}
|
||||||
const created = createKubernetesLock(releaseSet, sourceManifest, options);
|
const created = createKubernetesLock(
|
||||||
|
releaseSet,
|
||||||
|
sourceManifest,
|
||||||
|
boundOptions,
|
||||||
|
);
|
||||||
writeNoReplace(manifestTarget, created.outputManifest);
|
writeNoReplace(manifestTarget, created.outputManifest);
|
||||||
writeNoReplace(reportTarget, canonicalJson(created.report));
|
writeNoReplace(reportTarget, canonicalJson(created.report));
|
||||||
output.write(canonicalJson(created.report));
|
output.write(canonicalJson(created.report));
|
||||||
@@ -779,7 +847,7 @@ function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) {
|
|||||||
report,
|
report,
|
||||||
releaseSet,
|
releaseSet,
|
||||||
sourceManifest,
|
sourceManifest,
|
||||||
options,
|
boundOptions,
|
||||||
);
|
);
|
||||||
output.write(canonicalJson(audit));
|
output.write(canonicalJson(audit));
|
||||||
return audit;
|
return audit;
|
||||||
|
|||||||
@@ -812,6 +812,8 @@ function auditCeremonyBundle(options) {
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
compatible: true,
|
compatible: true,
|
||||||
releaseScope: identity.releaseScope,
|
releaseScope: identity.releaseScope,
|
||||||
|
sourceRepository: identity.sourceRepository,
|
||||||
|
workflowIdentity: catalog.workflowIdentity,
|
||||||
releaseSetDigest: releaseSet.value.releaseSetDigest,
|
releaseSetDigest: releaseSet.value.releaseSetDigest,
|
||||||
catalogManifestDigest: manifestDigest,
|
catalogManifestDigest: manifestDigest,
|
||||||
immutableReference: `${catalog.repository}@${manifestDigest}`,
|
immutableReference: `${catalog.repository}@${manifestDigest}`,
|
||||||
@@ -820,6 +822,7 @@ function auditCeremonyBundle(options) {
|
|||||||
externalToolResultsReplayed: false,
|
externalToolResultsReplayed: false,
|
||||||
deploymentMutation: false,
|
deploymentMutation: false,
|
||||||
contentDigest: expected.contentDigest,
|
contentDigest: expected.contentDigest,
|
||||||
|
releaseSet: releaseSet.value,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
@@ -16,6 +17,17 @@ const {
|
|||||||
parseArguments,
|
parseArguments,
|
||||||
runCli,
|
runCli,
|
||||||
} = require('../../scripts/ql3-deployment-lock-contract.cjs');
|
} = require('../../scripts/ql3-deployment-lock-contract.cjs');
|
||||||
|
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 {
|
||||||
|
runCeremony,
|
||||||
|
} = require('../../scripts/ql3-release-catalog-consumption-ceremony.cjs');
|
||||||
const {
|
const {
|
||||||
createReleaseSet,
|
createReleaseSet,
|
||||||
createVerifiedImageRecord,
|
createVerifiedImageRecord,
|
||||||
@@ -34,6 +46,7 @@ const identity = Object.freeze({
|
|||||||
sourceRevision: 'd'.repeat(40),
|
sourceRevision: 'd'.repeat(40),
|
||||||
sourceRef: `refs/tags/v${version}`,
|
sourceRef: `refs/tags/v${version}`,
|
||||||
repositoryOwner: 'qinglong-release',
|
repositoryOwner: 'qinglong-release',
|
||||||
|
sourceRepository: 'qinglong-release/qinglong',
|
||||||
});
|
});
|
||||||
const roleOrder = Object.freeze(['control', 'control-ai', 'admin', 'worker']);
|
const roleOrder = Object.freeze(['control', 'control-ai', 'admin', 'worker']);
|
||||||
|
|
||||||
@@ -85,10 +98,35 @@ function releaseSet(scope) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function options(scope, extra = {}) {
|
function sha256(value) {
|
||||||
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumptionAuthority(set) {
|
||||||
|
const manifestDigest = sha256(
|
||||||
|
Buffer.from(`catalog:${set.release.scope}`, 'utf8'),
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
compatible: true,
|
||||||
|
releaseScope: set.release.scope,
|
||||||
|
sourceRepository: identity.sourceRepository,
|
||||||
|
workflowIdentity: `https://github.com/${identity.sourceRepository}/.github/workflows/ql3-image-release.yml@${identity.sourceRef}`,
|
||||||
|
releaseSetDigest: set.releaseSetDigest,
|
||||||
|
catalogManifestDigest: manifestDigest,
|
||||||
|
immutableReference: `ghcr.io/${identity.repositoryOwner}/qinglong3-release-catalog@${manifestDigest}`,
|
||||||
|
imageCount: set.images.length,
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
externalToolResultsReplayed: false,
|
||||||
|
deploymentMutation: false,
|
||||||
|
contentDigest: sha256(Buffer.from(`report:${set.release.scope}`, 'utf8')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(set, extra = {}) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
...identity,
|
...identity,
|
||||||
releaseScope: scope,
|
releaseScope: set.release.scope,
|
||||||
|
consumption: consumptionAuthority(set),
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -189,12 +227,88 @@ function temporaryDirectory(t) {
|
|||||||
return directory;
|
return directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeCanonical(filePath, value) {
|
function catalogManifest(set) {
|
||||||
fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 });
|
const plan = createCatalogPlan(set, {
|
||||||
|
...identity,
|
||||||
|
releaseScope: set.release.scope,
|
||||||
|
});
|
||||||
|
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 kubernetesOptions(extra = {}) {
|
function consumptionBundle(t, parent, name, set) {
|
||||||
return options('cluster', {
|
const toolRoot = path.join(parent, `${name}-tools`);
|
||||||
|
fs.mkdirSync(toolRoot, { mode: 0o700 });
|
||||||
|
const releaseText = `${JSON.stringify(set)}\n`;
|
||||||
|
const manifestText = JSON.stringify(catalogManifest(set));
|
||||||
|
const manifestDigest = sha256(Buffer.from(manifestText, 'utf8'));
|
||||||
|
const tools = {};
|
||||||
|
for (const toolName of ['regctl', 'cosign', 'gh']) {
|
||||||
|
const toolPath = path.join(toolRoot, toolName);
|
||||||
|
const behavior =
|
||||||
|
toolName === 'regctl'
|
||||||
|
? `
|
||||||
|
if (args[0] === 'image' && args[1] === 'digest') {
|
||||||
|
process.stdout.write(${JSON.stringify(`${manifestDigest}\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(manifestText)});
|
||||||
|
} else {
|
||||||
|
process.exitCode = 7;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
: '';
|
||||||
|
fs.writeFileSync(
|
||||||
|
toolPath,
|
||||||
|
`#!${process.execPath}\n'use strict';\nconst args = process.argv.slice(2);\n${behavior}`,
|
||||||
|
{ mode: 0o700 },
|
||||||
|
);
|
||||||
|
tools[toolName] = toolPath;
|
||||||
|
}
|
||||||
|
const tokenFile = path.join(toolRoot, 'github-token');
|
||||||
|
fs.writeFileSync(tokenFile, 'github_pat_deployment_lock_fixture', {
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
const outputDirectory = path.join(parent, `${name}-bundle`);
|
||||||
|
runCeremony({
|
||||||
|
...identity,
|
||||||
|
releaseScope: set.release.scope,
|
||||||
|
outputDirectory,
|
||||||
|
regctl: tools.regctl,
|
||||||
|
cosign: tools.cosign,
|
||||||
|
gh: tools.gh,
|
||||||
|
githubTokenFile: tokenFile,
|
||||||
|
});
|
||||||
|
t.after(() => {
|
||||||
|
if (fs.existsSync(outputDirectory)) {
|
||||||
|
fs.rmSync(outputDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return outputDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kubernetesOptions(set, extra = {}) {
|
||||||
|
return options(set, {
|
||||||
requiredImages: roleOrder.join(','),
|
requiredImages: roleOrder.join(','),
|
||||||
...extra,
|
...extra,
|
||||||
});
|
});
|
||||||
@@ -205,21 +319,36 @@ test('selects one immutable Local Compose image without adding device work', ()
|
|||||||
const set = releaseSet(scope);
|
const set = releaseSet(scope);
|
||||||
const selection = createLocalSelection(
|
const selection = createLocalSelection(
|
||||||
set,
|
set,
|
||||||
options(scope, { allowRootService: false }),
|
options(set, { allowRootService: false }),
|
||||||
);
|
);
|
||||||
assert.equal(selection.deploymentFamily, 'local');
|
assert.equal(selection.deploymentFamily, 'local');
|
||||||
|
assert.equal(selection.schema, 'qinglong/local-compose-release-image@v2');
|
||||||
assert.equal(selection.releaseSetDigest, set.releaseSetDigest);
|
assert.equal(selection.releaseSetDigest, set.releaseSetDigest);
|
||||||
|
assert.equal(
|
||||||
|
selection.catalog.releaseSetDigest,
|
||||||
|
selection.releaseSetDigest,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
selection.catalog.immutableReference,
|
||||||
|
/^ghcr\.io\/qinglong-release\/qinglong3-release-catalog@sha256:[a-f0-9]{64}$/u,
|
||||||
|
);
|
||||||
|
assert.equal(selection.catalog.discoveryTagAuthority, 'none');
|
||||||
assert.equal(selection.service.kind, 'compose');
|
assert.equal(selection.service.kind, 'compose');
|
||||||
assert.equal(selection.service.image, references(set).local);
|
assert.equal(selection.service.image, references(set).local);
|
||||||
assert.equal(selection.service.allowRootService, false);
|
assert.equal(selection.service.allowRootService, false);
|
||||||
assert.equal(selection.verification.networkAccess, false);
|
assert.equal(selection.verification.networkAccess, false);
|
||||||
assert.equal(selection.verification.deploymentMutation, false);
|
assert.equal(selection.verification.deploymentMutation, false);
|
||||||
|
assert.equal(
|
||||||
|
selection.verification.catalogConsumption,
|
||||||
|
'offline_reconstructed',
|
||||||
|
);
|
||||||
|
assert.equal(selection.verification.externalToolResultsReplayed, false);
|
||||||
assert.match(selection.selectionDigest, /^sha256:[a-f0-9]{64}$/u);
|
assert.match(selection.selectionDigest, /^sha256:[a-f0-9]{64}$/u);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
auditLocalSelection(
|
auditLocalSelection(
|
||||||
selection,
|
selection,
|
||||||
set,
|
set,
|
||||||
options(scope, { allowRootService: false }),
|
options(set, { allowRootService: false }),
|
||||||
).compatible,
|
).compatible,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -232,18 +361,18 @@ test('Local selection rejects cluster scope, implicit root policy and drift', ()
|
|||||||
() =>
|
() =>
|
||||||
createLocalSelection(
|
createLocalSelection(
|
||||||
clusterSet,
|
clusterSet,
|
||||||
options('cluster', { allowRootService: false }),
|
options(clusterSet, { allowRootService: false }),
|
||||||
),
|
),
|
||||||
/local or all release set/,
|
/local or all release set/,
|
||||||
);
|
);
|
||||||
const localSet = releaseSet('local');
|
const localSet = releaseSet('local');
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createLocalSelection(localSet, options('local')),
|
() => createLocalSelection(localSet, options(localSet)),
|
||||||
/explicit boolean/,
|
/explicit boolean/,
|
||||||
);
|
);
|
||||||
const selection = createLocalSelection(
|
const selection = createLocalSelection(
|
||||||
localSet,
|
localSet,
|
||||||
options('local', { allowRootService: true }),
|
options(localSet, { allowRootService: true }),
|
||||||
);
|
);
|
||||||
const drifted = JSON.parse(JSON.stringify(selection));
|
const drifted = JSON.parse(JSON.stringify(selection));
|
||||||
drifted.service.image = 'ghcr.io/example/qinglong3-local:latest';
|
drifted.service.image = 'ghcr.io/example/qinglong3-local:latest';
|
||||||
@@ -252,19 +381,45 @@ test('Local selection rejects cluster scope, implicit root policy and drift', ()
|
|||||||
auditLocalSelection(
|
auditLocalSelection(
|
||||||
drifted,
|
drifted,
|
||||||
localSet,
|
localSet,
|
||||||
options('local', { allowRootService: true }),
|
options(localSet, { allowRootService: true }),
|
||||||
),
|
),
|
||||||
/differs from the verified release set/,
|
/differs from the verified release set/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deployment materialization rejects missing or mismatched catalog authority', () => {
|
||||||
|
const set = releaseSet('local');
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createLocalSelection(set, {
|
||||||
|
...identity,
|
||||||
|
releaseScope: 'local',
|
||||||
|
allowRootService: false,
|
||||||
|
}),
|
||||||
|
/catalog consumption authority is invalid/,
|
||||||
|
);
|
||||||
|
const drifted = {
|
||||||
|
...consumptionAuthority(set),
|
||||||
|
contentDigest: `sha256:${'0'.repeat(64)}`,
|
||||||
|
releaseSetDigest: `sha256:${'1'.repeat(64)}`,
|
||||||
|
};
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createLocalSelection(set, {
|
||||||
|
...options(set, { allowRootService: false }),
|
||||||
|
consumption: drifted,
|
||||||
|
}),
|
||||||
|
/catalog consumption authority is invalid/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('materializes every supported Kubernetes image authority from one release set', () => {
|
test('materializes every supported Kubernetes image authority from one release set', () => {
|
||||||
const set = releaseSet('cluster');
|
const set = releaseSet('cluster');
|
||||||
const expectedReferences = references(set);
|
const expectedReferences = references(set);
|
||||||
const created = createKubernetesLock(
|
const created = createKubernetesLock(
|
||||||
set,
|
set,
|
||||||
fixtureManifest(),
|
fixtureManifest(),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
);
|
);
|
||||||
const resources = [];
|
const resources = [];
|
||||||
yaml.loadAll(created.outputManifest, (resource) => resources.push(resource));
|
yaml.loadAll(created.outputManifest, (resource) => resources.push(resource));
|
||||||
@@ -288,6 +443,8 @@ test('materializes every supported Kubernetes image authority from one release s
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(created.report.manifest.resources, 6);
|
assert.equal(created.report.manifest.resources, 6);
|
||||||
|
assert.equal(created.report.schema, 'qinglong/kubernetes-deployment-lock@v2');
|
||||||
|
assert.equal(created.report.catalog.releaseSetDigest, set.releaseSetDigest);
|
||||||
assert.equal(created.report.manifest.changedResources, 5);
|
assert.equal(created.report.manifest.changedResources, 5);
|
||||||
assert.equal(created.report.manifest.admissionAuthorityCount, 1);
|
assert.equal(created.report.manifest.admissionAuthorityCount, 1);
|
||||||
assert.equal(created.report.verification.unknownImageAuthorities, 0);
|
assert.equal(created.report.verification.unknownImageAuthorities, 0);
|
||||||
@@ -314,6 +471,18 @@ test('materializes every supported Kubernetes image authority from one release s
|
|||||||
resource.metadata.annotations['qinglong.io/release-set-digest'],
|
resource.metadata.annotations['qinglong.io/release-set-digest'],
|
||||||
set.releaseSetDigest,
|
set.releaseSetDigest,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
resource.metadata.annotations[
|
||||||
|
'qinglong.io/release-catalog-manifest-digest'
|
||||||
|
],
|
||||||
|
created.report.catalog.manifestDigest,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
resource.metadata.annotations[
|
||||||
|
'qinglong.io/release-catalog-report-digest'
|
||||||
|
],
|
||||||
|
created.report.catalog.consumptionReportDigest,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
assert.equal(
|
assert.equal(
|
||||||
created.outputManifest.includes('qinglong3-cluster-control:source'),
|
created.outputManifest.includes('qinglong3-cluster-control:source'),
|
||||||
@@ -325,7 +494,7 @@ test('materializes every supported Kubernetes image authority from one release s
|
|||||||
created.report,
|
created.report,
|
||||||
set,
|
set,
|
||||||
fixtureManifest(),
|
fixtureManifest(),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
).compatible,
|
).compatible,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -336,7 +505,7 @@ test('Kubernetes materialization rejects unsupported scope and incomplete role c
|
|||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
createKubernetesLock(localSet, fixtureManifest(), {
|
createKubernetesLock(localSet, fixtureManifest(), {
|
||||||
...options('local'),
|
...options(localSet),
|
||||||
requiredImages: 'control',
|
requiredImages: 'control',
|
||||||
}),
|
}),
|
||||||
/cluster or all release set/,
|
/cluster or all release set/,
|
||||||
@@ -347,7 +516,12 @@ test('Kubernetes materialization rejects unsupported scope and incomplete role c
|
|||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createKubernetesLock(clusterSet, withoutWorker, kubernetesOptions()),
|
() =>
|
||||||
|
createKubernetesLock(
|
||||||
|
clusterSet,
|
||||||
|
withoutWorker,
|
||||||
|
kubernetesOptions(clusterSet),
|
||||||
|
),
|
||||||
/required image was not rendered: worker/,
|
/required image was not rendered: worker/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -358,7 +532,7 @@ test('rejects mutable, malformed and hidden QingLong image authorities', () => {
|
|||||||
references(set).control
|
references(set).control
|
||||||
}\n`;
|
}\n`;
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => createKubernetesLock(set, hidden, kubernetesOptions()),
|
() => createKubernetesLock(set, hidden, kubernetesOptions(set)),
|
||||||
/unhandled QingLong image authority: data.image/,
|
/unhandled QingLong image authority: data.image/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
@@ -369,7 +543,7 @@ test('rejects mutable, malformed and hidden QingLong image authorities', () => {
|
|||||||
'qinglong3-cluster-control@sha256:',
|
'qinglong3-cluster-control@sha256:',
|
||||||
'qinglong3-cluster-control-not-an-image@sha256:',
|
'qinglong3-cluster-control-not-an-image@sha256:',
|
||||||
),
|
),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/unhandled QingLong image authority|container image is malformed/,
|
/unhandled QingLong image authority|container image is malformed/,
|
||||||
);
|
);
|
||||||
@@ -381,7 +555,7 @@ test('rejects mutable, malformed and hidden QingLong image authorities', () => {
|
|||||||
'qinglong3-worker:source',
|
'qinglong3-worker:source',
|
||||||
'qinglong3-worker',
|
'qinglong3-worker',
|
||||||
),
|
),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/container image is malformed/,
|
/container image is malformed/,
|
||||||
);
|
);
|
||||||
@@ -394,7 +568,7 @@ test('rejects unsafe YAML structure and invalid admission authority', () => {
|
|||||||
createKubernetesLock(
|
createKubernetesLock(
|
||||||
set,
|
set,
|
||||||
'apiVersion: v1\nkind: Pod\nkind: Job\n',
|
'apiVersion: v1\nkind: Pod\nkind: Job\n',
|
||||||
kubernetesOptions({ requiredImages: 'control' }),
|
kubernetesOptions(set, { requiredImages: 'control' }),
|
||||||
),
|
),
|
||||||
/duplicate-free YAML/,
|
/duplicate-free YAML/,
|
||||||
);
|
);
|
||||||
@@ -403,7 +577,7 @@ test('rejects unsafe YAML structure and invalid admission authority', () => {
|
|||||||
createKubernetesLock(
|
createKubernetesLock(
|
||||||
set,
|
set,
|
||||||
'- not\n- a\n- resource\n',
|
'- not\n- a\n- resource\n',
|
||||||
kubernetesOptions({ requiredImages: 'control' }),
|
kubernetesOptions(set, { requiredImages: 'control' }),
|
||||||
),
|
),
|
||||||
/resource must be one mapping/,
|
/resource must be one mapping/,
|
||||||
);
|
);
|
||||||
@@ -415,7 +589,7 @@ test('rejects unsafe YAML structure and invalid admission authority', () => {
|
|||||||
'data:\n image: qinglong3-cluster-admin',
|
'data:\n image: qinglong3-cluster-admin',
|
||||||
'data:\n image: busybox',
|
'data:\n image: busybox',
|
||||||
),
|
),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/admission image authority is invalid/,
|
/admission image authority is invalid/,
|
||||||
);
|
);
|
||||||
@@ -426,7 +600,7 @@ test('audit detects source, locked manifest and report drift', () => {
|
|||||||
const created = createKubernetesLock(
|
const created = createKubernetesLock(
|
||||||
set,
|
set,
|
||||||
fixtureManifest(),
|
fixtureManifest(),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
);
|
);
|
||||||
const report = JSON.parse(JSON.stringify(created.report));
|
const report = JSON.parse(JSON.stringify(created.report));
|
||||||
report.manifest.resources += 1;
|
report.manifest.resources += 1;
|
||||||
@@ -437,7 +611,7 @@ test('audit detects source, locked manifest and report drift', () => {
|
|||||||
report,
|
report,
|
||||||
set,
|
set,
|
||||||
fixtureManifest(),
|
fixtureManifest(),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/differs from the verified release set/,
|
/differs from the verified release set/,
|
||||||
);
|
);
|
||||||
@@ -448,7 +622,7 @@ test('audit detects source, locked manifest and report drift', () => {
|
|||||||
created.report,
|
created.report,
|
||||||
set,
|
set,
|
||||||
fixtureManifest(),
|
fixtureManifest(),
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/differs from the verified release set/,
|
/differs from the verified release set/,
|
||||||
);
|
);
|
||||||
@@ -459,7 +633,7 @@ test('audit detects source, locked manifest and report drift', () => {
|
|||||||
created.report,
|
created.report,
|
||||||
set,
|
set,
|
||||||
`${fixtureManifest()}\n`,
|
`${fixtureManifest()}\n`,
|
||||||
kubernetesOptions(),
|
kubernetesOptions(set),
|
||||||
),
|
),
|
||||||
/differs from the verified release set/,
|
/differs from the verified release set/,
|
||||||
);
|
);
|
||||||
@@ -469,16 +643,16 @@ test('CLI creates and audits no-replace Local and Kubernetes outputs', (t) => {
|
|||||||
const directory = temporaryDirectory(t);
|
const directory = temporaryDirectory(t);
|
||||||
const output = { write() {} };
|
const output = { write() {} };
|
||||||
const localSet = releaseSet('local');
|
const localSet = releaseSet('local');
|
||||||
const localSetPath = path.join(directory, 'local-set.json');
|
const localBundle = consumptionBundle(t, directory, 'local', localSet);
|
||||||
const selectionPath = path.join(directory, 'selection.json');
|
const selectionPath = path.join(directory, 'selection.json');
|
||||||
writeCanonical(localSetPath, localSet);
|
|
||||||
const localIdentity = [
|
const localIdentity = [
|
||||||
`--version=${version}`,
|
`--version=${version}`,
|
||||||
`--source-revision=${identity.sourceRevision}`,
|
`--source-revision=${identity.sourceRevision}`,
|
||||||
`--source-ref=${identity.sourceRef}`,
|
`--source-ref=${identity.sourceRef}`,
|
||||||
'--release-scope=local',
|
'--release-scope=local',
|
||||||
`--repository-owner=${identity.repositoryOwner}`,
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
`--release-set=${localSetPath}`,
|
`--source-repository=${identity.sourceRepository}`,
|
||||||
|
`--consumption-bundle=${localBundle}`,
|
||||||
'--allow-root-service=false',
|
'--allow-root-service=false',
|
||||||
];
|
];
|
||||||
runCli(
|
runCli(
|
||||||
@@ -506,11 +680,10 @@ test('CLI creates and audits no-replace Local and Kubernetes outputs', (t) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clusterSet = releaseSet('cluster');
|
const clusterSet = releaseSet('cluster');
|
||||||
const clusterSetPath = path.join(directory, 'cluster-set.json');
|
const clusterBundle = consumptionBundle(t, directory, 'cluster', clusterSet);
|
||||||
const sourcePath = path.join(directory, 'rendered.yaml');
|
const sourcePath = path.join(directory, 'rendered.yaml');
|
||||||
const lockedPath = path.join(directory, 'locked.yaml');
|
const lockedPath = path.join(directory, 'locked.yaml');
|
||||||
const reportPath = path.join(directory, 'lock.json');
|
const reportPath = path.join(directory, 'lock.json');
|
||||||
writeCanonical(clusterSetPath, clusterSet);
|
|
||||||
fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 });
|
fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 });
|
||||||
const clusterIdentity = [
|
const clusterIdentity = [
|
||||||
`--version=${version}`,
|
`--version=${version}`,
|
||||||
@@ -518,7 +691,8 @@ test('CLI creates and audits no-replace Local and Kubernetes outputs', (t) => {
|
|||||||
`--source-ref=${identity.sourceRef}`,
|
`--source-ref=${identity.sourceRef}`,
|
||||||
'--release-scope=cluster',
|
'--release-scope=cluster',
|
||||||
`--repository-owner=${identity.repositoryOwner}`,
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
`--release-set=${clusterSetPath}`,
|
`--source-repository=${identity.sourceRepository}`,
|
||||||
|
`--consumption-bundle=${clusterBundle}`,
|
||||||
`--manifest=${sourcePath}`,
|
`--manifest=${sourcePath}`,
|
||||||
`--required-images=${roleOrder.join(',')}`,
|
`--required-images=${roleOrder.join(',')}`,
|
||||||
];
|
];
|
||||||
@@ -551,10 +725,10 @@ test('CLI creates and audits no-replace Local and Kubernetes outputs', (t) => {
|
|||||||
|
|
||||||
test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity', (t) => {
|
test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity', (t) => {
|
||||||
const directory = temporaryDirectory(t);
|
const directory = temporaryDirectory(t);
|
||||||
const localSetPath = path.join(directory, 'local-set.json');
|
const localSet = releaseSet('local');
|
||||||
const linkedSetPath = path.join(directory, 'linked-set.json');
|
const localBundle = consumptionBundle(t, directory, 'local', localSet);
|
||||||
writeCanonical(localSetPath, releaseSet('local'));
|
const linkedBundle = path.join(directory, 'linked-bundle');
|
||||||
fs.symlinkSync(localSetPath, linkedSetPath);
|
fs.symlinkSync(localBundle, linkedBundle, 'dir');
|
||||||
const base = [
|
const base = [
|
||||||
'--mode=local-create',
|
'--mode=local-create',
|
||||||
`--version=${version}`,
|
`--version=${version}`,
|
||||||
@@ -562,13 +736,14 @@ test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity
|
|||||||
`--source-ref=${identity.sourceRef}`,
|
`--source-ref=${identity.sourceRef}`,
|
||||||
'--release-scope=local',
|
'--release-scope=local',
|
||||||
`--repository-owner=${identity.repositoryOwner}`,
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
`--release-set=${linkedSetPath}`,
|
`--source-repository=${identity.sourceRepository}`,
|
||||||
|
`--consumption-bundle=${linkedBundle}`,
|
||||||
'--allow-root-service=false',
|
'--allow-root-service=false',
|
||||||
`--output=${path.join(directory, 'selection.json')}`,
|
`--output=${path.join(directory, 'selection.json')}`,
|
||||||
];
|
];
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => runCli(base, root, { write() {} }),
|
() => runCli(base, root, { write() {} }),
|
||||||
/canonical regular file/,
|
/canonical directory/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => parseArguments([...base, '--extra=value']),
|
() => parseArguments([...base, '--extra=value']),
|
||||||
@@ -585,11 +760,22 @@ test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity
|
|||||||
),
|
),
|
||||||
/must be true or false/,
|
/must be true or false/,
|
||||||
);
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseArguments(
|
||||||
|
base.map((argument) =>
|
||||||
|
argument.startsWith('--consumption-bundle=')
|
||||||
|
? `--release-set=${path.join(directory, 'loose-release-set.json')}`
|
||||||
|
: argument,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/arguments are invalid/,
|
||||||
|
);
|
||||||
|
|
||||||
const clusterSetPath = path.join(directory, 'cluster-set.json');
|
const clusterSet = releaseSet('cluster');
|
||||||
|
const clusterBundle = consumptionBundle(t, directory, 'cluster', clusterSet);
|
||||||
const sourcePath = path.join(directory, 'rendered.yaml');
|
const sourcePath = path.join(directory, 'rendered.yaml');
|
||||||
const aliasedOutput = path.join(directory, 'same-output');
|
const aliasedOutput = path.join(directory, 'same-output');
|
||||||
writeCanonical(clusterSetPath, releaseSet('cluster'));
|
|
||||||
fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 });
|
fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 });
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
@@ -601,7 +787,8 @@ test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity
|
|||||||
`--source-ref=${identity.sourceRef}`,
|
`--source-ref=${identity.sourceRef}`,
|
||||||
'--release-scope=cluster',
|
'--release-scope=cluster',
|
||||||
`--repository-owner=${identity.repositoryOwner}`,
|
`--repository-owner=${identity.repositoryOwner}`,
|
||||||
`--release-set=${clusterSetPath}`,
|
`--source-repository=${identity.sourceRepository}`,
|
||||||
|
`--consumption-bundle=${clusterBundle}`,
|
||||||
`--manifest=${sourcePath}`,
|
`--manifest=${sourcePath}`,
|
||||||
`--required-images=${roleOrder.join(',')}`,
|
`--required-images=${roleOrder.join(',')}`,
|
||||||
`--output-manifest=${aliasedOutput}`,
|
`--output-manifest=${aliasedOutput}`,
|
||||||
@@ -636,6 +823,19 @@ test('source-surface audit freezes every reviewed cluster and worker authority',
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('deployment-lock CLI cannot regress to a loose release-set input', () => {
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
path.join(root, 'scripts/ql3-deployment-lock-contract.cjs'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(source, /auditCeremonyBundle\(/u);
|
||||||
|
assert.match(source, /'consumption-bundle'/u);
|
||||||
|
assert.match(source, /'source-repository'/u);
|
||||||
|
assert.doesNotMatch(source, /['"]release-set['"]/u);
|
||||||
|
assert.match(source, /qinglong\/local-compose-release-image@v2/u);
|
||||||
|
assert.match(source, /qinglong\/kubernetes-deployment-lock@v2/u);
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'real Kustomize renders are locked after nested overlay transforms',
|
'real Kustomize renders are locked after nested overlay transforms',
|
||||||
{ skip: kubectlPath ? false : 'kubectl is unavailable' },
|
{ skip: kubectlPath ? false : 'kubectl is unavailable' },
|
||||||
@@ -678,7 +878,7 @@ test(
|
|||||||
const created = createKubernetesLock(
|
const created = createKubernetesLock(
|
||||||
set,
|
set,
|
||||||
rendered.stdout,
|
rendered.stdout,
|
||||||
kubernetesOptions({
|
kubernetesOptions(set, {
|
||||||
requiredImages: entry.requiredImages.join(','),
|
requiredImages: entry.requiredImages.join(','),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user