diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index c6ee4a6d..9758bc8c 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,7 @@ 最新增量证据(2026-08-16): +- D-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-338/ADR-0430(已接受;真实公开 catalog 运行待实际 release tag):发布端的 durable catalog 不再由部署者通过松散 shell 重定向手工消费。可信工作站上的 `ql3-release-catalog-consumption-ceremony.cjs` 从 exact source version/revision/tag、closed `local|cluster|all` scope 与 owner/source repository 推导唯一 discovery ref,前后两次解析必须得到同一 digest,后续只使用 catalog `@sha256:` immutable reference。ceremony 以绝对路径、dev/inode/size/SHA-256 固定 `regctl|cosign|gh`,owner-private token 只进入单个 GitHub provenance verifier;环境、cache/config/tmp 与最终写入均有封闭边界。下载的 canonical release set 经过 standalone identity/family/self-digest inspection,raw OCI manifest 同时按 digest、media type、empty config、单 layer、basename、size/content digest 与四项 annotation 重建 publication plan/receipt。成功后才以 `0700` no-replace 目录和三项 `0600` 文件发布 release set、raw manifest 与 self-digest report;offline audit 要求 exact-three-file,并完全重建结构/manifest/report,同时诚实声明网络签名结果未离线 replay。该 ceremony 无 registry/GitHub mutation、deployment action authority、Compose/Kubernetes apply 或数据库访问;D337 继续只消费审计后的 release set。Local/低配设备不安装任何工作站工具,Cluster 也不新增 controller/CRD/RBAC。独立 ceremony 20/20、完整定向发布链 121/121 已通过;backend 共 1,315 项,1,313 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改变数据库或 HA 拓扑,复用紧邻发布 Gate 的 PostgreSQL 18.6 arm64 physical HA 基线而不把它声明为本阶段新证据。公开 GHCR catalog 仍未实际产生,因此本门不宣称已取得真实 Cosign/GitHub/registry 成功证据。 - D-337/ADR-0429(已接受):D-336 的 durable release set 现在可以离线物化为最终部署 authority,而不是由运维者手工复制 digest。可信工作站上的 `ql3-deployment-lock-contract.cjs` 不联网、不连接 Kubernetes API、不执行 rollout:Local/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selection;Cluster/All 先消费 `kubectl kustomize` 的最终多文档 YAML,再只改写封闭的 Pod/Deployment/StatefulSet/DaemonSet/ReplicaSet/Job/CronJob container 字段和 exact Plugin Package admission ConfigMap,生成带输入/输出 digest、各 role occurrence、release annotation 与 self digest 的 locked manifest/report。调用方必须显式声明 required role,未知位置的完整 role authority、畸形 container image、缺失角色、YAML alias/cycle/非 mapping、超限或覆盖输出全部失败关闭;audit 从原 release set 与原 render byte-exact 重建。采用 post-render 是因为真实原型证明外层 Kustomize component 不能可靠覆盖内层已选 repository/digest,且 `images` transformer 不处理 ConfigMap `data.image`。本机 kubectl 1.36.1/Kustomize 5.8.1 已真实渲染 CloudNativePG Core、Cluster AI、Worker node 与 Plugin Package Executor 四类清单,内层零 digest 均被同一 release set 的精确引用替换;定向 deployment-lock 11/11、发布链路联动 101/101,静态审计冻结 224 个 YAML、31 个直接 role image 引用与两个 admission authority。工具只在工作站运行,低配路由器只消费 Local selection,不新增 Node/YAML/Kubernetes/registry 工具、package、依赖、常驻进程或资源;Cluster 也不新增 controller/webhook/CRD/RBAC。完整 backend 共 1,295 项,1,293 pass/2 条件 skip/0 fail;18-package clean build/test 退出 0,package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,10 项架构/部署审计与 14 档 Local artifact 全部 compatible。默认 Edge/Standalone 为 2,589,890/2,589,968 bytes,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes;Cluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。 diff --git a/docs/adr/ADR-0432-target-side-catalog-bound-local-compose-revisions.md b/docs/adr/ADR-0432-target-side-catalog-bound-local-compose-revisions.md new file mode 100644 index 00000000..bc1dc031 --- /dev/null +++ b/docs/adr/ADR-0432-target-side-catalog-bound-local-compose-revisions.md @@ -0,0 +1,84 @@ +# ADR-0432:目标侧 Catalog-bound Local Compose 修订 + +- 状态:Accepted +- 日期:2026-08-16 +- 关联 RFC:QL-RFC-0001 D-03、D-14、D-339、D-340 +- 关联 ADR:ADR-0199、ADR-0200、ADR-0431 + +## 上下文 + +ADR-0431 已让可信工作站从完整 catalog consumption bundle 生成 catalog-bound Local v2 selection, +但 Local prepare/upgrade 入口仍接收裸 `image`。运维者必须手工复制 `service.image`,目标侧的 Compose revision v1 +也只保存 image、generation 与 mutation。结果是 release-set、catalog manifest、consumption report、workflow identity +和 immutable catalog reference 在最后一跳被丢失;一个格式正确的任意 digest 可以绕开已经完成的 catalog 证据链。 + +把 Cosign、GitHub CLI、regctl、Kustomize 或完整 release bundle audit 下沉到低配路由器,会显著扩大磁盘、内存、网络、 +凭据与更新面。让发布验真成功后自动 rollout 又会混合 input authority 与 action authority。 + +## 决策 + +1. 现有 `@qinglong/local-owner-cli` 内部增加目标侧 Local selection consumer,不新增 workspace package 或生产依赖。 + Compose prepare 与 upgrade 删除裸 `image` 输入,改为 exact `releaseSelection.path + expectedSelectionDigest`;旧输入失败关闭。 +2. selection 必须是 absolute normalized path,父目录为 canonical/current-UID `0700`,文件为 canonical/current-UID、单链接 + `0600`、最大 64 KiB。consumer 通过 `O_NOFOLLOW` stable descriptor 有界读取 UTF-8 canonical JSON,并在读取前后复验 + identity/size/timestamp。 +3. 目标侧重新验证 `qinglong/local-compose-release-image@v2` exact shape/self-digest、3.x release/tag/revision、`local|all` + scope、release-set/catalog digest 闭包、exact image-release workflow identity、immutable catalog reference、唯一 + `ghcr.io//qinglong3-local-application@sha256:...` 和 explicit root policy。命令提供的 expected selection digest 是 + 本次目标部署的本地 operator authority;目标机不伪称重放网络签名或 provenance verifier。 +4. Compose image selection 升为 `qinglong/local-compose-image-selection@v2`。每个 immutable revision 持久保存 image 以及 + selection/release-set/catalog manifest/catalog report digest、release identity、catalog schema/source/workflow/immutable + reference、discovery tag non-authority 与 root policy;核心 digest 同时进入 container labels。 +5. upgrade 从已验证的 selection 取得完整 release authority;rollback 从目标 immutable revision 复制完整 authority,不能只复制 + image。Preflight、Apply、Restore、Evidence collection 与 durable status 继续通过同一 active/revision parser 验证 v2 canonical + binding。 +6. prepare/upgrade 只发布 revision,不访问 registry/GitHub,不启动容器、不调用 Compose up、不修改数据库。Preflight 与 Apply + 保持独立、显式命令,因此可信 selection 不会自动获得 rollout authority。 + +## 部署与资源影响 + +- 不新增 package、生产依赖、数据库、migration、SQL、Pool、Pod、controller、CRD、RBAC、listener、timer 或 watcher。 +- 低配路由器每次显式 prepare/upgrade 只增加一次最多 64 KiB 的私有文件读取、JSON 校验和 SHA-256;没有后台 CPU、内存或网络开销。 +- Cluster/Kubernetes lock 路径不变;本决策只闭合 Local/Compose 最后一跳。 +- v2 revision 比 v1 多约 2 KiB provenance 文本。revision 数量仍由既有显式 evidence collection/保留策略约束。 + +## 兼容与恢复 + +- QingLong 3.0 尚未正式发布,不保留 v1 裸 image 旁路。已有孵化环境必须用原 catalog-bound selection 重新 prepare;不要手改 + `compose.image.yaml` 或 revision。 +- command/selection 必须作为恢复证据保留。响应丢失时原样重放同一 command、path 与 expected digest;任何 selection byte、权限、 + parent、root policy 或 catalog binding 漂移都在 deployment mutation 前失败。 +- revision 切换后的 rollout 失败继续使用既有 generation CAS 和 roll-forward rollback;rollback revision 保留原目标 release 来源。 + +## 被拒绝的替代方案 + +### 继续手工复制 `service.image` + +拒绝。它在最后一跳重新引入无法机器证明来源的开放字段,使 ADR-0431 的 catalog binding 只停留在工作站文件中。 + +### 在目标机重新运行完整 catalog ceremony + +拒绝。低配设备不应承担 registry/GitHub 凭据、外部二进制、网络和完整 bundle 成本;在线验真仍属于可信工作站。 + +### 验证 selection 后自动 Apply + +拒绝。输入可信不等于变更获批;revision preparation、Docker preflight 与 rollout 必须保持不同 authority。 + +### 新建一个 selection-consumer package + +拒绝。该能力只服务现有 Local deployment Compose 边界;拆包会制造单一消费者和浅 package,而不会形成可独立部署的职责。 + +## 验证 + +- Local deployment 定向契约 30/30,覆盖 Prepare、Upgrade、Rollback、response-loss、Preflight、Apply、Restore、Evidence + collection、Status,以及 raw image、expected digest、权限、mutable image 和 catalog/release-set 漂移的 + mutation-before-failure;物理 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。workspace 保持 18 packages,`singleSourcePackages=[]`、`shallowSourcePackages=[]`; +- 10 项架构/部署审计和 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 制品保持 + 2,589,890/2,589,968 bytes,application 为 3,632,769/3,632,889 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 资源残留; +- live rollout 使用明确标记的临时 synthetic selection,只测试镜像/Compose 兼容性,不冒充公开 catalog ceremony。完整结果 + 同步记录在 QL-RFC-0001 D-340。 diff --git a/docs/adr/README.md b/docs/adr/README.md index c04c313b..c5e5878e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -435,6 +435,7 @@ | [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted | | [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag) | | [ADR-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 | ## 规则 diff --git a/docs/operations/ql3-local-deployment.md b/docs/operations/ql3-local-deployment.md index a136b8f3..953b2233 100644 --- a/docs/operations/ql3-local-deployment.md +++ b/docs/operations/ql3-local-deployment.md @@ -13,6 +13,8 @@ - 已安装 `ql3-local-deploy` 与 `ql3-local-application`; - 使用最终运行 QingLong 的同一个 POSIX 用户; - command file 的父目录为当前 UID 的 canonical `0700` 目录; +- Compose 的 catalog-bound Local v2 selection 也必须位于当前 UID 的 canonical + `0700` 目录中,文件自身为 canonical、current-UID、单链接 `0600`; - 生产环境建议使用非 root 用户。只有 root-only 路由器才将 `allowRootService` 明确设为 `true`。 @@ -502,16 +504,27 @@ sudo rc-service qinglong3 start ## 5. Rootless Compose -Compose 命令只接受不可变 digest,不接受 `latest` 或普通 tag: +Compose 命令不再接受裸 image 字段。先按 +[Release-set 部署流程](./ql3-release-set-deployment.md) 在可信工作站生成并审计 +catalog-bound Local v2 selection,再把该私有文件及 audit 返回的 exact +`selectionDigest` 交给目标机: ```json { "kind": "compose", - "image": "registry.example/qinglong3-local@sha256:REPLACE_WITH_64_HEX_DIGEST", + "releaseSelection": { + "path": "/secure/operator/qinglong3-local-selection-3.0.0.json", + "expectedSelectionDigest": "sha256:REPLACE_WITH_64_HEX_DIGEST" + }, "allowRootService": false } ``` +目标机只做一次有界私有 JSON 读取、canonical shape/self-digest/catalog binding +校验并取出唯一 `ghcr.io//qinglong3-local-application@sha256:...`。它不运行 +Cosign、GitHub CLI、regctl 或 Kustomize,不访问网络,也不会因此获得 rollout authority。 +旧 `image` 输入失败关闭,不提供手工复制旁路。 + 它生成 `service/compose.yaml`,固定 numeric UID:GID、read-only rootfs、唯一 bind mount、无网络、drop all capabilities、no-new-privileges、16 MiB tmpfs 与 Profile memory/PID 上限。application config 自动使用容器内 @@ -543,8 +556,10 @@ docker compose \ 输出必须精确等于已审核的 `@sha256` image。不得省略或调换第二个 override 文件, 也不得另加第三个 Compose 文件覆盖 image。 -基础文件包含从 `instanceId` 派生的稳定 Compose project name;override 同时把 -generation/mutation 写入 container label。不要使用 `-p`、`COMPOSE_PROJECT_NAME` +基础文件包含从 `instanceId` 派生的稳定 Compose project name;override 使用 v2 +revision 格式持久保存 selection/release-set/catalog manifest/catalog consumption report +digest、release identity、immutable catalog reference 与 root policy,并把 generation、 +mutation 和核心 provenance 写入 container label。不要使用 `-p`、`COMPOSE_PROJECT_NAME` 或第三个 override 改写这些身份。 当前仓库仍没有可引用的本机远端 release digest。ADR-0195 已提供 @@ -574,15 +589,16 @@ pnpm sbom:local-image:ql3 pnpm audit:image-release:ql3 ``` -本地 tag/image ID 不是可发布 digest。只有 release workflow 返回的 -`ghcr.io//qinglong3-local-application@sha256:...`,且同一 digest 的 -双架构 manifest、Cosign、SLSA、CycloneDX 远端 verify 全部成功后,才可写入 -D-184 Compose 私有输入;永远不得替换成 mutable tag。 +本地 tag/image ID 不是可发布 authority。只有 release workflow 返回的 +`ghcr.io//qinglong3-local-application@sha256:...`,且同一 release-set 的 +双架构 manifest、Cosign、SLSA、CycloneDX、immutable catalog ceremony 与 deployment-lock +audit 全部成功后,才可把 Local v2 selection 的路径和 digest 写入 Compose 私有输入; +永远不得替换成裸 digest 或 mutable tag。 ## 6. Compose 镜像升级和选择回退 -升级前先完成 D-186 的 digest/attestation/signature 回读,并把 exact image 预取到 -设备。随后创建新的私有 command file: +升级前先完成 release catalog consumption ceremony 与 Local deployment-lock audit,并按 +独立下载策略把 selection 绑定的 exact image 预取到设备。随后创建新的私有 command file: ```json { @@ -594,7 +610,10 @@ D-184 Compose 私有输入;永远不得替换成 mutable tag。 }, "request": { "expectedGeneration": 1, - "image": "registry.example/qinglong3-local@sha256:REPLACE_WITH_64_HEX_DIGEST", + "releaseSelection": { + "path": "/secure/operator/qinglong3-local-selection-3.0.1.json", + "expectedSelectionDigest": "sha256:REPLACE_WITH_64_HEX_DIGEST" + }, "mutationId": "REPLACE_WITH_UUID_V4", "changedAtMs": 1785254500000 } @@ -607,8 +626,8 @@ ql3-local-deploy compose-revision \ --command-file /secure/operator/qinglong3-compose-upgrade.json ``` -成功返回 generation 2。结果未知时原样重放同一文件;不要修改 mutation、时间或 -expected generation。再次用 `config --images` 检查 selection,再由 operator +成功返回 generation 2,并把完整 catalog provenance 固化到 immutable revision。结果未知时原样重放同一文件; +不要修改 selection path/digest、mutation、时间或 expected generation。再次用 `config --images` 检查 selection,再由 operator 先执行 rollout preflight。Docker 路径和 socket 都必须使用 `realpath`;典型 rootful Linux 分别为 `/usr/bin/docker` 与 `/var/run/docker.sock`,rootless socket 通常位于 `/run/user//docker.sock`: diff --git a/docs/operations/ql3-release-set-deployment.md b/docs/operations/ql3-release-set-deployment.md index c9235019..c4c7edc1 100644 --- a/docs/operations/ql3-release-set-deployment.md +++ b/docs/operations/ql3-release-set-deployment.md @@ -122,9 +122,11 @@ node scripts/ql3-deployment-lock-contract.cjs \ --selection="${selection}" ``` -v2 selection 同时绑定 catalog immutable reference、manifest digest、consumption report digest 与 release-set digest。把已审计的 -`service.image` 和 `service.allowRootService` 交给现有 Local private prepare/rollout 入口。selection 本身不修改 Compose 文件, -也不启动容器。是否允许 root service 必须显式给出,不能由设备默认值推断。 +v2 selection 同时绑定 catalog immutable reference、manifest digest、consumption report digest 与 release-set digest。不要再手工复制 +`service.image`。把 selection 放入目标运行 UID 控制的 canonical `0700` 目录,保持文件为单链接 `0600`,然后把它的 absolute path 与 +`local-audit` 返回的 exact `selectionDigest` 写入 Local prepare/upgrade 的 `releaseSelection`。目标侧会再次验证 canonical JSON、 +self-digest、release/catalog identity、唯一 GHCR Local image 与 explicit root policy,再把完整 provenance 固化到 Compose v2 revision。 +selection 本身不修改 Compose 文件,也不启动容器;prepare/upgrade 仍不等于 preflight/apply authority。 ### Kubernetes / Cluster / Worker diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/composePreflight.ts b/packages/ql3-local-owner-cli/src/deployment/compose/composePreflight.ts index e71d186b..9e17cf62 100644 --- a/packages/ql3-local-owner-cli/src/deployment/compose/composePreflight.ts +++ b/packages/ql3-local-owner-cli/src/deployment/compose/composePreflight.ts @@ -11,8 +11,8 @@ import { currentIdentity, LocalDeploymentConfigurationError, normalizeLocalDeploymentComposePreflightCommand, - normalizeLocalDeploymentPrepareCommand, type LocalDeploymentComposePreflightResult, + type LocalDeploymentPrepareCommand, type LocalDeploymentProfile, } from '../foundation/contract'; import { inspectActiveComposeImageSelection } from './composeRevision'; @@ -357,7 +357,7 @@ export async function preflightLocalDeploymentCompose( paths.applicationConfig, identity.uid, ); - const syntheticPrepare = normalizeLocalDeploymentPrepareCommand({ + const syntheticPrepare: Readonly = { schemaVersion: 1, operation: 'local.deployment.prepare', options: { @@ -369,7 +369,10 @@ export async function preflightLocalDeploymentCompose( : { busyTimeoutMs: application.busyTimeoutMs }), service: { kind: 'compose', - image: selection.image, + releaseSelection: { + path: paths.composeSelection, + expectedSelectionDigest: selection.selectionDigest, + }, allowRootService: command.options.allowRootService, }, }, @@ -380,7 +383,7 @@ export async function preflightLocalDeploymentCompose( registeredAtMs: 0, activatedAtMs: 0, }, - }); + }; preflightPublishedFile( paths.applicationConfig, applicationConfiguration(syntheticPrepare, paths), diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts index 24ea3a06..ac0ef9a4 100644 --- a/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts +++ b/packages/ql3-local-owner-cli/src/deployment/compose/composeRevision.ts @@ -7,10 +7,11 @@ import { currentIdentity, LocalDeploymentConfigurationError, normalizeLocalDeploymentComposeRevisionCommand, - type LocalDeploymentComposeRevisionCommand, type LocalDeploymentComposeRevisionResult, - type LocalDeploymentPrepareCommand, + type NormalizedLocalDeploymentComposeRevisionCommand, + type NormalizedLocalDeploymentPrepareCommand, } from '../foundation/contract'; +import type { LocalComposeReleaseAuthority } from './releaseSelection'; import { preflightPublishedFile, publishExactFile, @@ -20,19 +21,23 @@ import { } from '../foundation/files'; import { deploymentPaths } from '../foundation/render'; -const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v1'; +const SELECTION_SCHEMA = 'qinglong/local-compose-image-selection@v2'; +const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1'; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; const IMAGE_PATTERN = - /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@sha256:[0-9a-f]{64}$/; +const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; +const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; +const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -export interface ComposeImageSelection { +export interface ComposeImageSelection extends LocalComposeReleaseAuthority { readonly generation: number; readonly previousGeneration: number; readonly rollbackTargetGeneration: number; readonly mutationId: string; readonly changedAtMs: number; - readonly image: string; } function selectionContents(selection: Readonly): string { @@ -44,12 +49,30 @@ function selectionContents(selection: Readonly): string { ` rollback_target_generation: ${selection.rollbackTargetGeneration}`, ` mutation_id: ${selection.mutationId}`, ` changed_at_ms: ${selection.changedAtMs}`, + ` release_selection_digest: ${selection.selectionDigest}`, + ` release_set_digest: ${selection.releaseSetDigest}`, + ` release_version: ${selection.releaseVersion}`, + ` release_source_revision: ${selection.releaseSourceRevision}`, + ` release_source_ref: ${selection.releaseSourceRef}`, + ` release_scope: ${selection.releaseScope}`, + ` catalog_schema: ${selection.catalogSchema}`, + ` catalog_source_repository: ${selection.catalogSourceRepository}`, + ` catalog_workflow_identity: ${selection.catalogWorkflowIdentity}`, + ` catalog_immutable_reference: ${selection.catalogImmutableReference}`, + ` catalog_manifest_digest: ${selection.catalogManifestDigest}`, + ` catalog_consumption_report_digest: ${selection.catalogConsumptionReportDigest}`, + ` catalog_discovery_tag_authority: ${selection.catalogDiscoveryTagAuthority}`, + ` allow_root_service: ${selection.allowRootService}`, 'services:', ' qinglong3:', ` image: ${selection.image}`, ' labels:', ` io.qinglong.deployment.generation: "${selection.generation}"`, ` io.qinglong.deployment.mutation: "${selection.mutationId}"`, + ` io.qinglong.release.selection: "${selection.selectionDigest}"`, + ` io.qinglong.release.set: "${selection.releaseSetDigest}"`, + ` io.qinglong.release.catalog-manifest: "${selection.catalogManifestDigest}"`, + ` io.qinglong.release.catalog-report: "${selection.catalogConsumptionReportDigest}"`, '', ].join('\n'); } @@ -70,7 +93,7 @@ function parseSelection( label: string, ): Readonly { const match = - /^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v1\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n$/.exec( + /^x-qinglong-image-selection:\n schema: qinglong\/local-compose-image-selection@v2\n generation: (0|[1-9][0-9]{0,5})\n previous_generation: (0|[1-9][0-9]{0,5})\n rollback_target_generation: (0|[1-9][0-9]{0,5})\n mutation_id: ([0-9a-f-]+)\n changed_at_ms: ([0-9]+)\n release_selection_digest: (sha256:[a-f0-9]{64})\n release_set_digest: (sha256:[a-f0-9]{64})\n release_version: ([^\n]+)\n release_source_revision: ([a-f0-9]{40})\n release_source_ref: ([^\n]+)\n release_scope: (local|all)\n catalog_schema: qinglong\/release-catalog-consumption-ceremony@v1\n catalog_source_repository: ([^\n]+)\n catalog_workflow_identity: ([^\n]+)\n catalog_immutable_reference: ([^\n]+)\n catalog_manifest_digest: (sha256:[a-f0-9]{64})\n catalog_consumption_report_digest: (sha256:[a-f0-9]{64})\n catalog_discovery_tag_authority: none\n allow_root_service: (true|false)\nservices:\n qinglong3:\n image: ([^\n]+)\n labels:\n io\.qinglong\.deployment\.generation: "([0-9]+)"\n io\.qinglong\.deployment\.mutation: "([0-9a-f-]+)"\n io\.qinglong\.release\.selection: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.set: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-manifest: "(sha256:[a-f0-9]{64})"\n io\.qinglong\.release\.catalog-report: "(sha256:[a-f0-9]{64})"\n$/.exec( contents, ); if (!match) { @@ -87,9 +110,26 @@ function parseSelection( ); const mutationId = match[4]; const changedAtMs = Number(match[5]); - const image = match[6]; - const labelGeneration = Number(match[7]); - const labelMutationId = match[8]; + const selectionDigest = match[6]!; + const releaseSetDigest = match[7]!; + const releaseVersion = match[8]!; + const releaseSourceRevision = match[9]!; + const releaseSourceRef = match[10]!; + const releaseScope = match[11] as 'local' | 'all'; + const catalogSourceRepository = match[12]!; + const catalogWorkflowIdentity = match[13]!; + const catalogImmutableReference = match[14]!; + const catalogManifestDigest = match[15]!; + const catalogConsumptionReportDigest = match[16]!; + const allowRootService = match[17] === 'true'; + const image = match[18]!; + const labelGeneration = Number(match[19]); + const labelMutationId = match[20]; + const labelSelectionDigest = match[21]; + const labelReleaseSetDigest = match[22]; + const labelCatalogManifestDigest = match[23]; + const labelCatalogReportDigest = match[24]; + const imageMatch = IMAGE_PATTERN.exec(image); if ( generation < 1 || previousGeneration !== generation - 1 || @@ -98,12 +138,25 @@ function parseSelection( changedAtMs < 0 || !mutationId || !UUID_V4_PATTERN.test(mutationId) || - !image || - !IMAGE_PATTERN.test(image) || - image.includes('..') || - image.includes('//') || + !DIGEST_PATTERN.test(selectionDigest) || + !DIGEST_PATTERN.test(releaseSetDigest) || + !VERSION_PATTERN.test(releaseVersion) || + !SOURCE_REVISION_PATTERN.test(releaseSourceRevision) || + releaseSourceRef !== `refs/tags/v${releaseVersion}` || + !SOURCE_REPOSITORY_PATTERN.test(catalogSourceRepository) || + catalogWorkflowIdentity !== + `https://github.com/${catalogSourceRepository}/.github/workflows/ql3-image-release.yml@${releaseSourceRef}` || + !DIGEST_PATTERN.test(catalogManifestDigest) || + !DIGEST_PATTERN.test(catalogConsumptionReportDigest) || + !imageMatch || + catalogImmutableReference !== + `ghcr.io/${imageMatch?.[1]}/qinglong3-release-catalog@${catalogManifestDigest}` || labelGeneration !== generation || - labelMutationId !== mutationId + labelMutationId !== mutationId || + labelSelectionDigest !== selectionDigest || + labelReleaseSetDigest !== releaseSetDigest || + labelCatalogManifestDigest !== catalogManifestDigest || + labelCatalogReportDigest !== catalogConsumptionReportDigest ) { throw new LocalDeploymentConfigurationError(`${label} value is invalid`); } @@ -114,6 +167,20 @@ function parseSelection( mutationId, changedAtMs, image, + allowRootService, + selectionDigest, + releaseSetDigest, + releaseVersion, + releaseSourceRevision, + releaseSourceRef, + releaseScope, + catalogSchema: CATALOG_SCHEMA, + catalogSourceRepository, + catalogWorkflowIdentity, + catalogImmutableReference, + catalogManifestDigest, + catalogConsumptionReportDigest, + catalogDiscoveryTagAuthority: 'none' as const, }); if (selectionContents(selection) !== contents) { throw new LocalDeploymentConfigurationError(`${label} is not canonical`); @@ -157,11 +224,33 @@ function revisionPath(root: string, generation: number): string { } function commandIntent( - command: Readonly, + command: Readonly, ): string { return `${JSON.stringify(command, null, 2)}\n`; } +function releaseAuthorityFromSelection( + selection: Readonly, +): Readonly { + return Object.freeze({ + image: selection.image, + allowRootService: selection.allowRootService, + selectionDigest: selection.selectionDigest, + releaseSetDigest: selection.releaseSetDigest, + releaseVersion: selection.releaseVersion, + releaseSourceRevision: selection.releaseSourceRevision, + releaseSourceRef: selection.releaseSourceRef, + releaseScope: selection.releaseScope, + catalogSchema: selection.catalogSchema, + catalogSourceRepository: selection.catalogSourceRepository, + catalogWorkflowIdentity: selection.catalogWorkflowIdentity, + catalogImmutableReference: selection.catalogImmutableReference, + catalogManifestDigest: selection.catalogManifestDigest, + catalogConsumptionReportDigest: selection.catalogConsumptionReportDigest, + catalogDiscoveryTagAuthority: selection.catalogDiscoveryTagAuthority, + }); +} + function releaseLock(lockPath: string, intent: string, uid: number): void { preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock'); fs.unlinkSync(lockPath); @@ -169,7 +258,7 @@ function releaseLock(lockPath: string, intent: string, uid: number): void { } export function initialComposeImageSelection( - command: Readonly, + command: Readonly, ): string { if (command.options.service.kind !== 'compose') { throw new LocalDeploymentConfigurationError( @@ -182,7 +271,7 @@ export function initialComposeImageSelection( rollbackTargetGeneration: 0, mutationId: command.request.activateMutationId, changedAtMs: command.request.activatedAtMs, - image: command.options.service.image, + ...command.options.service.releaseSelection.authority, }); } @@ -314,10 +403,10 @@ export async function switchLocalDeploymentComposeRevision( 'active compose selection', ); const nextGeneration = command.request.expectedGeneration + 1; - let image: string; + let releaseAuthority: Readonly; let rollbackTargetGeneration = 0; if (command.operation === 'local.deployment.compose.upgrade') { - image = command.request.image; + releaseAuthority = command.request.releaseSelection.authority; } else { rollbackTargetGeneration = command.request.targetGeneration; const target = readSelectionFile( @@ -330,7 +419,7 @@ export async function switchLocalDeploymentComposeRevision( 'rollback target generation drifted', ); } - image = target.selection.image; + releaseAuthority = releaseAuthorityFromSelection(target.selection); } const nextContents = selectionContents({ generation: nextGeneration, @@ -338,7 +427,7 @@ export async function switchLocalDeploymentComposeRevision( rollbackTargetGeneration, mutationId: command.request.mutationId, changedAtMs: command.request.changedAtMs, - image, + ...releaseAuthority, }); if (command.request.changedAtMs < observed.selection.changedAtMs) { throw new LocalDeploymentConfigurationError( diff --git a/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts b/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts new file mode 100644 index 00000000..277c6900 --- /dev/null +++ b/packages/ql3-local-owner-cli/src/deployment/compose/releaseSelection.ts @@ -0,0 +1,281 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const MAX_SELECTION_BYTES = 64 * 1024; +const LOCAL_SELECTION_SCHEMA = 'qinglong/local-compose-release-image@v2'; +const CATALOG_SCHEMA = 'qinglong/release-catalog-consumption-ceremony@v1'; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const VERSION_PATTERN = /^3\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/; +const SOURCE_REVISION_PATTERN = /^[a-f0-9]{40}$/; +const SOURCE_REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const IMAGE_PATTERN = + /^ghcr\.io\/([a-z0-9](?:[a-z0-9-]{0,38}))\/qinglong3-local-application@(sha256:[a-f0-9]{64})$/; + +export interface LocalComposeReleaseSelectionInput { + readonly path: string; + readonly expectedSelectionDigest: string; +} + +export interface LocalComposeReleaseAuthority { + readonly image: string; + readonly allowRootService: boolean; + readonly selectionDigest: string; + readonly releaseSetDigest: string; + readonly releaseVersion: string; + readonly releaseSourceRevision: string; + readonly releaseSourceRef: string; + readonly releaseScope: 'local' | 'all'; + readonly catalogSchema: typeof CATALOG_SCHEMA; + readonly catalogSourceRepository: string; + readonly catalogWorkflowIdentity: string; + readonly catalogImmutableReference: string; + readonly catalogManifestDigest: string; + readonly catalogConsumptionReportDigest: string; + readonly catalogDiscoveryTagAuthority: 'none'; +} + +export interface ResolvedLocalComposeReleaseSelection + extends LocalComposeReleaseSelectionInput { + readonly authority: Readonly; +} + +export class LocalComposeReleaseSelectionError extends Error { + public constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'LocalComposeReleaseSelectionError'; + } +} + +function fail(message: string, cause?: unknown): never { + throw new LocalComposeReleaseSelectionError(message, { cause }); +} + +function exactKeys( + value: unknown, + keys: readonly string[], + label: string, +): asserts value is Record { + if ( + value === null || + typeof value !== 'object' || + Array.isArray(value) || + JSON.stringify(Object.keys(value).sort()) !== + JSON.stringify([...keys].sort()) + ) { + fail(`${label} shape is invalid`); + } +} + +function digest(value: string): string { + return `sha256:${crypto + .createHash('sha256') + .update(value, 'utf8') + .digest('hex')}`; +} + +function readSelectionFile(filePath: string, uid: number): string { + let parentStat: fs.Stats; + try { + parentStat = fs.lstatSync(path.dirname(filePath)); + } catch (error) { + fail('release selection is unavailable', error); + } + if ( + !parentStat.isDirectory() || + parentStat.isSymbolicLink() || + parentStat.uid !== uid || + (parentStat.mode & 0o777) !== 0o700 || + fs.realpathSync(path.dirname(filePath)) !== path.dirname(filePath) + ) { + fail('release selection must be a private canonical current-UID file'); + } + let descriptor: number | undefined; + let before: fs.Stats; + let after: fs.Stats; + let bytes: Buffer; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW, + ); + before = fs.fstatSync(descriptor); + if ( + !before.isFile() || + before.uid !== uid || + (before.mode & 0o777) !== 0o600 || + before.nlink !== 1 || + before.size < 2 || + before.size > MAX_SELECTION_BYTES || + fs.realpathSync(filePath) !== filePath + ) { + fail('release selection must be a private canonical current-UID file'); + } + bytes = fs.readFileSync(descriptor); + after = fs.fstatSync(descriptor); + } catch (error) { + if (error instanceof LocalComposeReleaseSelectionError) throw error; + fail('release selection cannot be read through a stable descriptor', error); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + } + if ( + before.dev !== after.dev || + before.ino !== after.ino || + before.size !== after.size || + before.mtimeMs !== after.mtimeMs || + before.ctimeMs !== after.ctimeMs || + bytes.byteLength !== before.size + ) { + fail('release selection changed while being read'); + } + const contents = bytes.toString('utf8'); + if (!Buffer.from(contents, 'utf8').equals(bytes)) { + fail('release selection must contain valid UTF-8'); + } + return contents; +} + +export function resolveLocalComposeReleaseSelection( + input: Readonly, + uid: number, + allowRootService: boolean, +): Readonly { + if ( + typeof input.path !== 'string' || + !path.isAbsolute(input.path) || + path.resolve(input.path) !== input.path || + typeof input.expectedSelectionDigest !== 'string' || + !DIGEST_PATTERN.test(input.expectedSelectionDigest) + ) { + fail('release selection input is invalid'); + } + const contents = readSelectionFile(input.path, uid); + let value: unknown; + try { + value = JSON.parse(contents); + } catch (error) { + fail('release selection must contain valid JSON', error); + } + if (`${JSON.stringify(value)}\n` !== contents) { + fail('release selection must use canonical JSON encoding'); + } + exactKeys( + value, + [ + 'catalog', + 'deploymentFamily', + 'release', + 'releaseSetDigest', + 'schema', + 'schemaVersion', + 'selectionDigest', + 'service', + 'verification', + ], + 'release selection', + ); + exactKeys( + value.release, + ['scope', 'sourceRef', 'sourceRevision', 'version'], + 'release', + ); + exactKeys( + value.catalog, + [ + 'consumptionReportDigest', + 'discoveryTagAuthority', + 'immutableReference', + 'manifestDigest', + 'releaseSetDigest', + 'schema', + 'sourceRepository', + 'workflowIdentity', + ], + 'catalog', + ); + exactKeys(value.service, ['allowRootService', 'image', 'kind'], 'service'); + exactKeys( + value.verification, + [ + 'catalogConsumption', + 'deploymentMutation', + 'externalToolResultsReplayed', + 'networkAccess', + 'releaseSet', + 'sourceRecordsReplayed', + ], + 'verification', + ); + const release = value.release; + const catalog = value.catalog; + const service = value.service; + const verification = value.verification; + const image = typeof service.image === 'string' ? service.image : ''; + const imageMatch = IMAGE_PATTERN.exec(image); + const { selectionDigest, ...unsigned } = value; + const calculatedDigest = digest(JSON.stringify(unsigned)); + if ( + value.schemaVersion !== 1 || + value.schema !== LOCAL_SELECTION_SCHEMA || + value.deploymentFamily !== 'local' || + typeof release.version !== 'string' || + !VERSION_PATTERN.test(release.version) || + typeof release.sourceRevision !== 'string' || + !SOURCE_REVISION_PATTERN.test(release.sourceRevision) || + release.sourceRef !== `refs/tags/v${release.version}` || + (release.scope !== 'local' && release.scope !== 'all') || + typeof value.releaseSetDigest !== 'string' || + !DIGEST_PATTERN.test(value.releaseSetDigest) || + catalog.schema !== CATALOG_SCHEMA || + typeof catalog.sourceRepository !== 'string' || + !SOURCE_REPOSITORY_PATTERN.test(catalog.sourceRepository) || + catalog.workflowIdentity !== + `https://github.com/${catalog.sourceRepository}/.github/workflows/ql3-image-release.yml@${release.sourceRef}` || + typeof catalog.manifestDigest !== 'string' || + !DIGEST_PATTERN.test(catalog.manifestDigest) || + typeof catalog.consumptionReportDigest !== 'string' || + !DIGEST_PATTERN.test(catalog.consumptionReportDigest) || + catalog.releaseSetDigest !== value.releaseSetDigest || + catalog.discoveryTagAuthority !== 'none' || + !imageMatch || + catalog.immutableReference !== + `ghcr.io/${imageMatch[1]}/qinglong3-release-catalog@${catalog.manifestDigest}` || + service.kind !== 'compose' || + service.allowRootService !== allowRootService || + verification.releaseSet !== + 'standalone_structure_identity_and_self_digest' || + verification.sourceRecordsReplayed !== false || + verification.catalogConsumption !== 'offline_reconstructed' || + verification.externalToolResultsReplayed !== false || + verification.networkAccess !== false || + verification.deploymentMutation !== false || + typeof selectionDigest !== 'string' || + !DIGEST_PATTERN.test(selectionDigest) || + selectionDigest !== calculatedDigest || + selectionDigest !== input.expectedSelectionDigest + ) { + fail('release selection identity or digest binding is invalid'); + } + return Object.freeze({ + path: input.path, + expectedSelectionDigest: input.expectedSelectionDigest, + authority: Object.freeze({ + image, + allowRootService, + selectionDigest, + releaseSetDigest: value.releaseSetDigest, + releaseVersion: release.version, + releaseSourceRevision: release.sourceRevision, + releaseSourceRef: release.sourceRef, + releaseScope: release.scope, + catalogSchema: CATALOG_SCHEMA, + catalogSourceRepository: catalog.sourceRepository, + catalogWorkflowIdentity: catalog.workflowIdentity, + catalogImmutableReference: catalog.immutableReference, + catalogManifestDigest: catalog.manifestDigest, + catalogConsumptionReportDigest: catalog.consumptionReportDigest, + catalogDiscoveryTagAuthority: 'none' as const, + }), + }); +} diff --git a/packages/ql3-local-owner-cli/src/deployment/foundation/contract.ts b/packages/ql3-local-owner-cli/src/deployment/foundation/contract.ts index ea12258a..0038f7fa 100644 --- a/packages/ql3-local-owner-cli/src/deployment/foundation/contract.ts +++ b/packages/ql3-local-owner-cli/src/deployment/foundation/contract.ts @@ -2,6 +2,12 @@ import fs from 'node:fs'; import path from 'node:path'; import type { LocalSetupResult } from '../../lifecycle/localSetup'; +import { + LocalComposeReleaseSelectionError, + resolveLocalComposeReleaseSelection, + type LocalComposeReleaseSelectionInput, + type ResolvedLocalComposeReleaseSelection, +} from '../compose/releaseSelection'; const MAX_PATH_BYTES = 4_096; const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; @@ -9,8 +15,6 @@ const INSTANCE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,127}$/; const KEY_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/; const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; -const IMAGE_DIGEST_PATTERN = - /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; export type LocalDeploymentProfile = 'edge' | 'standalone'; export type LocalDeploymentServiceKind = 'systemd' | 'openrc' | 'compose'; @@ -24,10 +28,15 @@ export interface LocalDeploymentProcessService { export interface LocalDeploymentComposeService { readonly kind: 'compose'; - readonly image: string; + readonly releaseSelection: Readonly; readonly allowRootService: boolean; } +export interface NormalizedLocalDeploymentComposeService + extends LocalDeploymentComposeService { + readonly releaseSelection: Readonly; +} + export type LocalDeploymentService = | LocalDeploymentProcessService | LocalDeploymentComposeService; @@ -51,6 +60,16 @@ export interface LocalDeploymentPrepareCommand { }>; } +export interface NormalizedLocalDeploymentPrepareCommand + extends Omit { + readonly options: Omit & + Readonly<{ + service: + | Readonly + | Readonly; + }>; +} + export interface LocalDeploymentPrepareResult { readonly schemaVersion: 1; readonly status: 'prepared' | 'existing'; @@ -121,12 +140,23 @@ export interface LocalDeploymentComposeUpgradeCommand { }>; readonly request: Readonly<{ expectedGeneration: number; - image: string; + releaseSelection: Readonly; mutationId: string; changedAtMs: number; }>; } +export interface NormalizedLocalDeploymentComposeUpgradeCommand + extends Omit { + readonly request: Omit< + LocalDeploymentComposeUpgradeCommand['request'], + 'releaseSelection' + > & + Readonly<{ + releaseSelection: Readonly; + }>; +} + export interface LocalDeploymentComposeRollbackCommand { readonly schemaVersion: 1; readonly operation: 'local.deployment.compose.rollback'; @@ -146,6 +176,10 @@ export type LocalDeploymentComposeRevisionCommand = | LocalDeploymentComposeUpgradeCommand | LocalDeploymentComposeRollbackCommand; +export type NormalizedLocalDeploymentComposeRevisionCommand = + | NormalizedLocalDeploymentComposeUpgradeCommand + | LocalDeploymentComposeRollbackCommand; + export interface LocalDeploymentComposeRevisionResult { readonly schemaVersion: 1; readonly operation: @@ -474,7 +508,9 @@ function validateExecutable( function normalizeService( value: unknown, uid: number, -): Readonly { +): + | Readonly + | Readonly { const service = object(value, 'service'); if (service.kind === 'systemd' || service.kind === 'openrc') { exact( @@ -503,25 +539,47 @@ function normalizeService( }); } if (service.kind === 'compose') { - exact(service, ['allowRootService', 'image', 'kind'], 'service'); - if ( - typeof service.image !== 'string' || - !IMAGE_DIGEST_PATTERN.test(service.image) || - service.image.includes('..') || - service.image.includes('//') || - service.image.split('@').length !== 2 - ) { - throw new LocalDeploymentConfigurationError( - 'compose image must be an immutable sha256 reference', + exact(service, ['allowRootService', 'kind', 'releaseSelection'], 'service'); + const allowRootService = validateRootAcknowledgement( + service.allowRootService, + uid, + ); + const releaseSelection = object( + service.releaseSelection, + 'releaseSelection', + ); + exact( + releaseSelection, + ['expectedSelectionDigest', 'path'], + 'releaseSelection', + ); + let resolved: Readonly; + try { + resolved = resolveLocalComposeReleaseSelection( + { + path: safeAbsolutePath( + releaseSelection.path, + 'releaseSelection.path', + ), + expectedSelectionDigest: + releaseSelection.expectedSelectionDigest as string, + }, + uid, + allowRootService, ); + } catch (error) { + if (error instanceof LocalComposeReleaseSelectionError) { + throw new LocalDeploymentConfigurationError( + 'compose release selection is invalid', + { cause: error }, + ); + } + throw error; } return Object.freeze({ kind: 'compose' as const, - image: service.image, - allowRootService: validateRootAcknowledgement( - service.allowRootService, - uid, - ), + releaseSelection: resolved, + allowRootService, }); } throw new LocalDeploymentConfigurationError('service kind is invalid'); @@ -529,7 +587,7 @@ function normalizeService( export function normalizeLocalDeploymentPrepareCommand( value: unknown, -): Readonly { +): Readonly { const command = object(value, 'command'); exact( command, @@ -659,7 +717,7 @@ export function normalizeLocalDeploymentStatusCommand( export function normalizeLocalDeploymentComposeRevisionCommand( value: unknown, -): Readonly { +): Readonly { const command = object(value, 'command'); exact( command, @@ -689,17 +747,17 @@ export function normalizeLocalDeploymentComposeRevisionCommand( if (command.operation === 'local.deployment.compose.upgrade') { exact( request, - ['changedAtMs', 'expectedGeneration', 'image', 'mutationId'], + ['changedAtMs', 'expectedGeneration', 'mutationId', 'releaseSelection'], 'request', ); const service = normalizeService( { kind: 'compose', - image: request.image, + releaseSelection: request.releaseSelection, allowRootService: normalizedOptions.allowRootService, }, identity.uid, - ) as Readonly; + ) as Readonly; if ( typeof request.mutationId !== 'string' || !UUID_V4_PATTERN.test(request.mutationId) @@ -719,7 +777,7 @@ export function normalizeLocalDeploymentComposeRevisionCommand( 99_999, 'expectedGeneration', ), - image: service.image, + releaseSelection: service.releaseSelection, mutationId: request.mutationId, changedAtMs: boundedInteger( request.changedAtMs, diff --git a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts index 1eb6263c..9de1961c 100644 --- a/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts +++ b/packages/ql3-local-owner-cli/src/deployment/localDeployment.ts @@ -108,6 +108,10 @@ export { type LocalDeploymentStatusCommand, type LocalDeploymentStatusResult, } from './foundation/contract'; +export { + type LocalComposeReleaseAuthority, + type LocalComposeReleaseSelectionInput, +} from './compose/releaseSelection'; export { normalizeLocalDeploymentLegacyStopCommand, type LocalDeploymentLegacyStopCommand, diff --git a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs index 6a8c2fd0..c4f09827 100644 --- a/packages/ql3-local-owner-cli/test/localDeployment.test.cjs +++ b/packages/ql3-local-owner-cli/test/localDeployment.test.cjs @@ -26,6 +26,67 @@ function rootAcknowledgement() { return typeof process.getuid === 'function' && process.getuid() === 0; } +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function releaseSelectionForImage(managementRoot, image, allowRootService) { + const releaseSetDigest = sha256(`release-set:${image}`); + const manifestDigest = sha256(`catalog-manifest:${image}`); + const consumptionReportDigest = sha256(`catalog-report:${image}`); + const unsigned = { + schemaVersion: 1, + schema: 'qinglong/local-compose-release-image@v2', + release: { + version: '3.0.0-alpha.0', + sourceRevision: '3'.repeat(40), + sourceRef: 'refs/tags/v3.0.0-alpha.0', + scope: 'local', + }, + releaseSetDigest, + catalog: { + schema: 'qinglong/release-catalog-consumption-ceremony@v1', + sourceRepository: 'example/qinglong', + workflowIdentity: + 'https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.0', + immutableReference: `ghcr.io/example/qinglong3-release-catalog@${manifestDigest}`, + manifestDigest, + consumptionReportDigest, + releaseSetDigest, + discoveryTagAuthority: 'none', + }, + deploymentFamily: 'local', + service: { + kind: 'compose', + image, + allowRootService, + }, + verification: { + releaseSet: 'standalone_structure_identity_and_self_digest', + sourceRecordsReplayed: false, + catalogConsumption: 'offline_reconstructed', + externalToolResultsReplayed: false, + networkAccess: false, + deploymentMutation: false, + }, + }; + const selectionDigest = sha256(JSON.stringify(unsigned)); + const contents = `${JSON.stringify({ ...unsigned, selectionDigest })}\n`; + const filePath = path.join( + managementRoot, + `release-selection-${selectionDigest.slice(7)}.json`, + ); + if (fs.existsSync(filePath)) { + assert.equal(fs.readFileSync(filePath, 'utf8'), contents); + } else { + fs.writeFileSync(filePath, contents, { mode: 0o600, flag: 'wx' }); + } + return Object.freeze({ + path: filePath, + expectedSelectionDigest: selectionDigest, + }); +} + function fixture(t, kind = 'systemd') { const managementRoot = fs.realpathSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-deployment-')), @@ -36,11 +97,18 @@ function fixture(t, kind = 'systemd') { const applicationEntrypoint = fs.realpathSync( path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'), ); + const composeImage = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat( + 64, + )}`; const service = kind === 'compose' ? { kind, - image: `registry.example/qinglong3-local@sha256:${'a'.repeat(64)}`, + releaseSelection: releaseSelectionForImage( + managementRoot, + composeImage, + rootAcknowledgement(), + ), allowRootService: rootAcknowledgement(), } : { @@ -71,7 +139,13 @@ function fixture(t, kind = 'systemd') { fs.writeFileSync(commandFilePath, `${JSON.stringify(command)}\n`, { mode: 0o600, }); - return { command, commandFilePath, deploymentRoot, managementRoot }; + return { + command, + commandFilePath, + composeImage, + deploymentRoot, + managementRoot, + }; } function mode(filePath) { @@ -133,6 +207,18 @@ function legacyStopCommand(state, cutoverId = 'cutover-edge-1') { } function composeRevisionCommand(state, operation, request) { + let normalizedRequest = request; + if (operation === 'local.deployment.compose.upgrade' && request.image) { + const { image, ...rest } = request; + normalizedRequest = { + ...rest, + releaseSelection: releaseSelectionForImage( + state.managementRoot, + image, + rootAcknowledgement(), + ), + }; + } return { schemaVersion: 1, operation, @@ -140,7 +226,7 @@ function composeRevisionCommand(state, operation, request) { deploymentRoot: state.deploymentRoot, allowRootService: rootAcknowledgement(), }, - request, + request: normalizedRequest, }; } @@ -250,7 +336,9 @@ async function createFailedRollbackRestoreState(state, suffix = '61') { await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'f'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'f'.repeat( + 64, + )}`, mutationId: `00000000-0000-4000-8000-000000000d${suffix}`, changedAtMs: 6_900, }), @@ -296,21 +384,41 @@ function composeSelection({ mutationId, changedAtMs, image, + releaseSelection, }) { + const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8')); return [ 'x-qinglong-image-selection:', - ' schema: qinglong/local-compose-image-selection@v1', + ' schema: qinglong/local-compose-image-selection@v2', ` generation: ${generation}`, ` previous_generation: ${previousGeneration}`, ` rollback_target_generation: ${rollbackTargetGeneration}`, ` mutation_id: ${mutationId}`, ` changed_at_ms: ${changedAtMs}`, + ` release_selection_digest: ${selected.selectionDigest}`, + ` release_set_digest: ${selected.releaseSetDigest}`, + ` release_version: ${selected.release.version}`, + ` release_source_revision: ${selected.release.sourceRevision}`, + ` release_source_ref: ${selected.release.sourceRef}`, + ` release_scope: ${selected.release.scope}`, + ` catalog_schema: ${selected.catalog.schema}`, + ` catalog_source_repository: ${selected.catalog.sourceRepository}`, + ` catalog_workflow_identity: ${selected.catalog.workflowIdentity}`, + ` catalog_immutable_reference: ${selected.catalog.immutableReference}`, + ` catalog_manifest_digest: ${selected.catalog.manifestDigest}`, + ` catalog_consumption_report_digest: ${selected.catalog.consumptionReportDigest}`, + ` catalog_discovery_tag_authority: ${selected.catalog.discoveryTagAuthority}`, + ` allow_root_service: ${selected.service.allowRootService}`, 'services:', ' qinglong3:', ` image: ${image}`, ' labels:', ` io.qinglong.deployment.generation: "${generation}"`, ` io.qinglong.deployment.mutation: "${mutationId}"`, + ` io.qinglong.release.selection: "${selected.selectionDigest}"`, + ` io.qinglong.release.set: "${selected.releaseSetDigest}"`, + ` io.qinglong.release.catalog-manifest: "${selected.catalog.manifestDigest}"`, + ` io.qinglong.release.catalog-report: "${selected.catalog.consumptionReportDigest}"`, '', ].join('\n'); } @@ -784,7 +892,9 @@ test('observes Compose generation and recovery fences with low constant work', a await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d31', changedAtMs: 6_000, }), @@ -919,6 +1029,18 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async fs.readFileSync(selectionPath, 'utf8'), /^ image: .*@sha256:[a-f0-9]{64}$/m, ); + assert.match( + fs.readFileSync(selectionPath, 'utf8'), + /^ schema: qinglong\/local-compose-image-selection@v2$/m, + ); + assert.match( + fs.readFileSync(selectionPath, 'utf8'), + /^ catalog_immutable_reference: ghcr\.io\/example\/qinglong3-release-catalog@sha256:[a-f0-9]{64}$/m, + ); + assert.match( + fs.readFileSync(selectionPath, 'utf8'), + /^ release_selection_digest: sha256:[a-f0-9]{64}$/m, + ); const config = JSON.parse( fs.readFileSync( path.join(compose.deploymentRoot, 'local-application.json'), @@ -935,11 +1057,82 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async ); }); +test('fails before deployment mutation on unbound or drifted release selections', async (t) => { + const state = fixture(t, 'compose'); + const selectionPath = state.command.options.service.releaseSelection.path; + await assert.rejects( + prepareLocalDeployment({ + ...state.command, + options: { + ...state.command.options, + service: { + kind: 'compose', + image: state.composeImage, + allowRootService: rootAcknowledgement(), + }, + }, + }), + LocalDeploymentConfigurationError, + ); + assert.equal(fs.existsSync(state.deploymentRoot), false); + + await assert.rejects( + prepareLocalDeployment({ + ...state.command, + options: { + ...state.command.options, + service: { + ...state.command.options.service, + releaseSelection: { + path: selectionPath, + expectedSelectionDigest: `sha256:${'f'.repeat(64)}`, + }, + }, + }, + }), + LocalDeploymentConfigurationError, + ); + assert.equal(fs.existsSync(state.deploymentRoot), false); + + fs.chmodSync(selectionPath, 0o644); + await assert.rejects( + prepareLocalDeployment(state.command), + LocalDeploymentConfigurationError, + ); + assert.equal(fs.existsSync(state.deploymentRoot), false); + + fs.chmodSync(selectionPath, 0o600); + const drifted = JSON.parse(fs.readFileSync(selectionPath, 'utf8')); + drifted.catalog.releaseSetDigest = `sha256:${'e'.repeat(64)}`; + delete drifted.selectionDigest; + drifted.selectionDigest = sha256(JSON.stringify(drifted)); + fs.writeFileSync(selectionPath, `${JSON.stringify(drifted)}\n`, { + mode: 0o600, + }); + await assert.rejects( + prepareLocalDeployment({ + ...state.command, + options: { + ...state.command.options, + service: { + ...state.command.options.service, + releaseSelection: { + path: selectionPath, + expectedSelectionDigest: drifted.selectionDigest, + }, + }, + }, + }), + LocalDeploymentConfigurationError, + ); + assert.equal(fs.existsSync(state.deploymentRoot), false); +}); + test('preflights exact local image, Compose merge and SQLite capability', async (t) => { const state = fixture(t, 'compose'); await prepareLocalDeployment(state.command); const dockerSocketPath = path.join(state.managementRoot, 'docker.sock'); - const image = state.command.options.service.image; + const image = state.composeImage; const composeSource = fs.readFileSync( path.join(state.deploymentRoot, 'service', 'compose.yaml'), 'utf8', @@ -1071,7 +1264,7 @@ test('Compose preflight rejects unproven image compatibility', async (t) => { const incompatibleImage = JSON.stringify([ { Id: `sha256:${'1'.repeat(64)}`, - RepoDigests: [state.command.options.service.image], + RepoDigests: [state.composeImage], Architecture: 'amd64', Os: 'linux', Config: { @@ -1201,7 +1394,7 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: generation - 1, - image: `registry.example/qinglong3-local@sha256:${imageCharacter.repeat( + image: `ghcr.io/example/qinglong3-local-application@sha256:${imageCharacter.repeat( 64, )}`, mutationId: `00000000-0000-4000-8000-000000000d${generation}0`, @@ -1260,7 +1453,9 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 4, - image: `registry.example/qinglong3-local@sha256:${'e'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'e'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d45', changedAtMs: 7_200, }), @@ -1377,7 +1572,9 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d52', changedAtMs: 6_900, }), @@ -1402,7 +1599,7 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async assert.match(selection, /^ rollback_target_generation: 1$/m); assert.match( selection, - new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'), + new RegExp(`^ image: ${state.composeImage}$`, 'm'), ); assert.equal( harness.calls.filter((args) => args[0] === 'compose' && args.includes('up')) @@ -1440,7 +1637,9 @@ test('records an unhealthy candidate observation failure as recovery unknown', a await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'b'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d65', changedAtMs: 6_900, }), @@ -1482,7 +1681,9 @@ test('resumes a rollback generation after response loss without restarting the f await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'c'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'c'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d55', changedAtMs: 6_950, }), @@ -1869,7 +2070,9 @@ test('fails closed before Compose up when the rollout backup cannot be created', await switchLocalDeploymentComposeRevision( composeRevisionCommand(state, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'d'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'d'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d57', changedAtMs: 6_975, }), @@ -1939,7 +2142,7 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy ); const revisions = path.join(state.deploymentRoot, 'service', 'revisions'); const stableDescriptor = fs.readFileSync(composePath, 'utf8'); - const upgradedImage = `registry.example/qinglong3-local@sha256:${'b'.repeat( + const upgradedImage = `ghcr.io/example/qinglong3-local-application@sha256:${'b'.repeat( 64, )}`; const upgrade = composeRevisionCommand( @@ -1983,10 +2186,7 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy const active = fs.readFileSync(selectionPath, 'utf8'); assert.match(active, /^ generation: 3$/m); assert.match(active, /^ rollback_target_generation: 1$/m); - assert.match( - active, - new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'), - ); + assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, 'm')); assert.equal( fs .readFileSync(path.join(revisions, '2.yaml'), 'utf8') @@ -2028,7 +2228,9 @@ test('upgrades and rolls back Compose image selections with generation CAS', asy test('recovers Compose response-loss and deterministic stage windows', async (t) => { const state = fixture(t, 'compose'); await prepareLocalDeployment(state.command); - const image = `registry.example/qinglong3-local@sha256:${'c'.repeat(64)}`; + const image = `ghcr.io/example/qinglong3-local-application@sha256:${'c'.repeat( + 64, + )}`; const mutationId = '00000000-0000-4000-8000-000000000d21'; const command = composeRevisionCommand( state, @@ -2053,6 +2255,7 @@ test('recovers Compose response-loss and deterministic stage windows', async (t) mutationId, changedAtMs: 4_000, image, + releaseSelection: command.request.releaseSelection, }); fs.writeFileSync(selectionStagePath, next, { mode: 0o600 }); const recoveredStage = await switchLocalDeploymentComposeRevision(command); @@ -2109,7 +2312,9 @@ test('fails closed on Compose revision drift and an unrelated in-flight lock', a switchLocalDeploymentComposeRevision( composeRevisionCommand(clean, 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'d'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'d'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d32', changedAtMs: 5_001, }), @@ -2131,7 +2336,9 @@ test('fails closed on Compose revision drift and an unrelated in-flight lock', a 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'f'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'f'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d57', changedAtMs: 5_002, }, @@ -2182,7 +2389,9 @@ test('Compose revision CLI emits only a low-sensitive generation result', async 'local.deployment.compose.upgrade', { expectedGeneration: 1, - image: `registry.example/qinglong3-local@sha256:${'e'.repeat(64)}`, + image: `ghcr.io/example/qinglong3-local-application@sha256:${'e'.repeat( + 64, + )}`, mutationId: '00000000-0000-4000-8000-000000000d41', changedAtMs: 6_000, }, @@ -2233,7 +2442,11 @@ test('rejects drift, widening, mutable images and unacknowledged root', async (t ...mutable.command.options, service: { ...mutable.command.options.service, - image: 'registry.example/qinglong3-local:latest', + releaseSelection: releaseSelectionForImage( + mutable.managementRoot, + 'ghcr.io/example/qinglong3-local-application:latest', + rootAcknowledgement(), + ), }, }, }), diff --git a/scripts/lib/ql3-local-release-selection-test-fixture.cjs b/scripts/lib/ql3-local-release-selection-test-fixture.cjs new file mode 100644 index 00000000..0f928247 --- /dev/null +++ b/scripts/lib/ql3-local-release-selection-test-fixture.cjs @@ -0,0 +1,67 @@ +'use strict'; + +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function writeSyntheticLocalReleaseSelection(options) { + const releaseSetDigest = sha256(`release-set:${options.image}`); + const manifestDigest = sha256(`catalog-manifest:${options.image}`); + const consumptionReportDigest = sha256(`catalog-report:${options.image}`); + const unsigned = { + schemaVersion: 1, + schema: 'qinglong/local-compose-release-image@v2', + release: { + version: '3.0.0-alpha.0', + sourceRevision: options.sourceRevision ?? '3'.repeat(40), + sourceRef: 'refs/tags/v3.0.0-alpha.0', + scope: 'local', + }, + releaseSetDigest, + catalog: { + schema: 'qinglong/release-catalog-consumption-ceremony@v1', + sourceRepository: 'example/qinglong', + workflowIdentity: + 'https://github.com/example/qinglong/.github/workflows/ql3-image-release.yml@refs/tags/v3.0.0-alpha.0', + immutableReference: `ghcr.io/example/qinglong3-release-catalog@${manifestDigest}`, + manifestDigest, + consumptionReportDigest, + releaseSetDigest, + discoveryTagAuthority: 'none', + }, + deploymentFamily: 'local', + service: { + kind: 'compose', + image: options.image, + allowRootService: options.allowRootService, + }, + verification: { + releaseSet: 'standalone_structure_identity_and_self_digest', + sourceRecordsReplayed: false, + catalogConsumption: 'offline_reconstructed', + externalToolResultsReplayed: false, + networkAccess: false, + deploymentMutation: false, + }, + }; + const selectionDigest = sha256(JSON.stringify(unsigned)); + const filePath = path.join( + options.directory, + `synthetic-local-release-selection-${selectionDigest.slice(7)}.json`, + ); + fs.writeFileSync( + filePath, + `${JSON.stringify({ ...unsigned, selectionDigest })}\n`, + { encoding: 'utf8', mode: 0o600, flag: 'wx' }, + ); + return Object.freeze({ + path: filePath, + expectedSelectionDigest: selectionDigest, + }); +} + +module.exports = { writeSyntheticLocalReleaseSelection }; diff --git a/scripts/ql3-local-compose-preflight-live-contract.cjs b/scripts/ql3-local-compose-preflight-live-contract.cjs index a98cc522..c2ca4694 100644 --- a/scripts/ql3-local-compose-preflight-live-contract.cjs +++ b/scripts/ql3-local-compose-preflight-live-contract.cjs @@ -4,6 +4,9 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +const { + writeSyntheticLocalReleaseSelection, +} = require('./lib/ql3-local-release-selection-test-fixture.cjs'); const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; @@ -74,6 +77,12 @@ async function main() { const deploymentRoot = path.join(temporaryRoot, 'deployment'); const commandPath = path.join(temporaryRoot, 'preflight.json'); const uid = process.getuid(); + const releaseSelection = writeSyntheticLocalReleaseSelection({ + directory: temporaryRoot, + image: input.image, + allowRootService: uid === 0, + sourceRevision: process.env.GITHUB_SHA, + }); try { const setup = await prepareLocalDeployment({ @@ -86,7 +95,7 @@ async function main() { busyTimeoutMs: 100, service: { kind: 'compose', - image: input.image, + releaseSelection, allowRootService: uid === 0, }, }, diff --git a/scripts/ql3-local-compose-rollout-live-contract.cjs b/scripts/ql3-local-compose-rollout-live-contract.cjs index b750abf3..a55246b5 100644 --- a/scripts/ql3-local-compose-rollout-live-contract.cjs +++ b/scripts/ql3-local-compose-rollout-live-contract.cjs @@ -7,6 +7,9 @@ const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); const { DatabaseSync } = require('node:sqlite'); +const { + writeSyntheticLocalReleaseSelection, +} = require('./lib/ql3-local-release-selection-test-fixture.cjs'); const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; @@ -178,6 +181,12 @@ async function main() { const deploymentRoot = path.join(temporaryRoot, 'deployment'); const commandPath = path.join(temporaryRoot, 'rollout.json'); const uid = process.getuid(); + const releaseSelection = writeSyntheticLocalReleaseSelection({ + directory: temporaryRoot, + image: input.image, + allowRootService: uid === 0, + sourceRevision: process.env.GITHUB_SHA, + }); let composeResourcesPresent = false; try { @@ -191,7 +200,7 @@ async function main() { busyTimeoutMs: 100, service: { kind: 'compose', - image: input.image, + releaseSelection, allowRootService: uid === 0, }, }, @@ -275,7 +284,7 @@ async function main() { }, request: { expectedGeneration: 1, - image: input.image, + releaseSelection, mutationId: '019f8680-143d-4000-8000-000000000205', changedAtMs: 1_785_254_600_004, }, @@ -392,7 +401,7 @@ async function main() { }, request: { expectedGeneration: 2, - image: input.image, + releaseSelection, mutationId: '019f8680-143d-4000-8000-000000000208', changedAtMs: 1_785_254_600_007, }, @@ -711,7 +720,7 @@ async function main() { }, request: { expectedGeneration: evidenceGeneration, - image: input.image, + releaseSelection, mutationId: transaction.revisionId, changedAtMs: 1_785_254_600_012 + index * 3, }, diff --git a/scripts/ql3-local-image-live-contract.cjs b/scripts/ql3-local-image-live-contract.cjs index 86a51fad..c2f4f1bb 100644 --- a/scripts/ql3-local-image-live-contract.cjs +++ b/scripts/ql3-local-image-live-contract.cjs @@ -6,6 +6,9 @@ const os = require('node:os'); const path = require('node:path'); const { spawn, spawnSync } = require('node:child_process'); const { DatabaseSync } = require('node:sqlite'); +const { + writeSyntheticLocalReleaseSelection, +} = require('./lib/ql3-local-release-selection-test-fixture.cjs'); const MAX_OUTPUT_BYTES = 64 * 1024; const ACTIVE_TIMEOUT_MS = 45_000; @@ -15,11 +18,7 @@ function fail(message) { } function imageArgument(argv) { - if ( - argv.length < 1 || - argv.length > 2 || - !argv[0].startsWith('--image=') - ) { + if (argv.length < 1 || argv.length > 2 || !argv[0].startsWith('--image=')) { fail('usage: --image=immutable-local-image [--profile=edge|standalone]'); } const image = argv[0].slice('--image='.length); @@ -104,13 +103,7 @@ function parseLines(buffer, events) { return remaining; } -async function runApplication( - image, - deploymentRoot, - uid, - gid, - profile, -) { +async function runApplication(image, deploymentRoot, uid, gid, profile) { const containerName = `ql3-local-image-${process.pid}-${crypto .randomUUID() .slice(0, 8)}`; @@ -173,10 +166,7 @@ async function runApplication( return; } stdout = parseLines(stdout, events); - if ( - !stopped && - events.some(({ event }) => event === 'active') - ) { + if (!stopped && events.some(({ event }) => event === 'active')) { stopped = true; runDocker(['stop', '--time', '30', containerName]); } @@ -254,20 +244,22 @@ async function main() { const profile = profileArgument(process.argv.slice(2)); const imageIdentity = inspectImage(image); const root = path.resolve(__dirname, '..'); - const { - prepareLocalDeployment, - } = require(path.join( + const { prepareLocalDeployment } = require(path.join( root, 'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js', )); const temporaryRoot = fs.realpathSync( - fs.mkdtempSync( - path.join(os.tmpdir(), 'ql3-local-image-live-'), - ), + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-image-live-')), ); + fs.chmodSync(temporaryRoot, 0o700); const deploymentRoot = path.join(temporaryRoot, 'deployment'); const uid = process.getuid(); const gid = process.getgid(); + const releaseSelection = writeSyntheticLocalReleaseSelection({ + directory: temporaryRoot, + image: `ghcr.io/example/qinglong3-local-application@${imageIdentity.id}`, + allowRootService: uid === 0, + }); try { const setup = await prepareLocalDeployment({ @@ -280,7 +272,7 @@ async function main() { busyTimeoutMs: 100, service: { kind: 'compose', - image: `local/qinglong3@${imageIdentity.id}`, + releaseSelection, allowRootService: uid === 0, }, }, @@ -309,8 +301,9 @@ async function main() { ); let integrity; try { - integrity = database.prepare('PRAGMA integrity_check').get() - .integrity_check; + integrity = database + .prepare('PRAGMA integrity_check') + .get().integrity_check; } finally { database.close(); } @@ -323,8 +316,7 @@ async function main() { architecture: imageIdentity.architecture, user: imageIdentity.user, profile, - memoryBytes: - (profile === 'edge' ? 128 : 256) * 1024 * 1024, + memoryBytes: (profile === 'edge' ? 128 : 256) * 1024 * 1024, pids: profile === 'edge' ? 64 : 256, network: 'none', readOnlyRoot: true, diff --git a/scripts/ql3-physical-edge-compose-storage.cjs b/scripts/ql3-physical-edge-compose-storage.cjs index 0ab4eea6..664ff1ae 100644 --- a/scripts/ql3-physical-edge-compose-storage.cjs +++ b/scripts/ql3-physical-edge-compose-storage.cjs @@ -13,6 +13,9 @@ const { mountForPath, parseMountTable, } = require('./ql3-physical-edge-evidence.cjs'); +const { + writeSyntheticLocalReleaseSelection, +} = require('./lib/ql3-local-release-selection-test-fixture.cjs'); const MIB = 1024 * 1024; const MAX_INPUT_BYTES = 256 * 1024; @@ -792,7 +795,15 @@ async function preparePhase(options, manifest) { ); } const timestamp = Date.now(); - const image = `physical.invalid/qinglong3-local@sha256:${'a'.repeat(64)}`; + const image = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat( + 64, + )}`; + fs.mkdirSync(deploymentRoot, { mode: 0o700 }); + const releaseSelection = writeSyntheticLocalReleaseSelection({ + directory: deploymentRoot, + image, + allowRootService: uid === 0, + }); await products.deployment.prepareLocalDeployment({ schemaVersion: 1, operation: 'local.deployment.prepare', @@ -803,7 +814,7 @@ async function preparePhase(options, manifest) { busyTimeoutMs: 100, service: { kind: 'compose', - image, + releaseSelection, allowRootService: uid === 0, }, }, @@ -829,7 +840,7 @@ async function preparePhase(options, manifest) { }, request: { expectedGeneration: generation - 1, - image, + releaseSelection, mutationId: crypto.randomUUID(), changedAtMs: timestamp + generation, }, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 51548f7b..29176dab 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, }, { - sourceFiles: 105, + sourceFiles: 106, rootSourceFiles: 1, rootSourceLines: 50, - nestedSourceFiles: 104, + nestedSourceFiles: 105, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );