mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): materialize offline deployment locks
This commit is contained in:
@@ -302,6 +302,7 @@ jobs:
|
|||||||
test/back/ql3ReleaseCandidateContract.test.cjs
|
test/back/ql3ReleaseCandidateContract.test.cjs
|
||||||
test/back/ql3ReleaseSetContract.test.cjs
|
test/back/ql3ReleaseSetContract.test.cjs
|
||||||
test/back/ql3ReleaseCatalogContract.test.cjs
|
test/back/ql3ReleaseCatalogContract.test.cjs
|
||||||
|
test/back/ql3DeploymentLockContract.test.cjs
|
||||||
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
test/back/ql3ImageOsVulnerabilityPolicy.test.cjs
|
||||||
test/back/ql3LocalImageAudit.test.cjs
|
test/back/ql3LocalImageAudit.test.cjs
|
||||||
test/back/ql3VersionTransition.test.cjs
|
test/back/ql3VersionTransition.test.cjs
|
||||||
@@ -309,6 +310,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pnpm audit:release-version:ql3
|
pnpm audit:release-version:ql3
|
||||||
pnpm audit:image-release:ql3
|
pnpm audit:image-release:ql3
|
||||||
|
pnpm audit:deployment-lock-surfaces:ql3
|
||||||
pnpm audit:local-image:ql3
|
pnpm audit:local-image:ql3
|
||||||
- name: Reject high or critical advisories in 3.0 Profile importers
|
- name: Reject high or critical advisories in 3.0 Profile importers
|
||||||
run: pnpm audit:profiles:ql3
|
run: pnpm audit:profiles:ql3
|
||||||
|
|||||||
@@ -161,21 +161,22 @@ pnpm audit:cluster-remote-manifest:ql3 \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The existence of the workflow is not publication evidence. Before production
|
The existence of the workflow is not publication evidence. Before production
|
||||||
rollout, obtain the `cluster` or `all` deployment release set from a successful
|
rollout, obtain and verify the `cluster` or `all` deployment release set, render
|
||||||
release run, verify its file provenance, and pin the four control, control-ai,
|
the selected overlay, and run the offline deployment-lock post-renderer. Apply
|
||||||
worker and admin `sha256:` references recorded in that one source-bound set.
|
only the audited locked manifest. Do not edit nested Kustomize image transforms
|
||||||
Never treat the version tag, a partial matrix run or one image's proof as
|
by hand or apply the pre-lock render: an outer transform cannot reliably replace
|
||||||
authority for another role. The exact operator procedure and non-atomic tag
|
an image digest already selected by an inner overlay. The exact operator
|
||||||
recovery boundary are documented in
|
procedure and non-atomic tag recovery boundary are documented in
|
||||||
[`docs/operations/ql3-release-set-deployment.md`](../../../docs/operations/ql3-release-set-deployment.md).
|
[`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
|
The committed CloudNativePG control, migration and Plugin Package recovery overlays use
|
||||||
an all-zero SHA-256 digest as an intentionally unpullable fail-closed
|
an all-zero SHA-256 digest as an intentionally unpullable fail-closed
|
||||||
placeholder. A private deployment overlay must replace the control placeholder
|
placeholder. The offline post-renderer must replace the control placeholder with
|
||||||
with the verified `qinglong3-cluster-control` digest and the recovery
|
the verified `qinglong3-cluster-control` digest and every Admin workload or
|
||||||
placeholder with the independently verified `qinglong3-cluster-admin` digest.
|
admission ConfigMap authority with the independently verified
|
||||||
Static deployment audit rejects replacing either production-oriented reference
|
`qinglong3-cluster-admin` digest from the same release set. Static deployment
|
||||||
with `newTag`.
|
audit freezes the reviewed source image surfaces, and the materializer rejects
|
||||||
|
unhandled role image authorities.
|
||||||
|
|
||||||
## CloudNativePG production profile
|
## CloudNativePG production profile
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ docker build \
|
|||||||
Production rollout must take the Worker `@sha256:` reference from the same
|
Production rollout must take the Worker `@sha256:` reference from the same
|
||||||
verified `cluster` or `all` release set as control, control-ai and admin. A
|
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
|
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).
|
[`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
|
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:
|
semantics before the first delivery:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
kubectl apply -k deploy/kubernetes/ql3-worker/base
|
kubectl kustomize deploy/kubernetes/ql3-worker/base > /private/workstation/path/worker-rendered.yaml
|
||||||
# or:
|
# or render deploy/kubernetes/ql3-worker/overlays/node
|
||||||
kubectl apply -k 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 apply -k deploy/kubernetes/ql3-worker/credential-admin
|
||||||
kubectl kustomize deploy/kubernetes/ql3-worker/credential-bootstrap \
|
kubectl kustomize deploy/kubernetes/ql3-worker/credential-bootstrap \
|
||||||
| kubectl create -f -
|
| kubectl create -f -
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(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/<owner>/qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v<version>-<scope>` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。
|
- D-336/ADR-0428(已接受;公开发布结果待实际 tag):D-335 的 90 天 workflow artifact 不再是长期唯一发布入口。完整 release set 现以单层 OCI artifact 发布到专用 `ghcr.io/<owner>/qinglong3-release-catalog`,固定 artifact/file media type、basename、四项 annotation 和 byte-exact round trip;`v<version>-<scope>` 仅作发现且 authority 明确为 `none`,部署权威是验证后的 catalog `@sha256:` immutable reference。发布后先按 digest 取回并逐字节比较、审计 raw manifest,再为该 digest 生成 exact workflow identity 的 keyless Cosign signature 与绑定 source tag/revision 的 GitHub OCI provenance;验证成功后才生成 canonical receipt 并为 receipt 增加 file provenance。release-set 新增不依赖短期 candidate/image-record 的 standalone inspect,重算结构、身份、Local/Cluster 镜像闭包与 self digest,同时显式声明未重放 source records。低配路由器可在可信工作站完成 registry/签名/provenance/Node 验真,只消费 `local` JSON 与镜像 digest,设备不增加工具、package、依赖或常驻资源;Cluster 使用同一 catalog 锁定 control、control-ai、worker、admin 四角色。真实本机 `ocidir://` 实验确认同一文件来自两个不同绝对目录时,`--file-title --strip-dirs` 产生相同 manifest digest `sha256:0443422e34edd448499a61f4580b01b9578dc35a117668c948c51a16638e4e9d`,immutable get 与源文件逐字节一致。定向发布契约/静态 workflow/Console 联动测试 93/93;backend 1282 项为 1280 pass、2 条件 skip、0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,release version、dependency、Edge import、Cluster/Worker deployment、image release、Local image 与 Console distribution 审计全部 compatible。14 档 Local artifact 均 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。公开 GHCR push、签名和 attestation 尚未执行,且 GHCR 保留/删除仍属于组织治理,因此不宣称 catalog 为 WORM 或已完成线上发布。
|
||||||
- D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。
|
- D-335/ADR-0427(已接受;公开发布结果待实际 tag):发布矩阵不再让每个镜像验证成功后独立写 version/source tag。每个 publisher 只产生不可变 digest,在远端 manifest、Cosign、四类 GitHub attestation 与适用的 Local rollout 全部验证后生成绑定同一 candidate/scope/source/owner/repository/platform/digest 的 canonical image record;唯一 release-set 终态 job 只在完整 publish matrix 成功后下载 exact `run_id/run_attempt` record,重新生成 candidate,并要求 Local 一镜像、Cluster 四镜像或 All 五镜像集合无遗漏、无重复、顺序一致。独立审计通过后才统一 promotion,写前回读全部 source digest/既有 tag、冲突失败、缺失才 copy、写后再验 digest;明确不宣称 GHCR 跨仓库原子性,以 `verify_exact_digest_then_continue` 支持同源幂等恢复。最终 `qinglong/release-set@v1` 同时冻结 deployment family、五类可选镜像、image-record digest 和 `@sha256:` 引用,获得 GitHub file provenance 并作为 90 天 deployment digest-lock artifact 发布。Edge/Standalone 只消费 `local` set,Cluster 只消费四角色 set,`all` 不把两族运行时耦合;不新增 package、生产依赖、Pod、controller、listener、timer、watcher、数据库、migration、SQL、Pool 或低配设备常驻开销。定向 contract/workflow 回归 73/73,联动发布/Console distribution 回归 77/77;backend 1,264 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,dependency、Edge import、Cluster/Worker deployment、image release 与 Local image 审计均 compatible。14 档 Local artifact 全部 compatible,默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 无数据库/HA 拓扑变化,复用 D-331/D-333 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线;公开 tag 尚未执行,因此不宣称真实 GHCR promotion 或线上 attestation 已成功。
|
||||||
- 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` 基线;完整回归未发现数据库或部署拓扑漂移。
|
- 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` 基线;完整回归未发现数据库或部署拓扑漂移。
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -432,6 +432,7 @@
|
|||||||
| [ADR-0426](./ADR-0426-source-derived-release-version-transition.md) | Source-derived QingLong 3.0 Release Version Transition | Accepted |
|
| [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-0427](./ADR-0427-complete-cross-image-release-set.md) | 完整跨镜像发布集与部署 Digest Lock | Accepted |
|
||||||
| [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted |
|
| [ADR-0428](./ADR-0428-durable-oci-release-catalog.md) | 持久化 OCI Release Catalog 与独立部署验真 | Accepted |
|
||||||
|
| [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,94 @@ gh attestation verify "${release_set}" \
|
|||||||
`inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image
|
`inspect` 会重算 release-set self digest 并验证结构、身份、镜像闭包和 family,但不会重放发布时已经过期的 image
|
||||||
records;其输出必须保持 `sourceRecordsReplayed:false`。
|
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
|
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`、
|
2. `schema` 必须为 `qinglong/release-set@v1`;`release.version`、`release.sourceRef`、
|
||||||
`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
`release.sourceRevision`、`release.scope` 必须与变更单一致。
|
||||||
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
3. 镜像集合必须与上表精确相等;每个 `reference` 必须是 digest reference,且 owner/repository 与部署目标一致。
|
||||||
4. Kubernetes overlay 用 `newName` 加 digest 或等价的 immutable image reference;不得把生产 placeholder 改成
|
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 locked manifest;嵌套 overlay 的
|
||||||
`newTag`。Local compose/rollout 同样固定 `@sha256:`。
|
`newName`/digest 不是最终 authority。Local 必须生成并审计 service selection。两族最终都只能消费 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。维护者在可信工作站完成上述 ceremony,
|
路由器或其他低配 Edge 设备不需要安装 Node、regctl、Cosign、GitHub CLI、Kustomize 或 materializer。维护者在可信工作站
|
||||||
再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable image reference 写入 compose/rollout。
|
完成上述 ceremony 和 Local selection 审计,再向设备传输已检查的 canonical JSON,并只把 `local` family 的 immutable
|
||||||
|
image reference 写入 compose/rollout。
|
||||||
设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。
|
设备不下载 Cluster 四镜像,也不加载 Kubernetes、CloudNativePG、PostgreSQL driver 或 Worker 私有发布证据。
|
||||||
|
|
||||||
如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与
|
如果设备本身不运行容器 registry client,可由工作站按 digest 拉取并通过既有离线交付渠道传送镜像;离线包的哈希与
|
||||||
|
|||||||
@@ -113,6 +113,8 @@
|
|||||||
"audit:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-evidence-audit.cjs",
|
"audit:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-evidence-audit.cjs",
|
||||||
"gate:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-gate.cjs",
|
"gate:worker-management-release:ql3": "node scripts/ql3-worker-credential-management-release-gate.cjs",
|
||||||
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
||||||
|
"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: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-image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
||||||
"audit:cluster-oci-layout:ql3": "node scripts/ql3-cluster-oci-layout-audit.cjs",
|
"audit:cluster-oci-layout:ql3": "node scripts/ql3-cluster-oci-layout-audit.cjs",
|
||||||
|
|||||||
@@ -274,14 +274,19 @@ function auditClusterImageCiWorkflow(
|
|||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/node --test[\s\S]*test\/back\/ql3ClusterImageSbom\.test\.cjs[\s\S]*test\/back\/ql3ClusterImageReleaseAudit\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCandidateContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseSetContract\.test\.cjs[\s\S]*test\/back\/ql3ReleaseCatalogContract\.test\.cjs/,
|
/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 and workflow negative tests',
|
'cluster image CI must run SBOM, candidate, release-set, durable catalog, deployment-lock and workflow negative tests',
|
||||||
);
|
);
|
||||||
requirePattern(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/pnpm audit:image-release:ql3/,
|
/pnpm audit:image-release:ql3/,
|
||||||
'image CI must audit the shared release workflow contract',
|
'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(
|
requirePattern(
|
||||||
source,
|
source,
|
||||||
/test\/back\/ql3VersionTransition\.test\.cjs/,
|
/test\/back\/ql3VersionTransition\.test\.cjs/,
|
||||||
@@ -386,6 +391,7 @@ function auditClusterImageCiWorkflow(
|
|||||||
clusterAdminContextPreflight: true,
|
clusterAdminContextPreflight: true,
|
||||||
clusterAdminContextReadiness: true,
|
clusterAdminContextReadiness: true,
|
||||||
releaseVersionAudit: true,
|
releaseVersionAudit: true,
|
||||||
|
deploymentLockMaterialization: true,
|
||||||
ociAttestations: true,
|
ociAttestations: true,
|
||||||
osVulnerabilityScan: {
|
osVulnerabilityScan: {
|
||||||
scanner: 'trivy@0.70.0',
|
scanner: 'trivy@0.70.0',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -254,6 +254,7 @@ function createReleaseCandidateContract(options) {
|
|||||||
'digest-signature-and-attestations',
|
'digest-signature-and-attestations',
|
||||||
'cross-image-release-set',
|
'cross-image-release-set',
|
||||||
'durable-oci-release-catalog',
|
'durable-oci-release-catalog',
|
||||||
|
'offline-deployment-lock-materialization',
|
||||||
...(options.releaseScope !== 'cluster'
|
...(options.releaseScope !== 'cluster'
|
||||||
? ['edge-and-standalone-rollout']
|
? ['edge-and-standalone-rollout']
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ test('accepts the reviewed native CI and digest release contracts', () => {
|
|||||||
clusterAdminContextPreflight: true,
|
clusterAdminContextPreflight: true,
|
||||||
clusterAdminContextReadiness: true,
|
clusterAdminContextReadiness: true,
|
||||||
releaseVersionAudit: true,
|
releaseVersionAudit: true,
|
||||||
|
deploymentLockMaterialization: true,
|
||||||
ociAttestations: true,
|
ociAttestations: true,
|
||||||
osVulnerabilityScan: {
|
osVulnerabilityScan: {
|
||||||
scanner: 'trivy@0.70.0',
|
scanner: 'trivy@0.70.0',
|
||||||
@@ -149,7 +150,29 @@ test('rejects removal of the durable release-catalog contract tests', () => {
|
|||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => auditClusterImageCiWorkflow(mutated),
|
() => 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/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -48,6 +48,10 @@ test('freezes an independent low-resource local release family', () => {
|
|||||||
/^sha256:[a-f0-9]{64}$/u,
|
/^sha256:[a-f0-9]{64}$/u,
|
||||||
);
|
);
|
||||||
assert.match(contract.contractDigest, /^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(
|
assert.deepEqual(
|
||||||
auditReleaseCandidateContract(contract, {
|
auditReleaseCandidateContract(contract, {
|
||||||
root,
|
root,
|
||||||
@@ -109,6 +113,10 @@ test('combines local and cluster families without weakening either gate', () =>
|
|||||||
contract.requiredGates.includes('durable-oci-release-catalog'),
|
contract.requiredGates.includes('durable-oci-release-catalog'),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
assert.equal(
|
||||||
|
contract.requiredGates.includes('offline-deployment-lock-materialization'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
contract.requiredGates.includes('worker-management-production-evidence'),
|
contract.requiredGates.includes('worker-management-production-evidence'),
|
||||||
true,
|
true,
|
||||||
|
|||||||
Reference in New Issue
Block a user