feat(ql3): bind local compose revisions to release catalogs

This commit is contained in:
whyour
2026-08-16 16:02:53 +08:00
parent 2ccdd71851
commit dce72c800f
17 changed files with 973 additions and 130 deletions
+1
View File
@@ -11,6 +11,7 @@
最新增量证据(2026-08-16): 最新增量证据(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 authorityrollback 精确复制目标 revision authorityPreflight、Apply、Restore、Evidence 与 Status 共用同一 fail-closed parser。prepare/upgrade 仍只发布 revision,不联网、不执行 rollout、不修改数据库,也不新增 package、生产依赖、常驻进程或 Cluster 对象;低配设备每次显式命令只增加一次最多 64 KiB 的私有文件读取、canonical JSON 校验和 SHA-256Cluster 路径不变。旧 v1 裸 image 在尚未正式发布的 3.0 中失败关闭,孵化环境须从原 catalog-bound selection 重新 prepare。Local 定向 30/30、物理 Edge Compose storage 6/6Local Owner 全量 171 项为 166 pass/5 条件 skip/0 failbackend 共 1,317 项为 1,315 pass/2 条件 skip/0 fail18-package clean build/test 退出 0。10 项架构/部署审计与 14 档 Local artifact 全部 compatiblepackage boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`Edge/Standalone 默认制品为 2,589,890/2,589,968 bytesapplication 为 3,632,769/3,632,889 bytesapplication-api 为 3,800,322/3,800,466 bytesAI 为 3,069,143/3,069,233 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 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 referenceCluster 复用既有 post-render 流程。完整定向发布链 123/123backend 共 1,317 项,1,315 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 referenceCluster 复用既有 post-render 流程。完整定向发布链 123/123backend 共 1,317 项,1,315 pass/2 条件 skip/0 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 inspectionraw 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 reportoffline 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 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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 inspectionraw 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 reportoffline 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 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`。10 项架构/部署审计与 14 档 Local artifact 全部 compatible,默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster 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、不执行 rolloutLocal/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selectionCluster/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 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`,10 项架构/部署审计与 14 档 Local artifact 全部 compatible。默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。 - D-337/ADR-0429(已接受):D-336 的 durable release set 现在可以离线物化为最终部署 authority,而不是由运维者手工复制 digest。可信工作站上的 `ql3-deployment-lock-contract.cjs` 不联网、不连接 Kubernetes API、不执行 rolloutLocal/All 生成绑定 release-set digest、唯一 Local `@sha256:` 与显式 root policy 的 canonical selectionCluster/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 fail18-package clean build/test 退出 0package boundary 保持 18 packages、`singleSourcePackages=[]``shallowSourcePackages=[]`,10 项架构/部署审计与 14 档 Local artifact 全部 compatible。默认 Edge/Standalone 为 2,589,890/2,589,968 bytesapplication+AI 为 4,493,043/4,493,175 bytesMCP 为 7,315,930/7,316,038 bytesCluster Admin pack 保持 250 files、271,238-byte tarball、1,690,196-byte unpacked。本 Gate 不改数据库或 HA 拓扑。
@@ -0,0 +1,84 @@
# ADR-0432:目标侧 Catalog-bound Local Compose 修订
- 状态:Accepted
- 日期:2026-08-16
- 关联 RFCQL-RFC-0001 D-03、D-14、D-339、D-340
- 关联 ADRADR-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/<owner>/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 authorityrollback 从目标 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 rollbackrollback 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 failbackend 1,317 项为 1,315 pass/2 条件 skip/0 fail18-package
clean build/test 退出 0。workspace 保持 18 packages`singleSourcePackages=[]``shallowSourcePackages=[]`
- 10 项架构/部署审计和 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 制品保持
2,589,890/2,589,968 bytesapplication 为 3,632,769/3,632,889 bytesMCP 为 7,315,930/7,316,038 bytesCluster
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。
+1
View File
@@ -435,6 +435,7 @@
| [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted | | [ADR-0429](./ADR-0429-offline-release-set-deployment-lock-materialization.md) | 离线 Release-set Deployment Lock 物化 | Accepted |
| [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag | | [ADR-0430](./ADR-0430-auditable-release-catalog-consumption-ceremony.md) | 可审计的 Release Catalog 消费工作站 Ceremony | Accepted(真实公开 catalog 运行待实际 release tag |
| [ADR-0431](./ADR-0431-catalog-bound-deployment-lock-chain.md) | Catalog-bound Deployment Lock 证据链 | Accepted(真实公开 catalog 运行待实际 release tag | | [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 |
## 规则 ## 规则
+32 -13
View File
@@ -13,6 +13,8 @@
- 已安装 `ql3-local-deploy``ql3-local-application` - 已安装 `ql3-local-deploy``ql3-local-application`
- 使用最终运行 QingLong 的同一个 POSIX 用户; - 使用最终运行 QingLong 的同一个 POSIX 用户;
- command file 的父目录为当前 UID 的 canonical `0700` 目录; - command file 的父目录为当前 UID 的 canonical `0700` 目录;
- Compose 的 catalog-bound Local v2 selection 也必须位于当前 UID 的 canonical
`0700` 目录中,文件自身为 canonical、current-UID、单链接 `0600`
- 生产环境建议使用非 root 用户。只有 root-only 路由器才将 - 生产环境建议使用非 root 用户。只有 root-only 路由器才将
`allowRootService` 明确设为 `true` `allowRootService` 明确设为 `true`
@@ -502,16 +504,27 @@ sudo rc-service qinglong3 start
## 5. Rootless Compose ## 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 ```json
{ {
"kind": "compose", "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 "allowRootService": false
} }
``` ```
目标机只做一次有界私有 JSON 读取、canonical shape/self-digest/catalog binding
校验并取出唯一 `ghcr.io/<owner>/qinglong3-local-application@sha256:...`。它不运行
Cosign、GitHub CLI、regctl 或 Kustomize,不访问网络,也不会因此获得 rollout authority。
`image` 输入失败关闭,不提供手工复制旁路。
它生成 `service/compose.yaml`,固定 numeric UID:GID、read-only rootfs、唯一 bind 它生成 `service/compose.yaml`,固定 numeric UID:GID、read-only rootfs、唯一 bind
mount、无网络、drop all capabilities、no-new-privileges、16 MiB tmpfs 与 mount、无网络、drop all capabilities、no-new-privileges、16 MiB tmpfs 与
Profile memory/PID 上限。application config 自动使用容器内 Profile memory/PID 上限。application config 自动使用容器内
@@ -543,8 +556,10 @@ docker compose \
输出必须精确等于已审核的 `@sha256` image。不得省略或调换第二个 override 文件, 输出必须精确等于已审核的 `@sha256` image。不得省略或调换第二个 override 文件,
也不得另加第三个 Compose 文件覆盖 image。 也不得另加第三个 Compose 文件覆盖 image。
基础文件包含从 `instanceId` 派生的稳定 Compose project nameoverride 同时把 基础文件包含从 `instanceId` 派生的稳定 Compose project nameoverride 使用 v2
generation/mutation 写入 container label。不要使用 `-p``COMPOSE_PROJECT_NAME` 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 改写这些身份。 或第三个 override 改写这些身份。
当前仓库仍没有可引用的本机远端 release digest。ADR-0195 已提供 当前仓库仍没有可引用的本机远端 release digest。ADR-0195 已提供
@@ -574,15 +589,16 @@ pnpm sbom:local-image:ql3
pnpm audit:image-release:ql3 pnpm audit:image-release:ql3
``` ```
本地 tag/image ID 不是可发布 digest。只有 release workflow 返回的 本地 tag/image ID 不是可发布 authority。只有 release workflow 返回的
`ghcr.io/<owner>/qinglong3-local-application@sha256:...`,且同一 digest 的 `ghcr.io/<owner>/qinglong3-local-application@sha256:...`,且同一 release-set 的
双架构 manifest、Cosign、SLSA、CycloneDX 远端 verify 全部成功后,才可写入 双架构 manifest、Cosign、SLSA、CycloneDX、immutable catalog ceremony 与 deployment-lock
D-184 Compose 私有输入;永远不得替换成 mutable tag。 audit 全部成功后,才可把 Local v2 selection 的路径和 digest 写入 Compose 私有输入;
永远不得替换成裸 digest 或 mutable tag。
## 6. Compose 镜像升级和选择回退 ## 6. Compose 镜像升级和选择回退
升级前先完成 D-186 的 digest/attestation/signature 回读,并把 exact image 预取到 升级前先完成 release catalog consumption ceremony 与 Local deployment-lock audit,并按
设备。随后创建新的私有 command file 独立下载策略把 selection 绑定的 exact image 预取到设备。随后创建新的私有 command file
```json ```json
{ {
@@ -594,7 +610,10 @@ D-184 Compose 私有输入;永远不得替换成 mutable tag。
}, },
"request": { "request": {
"expectedGeneration": 1, "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", "mutationId": "REPLACE_WITH_UUID_V4",
"changedAtMs": 1785254500000 "changedAtMs": 1785254500000
} }
@@ -607,8 +626,8 @@ ql3-local-deploy compose-revision \
--command-file /secure/operator/qinglong3-compose-upgrade.json --command-file /secure/operator/qinglong3-compose-upgrade.json
``` ```
成功返回 generation 2。结果未知时原样重放同一文件;不要修改 mutation、时间或 成功返回 generation 2,并把完整 catalog provenance 固化到 immutable revision。结果未知时原样重放同一文件;
expected generation。再次用 `config --images` 检查 selection,再由 operator 不要修改 selection path/digest、mutation、时间或 expected generation。再次用 `config --images` 检查 selection,再由 operator
先执行 rollout preflight。Docker 路径和 socket 都必须使用 `realpath`;典型 先执行 rollout preflight。Docker 路径和 socket 都必须使用 `realpath`;典型
rootful Linux 分别为 `/usr/bin/docker``/var/run/docker.sock`rootless socket rootful Linux 分别为 `/usr/bin/docker``/var/run/docker.sock`rootless socket
通常位于 `/run/user/<uid>/docker.sock` 通常位于 `/run/user/<uid>/docker.sock`
@@ -122,9 +122,11 @@ node scripts/ql3-deployment-lock-contract.cjs \
--selection="${selection}" --selection="${selection}"
``` ```
v2 selection 同时绑定 catalog immutable reference、manifest digest、consumption report digest 与 release-set digest。把已审计的 v2 selection 同时绑定 catalog immutable reference、manifest digest、consumption report digest 与 release-set digest。不要再手工复制
`service.image``service.allowRootService` 交给现有 Local private prepare/rollout 入口。selection 本身不修改 Compose 文件, `service.image`。把 selection 放入目标运行 UID 控制的 canonical `0700` 目录,保持文件为单链接 `0600`,然后把它的 absolute path 与
也不启动容器。是否允许 root service 必须显式给出,不能由设备默认值推断。 `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 ### Kubernetes / Cluster / Worker
@@ -11,8 +11,8 @@ import {
currentIdentity, currentIdentity,
LocalDeploymentConfigurationError, LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposePreflightCommand, normalizeLocalDeploymentComposePreflightCommand,
normalizeLocalDeploymentPrepareCommand,
type LocalDeploymentComposePreflightResult, type LocalDeploymentComposePreflightResult,
type LocalDeploymentPrepareCommand,
type LocalDeploymentProfile, type LocalDeploymentProfile,
} from '../foundation/contract'; } from '../foundation/contract';
import { inspectActiveComposeImageSelection } from './composeRevision'; import { inspectActiveComposeImageSelection } from './composeRevision';
@@ -357,7 +357,7 @@ export async function preflightLocalDeploymentCompose(
paths.applicationConfig, paths.applicationConfig,
identity.uid, identity.uid,
); );
const syntheticPrepare = normalizeLocalDeploymentPrepareCommand({ const syntheticPrepare: Readonly<LocalDeploymentPrepareCommand> = {
schemaVersion: 1, schemaVersion: 1,
operation: 'local.deployment.prepare', operation: 'local.deployment.prepare',
options: { options: {
@@ -369,7 +369,10 @@ export async function preflightLocalDeploymentCompose(
: { busyTimeoutMs: application.busyTimeoutMs }), : { busyTimeoutMs: application.busyTimeoutMs }),
service: { service: {
kind: 'compose', kind: 'compose',
image: selection.image, releaseSelection: {
path: paths.composeSelection,
expectedSelectionDigest: selection.selectionDigest,
},
allowRootService: command.options.allowRootService, allowRootService: command.options.allowRootService,
}, },
}, },
@@ -380,7 +383,7 @@ export async function preflightLocalDeploymentCompose(
registeredAtMs: 0, registeredAtMs: 0,
activatedAtMs: 0, activatedAtMs: 0,
}, },
}); };
preflightPublishedFile( preflightPublishedFile(
paths.applicationConfig, paths.applicationConfig,
applicationConfiguration(syntheticPrepare, paths), applicationConfiguration(syntheticPrepare, paths),
@@ -7,10 +7,11 @@ import {
currentIdentity, currentIdentity,
LocalDeploymentConfigurationError, LocalDeploymentConfigurationError,
normalizeLocalDeploymentComposeRevisionCommand, normalizeLocalDeploymentComposeRevisionCommand,
type LocalDeploymentComposeRevisionCommand,
type LocalDeploymentComposeRevisionResult, type LocalDeploymentComposeRevisionResult,
type LocalDeploymentPrepareCommand, type NormalizedLocalDeploymentComposeRevisionCommand,
type NormalizedLocalDeploymentPrepareCommand,
} from '../foundation/contract'; } from '../foundation/contract';
import type { LocalComposeReleaseAuthority } from './releaseSelection';
import { import {
preflightPublishedFile, preflightPublishedFile,
publishExactFile, publishExactFile,
@@ -20,19 +21,23 @@ import {
} from '../foundation/files'; } from '../foundation/files';
import { deploymentPaths } from '../foundation/render'; 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 = 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 = 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}$/; /^[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 generation: number;
readonly previousGeneration: number; readonly previousGeneration: number;
readonly rollbackTargetGeneration: number; readonly rollbackTargetGeneration: number;
readonly mutationId: string; readonly mutationId: string;
readonly changedAtMs: number; readonly changedAtMs: number;
readonly image: string;
} }
function selectionContents(selection: Readonly<ComposeImageSelection>): string { function selectionContents(selection: Readonly<ComposeImageSelection>): string {
@@ -44,12 +49,30 @@ function selectionContents(selection: Readonly<ComposeImageSelection>): string {
` rollback_target_generation: ${selection.rollbackTargetGeneration}`, ` rollback_target_generation: ${selection.rollbackTargetGeneration}`,
` mutation_id: ${selection.mutationId}`, ` mutation_id: ${selection.mutationId}`,
` changed_at_ms: ${selection.changedAtMs}`, ` 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:', 'services:',
' qinglong3:', ' qinglong3:',
` image: ${selection.image}`, ` image: ${selection.image}`,
' labels:', ' labels:',
` io.qinglong.deployment.generation: "${selection.generation}"`, ` io.qinglong.deployment.generation: "${selection.generation}"`,
` io.qinglong.deployment.mutation: "${selection.mutationId}"`, ` 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'); ].join('\n');
} }
@@ -70,7 +93,7 @@ function parseSelection(
label: string, label: string,
): Readonly<ComposeImageSelection> { ): Readonly<ComposeImageSelection> {
const match = 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, contents,
); );
if (!match) { if (!match) {
@@ -87,9 +110,26 @@ function parseSelection(
); );
const mutationId = match[4]; const mutationId = match[4];
const changedAtMs = Number(match[5]); const changedAtMs = Number(match[5]);
const image = match[6]; const selectionDigest = match[6]!;
const labelGeneration = Number(match[7]); const releaseSetDigest = match[7]!;
const labelMutationId = match[8]; 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 ( if (
generation < 1 || generation < 1 ||
previousGeneration !== generation - 1 || previousGeneration !== generation - 1 ||
@@ -98,12 +138,25 @@ function parseSelection(
changedAtMs < 0 || changedAtMs < 0 ||
!mutationId || !mutationId ||
!UUID_V4_PATTERN.test(mutationId) || !UUID_V4_PATTERN.test(mutationId) ||
!image || !DIGEST_PATTERN.test(selectionDigest) ||
!IMAGE_PATTERN.test(image) || !DIGEST_PATTERN.test(releaseSetDigest) ||
image.includes('..') || !VERSION_PATTERN.test(releaseVersion) ||
image.includes('//') || !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 || labelGeneration !== generation ||
labelMutationId !== mutationId labelMutationId !== mutationId ||
labelSelectionDigest !== selectionDigest ||
labelReleaseSetDigest !== releaseSetDigest ||
labelCatalogManifestDigest !== catalogManifestDigest ||
labelCatalogReportDigest !== catalogConsumptionReportDigest
) { ) {
throw new LocalDeploymentConfigurationError(`${label} value is invalid`); throw new LocalDeploymentConfigurationError(`${label} value is invalid`);
} }
@@ -114,6 +167,20 @@ function parseSelection(
mutationId, mutationId,
changedAtMs, changedAtMs,
image, image,
allowRootService,
selectionDigest,
releaseSetDigest,
releaseVersion,
releaseSourceRevision,
releaseSourceRef,
releaseScope,
catalogSchema: CATALOG_SCHEMA,
catalogSourceRepository,
catalogWorkflowIdentity,
catalogImmutableReference,
catalogManifestDigest,
catalogConsumptionReportDigest,
catalogDiscoveryTagAuthority: 'none' as const,
}); });
if (selectionContents(selection) !== contents) { if (selectionContents(selection) !== contents) {
throw new LocalDeploymentConfigurationError(`${label} is not canonical`); throw new LocalDeploymentConfigurationError(`${label} is not canonical`);
@@ -157,11 +224,33 @@ function revisionPath(root: string, generation: number): string {
} }
function commandIntent( function commandIntent(
command: Readonly<LocalDeploymentComposeRevisionCommand>, command: Readonly<NormalizedLocalDeploymentComposeRevisionCommand>,
): string { ): string {
return `${JSON.stringify(command, null, 2)}\n`; return `${JSON.stringify(command, null, 2)}\n`;
} }
function releaseAuthorityFromSelection(
selection: Readonly<ComposeImageSelection>,
): Readonly<LocalComposeReleaseAuthority> {
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 { function releaseLock(lockPath: string, intent: string, uid: number): void {
preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock'); preflightPublishedFile(lockPath, intent, 0o600, uid, 'compose revision lock');
fs.unlinkSync(lockPath); fs.unlinkSync(lockPath);
@@ -169,7 +258,7 @@ function releaseLock(lockPath: string, intent: string, uid: number): void {
} }
export function initialComposeImageSelection( export function initialComposeImageSelection(
command: Readonly<LocalDeploymentPrepareCommand>, command: Readonly<NormalizedLocalDeploymentPrepareCommand>,
): string { ): string {
if (command.options.service.kind !== 'compose') { if (command.options.service.kind !== 'compose') {
throw new LocalDeploymentConfigurationError( throw new LocalDeploymentConfigurationError(
@@ -182,7 +271,7 @@ export function initialComposeImageSelection(
rollbackTargetGeneration: 0, rollbackTargetGeneration: 0,
mutationId: command.request.activateMutationId, mutationId: command.request.activateMutationId,
changedAtMs: command.request.activatedAtMs, 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', 'active compose selection',
); );
const nextGeneration = command.request.expectedGeneration + 1; const nextGeneration = command.request.expectedGeneration + 1;
let image: string; let releaseAuthority: Readonly<LocalComposeReleaseAuthority>;
let rollbackTargetGeneration = 0; let rollbackTargetGeneration = 0;
if (command.operation === 'local.deployment.compose.upgrade') { if (command.operation === 'local.deployment.compose.upgrade') {
image = command.request.image; releaseAuthority = command.request.releaseSelection.authority;
} else { } else {
rollbackTargetGeneration = command.request.targetGeneration; rollbackTargetGeneration = command.request.targetGeneration;
const target = readSelectionFile( const target = readSelectionFile(
@@ -330,7 +419,7 @@ export async function switchLocalDeploymentComposeRevision(
'rollback target generation drifted', 'rollback target generation drifted',
); );
} }
image = target.selection.image; releaseAuthority = releaseAuthorityFromSelection(target.selection);
} }
const nextContents = selectionContents({ const nextContents = selectionContents({
generation: nextGeneration, generation: nextGeneration,
@@ -338,7 +427,7 @@ export async function switchLocalDeploymentComposeRevision(
rollbackTargetGeneration, rollbackTargetGeneration,
mutationId: command.request.mutationId, mutationId: command.request.mutationId,
changedAtMs: command.request.changedAtMs, changedAtMs: command.request.changedAtMs,
image, ...releaseAuthority,
}); });
if (command.request.changedAtMs < observed.selection.changedAtMs) { if (command.request.changedAtMs < observed.selection.changedAtMs) {
throw new LocalDeploymentConfigurationError( throw new LocalDeploymentConfigurationError(
@@ -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<LocalComposeReleaseAuthority>;
}
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<string, unknown> {
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<LocalComposeReleaseSelectionInput>,
uid: number,
allowRootService: boolean,
): Readonly<ResolvedLocalComposeReleaseSelection> {
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,
}),
});
}
@@ -2,6 +2,12 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import type { LocalSetupResult } from '../../lifecycle/localSetup'; import type { LocalSetupResult } from '../../lifecycle/localSetup';
import {
LocalComposeReleaseSelectionError,
resolveLocalComposeReleaseSelection,
type LocalComposeReleaseSelectionInput,
type ResolvedLocalComposeReleaseSelection,
} from '../compose/releaseSelection';
const MAX_PATH_BYTES = 4_096; const MAX_PATH_BYTES = 4_096;
const SAFE_PATH_PATTERN = /^\/[A-Za-z0-9._/@-]+$/; 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 KEY_ID_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/;
const UUID_V4_PATTERN = 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}$/; /^[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 LocalDeploymentProfile = 'edge' | 'standalone';
export type LocalDeploymentServiceKind = 'systemd' | 'openrc' | 'compose'; export type LocalDeploymentServiceKind = 'systemd' | 'openrc' | 'compose';
@@ -24,10 +28,15 @@ export interface LocalDeploymentProcessService {
export interface LocalDeploymentComposeService { export interface LocalDeploymentComposeService {
readonly kind: 'compose'; readonly kind: 'compose';
readonly image: string; readonly releaseSelection: Readonly<LocalComposeReleaseSelectionInput>;
readonly allowRootService: boolean; readonly allowRootService: boolean;
} }
export interface NormalizedLocalDeploymentComposeService
extends LocalDeploymentComposeService {
readonly releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
}
export type LocalDeploymentService = export type LocalDeploymentService =
| LocalDeploymentProcessService | LocalDeploymentProcessService
| LocalDeploymentComposeService; | LocalDeploymentComposeService;
@@ -51,6 +60,16 @@ export interface LocalDeploymentPrepareCommand {
}>; }>;
} }
export interface NormalizedLocalDeploymentPrepareCommand
extends Omit<LocalDeploymentPrepareCommand, 'options'> {
readonly options: Omit<LocalDeploymentPrepareCommand['options'], 'service'> &
Readonly<{
service:
| Readonly<LocalDeploymentProcessService>
| Readonly<NormalizedLocalDeploymentComposeService>;
}>;
}
export interface LocalDeploymentPrepareResult { export interface LocalDeploymentPrepareResult {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly status: 'prepared' | 'existing'; readonly status: 'prepared' | 'existing';
@@ -121,12 +140,23 @@ export interface LocalDeploymentComposeUpgradeCommand {
}>; }>;
readonly request: Readonly<{ readonly request: Readonly<{
expectedGeneration: number; expectedGeneration: number;
image: string; releaseSelection: Readonly<LocalComposeReleaseSelectionInput>;
mutationId: string; mutationId: string;
changedAtMs: number; changedAtMs: number;
}>; }>;
} }
export interface NormalizedLocalDeploymentComposeUpgradeCommand
extends Omit<LocalDeploymentComposeUpgradeCommand, 'request'> {
readonly request: Omit<
LocalDeploymentComposeUpgradeCommand['request'],
'releaseSelection'
> &
Readonly<{
releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
}>;
}
export interface LocalDeploymentComposeRollbackCommand { export interface LocalDeploymentComposeRollbackCommand {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly operation: 'local.deployment.compose.rollback'; readonly operation: 'local.deployment.compose.rollback';
@@ -146,6 +176,10 @@ export type LocalDeploymentComposeRevisionCommand =
| LocalDeploymentComposeUpgradeCommand | LocalDeploymentComposeUpgradeCommand
| LocalDeploymentComposeRollbackCommand; | LocalDeploymentComposeRollbackCommand;
export type NormalizedLocalDeploymentComposeRevisionCommand =
| NormalizedLocalDeploymentComposeUpgradeCommand
| LocalDeploymentComposeRollbackCommand;
export interface LocalDeploymentComposeRevisionResult { export interface LocalDeploymentComposeRevisionResult {
readonly schemaVersion: 1; readonly schemaVersion: 1;
readonly operation: readonly operation:
@@ -474,7 +508,9 @@ function validateExecutable(
function normalizeService( function normalizeService(
value: unknown, value: unknown,
uid: number, uid: number,
): Readonly<LocalDeploymentService> { ):
| Readonly<LocalDeploymentProcessService>
| Readonly<NormalizedLocalDeploymentComposeService> {
const service = object(value, 'service'); const service = object(value, 'service');
if (service.kind === 'systemd' || service.kind === 'openrc') { if (service.kind === 'systemd' || service.kind === 'openrc') {
exact( exact(
@@ -503,25 +539,47 @@ function normalizeService(
}); });
} }
if (service.kind === 'compose') { if (service.kind === 'compose') {
exact(service, ['allowRootService', 'image', 'kind'], 'service'); exact(service, ['allowRootService', 'kind', 'releaseSelection'], 'service');
if ( const allowRootService = validateRootAcknowledgement(
typeof service.image !== 'string' || service.allowRootService,
!IMAGE_DIGEST_PATTERN.test(service.image) || uid,
service.image.includes('..') || );
service.image.includes('//') || const releaseSelection = object(
service.image.split('@').length !== 2 service.releaseSelection,
) { 'releaseSelection',
throw new LocalDeploymentConfigurationError( );
'compose image must be an immutable sha256 reference', exact(
releaseSelection,
['expectedSelectionDigest', 'path'],
'releaseSelection',
);
let resolved: Readonly<ResolvedLocalComposeReleaseSelection>;
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({ return Object.freeze({
kind: 'compose' as const, kind: 'compose' as const,
image: service.image, releaseSelection: resolved,
allowRootService: validateRootAcknowledgement( allowRootService,
service.allowRootService,
uid,
),
}); });
} }
throw new LocalDeploymentConfigurationError('service kind is invalid'); throw new LocalDeploymentConfigurationError('service kind is invalid');
@@ -529,7 +587,7 @@ function normalizeService(
export function normalizeLocalDeploymentPrepareCommand( export function normalizeLocalDeploymentPrepareCommand(
value: unknown, value: unknown,
): Readonly<LocalDeploymentPrepareCommand> { ): Readonly<NormalizedLocalDeploymentPrepareCommand> {
const command = object(value, 'command'); const command = object(value, 'command');
exact( exact(
command, command,
@@ -659,7 +717,7 @@ export function normalizeLocalDeploymentStatusCommand(
export function normalizeLocalDeploymentComposeRevisionCommand( export function normalizeLocalDeploymentComposeRevisionCommand(
value: unknown, value: unknown,
): Readonly<LocalDeploymentComposeRevisionCommand> { ): Readonly<NormalizedLocalDeploymentComposeRevisionCommand> {
const command = object(value, 'command'); const command = object(value, 'command');
exact( exact(
command, command,
@@ -689,17 +747,17 @@ export function normalizeLocalDeploymentComposeRevisionCommand(
if (command.operation === 'local.deployment.compose.upgrade') { if (command.operation === 'local.deployment.compose.upgrade') {
exact( exact(
request, request,
['changedAtMs', 'expectedGeneration', 'image', 'mutationId'], ['changedAtMs', 'expectedGeneration', 'mutationId', 'releaseSelection'],
'request', 'request',
); );
const service = normalizeService( const service = normalizeService(
{ {
kind: 'compose', kind: 'compose',
image: request.image, releaseSelection: request.releaseSelection,
allowRootService: normalizedOptions.allowRootService, allowRootService: normalizedOptions.allowRootService,
}, },
identity.uid, identity.uid,
) as Readonly<LocalDeploymentComposeService>; ) as Readonly<NormalizedLocalDeploymentComposeService>;
if ( if (
typeof request.mutationId !== 'string' || typeof request.mutationId !== 'string' ||
!UUID_V4_PATTERN.test(request.mutationId) !UUID_V4_PATTERN.test(request.mutationId)
@@ -719,7 +777,7 @@ export function normalizeLocalDeploymentComposeRevisionCommand(
99_999, 99_999,
'expectedGeneration', 'expectedGeneration',
), ),
image: service.image, releaseSelection: service.releaseSelection,
mutationId: request.mutationId, mutationId: request.mutationId,
changedAtMs: boundedInteger( changedAtMs: boundedInteger(
request.changedAtMs, request.changedAtMs,
@@ -108,6 +108,10 @@ export {
type LocalDeploymentStatusCommand, type LocalDeploymentStatusCommand,
type LocalDeploymentStatusResult, type LocalDeploymentStatusResult,
} from './foundation/contract'; } from './foundation/contract';
export {
type LocalComposeReleaseAuthority,
type LocalComposeReleaseSelectionInput,
} from './compose/releaseSelection';
export { export {
normalizeLocalDeploymentLegacyStopCommand, normalizeLocalDeploymentLegacyStopCommand,
type LocalDeploymentLegacyStopCommand, type LocalDeploymentLegacyStopCommand,
@@ -26,6 +26,67 @@ function rootAcknowledgement() {
return typeof process.getuid === 'function' && process.getuid() === 0; 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') { function fixture(t, kind = 'systemd') {
const managementRoot = fs.realpathSync( const managementRoot = fs.realpathSync(
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-deployment-')), fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-deployment-')),
@@ -36,11 +97,18 @@ function fixture(t, kind = 'systemd') {
const applicationEntrypoint = fs.realpathSync( const applicationEntrypoint = fs.realpathSync(
path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'), path.resolve(__dirname, '../../ql3-local-application/dist/cli.js'),
); );
const composeImage = `ghcr.io/example/qinglong3-local-application@sha256:${'a'.repeat(
64,
)}`;
const service = const service =
kind === 'compose' kind === 'compose'
? { ? {
kind, kind,
image: `registry.example/qinglong3-local@sha256:${'a'.repeat(64)}`, releaseSelection: releaseSelectionForImage(
managementRoot,
composeImage,
rootAcknowledgement(),
),
allowRootService: rootAcknowledgement(), allowRootService: rootAcknowledgement(),
} }
: { : {
@@ -71,7 +139,13 @@ function fixture(t, kind = 'systemd') {
fs.writeFileSync(commandFilePath, `${JSON.stringify(command)}\n`, { fs.writeFileSync(commandFilePath, `${JSON.stringify(command)}\n`, {
mode: 0o600, mode: 0o600,
}); });
return { command, commandFilePath, deploymentRoot, managementRoot }; return {
command,
commandFilePath,
composeImage,
deploymentRoot,
managementRoot,
};
} }
function mode(filePath) { function mode(filePath) {
@@ -133,6 +207,18 @@ function legacyStopCommand(state, cutoverId = 'cutover-edge-1') {
} }
function composeRevisionCommand(state, operation, request) { 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 { return {
schemaVersion: 1, schemaVersion: 1,
operation, operation,
@@ -140,7 +226,7 @@ function composeRevisionCommand(state, operation, request) {
deploymentRoot: state.deploymentRoot, deploymentRoot: state.deploymentRoot,
allowRootService: rootAcknowledgement(), allowRootService: rootAcknowledgement(),
}, },
request, request: normalizedRequest,
}; };
} }
@@ -250,7 +336,9 @@ async function createFailedRollbackRestoreState(state, suffix = '61') {
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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}`, mutationId: `00000000-0000-4000-8000-000000000d${suffix}`,
changedAtMs: 6_900, changedAtMs: 6_900,
}), }),
@@ -296,21 +384,41 @@ function composeSelection({
mutationId, mutationId,
changedAtMs, changedAtMs,
image, image,
releaseSelection,
}) { }) {
const selected = JSON.parse(fs.readFileSync(releaseSelection.path, 'utf8'));
return [ return [
'x-qinglong-image-selection:', 'x-qinglong-image-selection:',
' schema: qinglong/local-compose-image-selection@v1', ' schema: qinglong/local-compose-image-selection@v2',
` generation: ${generation}`, ` generation: ${generation}`,
` previous_generation: ${previousGeneration}`, ` previous_generation: ${previousGeneration}`,
` rollback_target_generation: ${rollbackTargetGeneration}`, ` rollback_target_generation: ${rollbackTargetGeneration}`,
` mutation_id: ${mutationId}`, ` mutation_id: ${mutationId}`,
` changed_at_ms: ${changedAtMs}`, ` 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:', 'services:',
' qinglong3:', ' qinglong3:',
` image: ${image}`, ` image: ${image}`,
' labels:', ' labels:',
` io.qinglong.deployment.generation: "${generation}"`, ` io.qinglong.deployment.generation: "${generation}"`,
` io.qinglong.deployment.mutation: "${mutationId}"`, ` 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'); ].join('\n');
} }
@@ -784,7 +892,9 @@ test('observes Compose generation and recovery fences with low constant work', a
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d31',
changedAtMs: 6_000, changedAtMs: 6_000,
}), }),
@@ -919,6 +1029,18 @@ test('renders bounded OpenRC and immutable rootless Compose descriptors', async
fs.readFileSync(selectionPath, 'utf8'), fs.readFileSync(selectionPath, 'utf8'),
/^ image: .*@sha256:[a-f0-9]{64}$/m, /^ 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( const config = JSON.parse(
fs.readFileSync( fs.readFileSync(
path.join(compose.deploymentRoot, 'local-application.json'), 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) => { test('preflights exact local image, Compose merge and SQLite capability', async (t) => {
const state = fixture(t, 'compose'); const state = fixture(t, 'compose');
await prepareLocalDeployment(state.command); await prepareLocalDeployment(state.command);
const dockerSocketPath = path.join(state.managementRoot, 'docker.sock'); const dockerSocketPath = path.join(state.managementRoot, 'docker.sock');
const image = state.command.options.service.image; const image = state.composeImage;
const composeSource = fs.readFileSync( const composeSource = fs.readFileSync(
path.join(state.deploymentRoot, 'service', 'compose.yaml'), path.join(state.deploymentRoot, 'service', 'compose.yaml'),
'utf8', 'utf8',
@@ -1071,7 +1264,7 @@ test('Compose preflight rejects unproven image compatibility', async (t) => {
const incompatibleImage = JSON.stringify([ const incompatibleImage = JSON.stringify([
{ {
Id: `sha256:${'1'.repeat(64)}`, Id: `sha256:${'1'.repeat(64)}`,
RepoDigests: [state.command.options.service.image], RepoDigests: [state.composeImage],
Architecture: 'amd64', Architecture: 'amd64',
Os: 'linux', Os: 'linux',
Config: { Config: {
@@ -1201,7 +1394,7 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: generation - 1, expectedGeneration: generation - 1,
image: `registry.example/qinglong3-local@sha256:${imageCharacter.repeat( image: `ghcr.io/example/qinglong3-local-application@sha256:${imageCharacter.repeat(
64, 64,
)}`, )}`,
mutationId: `00000000-0000-4000-8000-000000000d${generation}0`, mutationId: `00000000-0000-4000-8000-000000000d${generation}0`,
@@ -1260,7 +1453,9 @@ test('explicitly collects the oldest Compose backup and preserves exact rollout
switchLocalDeploymentComposeRevision( switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 4, 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', mutationId: '00000000-0000-4000-8000-000000000d45',
changedAtMs: 7_200, changedAtMs: 7_200,
}), }),
@@ -1377,7 +1572,9 @@ test('rolls a failed Compose candidate forward to a healthy prior digest', async
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d52',
changedAtMs: 6_900, 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, /^ rollback_target_generation: 1$/m);
assert.match( assert.match(
selection, selection,
new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'), new RegExp(`^ image: ${state.composeImage}$`, 'm'),
); );
assert.equal( assert.equal(
harness.calls.filter((args) => args[0] === 'compose' && args.includes('up')) 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( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d65',
changedAtMs: 6_900, changedAtMs: 6_900,
}), }),
@@ -1482,7 +1681,9 @@ test('resumes a rollback generation after response loss without restarting the f
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d55',
changedAtMs: 6_950, changedAtMs: 6_950,
}), }),
@@ -1869,7 +2070,9 @@ test('fails closed before Compose up when the rollout backup cannot be created',
await switchLocalDeploymentComposeRevision( await switchLocalDeploymentComposeRevision(
composeRevisionCommand(state, 'local.deployment.compose.upgrade', { composeRevisionCommand(state, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d57',
changedAtMs: 6_975, 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 revisions = path.join(state.deploymentRoot, 'service', 'revisions');
const stableDescriptor = fs.readFileSync(composePath, 'utf8'); 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, 64,
)}`; )}`;
const upgrade = composeRevisionCommand( 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'); const active = fs.readFileSync(selectionPath, 'utf8');
assert.match(active, /^ generation: 3$/m); assert.match(active, /^ generation: 3$/m);
assert.match(active, /^ rollback_target_generation: 1$/m); assert.match(active, /^ rollback_target_generation: 1$/m);
assert.match( assert.match(active, new RegExp(`^ image: ${state.composeImage}$`, 'm'));
active,
new RegExp(`^ image: ${state.command.options.service.image}$`, 'm'),
);
assert.equal( assert.equal(
fs fs
.readFileSync(path.join(revisions, '2.yaml'), 'utf8') .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) => { test('recovers Compose response-loss and deterministic stage windows', async (t) => {
const state = fixture(t, 'compose'); const state = fixture(t, 'compose');
await prepareLocalDeployment(state.command); 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 mutationId = '00000000-0000-4000-8000-000000000d21';
const command = composeRevisionCommand( const command = composeRevisionCommand(
state, state,
@@ -2053,6 +2255,7 @@ test('recovers Compose response-loss and deterministic stage windows', async (t)
mutationId, mutationId,
changedAtMs: 4_000, changedAtMs: 4_000,
image, image,
releaseSelection: command.request.releaseSelection,
}); });
fs.writeFileSync(selectionStagePath, next, { mode: 0o600 }); fs.writeFileSync(selectionStagePath, next, { mode: 0o600 });
const recoveredStage = await switchLocalDeploymentComposeRevision(command); const recoveredStage = await switchLocalDeploymentComposeRevision(command);
@@ -2109,7 +2312,9 @@ test('fails closed on Compose revision drift and an unrelated in-flight lock', a
switchLocalDeploymentComposeRevision( switchLocalDeploymentComposeRevision(
composeRevisionCommand(clean, 'local.deployment.compose.upgrade', { composeRevisionCommand(clean, 'local.deployment.compose.upgrade', {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d32',
changedAtMs: 5_001, 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', 'local.deployment.compose.upgrade',
{ {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d57',
changedAtMs: 5_002, changedAtMs: 5_002,
}, },
@@ -2182,7 +2389,9 @@ test('Compose revision CLI emits only a low-sensitive generation result', async
'local.deployment.compose.upgrade', 'local.deployment.compose.upgrade',
{ {
expectedGeneration: 1, 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', mutationId: '00000000-0000-4000-8000-000000000d41',
changedAtMs: 6_000, changedAtMs: 6_000,
}, },
@@ -2233,7 +2442,11 @@ test('rejects drift, widening, mutable images and unacknowledged root', async (t
...mutable.command.options, ...mutable.command.options,
service: { service: {
...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(),
),
}, },
}, },
}), }),
@@ -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 };
@@ -4,6 +4,9 @@ const fs = require('node:fs');
const os = require('node:os'); const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const {
writeSyntheticLocalReleaseSelection,
} = require('./lib/ql3-local-release-selection-test-fixture.cjs');
const IMAGE_PATTERN = const IMAGE_PATTERN =
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; /^[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 deploymentRoot = path.join(temporaryRoot, 'deployment');
const commandPath = path.join(temporaryRoot, 'preflight.json'); const commandPath = path.join(temporaryRoot, 'preflight.json');
const uid = process.getuid(); const uid = process.getuid();
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: input.image,
allowRootService: uid === 0,
sourceRevision: process.env.GITHUB_SHA,
});
try { try {
const setup = await prepareLocalDeployment({ const setup = await prepareLocalDeployment({
@@ -86,7 +95,7 @@ async function main() {
busyTimeoutMs: 100, busyTimeoutMs: 100,
service: { service: {
kind: 'compose', kind: 'compose',
image: input.image, releaseSelection,
allowRootService: uid === 0, allowRootService: uid === 0,
}, },
}, },
@@ -7,6 +7,9 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { spawnSync } = require('node:child_process'); const { spawnSync } = require('node:child_process');
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const {
writeSyntheticLocalReleaseSelection,
} = require('./lib/ql3-local-release-selection-test-fixture.cjs');
const IMAGE_PATTERN = const IMAGE_PATTERN =
/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,254}@sha256:[0-9a-f]{64}$/; /^[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 deploymentRoot = path.join(temporaryRoot, 'deployment');
const commandPath = path.join(temporaryRoot, 'rollout.json'); const commandPath = path.join(temporaryRoot, 'rollout.json');
const uid = process.getuid(); const uid = process.getuid();
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: input.image,
allowRootService: uid === 0,
sourceRevision: process.env.GITHUB_SHA,
});
let composeResourcesPresent = false; let composeResourcesPresent = false;
try { try {
@@ -191,7 +200,7 @@ async function main() {
busyTimeoutMs: 100, busyTimeoutMs: 100,
service: { service: {
kind: 'compose', kind: 'compose',
image: input.image, releaseSelection,
allowRootService: uid === 0, allowRootService: uid === 0,
}, },
}, },
@@ -275,7 +284,7 @@ async function main() {
}, },
request: { request: {
expectedGeneration: 1, expectedGeneration: 1,
image: input.image, releaseSelection,
mutationId: '019f8680-143d-4000-8000-000000000205', mutationId: '019f8680-143d-4000-8000-000000000205',
changedAtMs: 1_785_254_600_004, changedAtMs: 1_785_254_600_004,
}, },
@@ -392,7 +401,7 @@ async function main() {
}, },
request: { request: {
expectedGeneration: 2, expectedGeneration: 2,
image: input.image, releaseSelection,
mutationId: '019f8680-143d-4000-8000-000000000208', mutationId: '019f8680-143d-4000-8000-000000000208',
changedAtMs: 1_785_254_600_007, changedAtMs: 1_785_254_600_007,
}, },
@@ -711,7 +720,7 @@ async function main() {
}, },
request: { request: {
expectedGeneration: evidenceGeneration, expectedGeneration: evidenceGeneration,
image: input.image, releaseSelection,
mutationId: transaction.revisionId, mutationId: transaction.revisionId,
changedAtMs: 1_785_254_600_012 + index * 3, changedAtMs: 1_785_254_600_012 + index * 3,
}, },
+19 -27
View File
@@ -6,6 +6,9 @@ const os = require('node:os');
const path = require('node:path'); const path = require('node:path');
const { spawn, spawnSync } = require('node:child_process'); const { spawn, spawnSync } = require('node:child_process');
const { DatabaseSync } = require('node:sqlite'); const { DatabaseSync } = require('node:sqlite');
const {
writeSyntheticLocalReleaseSelection,
} = require('./lib/ql3-local-release-selection-test-fixture.cjs');
const MAX_OUTPUT_BYTES = 64 * 1024; const MAX_OUTPUT_BYTES = 64 * 1024;
const ACTIVE_TIMEOUT_MS = 45_000; const ACTIVE_TIMEOUT_MS = 45_000;
@@ -15,11 +18,7 @@ function fail(message) {
} }
function imageArgument(argv) { function imageArgument(argv) {
if ( if (argv.length < 1 || argv.length > 2 || !argv[0].startsWith('--image=')) {
argv.length < 1 ||
argv.length > 2 ||
!argv[0].startsWith('--image=')
) {
fail('usage: --image=immutable-local-image [--profile=edge|standalone]'); fail('usage: --image=immutable-local-image [--profile=edge|standalone]');
} }
const image = argv[0].slice('--image='.length); const image = argv[0].slice('--image='.length);
@@ -104,13 +103,7 @@ function parseLines(buffer, events) {
return remaining; return remaining;
} }
async function runApplication( async function runApplication(image, deploymentRoot, uid, gid, profile) {
image,
deploymentRoot,
uid,
gid,
profile,
) {
const containerName = `ql3-local-image-${process.pid}-${crypto const containerName = `ql3-local-image-${process.pid}-${crypto
.randomUUID() .randomUUID()
.slice(0, 8)}`; .slice(0, 8)}`;
@@ -173,10 +166,7 @@ async function runApplication(
return; return;
} }
stdout = parseLines(stdout, events); stdout = parseLines(stdout, events);
if ( if (!stopped && events.some(({ event }) => event === 'active')) {
!stopped &&
events.some(({ event }) => event === 'active')
) {
stopped = true; stopped = true;
runDocker(['stop', '--time', '30', containerName]); runDocker(['stop', '--time', '30', containerName]);
} }
@@ -254,20 +244,22 @@ async function main() {
const profile = profileArgument(process.argv.slice(2)); const profile = profileArgument(process.argv.slice(2));
const imageIdentity = inspectImage(image); const imageIdentity = inspectImage(image);
const root = path.resolve(__dirname, '..'); const root = path.resolve(__dirname, '..');
const { const { prepareLocalDeployment } = require(path.join(
prepareLocalDeployment,
} = require(path.join(
root, root,
'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js', 'packages/ql3-local-owner-cli/dist/deployment/localDeployment.js',
)); ));
const temporaryRoot = fs.realpathSync( const temporaryRoot = fs.realpathSync(
fs.mkdtempSync( fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-image-live-')),
path.join(os.tmpdir(), 'ql3-local-image-live-'),
),
); );
fs.chmodSync(temporaryRoot, 0o700);
const deploymentRoot = path.join(temporaryRoot, 'deployment'); const deploymentRoot = path.join(temporaryRoot, 'deployment');
const uid = process.getuid(); const uid = process.getuid();
const gid = process.getgid(); const gid = process.getgid();
const releaseSelection = writeSyntheticLocalReleaseSelection({
directory: temporaryRoot,
image: `ghcr.io/example/qinglong3-local-application@${imageIdentity.id}`,
allowRootService: uid === 0,
});
try { try {
const setup = await prepareLocalDeployment({ const setup = await prepareLocalDeployment({
@@ -280,7 +272,7 @@ async function main() {
busyTimeoutMs: 100, busyTimeoutMs: 100,
service: { service: {
kind: 'compose', kind: 'compose',
image: `local/qinglong3@${imageIdentity.id}`, releaseSelection,
allowRootService: uid === 0, allowRootService: uid === 0,
}, },
}, },
@@ -309,8 +301,9 @@ async function main() {
); );
let integrity; let integrity;
try { try {
integrity = database.prepare('PRAGMA integrity_check').get() integrity = database
.integrity_check; .prepare('PRAGMA integrity_check')
.get().integrity_check;
} finally { } finally {
database.close(); database.close();
} }
@@ -323,8 +316,7 @@ async function main() {
architecture: imageIdentity.architecture, architecture: imageIdentity.architecture,
user: imageIdentity.user, user: imageIdentity.user,
profile, profile,
memoryBytes: memoryBytes: (profile === 'edge' ? 128 : 256) * 1024 * 1024,
(profile === 'edge' ? 128 : 256) * 1024 * 1024,
pids: profile === 'edge' ? 64 : 256, pids: profile === 'edge' ? 64 : 256,
network: 'none', network: 'none',
readOnlyRoot: true, readOnlyRoot: true,
+14 -3
View File
@@ -13,6 +13,9 @@ const {
mountForPath, mountForPath,
parseMountTable, parseMountTable,
} = require('./ql3-physical-edge-evidence.cjs'); } = require('./ql3-physical-edge-evidence.cjs');
const {
writeSyntheticLocalReleaseSelection,
} = require('./lib/ql3-local-release-selection-test-fixture.cjs');
const MIB = 1024 * 1024; const MIB = 1024 * 1024;
const MAX_INPUT_BYTES = 256 * 1024; const MAX_INPUT_BYTES = 256 * 1024;
@@ -792,7 +795,15 @@ async function preparePhase(options, manifest) {
); );
} }
const timestamp = Date.now(); 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({ await products.deployment.prepareLocalDeployment({
schemaVersion: 1, schemaVersion: 1,
operation: 'local.deployment.prepare', operation: 'local.deployment.prepare',
@@ -803,7 +814,7 @@ async function preparePhase(options, manifest) {
busyTimeoutMs: 100, busyTimeoutMs: 100,
service: { service: {
kind: 'compose', kind: 'compose',
image, releaseSelection,
allowRootService: uid === 0, allowRootService: uid === 0,
}, },
}, },
@@ -829,7 +840,7 @@ async function preparePhase(options, manifest) {
}, },
request: { request: {
expectedGeneration: generation - 1, expectedGeneration: generation - 1,
image, releaseSelection,
mutationId: crypto.randomUUID(), mutationId: crypto.randomUUID(),
changedAtMs: timestamp + generation, changedAtMs: timestamp + generation,
}, },
+2 -2
View File
@@ -207,10 +207,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: localOwnerCli.rootSourceFileRoles, rootSourceFileRoles: localOwnerCli.rootSourceFileRoles,
}, },
{ {
sourceFiles: 105, sourceFiles: 106,
rootSourceFiles: 1, rootSourceFiles: 1,
rootSourceLines: 50, rootSourceLines: 50,
nestedSourceFiles: 104, nestedSourceFiles: 105,
rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, rootSourceFileRoles: { 'cli.ts': 'binary_entry' },
}, },
); );