mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add catalog-bound cluster deployment ceremony
This commit is contained in:
@@ -11,6 +11,27 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-16):
|
最新增量证据(2026-08-16):
|
||||||
|
|
||||||
|
- D-341/ADR-0433(已接受;真实公开 catalog 运行待实际 release tag):Cluster 最后一跳不再交给裸
|
||||||
|
`kubectl apply -f locked.yaml`。新增工作站级 `cluster.deployment.preflight|apply|receipt.audit`,从 owner-private canonical
|
||||||
|
command 出发独立复验 Kubernetes v2 lock/report、locked manifest、release-set/catalog/workflow/image/annotation 闭包,并固定
|
||||||
|
kubectl executable digest、kubeconfig digest/context、`kube-system` Namespace UID 与 field manager
|
||||||
|
`qinglong3-catalog-lock`。kubeconfig `exec`/`auth-provider`、ambient namespace/HOME、`--force-conflicts`、symlink/replaceable input、
|
||||||
|
lock/target 漂移均失败关闭。preflight 只执行 pinned kubectl 的 server-side strict dry-run;apply 在再次 dry-run 后执行显式
|
||||||
|
server-side apply,再以同一 executable 的 managed-fields convergence read 复验 live UID/resourceVersion、期望字段、image/catalog
|
||||||
|
authority 与 Apply ownership;不用会隐式启动 ambient `diff` 的 `kubectl diff`。成功才发布绑定 command/preflight/lock/cluster/
|
||||||
|
executable/step transcript digest 的 no-replace receipt;多资源非原子事实显式为 false,响应丢失使用同 command 幂等重放后收敛,
|
||||||
|
不猜测自动删除或回滚。每个 kubectl 调用使用一次性私有 HOME/XDG/TMP,结束清理。实现没有新增 workspace package、生产依赖、
|
||||||
|
controller、webhook、CRD、RBAC、ServiceAccount、Pod、listener、timer、数据库或 Pool;Local/Edge/Standalone 零导入、零制品增量。
|
||||||
|
定向契约 10/10;完整 backend 1,327 项为 1,325 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0。10 项架构/部署
|
||||||
|
审计与 14 档 Local artifact 全部 compatible,package boundary 保持 18 packages、`singleSourcePackages=[]`、
|
||||||
|
`shallowSourcePackages=[]`;最小 Edge 仍为 2,589,890 bytes/315 files,14 档字节数均与 D-340 一致。Cluster Admin exact
|
||||||
|
dry-run pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。隔离三节点 K3s `v1.34.3+k3s1`/arm64 真实运行
|
||||||
|
6 个资源、4 个零副本 Deployment,server-side dry-run/apply/convergence/offline receipt audit 全部通过;最终 preflight/receipt
|
||||||
|
digest 为 `sha256:7c6db6236a704cd7791f94268b3883d6385a75a993a14d1611791f885fb65404`/
|
||||||
|
`sha256:58817a667bccf28c068fff40619cf13d2d0a1be84153de66a137b005feb536ba`,容器、网络和仓库 `.kube` cache 零残留。经允许
|
||||||
|
重跑 PostgreSQL 18.6 arm64 physical HA,142/142、timeline `1→2`,报告 SHA-256
|
||||||
|
`f5f4398229d0122e86f159bed005111b7f5ecacee9ce38ab6d532b0986b540cf`,离线审计通过且容器、卷、网络零残留。live 使用的
|
||||||
|
synthetic lock 只验证目标 apply 语义,不冒充尚未产生的公开 GHCR catalog。
|
||||||
- D-340/ADR-0432(已接受;真实公开 catalog 运行待实际 release tag):Local/Compose 最后一跳不再接受裸 `image`。现有 `@qinglong/local-owner-cli` 的 prepare/upgrade 只接收 owner-private `releaseSelection.path + expectedSelectionDigest`,以 `O_NOFOLLOW` stable descriptor 有界读取不超过 64 KiB 的 canonical v2 selection,并重新验证 self-digest、3.x release identity、release-set/catalog manifest/catalog report digest 闭包、exact workflow identity、immutable catalog reference、唯一 GHCR Local application digest 与 explicit root policy。Compose revision/active selection 升为 `qinglong/local-compose-image-selection@v2`,持久保存完整 catalog/release authority;rollback 精确复制目标 revision authority,Preflight、Apply、Restore、Evidence 与 Status 共用同一 fail-closed parser。prepare/upgrade 仍只发布 revision,不联网、不执行 rollout、不修改数据库,也不新增 package、生产依赖、常驻进程或 Cluster 对象;低配设备每次显式命令只增加一次最多 64 KiB 的私有文件读取、canonical JSON 校验和 SHA-256,Cluster 路径不变。旧 v1 裸 image 在尚未正式发布的 3.0 中失败关闭,孵化环境须从原 catalog-bound selection 重新 prepare。Local 定向 30/30、物理 Edge Compose storage 6/6;Local Owner 全量 171 项为 166 pass/5 条件 skip/0 fail,backend 共 1,317 项为 1,315 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Edge/Standalone 默认制品为 2,589,890/2,589,968 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes。Cluster Admin exact dry-run pack 为 250 files、271,238-byte tarball、1,690,196-byte unpacked。经允许重新运行的 PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,报告 SHA-256 为 `07c914551ec700da26b42cd42760ccb3b28ad31266a8bae5f62dee38eb97e6a9`,离线审计通过且无 `ql3-ha-*` Docker 资源残留。测试中的 synthetic selection 只验证本地 Compose 兼容性,不冒充公开 catalog ceremony;公开 GHCR catalog 尚未实际产生,因此不宣称真实线上验签成功。
|
- D-340/ADR-0432(已接受;真实公开 catalog 运行待实际 release tag):Local/Compose 最后一跳不再接受裸 `image`。现有 `@qinglong/local-owner-cli` 的 prepare/upgrade 只接收 owner-private `releaseSelection.path + expectedSelectionDigest`,以 `O_NOFOLLOW` stable descriptor 有界读取不超过 64 KiB 的 canonical v2 selection,并重新验证 self-digest、3.x release identity、release-set/catalog manifest/catalog report digest 闭包、exact workflow identity、immutable catalog reference、唯一 GHCR Local application digest 与 explicit root policy。Compose revision/active selection 升为 `qinglong/local-compose-image-selection@v2`,持久保存完整 catalog/release authority;rollback 精确复制目标 revision authority,Preflight、Apply、Restore、Evidence 与 Status 共用同一 fail-closed parser。prepare/upgrade 仍只发布 revision,不联网、不执行 rollout、不修改数据库,也不新增 package、生产依赖、常驻进程或 Cluster 对象;低配设备每次显式命令只增加一次最多 64 KiB 的私有文件读取、canonical JSON 校验和 SHA-256,Cluster 路径不变。旧 v1 裸 image 在尚未正式发布的 3.0 中失败关闭,孵化环境须从原 catalog-bound selection 重新 prepare。Local 定向 30/30、物理 Edge Compose storage 6/6;Local Owner 全量 171 项为 166 pass/5 条件 skip/0 fail,backend 共 1,317 项为 1,315 pass/2 条件 skip/0 fail,18-package clean build/test 退出 0。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`;Edge/Standalone 默认制品为 2,589,890/2,589,968 bytes,application 为 3,632,769/3,632,889 bytes,application-api 为 3,800,322/3,800,466 bytes,AI 为 3,069,143/3,069,233 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes。Cluster Admin exact dry-run pack 为 250 files、271,238-byte tarball、1,690,196-byte unpacked。经允许重新运行的 PostgreSQL 18.6 arm64 physical HA 通过 142/142、timeline `1→2`,报告 SHA-256 为 `07c914551ec700da26b42cd42760ccb3b28ad31266a8bae5f62dee38eb97e6a9`,离线审计通过且无 `ql3-ha-*` Docker 资源残留。测试中的 synthetic selection 只验证本地 Compose 兼容性,不冒充公开 catalog ceremony;公开 GHCR catalog 尚未实际产生,因此不宣称真实线上验签成功。
|
||||||
- D-339/ADR-0431(已接受;真实公开 catalog 运行待实际 release tag):D337 的 deployment-lock CLI 不再接受一份无法证明来源的松散 `--release-set`;Local/Kubernetes create/audit 必须同时接收 exact source repository 和 D338 生成的 owner-private three-file `--consumption-bundle`,先完整离线重建 release-set、raw OCI manifest、catalog plan/receipt、六步 argv/transcript digest 与 self-digest report,再把同一次 audit 读取的 release-set 对象交给 materializer,避免验真后重新按裸路径读取。Local selection 与 Kubernetes lock schema 升为 v2,显式绑定 consumption schema、source repository、exact workflow identity、catalog immutable reference、manifest digest、consumption report digest、release-set digest 和 `discoveryTagAuthority=none`;Cluster 被改写资源与 Pod template 也新增 catalog manifest/report digest annotations。旧 `--release-set`、bundle symlink/open shape、identity/scope/owner/image-count/digest 漂移均在创建任何输出前失败关闭。offline audit 诚实保持 `externalToolResultsReplayed=false`;本 Gate 不联网、不访问 Kubernetes API、不执行 Compose rollout/`kubectl apply`、不修改数据库,也不新增 package、生产依赖或运行期组件。供应链工作仍留在可信工作站,低配设备只接收 catalog-bound Local v2 selection 与一个 immutable image reference;Cluster 复用既有 post-render 流程。完整定向发布链 123/123;backend 共 1,317 项,1,315 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 未实际产生,因此不宣称真实线上验签成功。
|
- D-339/ADR-0431(已接受;真实公开 catalog 运行待实际 release tag):D337 的 deployment-lock CLI 不再接受一份无法证明来源的松散 `--release-set`;Local/Kubernetes create/audit 必须同时接收 exact source repository 和 D338 生成的 owner-private three-file `--consumption-bundle`,先完整离线重建 release-set、raw OCI manifest、catalog plan/receipt、六步 argv/transcript digest 与 self-digest report,再把同一次 audit 读取的 release-set 对象交给 materializer,避免验真后重新按裸路径读取。Local selection 与 Kubernetes lock schema 升为 v2,显式绑定 consumption schema、source repository、exact workflow identity、catalog immutable reference、manifest digest、consumption report digest、release-set digest 和 `discoveryTagAuthority=none`;Cluster 被改写资源与 Pod template 也新增 catalog manifest/report digest annotations。旧 `--release-set`、bundle symlink/open shape、identity/scope/owner/image-count/digest 漂移均在创建任何输出前失败关闭。offline audit 诚实保持 `externalToolResultsReplayed=false`;本 Gate 不联网、不访问 Kubernetes API、不执行 Compose rollout/`kubectl apply`、不修改数据库,也不新增 package、生产依赖或运行期组件。供应链工作仍留在可信工作站,低配设备只接收 catalog-bound Local v2 selection 与一个 immutable image reference;Cluster 复用既有 post-render 流程。完整定向发布链 123/123;backend 共 1,317 项,1,315 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 未实际产生,因此不宣称真实线上验签成功。
|
||||||
- D-338/ADR-0430(已接受;真实公开 catalog 运行待实际 release tag):发布端的 durable catalog 不再由部署者通过松散 shell 重定向手工消费。可信工作站上的 `ql3-release-catalog-consumption-ceremony.cjs` 从 exact source version/revision/tag、closed `local|cluster|all` scope 与 owner/source repository 推导唯一 discovery ref,前后两次解析必须得到同一 digest,后续只使用 catalog `@sha256:` immutable reference。ceremony 以绝对路径、dev/inode/size/SHA-256 固定 `regctl|cosign|gh`,owner-private token 只进入单个 GitHub provenance verifier;环境、cache/config/tmp 与最终写入均有封闭边界。下载的 canonical release set 经过 standalone identity/family/self-digest inspection,raw OCI manifest 同时按 digest、media type、empty config、单 layer、basename、size/content digest 与四项 annotation 重建 publication plan/receipt。成功后才以 `0700` no-replace 目录和三项 `0600` 文件发布 release set、raw manifest 与 self-digest report;offline audit 要求 exact-three-file,并完全重建结构/manifest/report,同时诚实声明网络签名结果未离线 replay。该 ceremony 无 registry/GitHub mutation、deployment action authority、Compose/Kubernetes apply 或数据库访问;D337 继续只消费审计后的 release set。Local/低配设备不安装任何工作站工具,Cluster 也不新增 controller/CRD/RBAC。独立 ceremony 20/20、完整定向发布链 121/121 已通过;backend 共 1,315 项,1,313 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 仍未实际产生,因此本门不宣称已取得真实 Cosign/GitHub/registry 成功证据。
|
- D-338/ADR-0430(已接受;真实公开 catalog 运行待实际 release tag):发布端的 durable catalog 不再由部署者通过松散 shell 重定向手工消费。可信工作站上的 `ql3-release-catalog-consumption-ceremony.cjs` 从 exact source version/revision/tag、closed `local|cluster|all` scope 与 owner/source repository 推导唯一 discovery ref,前后两次解析必须得到同一 digest,后续只使用 catalog `@sha256:` immutable reference。ceremony 以绝对路径、dev/inode/size/SHA-256 固定 `regctl|cosign|gh`,owner-private token 只进入单个 GitHub provenance verifier;环境、cache/config/tmp 与最终写入均有封闭边界。下载的 canonical release set 经过 standalone identity/family/self-digest inspection,raw OCI manifest 同时按 digest、media type、empty config、单 layer、basename、size/content digest 与四项 annotation 重建 publication plan/receipt。成功后才以 `0700` no-replace 目录和三项 `0600` 文件发布 release set、raw manifest 与 self-digest report;offline audit 要求 exact-three-file,并完全重建结构/manifest/report,同时诚实声明网络签名结果未离线 replay。该 ceremony 无 registry/GitHub mutation、deployment action authority、Compose/Kubernetes apply 或数据库访问;D337 继续只消费审计后的 release set。Local/低配设备不安装任何工作站工具,Cluster 也不新增 controller/CRD/RBAC。独立 ceremony 20/20、完整定向发布链 121/121 已通过;backend 共 1,315 项,1,313 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 仍未实际产生,因此本门不宣称已取得真实 Cosign/GitHub/registry 成功证据。
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# ADR-0433:Catalog-bound Kubernetes 目标部署 Ceremony
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-16
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-03、D-14、D-337、D-339、D-341
|
||||||
|
- 关联 ADR:ADR-0429、ADR-0431、ADR-0432
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0431 已把 verified release catalog 物化为 Kubernetes v2 locked manifest/report,但最后一步仍由运维者直接执行
|
||||||
|
`kubectl apply -f locked.yaml`。该命令没有再次绑定 report self-digest、目标 cluster、kubeconfig、kubectl executable 或
|
||||||
|
field manager;检查过的文件、实际输入和实际 API target 之间仍存在复制、context 漂移与 TOCTOU 窗口。成功输出也没有 durable
|
||||||
|
receipt,响应丢失后只能凭人工判断是否重放。
|
||||||
|
|
||||||
|
Cluster 需要解决这个缺口,但不能把 Kubernetes、YAML 或 registry 工具带到 Local/Edge,也不能为一次发布新增常驻 controller、
|
||||||
|
webhook 或长期 ServiceAccount authority。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 在现有维护工作站脚本边界增加 `cluster.deployment.preflight`、`cluster.deployment.apply` 与
|
||||||
|
`cluster.deployment.receipt.audit` 三种 canonical command;只提供一个
|
||||||
|
`pnpm cluster-deployment:ql3 -- --command-file=/absolute/private/command.json` 入口,不新增 workspace package 或生产依赖。
|
||||||
|
2. command、locked manifest、lock report、kubeconfig、preflight 与 receipt 必须位于 current-UID 的 canonical `0700` 目录,文件为
|
||||||
|
canonical、current-UID、单链接 `0600` regular file。所有输入使用 `O_NOFOLLOW|O_CLOEXEC` stable descriptor 有界读取,调用前后
|
||||||
|
复验 dev/inode/size/mtime/ctime 与 SHA-256。kubectl 必须是 absolute canonical、current-UID 或 root owner、不可 group/other write
|
||||||
|
的单链接 executable,并由 command 固定其 SHA-256。
|
||||||
|
3. 目标 consumer 独立验证 `qinglong/kubernetes-deployment-lock@v2` exact shape/self-digest、3.x release identity、catalog workflow/
|
||||||
|
immutable reference/release-set 闭包、required role 顺序、全部 GHCR digest reference、manifest byte digest、资源/authority 数量、
|
||||||
|
五项 release annotation,以及未知或畸形 QingLong image authority 为零。不能只相信 materializer 的 success stdout。
|
||||||
|
4. kubeconfig 必须由 command 固定 SHA-256 和 explicit context;禁止 `exec` 与 legacy `auth-provider`,避免稳定文件读取后再隐式执行
|
||||||
|
ambient credential plugin。每次网络动作前显式读取 `kube-system` Namespace UID,并与人工审核的 `expectedClusterUid` 精确匹配。
|
||||||
|
5. preflight 只执行固定 manager `qinglong3-catalog-lock` 的 `kubectl apply --server-side --dry-run=server --validate=strict -f=-`,通过
|
||||||
|
stdin 发送已验证的内存字节,不让 kubectl 按路径二次读取 manifest。所有承载 QingLong image authority 的资源必须显式携带
|
||||||
|
namespace,禁止依赖 context 的 ambient default namespace。成功后才 no-replace 发布 self-digest preflight report;它明确
|
||||||
|
`networkAccess=true`、`kubernetesMutation=false`。目标 namespace 必须预先存在,否则包含 namespaced resources 的 dry-run 自然失败关闭。
|
||||||
|
6. apply 必须消费 exact preflight digest,并重新执行全部离线检查、cluster UID 与 server-side dry-run;不使用
|
||||||
|
`--force-conflicts`。实际 apply 后以 `kubectl get -f=- -o=json --show-managed-fields=true` 读取同一资源集合:每个对象必须有
|
||||||
|
UID/resourceVersion,全部期望字段递归匹配;承载 QingLong image authority 的资源必须由固定 field manager 以 `Apply` 持有,所有
|
||||||
|
image/catalog annotation 再次精确验证。结束时再次检查 cluster UID 和全部稳定文件。
|
||||||
|
7. 只有所有步骤成功后才以 `0600` no-replace 发布 self-digest receipt。receipt 绑定 mutation、command、preflight、lock/manifest/
|
||||||
|
catalog digest、cluster UID、kubeconfig/kubectl digest、各 argv/stdout/stderr digest 与 byte count。已有 exact receipt 的相同 command
|
||||||
|
离线返回;apply 成功但 receipt 丢失时,使用同一 command/field manager 重放 server-side apply,再以 live convergence 收敛。
|
||||||
|
8. Kubernetes 多资源 apply 不是事务,receipt 必须固定 `crossResourceAtomicity=false`。失败时不自动删除或回滚资源;先审计 live state,
|
||||||
|
再用上一份 catalog-bound lock 执行新的显式 roll-forward。offline receipt audit 只重建 canonical/self/command binding,明确
|
||||||
|
`externalResultsReplayed=false`,不伪称离线重放 API Server 结果。
|
||||||
|
|
||||||
|
## 部署与资源影响
|
||||||
|
|
||||||
|
- Local/Edge/Standalone 零导入、零制品增量、零常驻 CPU/RSS/网络/写放大;低配路由器继续只消费 Local v2 selection。
|
||||||
|
- Cluster 不新增 controller、webhook、CRD、RBAC、ServiceAccount、Pod、listener、timer、watcher、数据库、migration、SQL 或 Pool。
|
||||||
|
ceremony 使用部署者原有 kubeconfig 权限并在命令结束后退出。每个 kubectl 子进程使用独立 `0700` 临时 HOME/XDG cache/TMPDIR,
|
||||||
|
结束即清理,不读取 ambient HOME,也不在仓库或 operator home 留下 discovery cache。
|
||||||
|
- 实现内聚在 `scripts/lib/ql3-kubernetes-deployment-ceremony.cjs` 与薄 CLI;没有为单一工作站流程拆出浅 workspace package。
|
||||||
|
- 每次 preflight 为一次 cluster identity read 和一次 server-side dry-run;apply 为 identity read、dry-run、apply、convergence read、末次
|
||||||
|
identity read。manifest/report/kubeconfig 各有明确 byte ceiling,process stdout/stderr 各最多 4 MiB。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 继续文档化裸 `kubectl apply`
|
||||||
|
|
||||||
|
拒绝。它无法证明 apply 的文件、report、context、cluster 和 executable 与人工审核对象相同,也没有 response-loss recovery receipt。
|
||||||
|
|
||||||
|
### 使用 `kubectl diff` 证明收敛
|
||||||
|
|
||||||
|
拒绝。真实 K3s 门证明 kubectl 会隐式从 `$PATH` 启动外部 `diff`,扩大未固定的 executable authority。受审 convergence read 只调用
|
||||||
|
同一 pinned kubectl,并直接验证 live object identity、managed field ownership 和完整期望字段。
|
||||||
|
|
||||||
|
### 自动 `--force-conflicts` 或失败后删除资源
|
||||||
|
|
||||||
|
拒绝。强夺其他 field manager 或跨资源猜测回滚会扩大故障;冲突必须失败关闭,多资源非原子事实必须进入 receipt 与人工恢复流程。
|
||||||
|
|
||||||
|
### 在集群内新增持续部署 controller
|
||||||
|
|
||||||
|
拒绝。当前缺口是显式 release rollout 的最后一跳,不值得新增常驻 availability、credential、certificate 与升级故障域。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 定向契约 10/10,覆盖 lock/report/manifest/annotation、cluster UID、文件权限、kubeconfig executable auth、server dry-run、apply、live
|
||||||
|
convergence、field manager、receipt 重签、response replay、closed CLI 与低敏失败;
|
||||||
|
- 隔离三节点 K3s `v1.34.3+k3s1`/Linux arm64 真实运行 6 个资源、4 个零副本 Deployment;preflight、server-side apply、live
|
||||||
|
convergence 与 offline receipt audit 全部通过,固定 manager 为 `qinglong3-catalog-lock`,临时 Docker container/network 零残留;
|
||||||
|
- 最终 live cluster UID 为 `7b2a5391-41a3-4905-90cc-3b831bef0058`,preflight digest 为
|
||||||
|
`sha256:7c6db6236a704cd7791f94268b3883d6385a75a993a14d1611791f885fb65404`,receipt digest 为
|
||||||
|
`sha256:58817a667bccf28c068fff40619cf13d2d0a1be84153de66a137b005feb536ba`。该 synthetic lock 只验证目标 apply 语义,不冒充公开
|
||||||
|
GHCR catalog ceremony;
|
||||||
|
- 完整 backend、18-package、边界审计与制品结果记录在 QL-RFC-0001 D-341。
|
||||||
@@ -436,6 +436,7 @@
|
|||||||
| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) |
|
| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) |
|
||||||
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag) |
|
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag) |
|
||||||
| [ADR-0432](./ADR-0432-target-side-catalog-bound-local-compose-revisions.md) | 目标侧 Catalog-bound Local Compose 修订 | Accepted |
|
| [ADR-0432](./ADR-0432-target-side-catalog-bound-local-compose-revisions.md) | 目标侧 Catalog-bound Local Compose 修订 | Accepted |
|
||||||
|
| [ADR-0433](./ADR-0433-catalog-bound-kubernetes-target-deployment-ceremony.md) | Catalog-bound Kubernetes 目标部署 Ceremony | Accepted |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -176,8 +176,111 @@ materializer 只改写 Pod、Deployment、StatefulSet、DaemonSet、ReplicaSet
|
|||||||
manifest、consumption report digest、source revision 与 version annotation;未知位置的完整 QingLong role image authority、畸形
|
manifest、consumption report digest、source revision 与 version annotation;未知位置的完整 QingLong role image authority、畸形
|
||||||
已知 container image、缺少 required role、YAML alias/cycle/非 mapping、超限输入或已有输出文件都会失败关闭。
|
已知 container image、缺少 required role、YAML alias/cycle/非 mapping、超限输入或已有输出文件都会失败关闭。
|
||||||
|
|
||||||
审计成功并完成人工差异检查后,才由有权限的独立步骤执行 `kubectl apply -f "${locked}"`。不要直接 apply
|
审计成功并完成人工差异检查后,不要再直接执行裸 `kubectl apply -f "${locked}"`,也不要 apply `${rendered}` 或使用
|
||||||
`${rendered}`,也不要使用 `kubectl apply -k` 绕过 deployment lock。
|
`kubectl apply -k` 绕过 deployment lock。目标侧必须使用下面的独立 preflight/apply ceremony。
|
||||||
|
|
||||||
|
### Kubernetes 目标 preflight 与 apply
|
||||||
|
|
||||||
|
先确认所有承载 QingLong image authority 的资源都显式填写 `metadata.namespace`,且目标 Namespace 已存在;禁止依赖 context 的
|
||||||
|
ambient default namespace。server-side dry-run 不会持久化同一 multi-document 输入中排在前面的 Namespace,因此不能把“创建
|
||||||
|
Namespace”和“在该 Namespace 内验证首批对象”混成一次隐式动作。取得并人工核对目标 cluster 的
|
||||||
|
`kube-system` Namespace UID:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl --kubeconfig="${kubeconfig}" --context="${context}" \
|
||||||
|
get namespace kube-system -o=jsonpath='{.metadata.uid}'
|
||||||
|
```
|
||||||
|
|
||||||
|
ceremony 目录必须为当前 UID 的 canonical `0700` 目录;command、locked manifest、lock report、kubeconfig、preflight 与 receipt
|
||||||
|
都必须是单链接 `0600` 文件。kubectl 使用 realpath 后的 absolute executable,并记录其 SHA-256。kubeconfig 禁止 `exec` 与
|
||||||
|
`auth-provider`。每个 kubectl 调用使用独立临时 HOME/XDG cache/TMPDIR,结束即清理,不读取 ambient HOME 或在当前目录创建
|
||||||
|
`.kube/cache`。以下是 preflight command 的逻辑结构;实际文件必须用 `JSON.stringify(value) + "\n"` 写成单行 canonical JSON,
|
||||||
|
并以 `0600` no-replace 创建:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"schema": "qinglong/kubernetes-deployment-command@v1",
|
||||||
|
"operation": "cluster.deployment.preflight",
|
||||||
|
"request": {
|
||||||
|
"preflightId": "<new UUID>",
|
||||||
|
"lockedManifest": {
|
||||||
|
"path": "<canonical absolute locked.yaml>",
|
||||||
|
"expectedDigest": "<lock report manifest.outputDigest>"
|
||||||
|
},
|
||||||
|
"lockReport": {
|
||||||
|
"path": "<canonical absolute lock.json>",
|
||||||
|
"expectedDigest": "<lock report lockDigest>"
|
||||||
|
},
|
||||||
|
"kubectl": {
|
||||||
|
"path": "<canonical absolute kubectl>",
|
||||||
|
"expectedDigest": "<SHA-256 of kubectl bytes>"
|
||||||
|
},
|
||||||
|
"kubeconfig": {
|
||||||
|
"path": "<canonical absolute kubeconfig>",
|
||||||
|
"expectedDigest": "<SHA-256 of kubeconfig bytes>"
|
||||||
|
},
|
||||||
|
"context": "<explicit context>",
|
||||||
|
"expectedClusterUid": "<reviewed kube-system UID>",
|
||||||
|
"output": "<unused canonical absolute preflight.json>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
运行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm cluster-deployment:ql3 -- --command-file="${preflight_command}"
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后,人工核对返回的 `preflightDigest`、lock/catalog digest、cluster UID 和
|
||||||
|
`kubernetesMutation:false`。apply 必须使用新的 mutation UUID,并精确复用所有 target/input authority:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"schema": "qinglong/kubernetes-deployment-command@v1",
|
||||||
|
"operation": "cluster.deployment.apply",
|
||||||
|
"request": {
|
||||||
|
"mutationId": "<new UUID>",
|
||||||
|
"preflight": {
|
||||||
|
"path": "<canonical absolute preflight.json>",
|
||||||
|
"expectedDigest": "<preflightDigest>"
|
||||||
|
},
|
||||||
|
"lockedManifest": {
|
||||||
|
"path": "<same locked.yaml>",
|
||||||
|
"expectedDigest": "<same manifest.outputDigest>"
|
||||||
|
},
|
||||||
|
"lockReport": {
|
||||||
|
"path": "<same lock.json>",
|
||||||
|
"expectedDigest": "<same lockDigest>"
|
||||||
|
},
|
||||||
|
"kubectl": {
|
||||||
|
"path": "<same kubectl>",
|
||||||
|
"expectedDigest": "<same kubectl SHA-256>"
|
||||||
|
},
|
||||||
|
"kubeconfig": {
|
||||||
|
"path": "<same kubeconfig>",
|
||||||
|
"expectedDigest": "<same kubeconfig SHA-256>"
|
||||||
|
},
|
||||||
|
"context": "<same context>",
|
||||||
|
"expectedClusterUid": "<same cluster UID>",
|
||||||
|
"output": "<unused canonical absolute receipt.json>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
同样通过 `cluster-deployment:ql3` 执行。apply 会重新完成 lock 检查、cluster identity 和 server-side dry-run,然后以固定
|
||||||
|
`qinglong3-catalog-lock` field manager 执行 server-side apply,不使用 `--force-conflicts`;最后读取 live objects,验证
|
||||||
|
UID/resourceVersion、完整期望字段、四类 immutable image/catalog annotations 和受影响资源的 managed-field ownership,再次确认
|
||||||
|
cluster UID 后才发布 receipt。
|
||||||
|
|
||||||
|
离线审计使用 `cluster.deployment.receipt.audit` command,其中 `applyCommand.expectedDigest` 是 apply command 文件完整字节的
|
||||||
|
SHA-256,`receipt.expectedDigest` 是 receipt 内的 `receiptDigest`。审计不会访问 Kubernetes API,结果必须保持
|
||||||
|
`externalResultsReplayed:false`、`kubernetesMutation:false`。
|
||||||
|
|
||||||
|
多资源 apply 不是事务,也不提供自动删除式 rollback。失败或 receipt 响应丢失时保留原文件:同一 command/field manager 可安全
|
||||||
|
重放并重新做 live convergence;需要回退时,以上一份 catalog-bound lock 创建新的显式 preflight/apply,而不是修改旧 receipt。
|
||||||
|
|
||||||
## 准入检查
|
## 准入检查
|
||||||
|
|
||||||
@@ -189,8 +292,9 @@ manifest、consumption report digest、source revision 与 version annotation;
|
|||||||
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的
|
4. Kubernetes 必须先渲染 overlay,再用离线 post-render materializer 生成和复验 v2 locked manifest;嵌套 overlay 的
|
||||||
`newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、
|
`newName`/digest 不是最终 authority。Local 必须生成并审计 v2 service selection。两族输出都必须绑定同一 catalog manifest、
|
||||||
consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。
|
consumption report 与 release-set digest,并且只能消费 release set 中的 `@sha256:` reference。
|
||||||
5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。version/source/catalog tag 都只能用于
|
5. rollout 前再次确认 catalog receipt/immutable reference 与已检查文件一致。Kubernetes 必须把 locked manifest/report、pinned
|
||||||
发现;部署始终以 release set 中的镜像 digest 为准。
|
kubectl/kubeconfig 和目标 cluster UID 绑定进 preflight/apply receipt;version/source/catalog tag 都只能用于发现,部署始终以
|
||||||
|
release set 中的镜像 digest 为准。
|
||||||
|
|
||||||
## 低资源设备
|
## 低资源设备
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,8 @@
|
|||||||
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
"audit:image-release:ql3": "node scripts/ql3-cluster-image-release-audit.cjs",
|
||||||
"release-catalog-consumption:ql3": "node scripts/ql3-release-catalog-consumption-ceremony.cjs",
|
"release-catalog-consumption:ql3": "node scripts/ql3-release-catalog-consumption-ceremony.cjs",
|
||||||
"deployment-lock:ql3": "node scripts/ql3-deployment-lock-contract.cjs",
|
"deployment-lock:ql3": "node scripts/ql3-deployment-lock-contract.cjs",
|
||||||
|
"cluster-deployment:ql3": "node scripts/ql3-kubernetes-deployment-ceremony.cjs",
|
||||||
|
"test:cluster-deployment-live:ql3": "node scripts/ql3-kubernetes-deployment-live-contract.cjs",
|
||||||
"audit:deployment-lock-surfaces:ql3": "node scripts/ql3-deployment-lock-contract.cjs --mode=surfaces-audit",
|
"audit:deployment-lock-surfaces:ql3": "node scripts/ql3-deployment-lock-contract.cjs --mode=surfaces-audit",
|
||||||
"audit:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs",
|
"audit:image-os-vulnerability-policy:ql3": "node scripts/ql3-image-os-vulnerability-policy.cjs",
|
||||||
"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",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
QingLong3KubernetesDeploymentCeremonyError,
|
||||||
|
canonicalJson,
|
||||||
|
executeCommand,
|
||||||
|
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
|
||||||
|
|
||||||
|
function commandFile(argv) {
|
||||||
|
if (argv.length !== 1) throw new Error('arguments are invalid');
|
||||||
|
const match = /^--command-file=(.+)$/u.exec(argv[0]);
|
||||||
|
if (!match) throw new Error('arguments are invalid');
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function lowSensitivityFailure() {
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
component: 'qinglong3-kubernetes-deployment-ceremony',
|
||||||
|
code: 'QL3_KUBERNETES_DEPLOYMENT_CEREMONY_FAILED',
|
||||||
|
message: 'QingLong 3 Kubernetes deployment ceremony failed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCli(argv, output = process.stdout, errorOutput = process.stderr) {
|
||||||
|
try {
|
||||||
|
const result = executeCommand(commandFile(argv));
|
||||||
|
output.write(canonicalJson(result));
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
errorOutput.write(canonicalJson(lowSensitivityFailure()));
|
||||||
|
if (
|
||||||
|
!(error instanceof QingLong3KubernetesDeploymentCeremonyError) &&
|
||||||
|
process.env.QL3_DEBUG_DEPLOYMENT_CEREMONY === 'true'
|
||||||
|
) {
|
||||||
|
errorOutput.write(
|
||||||
|
`${error instanceof Error ? error.message : 'error'}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) runCli(process.argv.slice(2));
|
||||||
|
|
||||||
|
module.exports = Object.freeze({ commandFile, runCli });
|
||||||
@@ -0,0 +1,377 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const {
|
||||||
|
COMMAND_SCHEMA,
|
||||||
|
FIELD_MANAGER,
|
||||||
|
canonicalJson,
|
||||||
|
executeCommand,
|
||||||
|
} = require('./lib/ql3-kubernetes-deployment-ceremony.cjs');
|
||||||
|
const { K3sDockerLiveFixture } = require('./lib/ql3-k3s-docker-live.cjs');
|
||||||
|
const { readReleaseIdentity } = require('./lib/ql3-release-identity.cjs');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const VERSION = readReleaseIdentity(ROOT).version;
|
||||||
|
const SOURCE_REVISION = 'd'.repeat(40);
|
||||||
|
const NAMESPACE = 'ql3-deployment-live';
|
||||||
|
const CONTEXT = 'default';
|
||||||
|
const OWNER = 'qinglong-release';
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(
|
||||||
|
`QingLong Kubernetes deployment live contract failed: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateFile(directory, name, contents) {
|
||||||
|
const target = path.join(directory, name);
|
||||||
|
fs.writeFileSync(target, contents, { mode: 0o600, flag: 'wx' });
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executablePath(input) {
|
||||||
|
const candidates = [
|
||||||
|
input,
|
||||||
|
'/Applications/Docker.app/Contents/Resources/bin/kubectl',
|
||||||
|
...(process.env.PATH ?? '')
|
||||||
|
.split(path.delimiter)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((directory) => path.join(directory, input)),
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
try {
|
||||||
|
const resolved = fs.realpathSync(candidate);
|
||||||
|
if (fs.lstatSync(resolved).isFile()) return resolved;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
fail('kubectl executable is unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
function references() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
ROLE_ORDER.map((role, index) => [
|
||||||
|
role,
|
||||||
|
`ghcr.io/${OWNER}/${IMAGE_NAMES[role]}@sha256:${String(index + 1).repeat(
|
||||||
|
64,
|
||||||
|
)}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockedArtifacts() {
|
||||||
|
const releaseSetDigest = digest('d341-live-release-set');
|
||||||
|
const catalogManifestDigest = digest('d341-live-catalog-manifest');
|
||||||
|
const catalogReportDigest = digest('d341-live-catalog-report');
|
||||||
|
const imageReferences = references();
|
||||||
|
const annotations = {
|
||||||
|
'qinglong.io/release-set-digest': releaseSetDigest,
|
||||||
|
'qinglong.io/release-catalog-manifest-digest': catalogManifestDigest,
|
||||||
|
'qinglong.io/release-catalog-report-digest': catalogReportDigest,
|
||||||
|
'qinglong.io/release-source-revision': SOURCE_REVISION,
|
||||||
|
'qinglong.io/release-version': VERSION,
|
||||||
|
};
|
||||||
|
const deployment = (role) => ({
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
kind: 'Deployment',
|
||||||
|
metadata: {
|
||||||
|
name: `ql3-${role.replace('control-ai', 'control-ai')}`,
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
annotations,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
replicas: 0,
|
||||||
|
selector: { matchLabels: { 'app.kubernetes.io/name': `ql3-${role}` } },
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: { 'app.kubernetes.io/name': `ql3-${role}` },
|
||||||
|
annotations,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: role,
|
||||||
|
image: imageReferences[role],
|
||||||
|
command: ['/bin/false'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resources = [
|
||||||
|
{
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Namespace',
|
||||||
|
metadata: { name: NAMESPACE },
|
||||||
|
},
|
||||||
|
...ROLE_ORDER.map(deployment),
|
||||||
|
{
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'ConfigMap',
|
||||||
|
metadata: {
|
||||||
|
name: 'ql3-plugin-package-secret-action-admission',
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
annotations,
|
||||||
|
},
|
||||||
|
data: { image: imageReferences.admin },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const manifest = `${resources
|
||||||
|
.map((resource) => JSON.stringify(resource))
|
||||||
|
.join('\n---\n')}\n`;
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/kubernetes-deployment-lock@v2',
|
||||||
|
release: {
|
||||||
|
version: VERSION,
|
||||||
|
sourceRevision: SOURCE_REVISION,
|
||||||
|
sourceRef: `refs/tags/v${VERSION}`,
|
||||||
|
scope: 'cluster',
|
||||||
|
},
|
||||||
|
releaseSetDigest,
|
||||||
|
catalog: {
|
||||||
|
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
||||||
|
sourceRepository: `${OWNER}/qinglong`,
|
||||||
|
workflowIdentity: `https://github.com/${OWNER}/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
|
||||||
|
immutableReference: `ghcr.io/${OWNER}/qinglong3-release-catalog@${catalogManifestDigest}`,
|
||||||
|
manifestDigest: catalogManifestDigest,
|
||||||
|
consumptionReportDigest: catalogReportDigest,
|
||||||
|
releaseSetDigest,
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
},
|
||||||
|
deploymentFamily: 'cluster',
|
||||||
|
requiredImages: [...ROLE_ORDER],
|
||||||
|
imageOccurrences: ROLE_ORDER.map((role) => ({
|
||||||
|
name: role,
|
||||||
|
reference: imageReferences[role],
|
||||||
|
count: role === 'admin' ? 2 : 1,
|
||||||
|
})),
|
||||||
|
manifest: {
|
||||||
|
inputDigest: digest('d341-live-source-render'),
|
||||||
|
outputDigest: digest(manifest),
|
||||||
|
resources: resources.length,
|
||||||
|
changedResources: 5,
|
||||||
|
admissionAuthorityCount: 1,
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
releaseSet: 'standalone_structure_identity_and_self_digest',
|
||||||
|
sourceRecordsReplayed: false,
|
||||||
|
catalogConsumption: 'offline_reconstructed',
|
||||||
|
externalToolResultsReplayed: false,
|
||||||
|
unknownImageAuthorities: 0,
|
||||||
|
mutableQingLongImages: 0,
|
||||||
|
networkAccess: false,
|
||||||
|
kubernetesMutation: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return Object.freeze({
|
||||||
|
manifest,
|
||||||
|
report: Object.freeze({
|
||||||
|
...unsigned,
|
||||||
|
lockDigest: digest(JSON.stringify(unsigned)),
|
||||||
|
}),
|
||||||
|
imageReferences: Object.freeze(imageReferences),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCommand(directory, name, operation, request) {
|
||||||
|
return privateFile(
|
||||||
|
directory,
|
||||||
|
name,
|
||||||
|
canonicalJson({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: COMMAND_SCHEMA,
|
||||||
|
operation,
|
||||||
|
request,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const fixture = new K3sDockerLiveFixture({
|
||||||
|
prefix: 'ql3-deploy-live',
|
||||||
|
kubectl: process.env.QL3_KUBECTL_BIN,
|
||||||
|
});
|
||||||
|
let evidence;
|
||||||
|
let cleanupComplete = false;
|
||||||
|
try {
|
||||||
|
const nodes = await fixture.start();
|
||||||
|
fs.chmodSync(fixture.temporary, 0o700);
|
||||||
|
const ceremonyDirectory = fs.realpathSync(fixture.temporary);
|
||||||
|
const kubeconfig = fs.realpathSync(fixture.kubeconfig);
|
||||||
|
const kubectl = executablePath(fixture.kubectlBinary);
|
||||||
|
const clusterUid = fixture
|
||||||
|
.kubectl(
|
||||||
|
['get', 'namespace', 'kube-system', '-o=jsonpath={.metadata.uid}'],
|
||||||
|
{ capture: true, quiet: true },
|
||||||
|
)
|
||||||
|
.stdout.trim();
|
||||||
|
fixture.apply({
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'Namespace',
|
||||||
|
metadata: { name: NAMESPACE },
|
||||||
|
});
|
||||||
|
const artifacts = lockedArtifacts();
|
||||||
|
const manifestPath = privateFile(
|
||||||
|
ceremonyDirectory,
|
||||||
|
'locked.yaml',
|
||||||
|
artifacts.manifest,
|
||||||
|
);
|
||||||
|
const reportPath = privateFile(
|
||||||
|
ceremonyDirectory,
|
||||||
|
'lock.json',
|
||||||
|
canonicalJson(artifacts.report),
|
||||||
|
);
|
||||||
|
const common = {
|
||||||
|
lockedManifest: {
|
||||||
|
path: manifestPath,
|
||||||
|
expectedDigest: artifacts.report.manifest.outputDigest,
|
||||||
|
},
|
||||||
|
lockReport: {
|
||||||
|
path: reportPath,
|
||||||
|
expectedDigest: artifacts.report.lockDigest,
|
||||||
|
},
|
||||||
|
kubectl: {
|
||||||
|
path: kubectl,
|
||||||
|
expectedDigest: digest(fs.readFileSync(kubectl)),
|
||||||
|
},
|
||||||
|
kubeconfig: {
|
||||||
|
path: kubeconfig,
|
||||||
|
expectedDigest: digest(fs.readFileSync(kubeconfig)),
|
||||||
|
},
|
||||||
|
context: CONTEXT,
|
||||||
|
expectedClusterUid: clusterUid,
|
||||||
|
};
|
||||||
|
const preflightPath = path.join(ceremonyDirectory, 'preflight.json');
|
||||||
|
const preflightCommand = writeCommand(
|
||||||
|
ceremonyDirectory,
|
||||||
|
'preflight-command.json',
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId: crypto.randomUUID(),
|
||||||
|
...common,
|
||||||
|
output: preflightPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const preflight = executeCommand(preflightCommand);
|
||||||
|
const receiptPath = path.join(ceremonyDirectory, 'receipt.json');
|
||||||
|
const applyCommand = writeCommand(
|
||||||
|
ceremonyDirectory,
|
||||||
|
'apply-command.json',
|
||||||
|
'cluster.deployment.apply',
|
||||||
|
{
|
||||||
|
mutationId: crypto.randomUUID(),
|
||||||
|
preflight: {
|
||||||
|
path: preflightPath,
|
||||||
|
expectedDigest: preflight.preflightDigest,
|
||||||
|
},
|
||||||
|
...common,
|
||||||
|
output: receiptPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const receipt = executeCommand(applyCommand);
|
||||||
|
const auditCommand = writeCommand(
|
||||||
|
ceremonyDirectory,
|
||||||
|
'audit-command.json',
|
||||||
|
'cluster.deployment.receipt.audit',
|
||||||
|
{
|
||||||
|
applyCommand: {
|
||||||
|
path: applyCommand,
|
||||||
|
expectedDigest: digest(fs.readFileSync(applyCommand)),
|
||||||
|
},
|
||||||
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const audit = executeCommand(auditCommand);
|
||||||
|
const deployments = fixture.kubectlJson([
|
||||||
|
'get',
|
||||||
|
'deployments',
|
||||||
|
'-n',
|
||||||
|
NAMESPACE,
|
||||||
|
'--show-managed-fields=true',
|
||||||
|
]).items;
|
||||||
|
if (deployments.length !== ROLE_ORDER.length) {
|
||||||
|
fail('applied deployment count is invalid');
|
||||||
|
}
|
||||||
|
for (const deployment of deployments) {
|
||||||
|
const container = deployment.spec?.template?.spec?.containers?.[0];
|
||||||
|
if (
|
||||||
|
!Object.values(artifacts.imageReferences).includes(container?.image)
|
||||||
|
) {
|
||||||
|
fail('applied immutable image authority is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!deployment.metadata?.managedFields?.some(
|
||||||
|
(entry) => entry.manager === FIELD_MANAGER,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail('server-side apply field manager is unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const version = JSON.parse(
|
||||||
|
fixture.kubectl(['version', '-o=json'], {
|
||||||
|
capture: true,
|
||||||
|
quiet: true,
|
||||||
|
}).stdout,
|
||||||
|
);
|
||||||
|
evidence = Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/kubernetes-deployment-live-evidence@v1',
|
||||||
|
kubernetes: {
|
||||||
|
serverVersion: version.serverVersion.gitVersion,
|
||||||
|
architecture: version.serverVersion.platform,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
clusterUid,
|
||||||
|
},
|
||||||
|
deployment: {
|
||||||
|
namespace: NAMESPACE,
|
||||||
|
resourceCount: artifacts.report.manifest.resources,
|
||||||
|
deploymentCount: deployments.length,
|
||||||
|
replicas: 0,
|
||||||
|
fieldManager: FIELD_MANAGER,
|
||||||
|
immutableImages: true,
|
||||||
|
},
|
||||||
|
preflightDigest: preflight.preflightDigest,
|
||||||
|
receiptDigest: receipt.receiptDigest,
|
||||||
|
receiptAuditCompatible: audit.compatible,
|
||||||
|
serverSideDryRun: preflight.verification.serverSideDryRun,
|
||||||
|
serverSideApply: receipt.verification.serverSideApply,
|
||||||
|
convergenceRead: receipt.verification.convergenceRead,
|
||||||
|
crossResourceAtomicity: receipt.verification.crossResourceAtomicity,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fixture.cleanup().catch(() => undefined);
|
||||||
|
cleanupComplete = true;
|
||||||
|
}
|
||||||
|
process.stdout.write(canonicalJson({ ...evidence, cleanupComplete }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`${
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'deployment live contract failed'
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Object.freeze({ lockedArtifacts, main });
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
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 {
|
||||||
|
COMMAND_SCHEMA,
|
||||||
|
FIELD_MANAGER,
|
||||||
|
PREFLIGHT_SCHEMA,
|
||||||
|
RECEIPT_SCHEMA,
|
||||||
|
canonicalJson,
|
||||||
|
executeCommand,
|
||||||
|
parseCommand,
|
||||||
|
validateLockReport,
|
||||||
|
} = require('../../scripts/lib/ql3-kubernetes-deployment-ceremony.cjs');
|
||||||
|
const {
|
||||||
|
commandFile,
|
||||||
|
} = require('../../scripts/ql3-kubernetes-deployment-ceremony.cjs');
|
||||||
|
|
||||||
|
const CLUSTER_UID = '123e4567-e89b-42d3-a456-426614174000';
|
||||||
|
const RELEASE_SET_DIGEST = digest('release-set');
|
||||||
|
const CATALOG_MANIFEST_DIGEST = digest('catalog-manifest');
|
||||||
|
const CATALOG_REPORT_DIGEST = digest('catalog-report');
|
||||||
|
const SOURCE_REVISION = 'd'.repeat(40);
|
||||||
|
const VERSION = '3.0.0-alpha.0';
|
||||||
|
const CONTEXT = 'qinglong-production';
|
||||||
|
const REFERENCES = Object.freeze({
|
||||||
|
control: image('qinglong3-cluster-control', '1'),
|
||||||
|
'control-ai': image('qinglong3-cluster-control-ai', '2'),
|
||||||
|
admin: image('qinglong3-cluster-admin', '3'),
|
||||||
|
worker: image('qinglong3-worker', '4'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function digest(value) {
|
||||||
|
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function image(repository, digit) {
|
||||||
|
return `ghcr.io/qinglong-release/${repository}@sha256:${digit.repeat(64)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotations() {
|
||||||
|
return {
|
||||||
|
'qinglong.io/release-set-digest': RELEASE_SET_DIGEST,
|
||||||
|
'qinglong.io/release-catalog-manifest-digest': CATALOG_MANIFEST_DIGEST,
|
||||||
|
'qinglong.io/release-catalog-report-digest': CATALOG_REPORT_DIGEST,
|
||||||
|
'qinglong.io/release-source-revision': SOURCE_REVISION,
|
||||||
|
'qinglong.io/release-version': VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function manifest() {
|
||||||
|
const metadata = (name) => ({
|
||||||
|
name,
|
||||||
|
namespace: 'qinglong-system',
|
||||||
|
annotations: annotations(),
|
||||||
|
});
|
||||||
|
const deployment = (name, containerName, reference) => ({
|
||||||
|
apiVersion: 'apps/v1',
|
||||||
|
kind: 'Deployment',
|
||||||
|
metadata: metadata(name),
|
||||||
|
spec: {
|
||||||
|
selector: { matchLabels: { app: name } },
|
||||||
|
template: {
|
||||||
|
metadata: { labels: { app: name }, annotations: annotations() },
|
||||||
|
spec: { containers: [{ name: containerName, image: reference }] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const resources = [
|
||||||
|
deployment('ql3-control', 'control', REFERENCES.control),
|
||||||
|
deployment('ql3-control-ai', 'control-ai', REFERENCES['control-ai']),
|
||||||
|
{
|
||||||
|
apiVersion: 'batch/v1',
|
||||||
|
kind: 'Job',
|
||||||
|
metadata: metadata('ql3-admin'),
|
||||||
|
spec: {
|
||||||
|
template: {
|
||||||
|
metadata: { annotations: annotations() },
|
||||||
|
spec: {
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
containers: [{ name: 'admin', image: REFERENCES.admin }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deployment('ql3-worker', 'worker', REFERENCES.worker),
|
||||||
|
{
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'ConfigMap',
|
||||||
|
metadata: metadata('ql3-plugin-package-secret-action-admission'),
|
||||||
|
data: { image: REFERENCES.admin },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return `${resources
|
||||||
|
.map((resource) => JSON.stringify(resource))
|
||||||
|
.join('\n---\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockReport(manifestContents = manifest()) {
|
||||||
|
const unsigned = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/kubernetes-deployment-lock@v2',
|
||||||
|
release: {
|
||||||
|
version: VERSION,
|
||||||
|
sourceRevision: SOURCE_REVISION,
|
||||||
|
sourceRef: `refs/tags/v${VERSION}`,
|
||||||
|
scope: 'cluster',
|
||||||
|
},
|
||||||
|
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||||
|
catalog: {
|
||||||
|
schema: 'qinglong/release-catalog-consumption-ceremony@v1',
|
||||||
|
sourceRepository: 'qinglong-release/qinglong',
|
||||||
|
workflowIdentity: `https://github.com/qinglong-release/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v${VERSION}`,
|
||||||
|
immutableReference: `ghcr.io/qinglong-release/qinglong3-release-catalog@${CATALOG_MANIFEST_DIGEST}`,
|
||||||
|
manifestDigest: CATALOG_MANIFEST_DIGEST,
|
||||||
|
consumptionReportDigest: CATALOG_REPORT_DIGEST,
|
||||||
|
releaseSetDigest: RELEASE_SET_DIGEST,
|
||||||
|
discoveryTagAuthority: 'none',
|
||||||
|
},
|
||||||
|
deploymentFamily: 'cluster',
|
||||||
|
requiredImages: ['control', 'control-ai', 'admin', 'worker'],
|
||||||
|
imageOccurrences: [
|
||||||
|
{ name: 'control', reference: REFERENCES.control, count: 1 },
|
||||||
|
{
|
||||||
|
name: 'control-ai',
|
||||||
|
reference: REFERENCES['control-ai'],
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
{ name: 'admin', reference: REFERENCES.admin, count: 2 },
|
||||||
|
{ name: 'worker', reference: REFERENCES.worker, count: 1 },
|
||||||
|
],
|
||||||
|
manifest: {
|
||||||
|
inputDigest: digest('source-render'),
|
||||||
|
outputDigest: digest(manifestContents),
|
||||||
|
resources: 5,
|
||||||
|
changedResources: 5,
|
||||||
|
admissionAuthorityCount: 1,
|
||||||
|
},
|
||||||
|
verification: {
|
||||||
|
releaseSet: 'standalone_structure_identity_and_self_digest',
|
||||||
|
sourceRecordsReplayed: false,
|
||||||
|
catalogConsumption: 'offline_reconstructed',
|
||||||
|
externalToolResultsReplayed: false,
|
||||||
|
unknownImageAuthorities: 0,
|
||||||
|
mutableQingLongImages: 0,
|
||||||
|
networkAccess: false,
|
||||||
|
kubernetesMutation: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...unsigned,
|
||||||
|
lockDigest: digest(JSON.stringify(unsigned)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(t) {
|
||||||
|
const directory = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-kubernetes-deployment-')),
|
||||||
|
);
|
||||||
|
fs.chmodSync(directory, 0o700);
|
||||||
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
|
const lockedManifest = privateFile(directory, 'locked.yaml', manifest());
|
||||||
|
const report = lockReport();
|
||||||
|
const lockReportPath = privateFile(
|
||||||
|
directory,
|
||||||
|
'lock.json',
|
||||||
|
canonicalJson(report),
|
||||||
|
);
|
||||||
|
const kubectl = privateFile(
|
||||||
|
directory,
|
||||||
|
'kubectl',
|
||||||
|
'#!/bin/sh\nexit 97\n',
|
||||||
|
0o700,
|
||||||
|
);
|
||||||
|
const kubeconfig = privateFile(
|
||||||
|
directory,
|
||||||
|
'kubeconfig.yaml',
|
||||||
|
`apiVersion: v1
|
||||||
|
kind: Config
|
||||||
|
current-context: ${CONTEXT}
|
||||||
|
clusters:
|
||||||
|
- name: ql3
|
||||||
|
cluster:
|
||||||
|
server: https://cluster.example.test
|
||||||
|
certificate-authority-data: Y2E=
|
||||||
|
contexts:
|
||||||
|
- name: ${CONTEXT}
|
||||||
|
context:
|
||||||
|
cluster: ql3
|
||||||
|
user: operator
|
||||||
|
users:
|
||||||
|
- name: operator
|
||||||
|
user:
|
||||||
|
token: bounded-test-token
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
directory,
|
||||||
|
lockedManifest,
|
||||||
|
lockReportPath,
|
||||||
|
kubectl,
|
||||||
|
kubeconfig,
|
||||||
|
report,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateFile(directory, name, contents, mode = 0o600) {
|
||||||
|
const target = path.join(directory, name);
|
||||||
|
fs.writeFileSync(target, contents, { mode });
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileDigest(filePath) {
|
||||||
|
return digest(fs.readFileSync(filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeCommand(directory, name, operation, request) {
|
||||||
|
const value = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: COMMAND_SCHEMA,
|
||||||
|
operation,
|
||||||
|
request,
|
||||||
|
};
|
||||||
|
return privateFile(directory, name, canonicalJson(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonRequest(fixtureValue) {
|
||||||
|
return {
|
||||||
|
lockedManifest: {
|
||||||
|
path: fixtureValue.lockedManifest,
|
||||||
|
expectedDigest: fixtureValue.report.manifest.outputDigest,
|
||||||
|
},
|
||||||
|
lockReport: {
|
||||||
|
path: fixtureValue.lockReportPath,
|
||||||
|
expectedDigest: fixtureValue.report.lockDigest,
|
||||||
|
},
|
||||||
|
kubectl: {
|
||||||
|
path: fixtureValue.kubectl,
|
||||||
|
expectedDigest: fileDigest(fixtureValue.kubectl),
|
||||||
|
},
|
||||||
|
kubeconfig: {
|
||||||
|
path: fixtureValue.kubeconfig,
|
||||||
|
expectedDigest: fileDigest(fixtureValue.kubeconfig),
|
||||||
|
},
|
||||||
|
context: CONTEXT,
|
||||||
|
expectedClusterUid: CLUSTER_UID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function successfulRunner(calls) {
|
||||||
|
return (_executable, args, input) => {
|
||||||
|
calls.push({ args: [...args], input });
|
||||||
|
if (args.includes('get') && args.includes('-f=-')) {
|
||||||
|
return { status: 0, stdout: convergenceList(), stderr: '' };
|
||||||
|
}
|
||||||
|
if (args.includes('get')) {
|
||||||
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
||||||
|
}
|
||||||
|
if (args.includes('--dry-run=server')) {
|
||||||
|
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
||||||
|
}
|
||||||
|
return { status: 0, stdout: 'deployment.apps/ql3-control\n', stderr: '' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function convergenceList() {
|
||||||
|
const resources = [];
|
||||||
|
yaml.loadAll(manifest(), (resource) => resources.push(resource));
|
||||||
|
return JSON.stringify({
|
||||||
|
apiVersion: 'v1',
|
||||||
|
kind: 'List',
|
||||||
|
items: resources.map((resource, index) => ({
|
||||||
|
...resource,
|
||||||
|
metadata: {
|
||||||
|
...resource.metadata,
|
||||||
|
uid: `123e4567-e89b-42d3-a456-4266141741${String(index).padStart(
|
||||||
|
2,
|
||||||
|
'0',
|
||||||
|
)}`,
|
||||||
|
resourceVersion: String(index + 1),
|
||||||
|
managedFields: [
|
||||||
|
{
|
||||||
|
manager: FIELD_MANAGER,
|
||||||
|
operation: 'Apply',
|
||||||
|
apiVersion: resource.apiVersion,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepare(t) {
|
||||||
|
const value = fixture(t);
|
||||||
|
const output = path.join(value.directory, 'preflight.json');
|
||||||
|
const command = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'preflight-command.json',
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId: '123e4567-e89b-42d3-a456-426614174001',
|
||||||
|
...commonRequest(value),
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const calls = [];
|
||||||
|
const report = executeCommand(command, {
|
||||||
|
runProcess: successfulRunner(calls),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
preflightCommand: command,
|
||||||
|
preflightPath: output,
|
||||||
|
preflight: report,
|
||||||
|
calls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('preflight binds one catalog lock to one Kubernetes target without mutation', (t) => {
|
||||||
|
const value = prepare(t);
|
||||||
|
assert.equal(value.preflight.schema, PREFLIGHT_SCHEMA);
|
||||||
|
assert.equal(value.preflight.lock.lockDigest, value.report.lockDigest);
|
||||||
|
assert.equal(
|
||||||
|
value.preflight.lock.manifestDigest,
|
||||||
|
value.report.manifest.outputDigest,
|
||||||
|
);
|
||||||
|
assert.equal(value.preflight.target.clusterUid, CLUSTER_UID);
|
||||||
|
assert.equal(value.preflight.target.fieldManager, FIELD_MANAGER);
|
||||||
|
assert.equal(value.preflight.verification.serverSideDryRun, true);
|
||||||
|
assert.equal(value.preflight.verification.kubernetesMutation, false);
|
||||||
|
assert.deepEqual(
|
||||||
|
value.preflight.steps.map(({ name }) => name),
|
||||||
|
['cluster_identity_before', 'server_side_dry_run'],
|
||||||
|
);
|
||||||
|
assert.equal(fs.statSync(value.preflightPath).mode & 0o777, 0o600);
|
||||||
|
assert.equal(value.calls.length, 2);
|
||||||
|
assert.equal(value.calls[0].args.includes('get'), true);
|
||||||
|
assert.equal(value.calls[1].args.includes('--dry-run=server'), true);
|
||||||
|
assert.equal(
|
||||||
|
value.calls[1].args.includes(`--field-manager=${FIELD_MANAGER}`),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(value.calls[1].input, manifest());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preflight rejects lock, manifest and annotation drift before network access', (t) => {
|
||||||
|
const cases = [
|
||||||
|
(target) => fs.appendFileSync(target.lockedManifest, '\n'),
|
||||||
|
(target) => {
|
||||||
|
const drifted = { ...target.report, lockDigest: digest('forged') };
|
||||||
|
fs.writeFileSync(target.lockReportPath, canonicalJson(drifted));
|
||||||
|
},
|
||||||
|
(target) => {
|
||||||
|
const driftedManifest = manifest().replace(
|
||||||
|
CATALOG_REPORT_DIGEST,
|
||||||
|
digest('different-catalog-report'),
|
||||||
|
);
|
||||||
|
fs.writeFileSync(target.lockedManifest, driftedManifest);
|
||||||
|
const report = lockReport(driftedManifest);
|
||||||
|
fs.writeFileSync(target.lockReportPath, canonicalJson(report));
|
||||||
|
target.report = report;
|
||||||
|
},
|
||||||
|
(target) => {
|
||||||
|
const implicitNamespaceManifest = manifest().replace(
|
||||||
|
'"namespace":"qinglong-system",',
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(target.lockedManifest, implicitNamespaceManifest);
|
||||||
|
const report = lockReport(implicitNamespaceManifest);
|
||||||
|
fs.writeFileSync(target.lockReportPath, canonicalJson(report));
|
||||||
|
target.report = report;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, mutate] of cases.entries()) {
|
||||||
|
const fresh = fixture(t);
|
||||||
|
mutate(fresh);
|
||||||
|
const output = path.join(fresh.directory, `rejected-${index}.json`);
|
||||||
|
const request = commonRequest(fresh);
|
||||||
|
request.lockedManifest.expectedDigest = fresh.report.manifest.outputDigest;
|
||||||
|
request.lockReport.expectedDigest = fresh.report.lockDigest;
|
||||||
|
const command = writeCommand(
|
||||||
|
fresh.directory,
|
||||||
|
`rejected-command-${index}.json`,
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId: `123e4567-e89b-42d3-a456-42661417400${index + 2}`,
|
||||||
|
...request,
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let calls = 0;
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
executeCommand(command, {
|
||||||
|
runProcess() {
|
||||||
|
calls += 1;
|
||||||
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/deployment ceremony failed/,
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(fs.existsSync(output), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preflight rejects the wrong cluster and rejected server dry-run', (t) => {
|
||||||
|
for (const failure of ['identity', 'dry-run']) {
|
||||||
|
const value = fixture(t);
|
||||||
|
const output = path.join(value.directory, `${failure}.json`);
|
||||||
|
const command = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
`${failure}-command.json`,
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId:
|
||||||
|
failure === 'identity'
|
||||||
|
? '123e4567-e89b-42d3-a456-426614174010'
|
||||||
|
: '123e4567-e89b-42d3-a456-426614174011',
|
||||||
|
...commonRequest(value),
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
executeCommand(command, {
|
||||||
|
runProcess(_executable, args) {
|
||||||
|
if (args.includes('get')) {
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout:
|
||||||
|
failure === 'identity' ? crypto.randomUUID() : CLUSTER_UID,
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 1, stdout: '', stderr: 'redacted admission error' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(output), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('kubeconfig executable authentication and weak private files fail closed', (t) => {
|
||||||
|
for (const mode of ['exec', 'public']) {
|
||||||
|
const value = fixture(t);
|
||||||
|
if (mode === 'exec') {
|
||||||
|
fs.writeFileSync(
|
||||||
|
value.kubeconfig,
|
||||||
|
fs
|
||||||
|
.readFileSync(value.kubeconfig, 'utf8')
|
||||||
|
.replace(
|
||||||
|
'token: bounded-test-token',
|
||||||
|
'exec:\n command: owned',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
fs.chmodSync(value.kubeconfig, 0o644);
|
||||||
|
}
|
||||||
|
const output = path.join(value.directory, `${mode}.json`);
|
||||||
|
const command = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
`${mode}-command.json`,
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId:
|
||||||
|
mode === 'exec'
|
||||||
|
? '123e4567-e89b-42d3-a456-426614174020'
|
||||||
|
: '123e4567-e89b-42d3-a456-426614174021',
|
||||||
|
...commonRequest(value),
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let calls = 0;
|
||||||
|
assert.throws(() =>
|
||||||
|
executeCommand(command, {
|
||||||
|
runProcess() {
|
||||||
|
calls += 1;
|
||||||
|
return { status: 0, stdout: CLUSTER_UID, stderr: '' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(calls, 0);
|
||||||
|
assert.equal(fs.existsSync(output), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apply revalidates preflight, mutates explicitly and proves convergence', (t) => {
|
||||||
|
const value = prepare(t);
|
||||||
|
const receiptPath = path.join(value.directory, 'receipt.json');
|
||||||
|
const applyCommand = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'apply-command.json',
|
||||||
|
'cluster.deployment.apply',
|
||||||
|
{
|
||||||
|
mutationId: '123e4567-e89b-42d3-a456-426614174030',
|
||||||
|
preflight: {
|
||||||
|
path: value.preflightPath,
|
||||||
|
expectedDigest: value.preflight.preflightDigest,
|
||||||
|
},
|
||||||
|
...commonRequest(value),
|
||||||
|
output: receiptPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const calls = [];
|
||||||
|
const receipt = executeCommand(applyCommand, {
|
||||||
|
runProcess: successfulRunner(calls),
|
||||||
|
});
|
||||||
|
assert.equal(receipt.schema, RECEIPT_SCHEMA);
|
||||||
|
assert.equal(receipt.preflightDigest, value.preflight.preflightDigest);
|
||||||
|
assert.equal(receipt.verification.kubernetesMutation, true);
|
||||||
|
assert.equal(receipt.verification.crossResourceAtomicity, false);
|
||||||
|
assert.equal(
|
||||||
|
receipt.verification.recovery,
|
||||||
|
'reapply_exact_lock_with_same_field_manager',
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
receipt.steps.map(({ name }) => name),
|
||||||
|
[
|
||||||
|
'cluster_identity_before',
|
||||||
|
'server_side_dry_run',
|
||||||
|
'server_side_apply',
|
||||||
|
'server_side_convergence_read',
|
||||||
|
'cluster_identity_after',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 5);
|
||||||
|
assert.equal(fs.statSync(receiptPath).mode & 0o777, 0o600);
|
||||||
|
|
||||||
|
const auditCommand = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'audit-command.json',
|
||||||
|
'cluster.deployment.receipt.audit',
|
||||||
|
{
|
||||||
|
applyCommand: {
|
||||||
|
path: applyCommand,
|
||||||
|
expectedDigest: fileDigest(applyCommand),
|
||||||
|
},
|
||||||
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(executeCommand(auditCommand), {
|
||||||
|
compatible: true,
|
||||||
|
deploymentFamily: 'cluster',
|
||||||
|
mutationId: receipt.mutationId,
|
||||||
|
receiptDigest: receipt.receiptDigest,
|
||||||
|
preflightDigest: receipt.preflightDigest,
|
||||||
|
lockDigest: value.report.lockDigest,
|
||||||
|
manifestDigest: value.report.manifest.outputDigest,
|
||||||
|
clusterUid: CLUSTER_UID,
|
||||||
|
externalResultsReplayed: false,
|
||||||
|
kubernetesMutation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let replayCalls = 0;
|
||||||
|
assert.equal(
|
||||||
|
executeCommand(applyCommand, {
|
||||||
|
runProcess() {
|
||||||
|
replayCalls += 1;
|
||||||
|
throw new Error('must not run');
|
||||||
|
},
|
||||||
|
}).receiptDigest,
|
||||||
|
receipt.receiptDigest,
|
||||||
|
);
|
||||||
|
assert.equal(replayCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apply failure or post-apply drift never publishes a success receipt', (t) => {
|
||||||
|
for (const failure of ['apply', 'convergence', 'identity-after']) {
|
||||||
|
const value = prepare(t);
|
||||||
|
const receiptPath = path.join(value.directory, `${failure}-receipt.json`);
|
||||||
|
const applyCommand = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
`${failure}-apply-command.json`,
|
||||||
|
'cluster.deployment.apply',
|
||||||
|
{
|
||||||
|
mutationId:
|
||||||
|
failure === 'apply'
|
||||||
|
? '123e4567-e89b-42d3-a456-426614174040'
|
||||||
|
: failure === 'convergence'
|
||||||
|
? '123e4567-e89b-42d3-a456-426614174041'
|
||||||
|
: '123e4567-e89b-42d3-a456-426614174042',
|
||||||
|
preflight: {
|
||||||
|
path: value.preflightPath,
|
||||||
|
expectedDigest: value.preflight.preflightDigest,
|
||||||
|
},
|
||||||
|
...commonRequest(value),
|
||||||
|
output: receiptPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let identityCount = 0;
|
||||||
|
assert.throws(() =>
|
||||||
|
executeCommand(applyCommand, {
|
||||||
|
runProcess(_executable, args) {
|
||||||
|
if (args.includes('get') && args.includes('-f=-')) {
|
||||||
|
return {
|
||||||
|
status: failure === 'convergence' ? 1 : 0,
|
||||||
|
stdout: failure === 'convergence' ? '' : convergenceList(),
|
||||||
|
stderr:
|
||||||
|
failure === 'convergence' ? 'redacted convergence error' : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args.includes('get')) {
|
||||||
|
identityCount += 1;
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
stdout:
|
||||||
|
failure === 'identity-after' && identityCount === 2
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: CLUSTER_UID,
|
||||||
|
stderr: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (args.includes('--dry-run=server')) {
|
||||||
|
return { status: 0, stdout: 'dry-run', stderr: '' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: failure === 'apply' ? 1 : 0,
|
||||||
|
stdout: '',
|
||||||
|
stderr: failure === 'apply' ? 'redacted apply error' : '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(fs.existsSync(receiptPath), false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('receipt audit rejects a different command or recomputed receipt', (t) => {
|
||||||
|
const value = prepare(t);
|
||||||
|
const receiptPath = path.join(value.directory, 'receipt.json');
|
||||||
|
const applyCommand = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'apply-command.json',
|
||||||
|
'cluster.deployment.apply',
|
||||||
|
{
|
||||||
|
mutationId: '123e4567-e89b-42d3-a456-426614174050',
|
||||||
|
preflight: {
|
||||||
|
path: value.preflightPath,
|
||||||
|
expectedDigest: value.preflight.preflightDigest,
|
||||||
|
},
|
||||||
|
...commonRequest(value),
|
||||||
|
output: receiptPath,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const receipt = executeCommand(applyCommand, {
|
||||||
|
runProcess: successfulRunner([]),
|
||||||
|
});
|
||||||
|
const forged = { ...receipt, mutationId: crypto.randomUUID() };
|
||||||
|
const { receiptDigest: ignored, ...unsigned } = forged;
|
||||||
|
forged.receiptDigest = digest(JSON.stringify(unsigned));
|
||||||
|
fs.writeFileSync(receiptPath, canonicalJson(forged));
|
||||||
|
const auditCommand = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'audit-command.json',
|
||||||
|
'cluster.deployment.receipt.audit',
|
||||||
|
{
|
||||||
|
applyCommand: {
|
||||||
|
path: applyCommand,
|
||||||
|
expectedDigest: fileDigest(applyCommand),
|
||||||
|
},
|
||||||
|
receipt: { path: receiptPath, expectedDigest: receipt.receiptDigest },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.throws(() => executeCommand(auditCommand));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('command surface is closed and lock reports require exact canonical identity', () => {
|
||||||
|
assert.equal(
|
||||||
|
commandFile(['--command-file=/private/command.json']),
|
||||||
|
'/private/command.json',
|
||||||
|
);
|
||||||
|
assert.throws(() => commandFile([]));
|
||||||
|
assert.throws(() => commandFile(['--command-file=a', '--extra=b']));
|
||||||
|
assert.throws(() =>
|
||||||
|
parseCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: COMMAND_SCHEMA,
|
||||||
|
operation: 'cluster.deployment.apply-now',
|
||||||
|
request: {},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const report = lockReport();
|
||||||
|
assert.equal(validateLockReport(report).report.lockDigest, report.lockDigest);
|
||||||
|
const drifted = JSON.parse(JSON.stringify(report));
|
||||||
|
drifted.catalog.discoveryTagAuthority = 'fallback';
|
||||||
|
const { lockDigest: ignored, ...unsigned } = drifted;
|
||||||
|
drifted.lockDigest = digest(JSON.stringify(unsigned));
|
||||||
|
assert.throws(() => validateLockReport(drifted));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repository exposes the reviewed ceremony and removes the bare apply handoff', () => {
|
||||||
|
const root = path.resolve(__dirname, '../..');
|
||||||
|
const packageManifest = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(root, 'package.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
packageManifest.scripts['cluster-deployment:ql3'],
|
||||||
|
'node scripts/ql3-kubernetes-deployment-ceremony.cjs',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
packageManifest.scripts['test:cluster-deployment-live:ql3'],
|
||||||
|
'node scripts/ql3-kubernetes-deployment-live-contract.cjs',
|
||||||
|
);
|
||||||
|
const operations = fs.readFileSync(
|
||||||
|
path.join(root, 'docs/operations/ql3-release-set-deployment.md'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.match(operations, /cluster\.deployment\.preflight/);
|
||||||
|
assert.match(operations, /cluster\.deployment\.apply/);
|
||||||
|
assert.match(operations, /cluster\.deployment\.receipt\.audit/);
|
||||||
|
assert.match(operations, /qinglong3-catalog-lock/);
|
||||||
|
assert.equal(
|
||||||
|
operations.includes('才由有权限的独立步骤执行 `kubectl apply'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thin CLI uses the pinned executable and keeps failures low-sensitive', (t) => {
|
||||||
|
const value = fixture(t);
|
||||||
|
fs.writeFileSync(
|
||||||
|
value.kubectl,
|
||||||
|
`#!${process.execPath}
|
||||||
|
'use strict';
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
if (args.includes('get') && args.includes('-f=-')) process.stdout.write(${JSON.stringify(
|
||||||
|
convergenceList(),
|
||||||
|
)});
|
||||||
|
else if (args.includes('get')) process.stdout.write(${JSON.stringify(
|
||||||
|
CLUSTER_UID,
|
||||||
|
)});
|
||||||
|
else if (args.includes('--dry-run=server')) process.stdout.write('deployment.apps/ql3-control\\n');
|
||||||
|
else process.exitCode = 91;
|
||||||
|
`,
|
||||||
|
{ mode: 0o700 },
|
||||||
|
);
|
||||||
|
const output = path.join(value.directory, 'cli-preflight.json');
|
||||||
|
const command = writeCommand(
|
||||||
|
value.directory,
|
||||||
|
'cli-command.json',
|
||||||
|
'cluster.deployment.preflight',
|
||||||
|
{
|
||||||
|
preflightId: '123e4567-e89b-42d3-a456-426614174060',
|
||||||
|
...commonRequest(value),
|
||||||
|
output,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const cli = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../scripts/ql3-kubernetes-deployment-ceremony.cjs',
|
||||||
|
);
|
||||||
|
const accepted = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[cli, `--command-file=${command}`],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
accepted.status,
|
||||||
|
0,
|
||||||
|
JSON.stringify({ stdout: accepted.stdout, stderr: accepted.stderr }),
|
||||||
|
);
|
||||||
|
assert.equal(accepted.stderr, '');
|
||||||
|
assert.equal(JSON.parse(accepted.stdout).schema, PREFLIGHT_SCHEMA);
|
||||||
|
|
||||||
|
fs.chmodSync(value.kubeconfig, 0o644);
|
||||||
|
const rejected = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[cli, `--command-file=${command}`],
|
||||||
|
{ encoding: 'utf8' },
|
||||||
|
);
|
||||||
|
assert.equal(rejected.status, 1);
|
||||||
|
assert.equal(rejected.stdout, '');
|
||||||
|
assert.deepEqual(JSON.parse(rejected.stderr), {
|
||||||
|
schemaVersion: 1,
|
||||||
|
component: 'qinglong3-kubernetes-deployment-ceremony',
|
||||||
|
code: 'QL3_KUBERNETES_DEPLOYMENT_CEREMONY_FAILED',
|
||||||
|
message: 'QingLong 3 Kubernetes deployment ceremony failed',
|
||||||
|
});
|
||||||
|
assert.equal(rejected.stderr.includes(value.directory), false);
|
||||||
|
assert.equal(rejected.stderr.includes(CONTEXT), false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user