From 8c0985024922c5e08f10e8e83fe583c1c65d35a0 Mon Sep 17 00:00:00 2001 From: whyour Date: Sun, 16 Aug 2026 13:39:50 +0800 Subject: [PATCH] feat(ql3): materialize offline deployment locks --- .github/workflows/ql3-ci.yml | 2 + deploy/kubernetes/ql3-cluster/README.md | 23 +- deploy/kubernetes/ql3-worker/README.md | 12 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 1 + ...ase-set-deployment-lock-materialization.md | 88 ++ docs/adr/README.md | 1 + docs/operations/ql3-release-set-deployment.md | 98 ++- package.json | 2 + scripts/ql3-cluster-image-release-audit.cjs | 10 +- scripts/ql3-deployment-lock-contract.cjs | 810 ++++++++++++++++++ scripts/ql3-release-candidate-contract.cjs | 1 + .../back/ql3ClusterImageReleaseAudit.test.cjs | 25 +- test/back/ql3DeploymentLockContract.test.cjs | 704 +++++++++++++++ .../back/ql3ReleaseCandidateContract.test.cjs | 8 + 14 files changed, 1763 insertions(+), 22 deletions(-) create mode 100644 docs/adr/ADR-0429-offline-release-set-deployment-lock-materialization.md create mode 100644 scripts/ql3-deployment-lock-contract.cjs create mode 100644 test/back/ql3DeploymentLockContract.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index dd2a447c..6cee5427 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -302,6 +302,7 @@ jobs: test/back/ql3ReleaseCandidateContract.test.cjs test/back/ql3ReleaseSetContract.test.cjs test/back/ql3ReleaseCatalogContract.test.cjs + test/back/ql3DeploymentLockContract.test.cjs test/back/ql3ImageOsVulnerabilityPolicy.test.cjs test/back/ql3LocalImageAudit.test.cjs test/back/ql3VersionTransition.test.cjs @@ -309,6 +310,7 @@ jobs: run: | pnpm audit:release-version:ql3 pnpm audit:image-release:ql3 + pnpm audit:deployment-lock-surfaces:ql3 pnpm audit:local-image:ql3 - name: Reject high or critical advisories in 3.0 Profile importers run: pnpm audit:profiles:ql3 diff --git a/deploy/kubernetes/ql3-cluster/README.md b/deploy/kubernetes/ql3-cluster/README.md index 09b3f33d..ea8204ea 100644 --- a/deploy/kubernetes/ql3-cluster/README.md +++ b/deploy/kubernetes/ql3-cluster/README.md @@ -161,21 +161,22 @@ pnpm audit:cluster-remote-manifest:ql3 \ ``` The existence of the workflow is not publication evidence. Before production -rollout, obtain the `cluster` or `all` deployment release set from a successful -release run, verify its file provenance, and pin the four control, control-ai, -worker and admin `sha256:` references recorded in that one source-bound set. -Never treat the version tag, a partial matrix run or one image's proof as -authority for another role. The exact operator procedure and non-atomic tag -recovery boundary are documented in +rollout, obtain and verify the `cluster` or `all` deployment release set, render +the selected overlay, and run the offline deployment-lock post-renderer. Apply +only the audited locked manifest. Do not edit nested Kustomize image transforms +by hand or apply the pre-lock render: an outer transform cannot reliably replace +an image digest already selected by an inner overlay. The exact operator +procedure and non-atomic tag recovery boundary are documented in [`docs/operations/ql3-release-set-deployment.md`](../../../docs/operations/ql3-release-set-deployment.md). The committed CloudNativePG control, migration and Plugin Package recovery overlays use an all-zero SHA-256 digest as an intentionally unpullable fail-closed -placeholder. A private deployment overlay must replace the control placeholder -with the verified `qinglong3-cluster-control` digest and the recovery -placeholder with the independently verified `qinglong3-cluster-admin` digest. -Static deployment audit rejects replacing either production-oriented reference -with `newTag`. +placeholder. The offline post-renderer must replace the control placeholder with +the verified `qinglong3-cluster-control` digest and every Admin workload or +admission ConfigMap authority with the independently verified +`qinglong3-cluster-admin` digest from the same release set. Static deployment +audit freezes the reviewed source image surfaces, and the materializer rejects +unhandled role image authorities. ## CloudNativePG production profile diff --git a/deploy/kubernetes/ql3-worker/README.md b/deploy/kubernetes/ql3-worker/README.md index e8761f91..8c4cceb8 100644 --- a/deploy/kubernetes/ql3-worker/README.md +++ b/deploy/kubernetes/ql3-worker/README.md @@ -34,7 +34,9 @@ docker build \ Production rollout must take the Worker `@sha256:` reference from the same verified `cluster` or `all` release set as control, control-ai and admin. A version/source tag or a successful control image alone is not Worker release -authority. See +authority. Render the private Worker overlay, run the offline deployment-lock +post-renderer with `--required-images=worker`, audit the locked manifest, and +apply only that locked output. See [`docs/operations/ql3-release-set-deployment.md`](../../../docs/operations/ql3-release-set-deployment.md). The builder and runtime dependency roots have separate npm v3 locks generated @@ -61,9 +63,11 @@ the private credential-bootstrap overlay and submit it with create-only semantics before the first delivery: ```bash -kubectl apply -k deploy/kubernetes/ql3-worker/base -# or: -kubectl apply -k deploy/kubernetes/ql3-worker/overlays/node +kubectl kustomize deploy/kubernetes/ql3-worker/base > /private/workstation/path/worker-rendered.yaml +# or render deploy/kubernetes/ql3-worker/overlays/node +# Materialize and audit worker-locked.yaml using the release-set procedure, +# then apply only the locked output: +kubectl apply -f /private/workstation/path/worker-locked.yaml kubectl apply -k deploy/kubernetes/ql3-worker/credential-admin kubectl kustomize deploy/kubernetes/ql3-worker/credential-bootstrap \ | kubectl create -f - diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index a10822d6..138bf089 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- 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//qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v-` 仅作发现且 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-334/ADR-0426(已接受):根级 canonical `ql3-release.json` 现在是唯一 QingLong 3 release identity authority,精确冻结 3.x SemVer、Node 24.18.0/engine、18-package 边界和 legacy 2.x 排除事实;发布候选、四组容器、Cluster/Worker/Console 部署、Local/Cluster image audit、CloudNativePG、物理 Edge 与外部恢复审计均改为读取同一 authority,candidate contract 额外绑定 identity schema 与 SHA-256。共享 CI 新增 `audit:release-version:ql3`,失败关闭 18 个 workspace、四组 build/runtime manifest+lock、Dockerfile Node/version label 与 242 个部署文本文件中的 32 个 image reference/36 个版本 occurrence。维护者升级版本必须走 closed `audit|plan|apply`:plan 只接受严格递增 exact v3 SemVer并生成 no-replace `0600`、逐文件 path/mode/replacement/before-after bytes+digest 和自身 digest;apply 先全量预检 65 文件/83 处替换,再用同目录确定性临时文件、fsync+rename 逐文件收敛,允许 source/target 混合状态原 plan 幂等恢复并生成 digest-bound report,绝不修改 legacy 根 2.x、自动 commit/tag/push 或宣称跨文件单事务。实现不新增 workspace package、生产依赖、数据库、migration、SQL、Pool、Pod、listener、timer、watcher 或任何低配/集群常驻开销。定向回归 177/177,backend 1,254 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0;package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster 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` 基线;完整回归未发现数据库或部署拓扑漂移。 diff --git a/docs/adr/ADR-0429-offline-release-set-deployment-lock-materialization.md b/docs/adr/ADR-0429-offline-release-set-deployment-lock-materialization.md new file mode 100644 index 00000000..68011233 --- /dev/null +++ b/docs/adr/ADR-0429-offline-release-set-deployment-lock-materialization.md @@ -0,0 +1,88 @@ +# ADR-0429:离线 Release-set Deployment Lock 物化 + +- 状态:Accepted +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-333、D-334、D-335、D-336、D-337 + +## 上下文 + +ADR-0428 让部署者能够从持久 OCI catalog 取得并独立验证完整 release set,但验证后的 +`images[].reference` 仍需人工写入 Local Compose 或 Kubernetes 清单。Cluster 的 Kustomize 结构包含嵌套 overlay;当内层 +已经把镜像转换为另一个 repository 和 digest 时,外层 image transform 不保证再次覆盖它。Plugin Package admission +ConfigMap 还把短生命周期 Admin 镜像 authority 保存于 `data.image`,不属于 Kustomize 内建 `images` transformer 的处理面。 +因此,“release set 已验证”与“最终 apply 的清单确实只消费该 release set”之间仍存在人工复制和遗漏窗口。 + +同一解决方案还必须保持部署形态隔离。低配路由设备只能承担 Local 单镜像消费,不能为 Cluster 的渲染工具、YAML parser +或 Kubernetes client 付出安装和常驻资源;Cluster 工作站则需要处理 Core、AI、Worker 与短生命周期 Admin 多种独立清单, +但生成工具不能因此获得 Kubernetes mutation authority。 + +## 决策 + +1. 新增独立的 `ql3-deployment-lock-contract.cjs` 工作站工具。输入只能是已通过 standalone inspection 的 canonical + release set,以及显式 release identity;所有模式均不访问网络、不读取 registry、不连接 Kubernetes API、不执行 + rollout。 +2. Local 模式只接受 `local|all` scope,并生成 canonical + `qinglong/local-compose-release-image@v1` selection。输出绑定 release-set digest、精确 Local digest reference 和显式 + `allowRootService` boolean;它不修改 Compose,也不启动服务。低配设备只消费该选择结果,不安装 materializer。 +3. Kubernetes 模式只接受 `cluster|all` scope。运维者先执行 `kubectl kustomize`,materializer 再处理最终多文档 YAML, + 从而穿透任意嵌套 overlay 的 transform 顺序。调用者必须按发布顺序显式声明当前清单必含的 role;缺少任一 required role + 时失败关闭。 +4. 可改写面封闭为 Pod、Deployment、StatefulSet、DaemonSet、ReplicaSet、Job、CronJob 的 + `containers`、`initContainers`、`ephemeralContainers`,以及 exact-name + `ql3-plugin-package-secret-action-admission` ConfigMap 的 `data.image`。完整 QingLong role tag/digest 出现在其他位置时拒绝; + 已知 container 中的裸名、未知 role-like name 或畸形引用同样拒绝。非 QingLong sidecar 保持原样。 +5. 每个被改写的资源和适用的 Pod template 写入 release-set digest、source revision、version annotation。输出 report 固定 + 输入/输出 SHA-256、资源数、改写资源数、各 role reference/出现次数、admission authority 次数与 no-network/no-mutation + 结论,并以 self digest 封闭。 +6. `local-audit` 与 `kubernetes-audit` 从原 release set 和原始 render 完整重建期望输出,要求 byte/object exact matching, + 不把“输出中看见 digest”当作充分证明。输入限制为 canonical absolute、非 symlink、有界 UTF-8 regular file;JSON 必须 + canonical,YAML 禁止 alias/cycle、非 mapping resource、过深/过多/过大结构。所有输出以 0600、no-replace 创建。 +7. 仓库静态审计固定 Cluster/Worker 的 224 个 YAML 文件、31 个直接 role image 引用与两个 admission ConfigMap authority。 + 新增或移动镜像 authority 必须先扩展受支持处理面与负向测试,不能静默绕过 post-renderer。 + +## 部署与资源影响 + +- Local/Edge/Standalone runtime、镜像、workspace package、生产依赖、进程、listener、timer、watcher、数据库连接和内存预算 + 均不变化。Node、`js-yaml`、registry/Kubernetes 工具只存在于可信维护工作站;路由器接收一个 Local selection 与一个 + immutable image。 +- Cluster 不新增 controller、admission webhook、CRD、ServiceAccount 或 API 权限。materializer 在 apply 之前退出;真正的 + `kubectl apply -f locked.yaml` 是独立、显式、可审阅的运维步骤。 +- 本决策不修改 schema、migration、SQL、PostgreSQL role、Pool、连接或 HA 拓扑,因此不制造新的数据库发布证据要求。 + +## 被拒绝的替代方案 + +### 在每层 Kustomize overlay 增加 image component + +拒绝。外层 component 不能可靠覆盖内层已转换的 repository/digest,且 Kustomize `images` 不处理 ConfigMap 中的 Admin +authority;继续堆叠 component 会让最终 authority 取决于难以审计的 transform 顺序。 + +### 直接修改仓库中的零 digest 占位符 + +拒绝。它把环境私有 release identity 写回共享源码,容易产生脏工作区、错误复用和漏改,而且不能证明多个清单来自同一 +release set。 + +### 在 Cluster 内运行常驻 image policy controller + +拒绝。当前缺口可以在工作站离线关闭。新增 controller/webhook 会引入可用性、升级、证书和 API authority 故障域,也会 +错误地把发布供应链验真变成集群运行时依赖。 + +### 让路由器自行验证和物化 + +拒绝。低资源设备没有必要承担 Node、YAML、registry、Cosign、GitHub CLI 或 Kubernetes 工具链;可信工作站可以生成并 +审计更小的 Local selection,而设备仍以 digest 消费。 + +## 验证 + +- deployment-lock 契约覆盖 Local/All selection、Cluster/All materialization、全部 workload container 类型、固定 admission + ConfigMap、required role closure、unknown/malformed authority、release/source/report/render drift、duplicate YAML、非 + mapping、closed CLI、symlink、0600 与 no-replace;定向测试 11/11; +- 本机 `kubectl v1.36.1`/Kustomize `v5.8.1` 真实渲染 CloudNativePG Core、Cluster AI、Worker node 与 Plugin Package + Executor 四类清单后,post-render 全部生成 release-set exact digest,内层全零占位 digest 均消失; +- 发布契约、release set/catalog、静态 workflow 与 deployment-lock 联动测试 101/101,部署面审计确认 224 个 YAML、31 个 + 直接 role image 引用和两个 admission authority; +- 完整 backend 共 1,295 项,1,293 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 与 deployment-lock surface 等 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。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 6d99ac1d..913bf93c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -432,6 +432,7 @@ | [ADR-0426](./ADR-0426-source-derived-release-version-transition.md) | Source-derived QingLong 3.0 Release Version Transition | 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-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted | ## 规则 diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index cd46da80..c6e5f2b7 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -88,6 +88,94 @@ gh attestation verify "${release_set}" \ `inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image records;其输出必须保持 `sourceRecordsReplayed:false`。 +## 生成离线 deployment lock + +`inspect` 成功后,不要手工复制 digest,也不要直接修改仓库中的 Kustomize 占位符。deployment-lock +materializer 在可信工作站离线运行,只读取已经验证的 release set 与本地清单;它不访问 registry、不连接 Kubernetes +API,也不会执行 `kubectl apply`。 + +### Local / Compose + +对 `local` 或 `all` scope 生成一个 canonical、0600、no-replace 的 service selection: + +```sh +selection="$(pwd)/qinglong3-local-selection-${version}.json" + +node scripts/ql3-deployment-lock-contract.cjs \ + --mode=local-create \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --release-set="${release_set}" \ + --allow-root-service=false \ + --output="${selection}" + +node scripts/ql3-deployment-lock-contract.cjs \ + --mode=local-audit \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --release-set="${release_set}" \ + --allow-root-service=false \ + --selection="${selection}" +``` + +把已审计的 `service.image` 和 `service.allowRootService` 交给现有 Local private prepare/rollout 入口。selection +本身不修改 Compose 文件,也不启动容器。是否允许 root service 必须显式给出,不能由设备默认值推断。 + +### Kubernetes / Cluster / Worker + +先用已审核 overlay 生成普通多文档 YAML,再把它作为 post-render 输入。以下是 Cluster Core 示例;AI、Worker、Admin +清单分别把 `required-images` 设为 `control-ai`、`worker`、`admin`,组合清单则按发布顺序使用 +`control,control-ai,admin,worker`: + +```sh +rendered="$(pwd)/ql3-cluster-rendered.yaml" +locked="$(pwd)/ql3-cluster-locked.yaml" +lock_report="$(pwd)/ql3-cluster-deployment-lock.json" + +kubectl kustomize deploy/kubernetes/ql3-cluster/overlays/cloudnative-pg > "${rendered}" + +node scripts/ql3-deployment-lock-contract.cjs \ + --mode=kubernetes-create \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --release-set="${release_set}" \ + --manifest="${rendered}" \ + --required-images=control \ + --output-manifest="${locked}" \ + --output-report="${lock_report}" + +node scripts/ql3-deployment-lock-contract.cjs \ + --mode=kubernetes-audit \ + --version="${version}" \ + --source-revision="${source_revision}" \ + --source-ref="${source_ref}" \ + --release-scope="${scope}" \ + --repository-owner="${owner}" \ + --release-set="${release_set}" \ + --manifest="${rendered}" \ + --locked-manifest="${locked}" \ + --report="${lock_report}" \ + --required-images=control +``` + +materializer 只改写 Pod、Deployment、StatefulSet、DaemonSet、ReplicaSet、Job、CronJob 的 +`containers`/`initContainers`/`ephemeralContainers`,以及固定名称 +`ql3-plugin-package-secret-action-admission` ConfigMap 的 `data.image`。每个改写资源及其 Pod template 都绑定 +release-set digest、source revision 与 version annotation;未知位置的完整 QingLong role image authority、畸形已知 +container image、缺少 required role、YAML alias/cycle/非 mapping、超限输入或已有输出文件都会失败关闭。 + +审计成功并完成人工差异检查后,才由有权限的独立步骤执行 `kubectl apply -f "${locked}"`。不要直接 apply +`${rendered}`,也不要使用 `kubectl apply -k` 绕过 deployment lock。 + ## 准入检查 1. 只接受已验证 Cosign exact workflow identity 与 GitHub source tag/revision provenance 的 catalog immutable @@ -95,15 +183,17 @@ records;其输出必须保持 `sourceRecordsReplayed:false`。 2. `schema` 必须为 `qinglong/release-set@v1`;`release.version`、`release.sourceRef`、 `release.sourceRevision`、`release.scope` 必须与变更单一致。 3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。 -4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成 - `newTag`。Local compose/rollout 同样固定 `@sha256:`。 +4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 locked manifest;嵌套 overlay 的 + `newName`/digest 不是最终 authority。Local 必须生成并审计 service selection。两族最终都只能消费 release set 中的 + `@sha256:` reference。 5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于 发现;部署始终以 release set 中的镜像 digest 为准。 ## 低资源设备 -路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign 或 GitHub CLI。维护者在可信工作站完成上述 ceremony, -再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable image reference 写入 compose/rollout。 +路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站 +完成上述 ceremony 和 Local selection 审计,再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable +image reference 写入 compose/rollout。 设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。 如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与 diff --git a/package.json b/package.json index 41aa0332..b6ae2880 100644 --- a/package.json +++ b/package.json @@ -113,6 +113,8 @@ "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", "audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.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:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs", "audit:cluster-image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs", "audit:cluster-oci-layout:ql3": "node scripts/ql3-cluster-oci-layout-audit.cjs", diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 2e0edae7..b1b74e0f 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -274,14 +274,19 @@ function auditClusterImageCiWorkflow( ); requirePattern( 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/, - 'cluster image CI must run SBOM, candidate, release-set, durable catalog and workflow negative tests', + /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/, + 'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests', ); requirePattern( source, /pnpm audit:image-release:ql3/, 'image CI must audit the shared release workflow contract', ); + requirePattern( + source, + /pnpm audit:deployment-lock-surfaces:ql3/, + 'supply-chain CI must freeze the reviewed deployment image surfaces', + ); requirePattern( source, /test\/back\/ql3VersionTransition\.test\.cjs/, @@ -386,6 +391,7 @@ function auditClusterImageCiWorkflow( clusterAdminContextPreflight: true, clusterAdminContextReadiness: true, releaseVersionAudit: true, + deploymentLockMaterialization: true, ociAttestations: true, osVulnerabilityScan: { scanner: 'trivy@0.70.0', diff --git a/scripts/ql3-deployment-lock-contract.cjs b/scripts/ql3-deployment-lock-contract.cjs new file mode 100644 index 00000000..f4e467a6 --- /dev/null +++ b/scripts/ql3-deployment-lock-contract.cjs @@ -0,0 +1,810 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); +const { inspectReleaseSet } = require('./ql3-release-set-contract.cjs'); + +const DEFAULT_ROOT = path.resolve(__dirname, '..'); +const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v1'; +const KUBERNETES_LOCK_SCHEMA = 'qinglong/kubernetes-deployment-lock@v1'; +const MAX_RELEASE_SET_BYTES = 1024 * 1024; +const MAX_MANIFEST_BYTES = 8 * 1024 * 1024; +const MAX_RESOURCE_COUNT = 2048; +const MAX_STRUCTURE_DEPTH = 64; +const ROLE_ORDER = Object.freeze(['control', 'control-ai', 'admin', 'worker']); +const IMAGE_NAMES = Object.freeze({ + control: 'qinglong3-cluster-control', + 'control-ai': 'qinglong3-cluster-control-ai', + admin: 'qinglong3-cluster-admin', + worker: 'qinglong3-worker', +}); +const EXPECTED_SOURCE_SURFACES = Object.freeze({ + control: 2, + 'control-ai': 1, + admin: 26, + worker: 2, +}); +const ADMISSION_CONFIG_NAME = 'ql3-plugin-package-secret-action-admission'; + +class QingLong3DeploymentLockError extends Error { + constructor(message) { + super(`QingLong 3 deployment lock failed: ${message}`); + this.name = 'QingLong3DeploymentLockError'; + } +} + +function fail(message) { + throw new QingLong3DeploymentLockError(message); +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function canonicalJson(value) { + return `${JSON.stringify(value)}\n`; +} + +function resolveCanonicalAbsolute(input, label) { + if (typeof input !== 'string' || !path.isAbsolute(input)) { + fail(`${label} path must be absolute`); + } + const resolved = path.resolve(input); + if (resolved !== input) fail(`${label} path must be normalized`); + return resolved; +} + +function readBoundedFile(filePath, label, maximumBytes) { + const resolved = resolveCanonicalAbsolute(filePath, label); + let stat; + try { + stat = fs.lstatSync(resolved); + } catch { + fail(`${label} is unavailable`); + } + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 1 || + stat.size > maximumBytes || + fs.realpathSync(resolved) !== resolved || + fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) + ) { + fail(`${label} must be one bounded canonical regular file`); + } + const buffer = fs.readFileSync(resolved); + const contents = buffer.toString('utf8'); + if (!Buffer.from(contents, 'utf8').equals(buffer)) { + fail(`${label} must contain valid UTF-8`); + } + return Object.freeze({ + path: resolved, + contents, + }); +} + +function parseCanonicalJson(contents, label) { + let value; + try { + value = JSON.parse(contents); + } catch { + fail(`${label} must contain valid JSON`); + } + if (canonicalJson(value) !== contents) { + fail(`${label} must use exact canonical JSON encoding`); + } + return value; +} + +function readCanonicalJson(filePath, label, maximumBytes) { + const file = readBoundedFile(filePath, label, maximumBytes); + return Object.freeze({ + ...file, + value: parseCanonicalJson(file.contents, label), + }); +} + +function preflightOutput(filePath, label) { + const resolved = resolveCanonicalAbsolute(filePath, label); + if ( + fs.existsSync(resolved) || + fs.realpathSync(path.dirname(resolved)) !== path.dirname(resolved) + ) { + fail(`${label} must be unused in one canonical directory`); + } + return resolved; +} + +function writeNoReplace(filePath, contents) { + fs.writeFileSync(filePath, contents, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); +} + +function inspectInputReleaseSet(releaseSet, options) { + return inspectReleaseSet(releaseSet, { + version: options.version, + sourceRevision: options.sourceRevision, + sourceRef: options.sourceRef, + releaseScope: options.releaseScope, + repositoryOwner: options.repositoryOwner, + }); +} + +function imageByName(releaseSet, name) { + const matches = releaseSet.images.filter((entry) => entry.name === name); + if (matches.length !== 1) fail(`release image is unavailable: ${name}`); + return matches[0]; +} + +function createLocalSelection(releaseSet, options) { + const inspection = inspectInputReleaseSet(releaseSet, options); + if (!['local', 'all'].includes(options.releaseScope)) { + fail('local selection requires a local or all release set'); + } + if (options.allowRootService !== true && options.allowRootService !== false) { + fail('allow-root-service must be an explicit boolean'); + } + const local = imageByName(releaseSet, 'local'); + const unsigned = { + schemaVersion: 1, + schema: LOCAL_SELECTION_SCHEMA, + release: { ...releaseSet.release }, + releaseSetDigest: releaseSet.releaseSetDigest, + deploymentFamily: 'local', + service: { + kind: 'compose', + image: local.reference, + allowRootService: options.allowRootService, + }, + verification: { + releaseSet: inspection.verification, + sourceRecordsReplayed: inspection.sourceRecordsReplayed, + networkAccess: false, + deploymentMutation: false, + }, + }; + return Object.freeze({ + ...unsigned, + selectionDigest: sha256(JSON.stringify(unsigned)), + }); +} + +function auditLocalSelection(actual, releaseSet, options) { + const expected = createLocalSelection(releaseSet, options); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + fail('local selection differs from the verified release set'); + } + return Object.freeze({ + compatible: true, + deploymentFamily: 'local', + releaseSetDigest: actual.releaseSetDigest, + selectionDigest: actual.selectionDigest, + image: actual.service.image, + networkAccess: false, + deploymentMutation: false, + }); +} + +function parseRequiredImages(value) { + if (typeof value !== 'string' || value.length === 0) { + fail('required images must be explicit'); + } + const names = value.split(','); + const ordered = ROLE_ORDER.filter((name) => names.includes(name)); + if ( + names.some((name) => !ROLE_ORDER.includes(name)) || + new Set(names).size !== names.length || + JSON.stringify(names) !== JSON.stringify(ordered) + ) { + fail('required images must be unique and use release order'); + } + return Object.freeze(names); +} + +function normalizePlain(value, seen = new WeakSet(), depth = 0) { + if (depth > MAX_STRUCTURE_DEPTH) fail('manifest structure is too deep'); + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)) + ) { + return value; + } + if (typeof value !== 'object') fail('manifest contains unsupported data'); + if (seen.has(value)) fail('manifest aliases and cycles are not allowed'); + seen.add(value); + if (Array.isArray(value)) { + return value.map((entry) => normalizePlain(entry, seen, depth + 1)); + } + if (Object.getPrototypeOf(value) !== Object.prototype) { + fail('manifest contains a non-plain object'); + } + const normalized = {}; + for (const [key, entry] of Object.entries(value)) { + if (typeof key !== 'string' || key.length === 0) { + fail('manifest key is invalid'); + } + normalized[key] = normalizePlain(entry, seen, depth + 1); + } + return normalized; +} + +function parseManifest(contents) { + const resources = []; + try { + yaml.loadAll(contents, (document) => { + if (document !== undefined && document !== null) resources.push(document); + }); + } catch { + fail('input manifest must be valid duplicate-free YAML'); + } + if (resources.length === 0 || resources.length > MAX_RESOURCE_COUNT) { + fail('input manifest resource count is invalid'); + } + return resources.map((resource) => { + const normalized = normalizePlain(resource); + if ( + normalized === null || + typeof normalized !== 'object' || + Array.isArray(normalized) + ) { + fail('each manifest resource must be one mapping'); + } + return normalized; + }); +} + +function renderManifest(resources) { + return resources + .map((resource) => + yaml.dump(resource, { + noCompatMode: true, + noRefs: true, + lineWidth: -1, + sortKeys: false, + }), + ) + .join('---\n'); +} + +function roleFromImage(value) { + if (typeof value !== 'string') return null; + for (const role of ROLE_ORDER) { + const imageName = IMAGE_NAMES[role]; + if ( + new RegExp( + `^(?:[A-Za-z0-9][A-Za-z0-9._-]*(?::[0-9]+)?/)*${imageName}(?::[^@\\s]+|@sha256:[a-f0-9]{64})$`, + 'u', + ).test(value) + ) { + return role; + } + } + return null; +} + +function mentionsRoleImageName(value) { + return ( + typeof value === 'string' && + ROLE_ORDER.some((role) => value.includes(IMAGE_NAMES[role])) + ); +} + +function podSpecFor(resource) { + const kind = resource.kind; + if (kind === 'Pod') return resource.spec; + if (['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'].includes(kind)) { + return resource.spec?.template?.spec; + } + if (kind === 'Job') return resource.spec?.template?.spec; + if (kind === 'CronJob') + return resource.spec?.jobTemplate?.spec?.template?.spec; + return undefined; +} + +function ensureAnnotations(target, releaseSet) { + if (target === null || typeof target !== 'object' || Array.isArray(target)) { + fail('rewritten resource metadata is invalid'); + } + const metadata = target.metadata ?? {}; + if ( + metadata === null || + typeof metadata !== 'object' || + Array.isArray(metadata) || + (metadata.annotations !== undefined && + (metadata.annotations === null || + typeof metadata.annotations !== 'object' || + Array.isArray(metadata.annotations))) + ) { + fail('rewritten resource annotations are invalid'); + } + metadata.annotations = { + ...(metadata.annotations ?? {}), + 'qinglong.io/release-set-digest': releaseSet.releaseSetDigest, + 'qinglong.io/release-source-revision': releaseSet.release.sourceRevision, + 'qinglong.io/release-version': releaseSet.release.version, + }; + target.metadata = metadata; +} + +function rewriteContainerList(list, references, counts, handledAuthorities) { + if (list === undefined) return 0; + if (!Array.isArray(list)) fail('container list is invalid'); + let rewritten = 0; + for (const container of list) { + if ( + container === null || + typeof container !== 'object' || + Array.isArray(container) + ) { + fail('container is invalid'); + } + const role = roleFromImage(container.image); + if (!role) { + if (mentionsRoleImageName(container.image)) + fail('container image is malformed'); + continue; + } + container.image = references[role]; + handledAuthorities.add(container); + counts[role] += 1; + rewritten += 1; + } + return rewritten; +} + +function scanForUnhandledRoleImages( + value, + references, + handledAuthorities, + pathSegments = [], + parent, + parentKey, +) { + if (typeof value === 'string') { + const role = roleFromImage(value); + if ( + role && + (value !== references[role] || + parentKey !== 'image' || + !handledAuthorities.has(parent)) + ) { + fail(`unhandled QingLong image authority: ${pathSegments.join('.')}`); + } + return; + } + if (value === null || typeof value !== 'object') return; + if (Array.isArray(value)) { + value.forEach((entry, index) => + scanForUnhandledRoleImages( + entry, + references, + handledAuthorities, + [...pathSegments, String(index)], + value, + String(index), + ), + ); + return; + } + for (const [key, entry] of Object.entries(value)) { + scanForUnhandledRoleImages( + entry, + references, + handledAuthorities, + [...pathSegments, key], + value, + key, + ); + } +} + +function rewriteKubernetesResources(resources, releaseSet, requiredImages) { + const references = Object.fromEntries( + ROLE_ORDER.map((role) => [role, imageByName(releaseSet, role).reference]), + ); + const counts = Object.fromEntries(ROLE_ORDER.map((role) => [role, 0])); + const handledAuthorities = new WeakSet(); + let changedResources = 0; + let admissionAuthorityCount = 0; + for (const resource of resources) { + let changed = 0; + const podSpec = podSpecFor(resource); + if (podSpec !== undefined) { + if ( + podSpec === null || + typeof podSpec !== 'object' || + Array.isArray(podSpec) + ) { + fail('pod spec is invalid'); + } + for (const key of [ + 'initContainers', + 'containers', + 'ephemeralContainers', + ]) { + changed += rewriteContainerList( + podSpec[key], + references, + counts, + handledAuthorities, + ); + } + } + if ( + resource.apiVersion === 'v1' && + resource.kind === 'ConfigMap' && + resource.metadata?.name === ADMISSION_CONFIG_NAME + ) { + if ( + resource.data === null || + typeof resource.data !== 'object' || + Array.isArray(resource.data) || + roleFromImage(resource.data.image) !== 'admin' + ) { + fail('plugin-package admission image authority is invalid'); + } + resource.data.image = references.admin; + handledAuthorities.add(resource.data); + counts.admin += 1; + admissionAuthorityCount += 1; + changed += 1; + } + if (changed > 0) { + ensureAnnotations(resource, releaseSet); + const template = + resource.kind === 'CronJob' + ? resource.spec?.jobTemplate?.spec?.template + : resource.kind === 'Job' + ? resource.spec?.template + : ['Deployment', 'StatefulSet', 'DaemonSet', 'ReplicaSet'].includes( + resource.kind, + ) + ? resource.spec?.template + : undefined; + if (template) ensureAnnotations(template, releaseSet); + changedResources += 1; + } + } + for (const resource of resources) { + scanForUnhandledRoleImages(resource, references, handledAuthorities); + } + for (const role of requiredImages) { + if (counts[role] < 1) fail(`required image was not rendered: ${role}`); + } + if (Object.values(counts).every((count) => count === 0)) { + fail('manifest contains no QingLong deployment image'); + } + return Object.freeze({ + resources, + counts: Object.freeze({ ...counts }), + changedResources, + admissionAuthorityCount, + }); +} + +function createKubernetesLock(releaseSet, manifestContents, options) { + const inspection = inspectInputReleaseSet(releaseSet, options); + if (!['cluster', 'all'].includes(options.releaseScope)) { + fail('Kubernetes materialization requires a cluster or all release set'); + } + const requiredImages = parseRequiredImages(options.requiredImages); + const parsed = parseManifest(manifestContents); + const rewritten = rewriteKubernetesResources( + parsed, + releaseSet, + requiredImages, + ); + const outputManifest = renderManifest(rewritten.resources); + if (Buffer.byteLength(outputManifest, 'utf8') > MAX_MANIFEST_BYTES) { + fail('output manifest exceeds the bounded size'); + } + const unsigned = { + schemaVersion: 1, + schema: KUBERNETES_LOCK_SCHEMA, + release: { ...releaseSet.release }, + releaseSetDigest: releaseSet.releaseSetDigest, + deploymentFamily: 'cluster', + requiredImages: [...requiredImages], + imageOccurrences: ROLE_ORDER.map((name) => ({ + name, + reference: imageByName(releaseSet, name).reference, + count: rewritten.counts[name], + })), + manifest: { + inputDigest: sha256(manifestContents), + outputDigest: sha256(outputManifest), + resources: parsed.length, + changedResources: rewritten.changedResources, + admissionAuthorityCount: rewritten.admissionAuthorityCount, + }, + verification: { + releaseSet: inspection.verification, + sourceRecordsReplayed: inspection.sourceRecordsReplayed, + unknownImageAuthorities: 0, + mutableQingLongImages: 0, + networkAccess: false, + kubernetesMutation: false, + }, + }; + const report = Object.freeze({ + ...unsigned, + lockDigest: sha256(JSON.stringify(unsigned)), + }); + return Object.freeze({ outputManifest, report }); +} + +function auditKubernetesLock( + actualManifest, + actualReport, + releaseSet, + sourceManifest, + options, +) { + const expected = createKubernetesLock(releaseSet, sourceManifest, options); + if ( + actualManifest !== expected.outputManifest || + JSON.stringify(actualReport) !== JSON.stringify(expected.report) + ) { + fail('Kubernetes deployment lock differs from the verified release set'); + } + return Object.freeze({ + compatible: true, + deploymentFamily: 'cluster', + releaseSetDigest: actualReport.releaseSetDigest, + lockDigest: actualReport.lockDigest, + outputManifestDigest: actualReport.manifest.outputDigest, + requiredImages: Object.freeze([...actualReport.requiredImages]), + networkAccess: false, + kubernetesMutation: false, + }); +} + +function listYamlFiles(root) { + const files = []; + const visit = (directory) => { + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + fail('deployment source directory is invalid'); + } + for (const name of fs.readdirSync(directory).sort()) { + const target = path.join(directory, name); + const child = fs.lstatSync(target); + if (child.isSymbolicLink()) fail('deployment source symlink is invalid'); + if (child.isDirectory()) visit(target); + else if (child.isFile() && /\.ya?ml$/u.test(name)) files.push(target); + } + }; + visit(root); + return files; +} + +function auditDeploymentImageSurfaces(root = DEFAULT_ROOT) { + const deploymentRoots = [ + path.join(root, 'deploy/kubernetes/ql3-cluster'), + path.join(root, 'deploy/kubernetes/ql3-worker'), + ]; + const files = deploymentRoots.flatMap((directory) => + listYamlFiles(directory), + ); + const counts = Object.fromEntries(ROLE_ORDER.map((role) => [role, 0])); + let admissionAuthorityCount = 0; + for (const file of files) { + const source = fs.readFileSync(file, 'utf8'); + for (const role of ROLE_ORDER) { + const matches = source.match( + new RegExp(`${IMAGE_NAMES[role]}(?=[:@])`, 'gu'), + ); + counts[role] += matches?.length ?? 0; + } + admissionAuthorityCount += + source.match( + /image:\s+[^\n]*qinglong3-cluster-admin@sha256:[a-f0-9]{64}/gu, + )?.length ?? 0; + } + if ( + JSON.stringify(counts) !== JSON.stringify(EXPECTED_SOURCE_SURFACES) || + admissionAuthorityCount !== 2 + ) { + fail('deployment image surfaces differ from the reviewed post-renderer'); + } + return Object.freeze({ + schemaVersion: 1, + deploymentYamlFiles: files.length, + imageOccurrences: Object.freeze({ ...counts }), + admissionAuthorityCount, + materialization: 'offline_post_render', + networkAccess: false, + kubernetesMutation: false, + compatible: true, + }); +} + +function parseArguments(argv) { + const values = {}; + for (const argument of argv) { + const match = /^--([a-z-]+)=(.+)$/u.exec(argument); + if (!match || Object.hasOwn(values, match[1])) + fail('arguments are invalid'); + values[match[1]] = match[2]; + } + if (values.mode === 'surfaces-audit') { + if (Object.keys(values).length !== 1) fail('arguments are invalid'); + return Object.freeze({ mode: values.mode }); + } + const identity = [ + 'mode', + 'release-scope', + 'repository-owner', + 'source-ref', + 'source-revision', + 'version', + 'release-set', + ]; + const expected = + values.mode === 'local-create' + ? [...identity, 'allow-root-service', 'output'] + : values.mode === 'local-audit' + ? [...identity, 'allow-root-service', 'selection'] + : values.mode === 'kubernetes-create' + ? [ + ...identity, + 'manifest', + 'output-manifest', + 'output-report', + 'required-images', + ] + : values.mode === 'kubernetes-audit' + ? [ + ...identity, + 'locked-manifest', + 'manifest', + 'report', + 'required-images', + ] + : []; + if ( + expected.length === 0 || + JSON.stringify(Object.keys(values).sort()) !== + JSON.stringify(expected.sort()) + ) { + fail('arguments are invalid'); + } + return Object.freeze({ + mode: values.mode, + version: values.version, + sourceRevision: values['source-revision'], + sourceRef: values['source-ref'], + releaseScope: values['release-scope'], + repositoryOwner: values['repository-owner'], + releaseSet: values['release-set'], + ...(values['allow-root-service'] + ? { + allowRootService: + values['allow-root-service'] === 'true' + ? true + : values['allow-root-service'] === 'false' + ? false + : fail('allow-root-service must be true or false'), + } + : {}), + ...(values.output ? { output: values.output } : {}), + ...(values.selection ? { selection: values.selection } : {}), + ...(values.manifest ? { manifest: values.manifest } : {}), + ...(values['locked-manifest'] + ? { lockedManifest: values['locked-manifest'] } + : {}), + ...(values['output-manifest'] + ? { outputManifest: values['output-manifest'] } + : {}), + ...(values['output-report'] + ? { outputReport: values['output-report'] } + : {}), + ...(values.report ? { report: values.report } : {}), + ...(values['required-images'] + ? { requiredImages: values['required-images'] } + : {}), + }); +} + +function runCli(argv, root = DEFAULT_ROOT, output = process.stdout) { + const options = parseArguments(argv); + if (options.mode === 'surfaces-audit') { + const audit = auditDeploymentImageSurfaces(root); + output.write(canonicalJson(audit)); + return audit; + } + const releaseSet = readCanonicalJson( + options.releaseSet, + 'release set', + MAX_RELEASE_SET_BYTES, + ).value; + if (options.mode === 'local-create') { + const selection = createLocalSelection(releaseSet, options); + const target = preflightOutput(options.output, 'output'); + writeNoReplace(target, canonicalJson(selection)); + output.write(canonicalJson(selection)); + return selection; + } + if (options.mode === 'local-audit') { + const selection = readCanonicalJson( + options.selection, + 'local selection', + MAX_RELEASE_SET_BYTES, + ).value; + const audit = auditLocalSelection(selection, releaseSet, options); + output.write(canonicalJson(audit)); + return audit; + } + const sourceManifest = readBoundedFile( + options.manifest, + 'input manifest', + MAX_MANIFEST_BYTES, + ).contents; + if (options.mode === 'kubernetes-create') { + const manifestTarget = preflightOutput( + options.outputManifest, + 'output manifest', + ); + const reportTarget = preflightOutput(options.outputReport, 'output report'); + if (manifestTarget === reportTarget) { + fail('output manifest and report paths must differ'); + } + const created = createKubernetesLock(releaseSet, sourceManifest, options); + writeNoReplace(manifestTarget, created.outputManifest); + writeNoReplace(reportTarget, canonicalJson(created.report)); + output.write(canonicalJson(created.report)); + return created; + } + const lockedManifest = readBoundedFile( + options.lockedManifest, + 'locked manifest', + MAX_MANIFEST_BYTES, + ).contents; + const report = readCanonicalJson( + options.report, + 'deployment lock report', + MAX_RELEASE_SET_BYTES, + ).value; + const audit = auditKubernetesLock( + lockedManifest, + report, + releaseSet, + sourceManifest, + options, + ); + output.write(canonicalJson(audit)); + return audit; +} + +if (require.main === module) { + try { + runCli(process.argv.slice(2)); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : 'deployment lock failed'}\n`, + ); + process.exitCode = 1; + } +} + +module.exports = Object.freeze({ + KUBERNETES_LOCK_SCHEMA, + LOCAL_SELECTION_SCHEMA, + QingLong3DeploymentLockError, + auditDeploymentImageSurfaces, + auditKubernetesLock, + auditLocalSelection, + createKubernetesLock, + createLocalSelection, + parseArguments, + runCli, +}); diff --git a/scripts/ql3-release-candidate-contract.cjs b/scripts/ql3-release-candidate-contract.cjs index 707d8cc5..8d49e1b4 100644 --- a/scripts/ql3-release-candidate-contract.cjs +++ b/scripts/ql3-release-candidate-contract.cjs @@ -254,6 +254,7 @@ function createReleaseCandidateContract(options) { 'digest-signature-and-attestations', 'cross-image-release-set', 'durable-oci-release-catalog', + 'offline-deployment-lock-materialization', ...(options.releaseScope !== 'cluster' ? ['edge-and-standalone-rollout'] : []), diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index ccd3e0a4..b564df92 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -31,6 +31,7 @@ test('accepts the reviewed native CI and digest release contracts', () => { clusterAdminContextPreflight: true, clusterAdminContextReadiness: true, releaseVersionAudit: true, + deploymentLockMaterialization: true, ociAttestations: true, osVulnerabilityScan: { scanner: 'trivy@0.70.0', @@ -149,7 +150,29 @@ test('rejects removal of the durable release-catalog contract tests', () => { ); assert.throws( () => auditClusterImageCiWorkflow(mutated), - /durable catalog and workflow negative tests/, + /durable catalog, deployment-lock and workflow negative tests/, + ); +}); + +test('rejects removal of the deployment-lock materialization contract tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3DeploymentLockContract.test.cjs', + 'test/back/deployment-lock-tests-removed.test.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /deployment-lock and workflow negative tests/, + ); +}); + +test('rejects removal of the deployment image surface audit', () => { + const mutated = ciSource.replace( + 'pnpm audit:deployment-lock-surfaces:ql3', + 'echo deployment-image-surfaces-removed', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /reviewed deployment image surfaces/, ); }); diff --git a/test/back/ql3DeploymentLockContract.test.cjs b/test/back/ql3DeploymentLockContract.test.cjs new file mode 100644 index 00000000..36b1ad71 --- /dev/null +++ b/test/back/ql3DeploymentLockContract.test.cjs @@ -0,0 +1,704 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const test = require('node:test'); +const yaml = require('js-yaml'); +const { + auditDeploymentImageSurfaces, + auditKubernetesLock, + auditLocalSelection, + createKubernetesLock, + createLocalSelection, + parseArguments, + runCli, +} = require('../../scripts/ql3-deployment-lock-contract.cjs'); +const { + createReleaseSet, + createVerifiedImageRecord, +} = require('../../scripts/ql3-release-set-contract.cjs'); +const { + createReleaseCandidateContract, +} = require('../../scripts/ql3-release-candidate-contract.cjs'); +const { + readReleaseIdentity, +} = require('../../scripts/lib/ql3-release-identity.cjs'); + +const root = path.resolve(__dirname, '../..'); +const version = readReleaseIdentity(root).version; +const identity = Object.freeze({ + version, + sourceRevision: 'd'.repeat(40), + sourceRef: `refs/tags/v${version}`, + repositoryOwner: 'qinglong-release', +}); +const roleOrder = Object.freeze(['control', 'control-ai', 'admin', 'worker']); + +function executableOnPath(name) { + const candidates = [ + process.env.QL3_KUBECTL_BIN, + '/Applications/Docker.app/Contents/Resources/bin/kubectl', + ...(process.env.PATH ?? '') + .split(path.delimiter) + .filter(Boolean) + .map((directory) => path.join(directory, name)), + ].filter(Boolean); + return candidates.find((candidate) => { + try { + fs.accessSync(candidate, fs.constants.X_OK); + return fs.lstatSync(candidate).isFile(); + } catch { + return false; + } + }); +} + +const kubectlPath = executableOnPath('kubectl'); + +function releaseSet(scope) { + const candidate = createReleaseCandidateContract({ + root, + version, + sourceRevision: identity.sourceRevision, + sourceRef: identity.sourceRef, + releaseScope: scope, + }); + const records = candidate.images.map((entry, index) => + createVerifiedImageRecord({ + root, + candidate, + ...identity, + releaseScope: scope, + image: entry.image, + digest: `sha256:${String(index + 1).repeat(64)}`, + }), + ); + return createReleaseSet({ + root, + candidate, + records, + ...identity, + releaseScope: scope, + }); +} + +function options(scope, extra = {}) { + return Object.freeze({ + ...identity, + releaseScope: scope, + ...extra, + }); +} + +function references(set) { + return Object.fromEntries( + set.images.map((image) => [image.name, image.reference]), + ); +} + +function fixtureManifest() { + return `apiVersion: apps/v1 +kind: Deployment +metadata: + name: ql3-control +spec: + template: + metadata: + labels: + app: ql3-control + spec: + initContainers: + - name: migrate + image: ghcr.io/example/qinglong3-cluster-control:source + containers: + - name: control + image: qinglong3-cluster-control@sha256:${'0'.repeat(64)} + - name: sidecar + image: busybox:1.36 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ql3-control-ai +spec: + template: + metadata: {} + spec: + containers: + - name: control-ai + image: registry.example:5000/team/qinglong3-cluster-control-ai:source +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: ql3-admin +spec: + template: + metadata: {} + spec: + restartPolicy: Never + containers: + - name: admin + image: qinglong3-cluster-admin@sha256:${'0'.repeat(64)} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ql3-worker +spec: + template: + metadata: {} + spec: + initContainers: + - name: worker-init + image: qinglong3-worker:source + containers: + - name: worker + image: ghcr.io/example/qinglong3-worker@sha256:${'0'.repeat(64)} + ephemeralContainers: + - name: worker-debug + image: qinglong3-worker:debug +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ql3-plugin-package-secret-action-admission +data: + image: qinglong3-cluster-admin@sha256:${'0'.repeat(64)} +--- +apiVersion: v1 +kind: Service +metadata: + name: ql3-control +spec: + selector: + app: ql3-control + ports: + - port: 80 +`; +} + +function temporaryDirectory(t) { + const directory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-deployment-lock-')), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + return directory; +} + +function writeCanonical(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, { mode: 0o600 }); +} + +function kubernetesOptions(extra = {}) { + return options('cluster', { + requiredImages: roleOrder.join(','), + ...extra, + }); +} + +test('selects one immutable Local Compose image without adding device work', () => { + for (const scope of ['local', 'all']) { + const set = releaseSet(scope); + const selection = createLocalSelection( + set, + options(scope, { allowRootService: false }), + ); + assert.equal(selection.deploymentFamily, 'local'); + assert.equal(selection.releaseSetDigest, set.releaseSetDigest); + assert.equal(selection.service.kind, 'compose'); + assert.equal(selection.service.image, references(set).local); + assert.equal(selection.service.allowRootService, false); + assert.equal(selection.verification.networkAccess, false); + assert.equal(selection.verification.deploymentMutation, false); + assert.match(selection.selectionDigest, /^sha256:[a-f0-9]{64}$/u); + assert.equal( + auditLocalSelection( + selection, + set, + options(scope, { allowRootService: false }), + ).compatible, + true, + ); + } +}); + +test('Local selection rejects cluster scope, implicit root policy and drift', () => { + const clusterSet = releaseSet('cluster'); + assert.throws( + () => + createLocalSelection( + clusterSet, + options('cluster', { allowRootService: false }), + ), + /local or all release set/, + ); + const localSet = releaseSet('local'); + assert.throws( + () => createLocalSelection(localSet, options('local')), + /explicit boolean/, + ); + const selection = createLocalSelection( + localSet, + options('local', { allowRootService: true }), + ); + const drifted = JSON.parse(JSON.stringify(selection)); + drifted.service.image = 'ghcr.io/example/qinglong3-local:latest'; + assert.throws( + () => + auditLocalSelection( + drifted, + localSet, + options('local', { allowRootService: true }), + ), + /differs from the verified release set/, + ); +}); + +test('materializes every supported Kubernetes image authority from one release set', () => { + const set = releaseSet('cluster'); + const expectedReferences = references(set); + const created = createKubernetesLock( + set, + fixtureManifest(), + kubernetesOptions(), + ); + const resources = []; + yaml.loadAll(created.outputManifest, (resource) => resources.push(resource)); + const renderedImages = resources.flatMap((resource) => { + const podSpec = + resource.kind === 'Job' + ? resource.spec?.template?.spec + : resource.spec?.template?.spec; + return ['initContainers', 'containers', 'ephemeralContainers'].flatMap( + (key) => podSpec?.[key]?.map((container) => container.image) ?? [], + ); + }); + assert.deepEqual(created.report.requiredImages, roleOrder); + assert.deepEqual( + created.report.imageOccurrences.map(({ name, count }) => [name, count]), + [ + ['control', 2], + ['control-ai', 1], + ['admin', 2], + ['worker', 3], + ], + ); + assert.equal(created.report.manifest.resources, 6); + assert.equal(created.report.manifest.changedResources, 5); + assert.equal(created.report.manifest.admissionAuthorityCount, 1); + assert.equal(created.report.verification.unknownImageAuthorities, 0); + assert.equal(created.report.verification.mutableQingLongImages, 0); + assert.equal(created.report.verification.networkAccess, false); + assert.equal(created.report.verification.kubernetesMutation, false); + assert.equal(renderedImages.includes('busybox:1.36'), true); + for (const role of roleOrder) { + assert.equal( + renderedImages.includes(expectedReferences[role]) || role === 'admin', + true, + ); + } + const admission = resources.find( + (resource) => + resource.kind === 'ConfigMap' && + resource.metadata.name === 'ql3-plugin-package-secret-action-admission', + ); + assert.equal(admission.data.image, expectedReferences.admin); + for (const resource of resources.filter( + (entry) => entry.kind !== 'Service', + )) { + assert.equal( + resource.metadata.annotations['qinglong.io/release-set-digest'], + set.releaseSetDigest, + ); + } + assert.equal( + created.outputManifest.includes('qinglong3-cluster-control:source'), + false, + ); + assert.equal( + auditKubernetesLock( + created.outputManifest, + created.report, + set, + fixtureManifest(), + kubernetesOptions(), + ).compatible, + true, + ); +}); + +test('Kubernetes materialization rejects unsupported scope and incomplete role closure', () => { + const localSet = releaseSet('local'); + assert.throws( + () => + createKubernetesLock(localSet, fixtureManifest(), { + ...options('local'), + requiredImages: 'control', + }), + /cluster or all release set/, + ); + const clusterSet = releaseSet('cluster'); + const withoutWorker = fixtureManifest().replace( + /---\napiVersion: apps\/v1\nkind: Deployment\nmetadata:\n name: ql3-worker[\s\S]*?(?=---\napiVersion: v1\nkind: ConfigMap)/u, + '', + ); + assert.throws( + () => createKubernetesLock(clusterSet, withoutWorker, kubernetesOptions()), + /required image was not rendered: worker/, + ); +}); + +test('rejects mutable, malformed and hidden QingLong image authorities', () => { + const set = releaseSet('cluster'); + const hidden = `${fixtureManifest()}---\napiVersion: v1\nkind: ConfigMap\nmetadata:\n name: hidden-authority\ndata:\n image: ${ + references(set).control + }\n`; + assert.throws( + () => createKubernetesLock(set, hidden, kubernetesOptions()), + /unhandled QingLong image authority: data.image/, + ); + assert.throws( + () => + createKubernetesLock( + set, + fixtureManifest().replace( + 'qinglong3-cluster-control@sha256:', + 'qinglong3-cluster-control-not-an-image@sha256:', + ), + kubernetesOptions(), + ), + /unhandled QingLong image authority|container image is malformed/, + ); + assert.throws( + () => + createKubernetesLock( + set, + fixtureManifest().replace( + 'qinglong3-worker:source', + 'qinglong3-worker', + ), + kubernetesOptions(), + ), + /container image is malformed/, + ); +}); + +test('rejects unsafe YAML structure and invalid admission authority', () => { + const set = releaseSet('cluster'); + assert.throws( + () => + createKubernetesLock( + set, + 'apiVersion: v1\nkind: Pod\nkind: Job\n', + kubernetesOptions({ requiredImages: 'control' }), + ), + /duplicate-free YAML/, + ); + assert.throws( + () => + createKubernetesLock( + set, + '- not\n- a\n- resource\n', + kubernetesOptions({ requiredImages: 'control' }), + ), + /resource must be one mapping/, + ); + assert.throws( + () => + createKubernetesLock( + set, + fixtureManifest().replace( + 'data:\n image: qinglong3-cluster-admin', + 'data:\n image: busybox', + ), + kubernetesOptions(), + ), + /admission image authority is invalid/, + ); +}); + +test('audit detects source, locked manifest and report drift', () => { + const set = releaseSet('cluster'); + const created = createKubernetesLock( + set, + fixtureManifest(), + kubernetesOptions(), + ); + const report = JSON.parse(JSON.stringify(created.report)); + report.manifest.resources += 1; + assert.throws( + () => + auditKubernetesLock( + created.outputManifest, + report, + set, + fixtureManifest(), + kubernetesOptions(), + ), + /differs from the verified release set/, + ); + assert.throws( + () => + auditKubernetesLock( + `${created.outputManifest}\n`, + created.report, + set, + fixtureManifest(), + kubernetesOptions(), + ), + /differs from the verified release set/, + ); + assert.throws( + () => + auditKubernetesLock( + created.outputManifest, + created.report, + set, + `${fixtureManifest()}\n`, + kubernetesOptions(), + ), + /differs from the verified release set/, + ); +}); + +test('CLI creates and audits no-replace Local and Kubernetes outputs', (t) => { + const directory = temporaryDirectory(t); + const output = { write() {} }; + const localSet = releaseSet('local'); + const localSetPath = path.join(directory, 'local-set.json'); + const selectionPath = path.join(directory, 'selection.json'); + writeCanonical(localSetPath, localSet); + const localIdentity = [ + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=local', + `--repository-owner=${identity.repositoryOwner}`, + `--release-set=${localSetPath}`, + '--allow-root-service=false', + ]; + runCli( + ['--mode=local-create', ...localIdentity, `--output=${selectionPath}`], + root, + output, + ); + assert.equal(fs.statSync(selectionPath).mode & 0o777, 0o600); + assert.equal( + runCli( + ['--mode=local-audit', ...localIdentity, `--selection=${selectionPath}`], + root, + output, + ).compatible, + true, + ); + assert.throws( + () => + runCli( + ['--mode=local-create', ...localIdentity, `--output=${selectionPath}`], + root, + output, + ), + /output must be unused/, + ); + + const clusterSet = releaseSet('cluster'); + const clusterSetPath = path.join(directory, 'cluster-set.json'); + const sourcePath = path.join(directory, 'rendered.yaml'); + const lockedPath = path.join(directory, 'locked.yaml'); + const reportPath = path.join(directory, 'lock.json'); + writeCanonical(clusterSetPath, clusterSet); + fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 }); + const clusterIdentity = [ + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=cluster', + `--repository-owner=${identity.repositoryOwner}`, + `--release-set=${clusterSetPath}`, + `--manifest=${sourcePath}`, + `--required-images=${roleOrder.join(',')}`, + ]; + runCli( + [ + '--mode=kubernetes-create', + ...clusterIdentity, + `--output-manifest=${lockedPath}`, + `--output-report=${reportPath}`, + ], + root, + output, + ); + assert.equal(fs.statSync(lockedPath).mode & 0o777, 0o600); + assert.equal(fs.statSync(reportPath).mode & 0o777, 0o600); + assert.equal( + runCli( + [ + '--mode=kubernetes-audit', + ...clusterIdentity, + `--locked-manifest=${lockedPath}`, + `--report=${reportPath}`, + ], + root, + output, + ).compatible, + true, + ); +}); + +test('CLI rejects symlinks, open arguments, output aliasing and policy ambiguity', (t) => { + const directory = temporaryDirectory(t); + const localSetPath = path.join(directory, 'local-set.json'); + const linkedSetPath = path.join(directory, 'linked-set.json'); + writeCanonical(localSetPath, releaseSet('local')); + fs.symlinkSync(localSetPath, linkedSetPath); + const base = [ + '--mode=local-create', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=local', + `--repository-owner=${identity.repositoryOwner}`, + `--release-set=${linkedSetPath}`, + '--allow-root-service=false', + `--output=${path.join(directory, 'selection.json')}`, + ]; + assert.throws( + () => runCli(base, root, { write() {} }), + /canonical regular file/, + ); + assert.throws( + () => parseArguments([...base, '--extra=value']), + /arguments are invalid/, + ); + assert.throws( + () => + parseArguments( + base.map((argument) => + argument === '--allow-root-service=false' + ? '--allow-root-service=maybe' + : argument, + ), + ), + /must be true or false/, + ); + + const clusterSetPath = path.join(directory, 'cluster-set.json'); + const sourcePath = path.join(directory, 'rendered.yaml'); + const aliasedOutput = path.join(directory, 'same-output'); + writeCanonical(clusterSetPath, releaseSet('cluster')); + fs.writeFileSync(sourcePath, fixtureManifest(), { mode: 0o600 }); + assert.throws( + () => + runCli( + [ + '--mode=kubernetes-create', + `--version=${version}`, + `--source-revision=${identity.sourceRevision}`, + `--source-ref=${identity.sourceRef}`, + '--release-scope=cluster', + `--repository-owner=${identity.repositoryOwner}`, + `--release-set=${clusterSetPath}`, + `--manifest=${sourcePath}`, + `--required-images=${roleOrder.join(',')}`, + `--output-manifest=${aliasedOutput}`, + `--output-report=${aliasedOutput}`, + ], + root, + { write() {} }, + ), + /paths must differ/, + ); +}); + +test('source-surface audit freezes every reviewed cluster and worker authority', () => { + assert.deepEqual(auditDeploymentImageSurfaces(root), { + schemaVersion: 1, + deploymentYamlFiles: 224, + imageOccurrences: { + control: 2, + 'control-ai': 1, + admin: 26, + worker: 2, + }, + admissionAuthorityCount: 2, + materialization: 'offline_post_render', + networkAccess: false, + kubernetesMutation: false, + compatible: true, + }); + assert.deepEqual( + runCli(['--mode=surfaces-audit'], root, { write() {} }), + auditDeploymentImageSurfaces(root), + ); +}); + +test( + 'real Kustomize renders are locked after nested overlay transforms', + { skip: kubectlPath ? false : 'kubectl is unavailable' }, + () => { + const set = releaseSet('cluster'); + const expectedReferences = references(set); + for (const entry of [ + { + directory: 'deploy/kubernetes/ql3-cluster/overlays/cloudnative-pg', + requiredImages: ['control'], + }, + { + directory: 'deploy/kubernetes/ql3-cluster/overlays/cluster-ai-example', + requiredImages: ['control-ai'], + }, + { + directory: 'deploy/kubernetes/ql3-worker/overlays/node', + requiredImages: ['worker'], + }, + { + directory: + 'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg', + requiredImages: ['admin'], + }, + ]) { + const rendered = spawnSync( + kubectlPath, + ['kustomize', path.join(root, entry.directory)], + { + cwd: root, + encoding: 'utf8', + maxBuffer: 16 * 1024 * 1024, + }, + ); + assert.equal( + rendered.status, + 0, + `${entry.directory}: ${rendered.stderr}`, + ); + const created = createKubernetesLock( + set, + rendered.stdout, + kubernetesOptions({ + requiredImages: entry.requiredImages.join(','), + }), + ); + for (const role of entry.requiredImages) { + assert.equal( + created.outputManifest.includes(expectedReferences[role]), + true, + ); + assert.equal( + created.report.imageOccurrences.find((image) => image.name === role) + .count > 0, + true, + ); + } + assert.equal( + created.outputManifest.includes( + '@sha256:0000000000000000000000000000000000000000000000000000000000000000', + ), + false, + ); + } + }, +); diff --git a/test/back/ql3ReleaseCandidateContract.test.cjs b/test/back/ql3ReleaseCandidateContract.test.cjs index 8572ff25..f1403c2f 100644 --- a/test/back/ql3ReleaseCandidateContract.test.cjs +++ b/test/back/ql3ReleaseCandidateContract.test.cjs @@ -48,6 +48,10 @@ test('freezes an independent low-resource local release family', () => { /^sha256:[a-f0-9]{64}$/u, ); assert.match(contract.contractDigest, /^sha256:[a-f0-9]{64}$/u); + assert.equal( + contract.requiredGates.includes('offline-deployment-lock-materialization'), + true, + ); assert.deepEqual( auditReleaseCandidateContract(contract, { root, @@ -109,6 +113,10 @@ test('combines local and cluster families without weakening either gate', () => contract.requiredGates.includes('durable-oci-release-catalog'), true, ); + assert.equal( + contract.requiredGates.includes('offline-deployment-lock-materialization'), + true, + ); assert.equal( contract.requiredGates.includes('worker-management-production-evidence'), true,