mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): bind local compose revisions to release catalogs
This commit is contained in:
@@ -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 拓扑。
|
||||
|
||||
@@ -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/<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 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。
|
||||
@@ -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 |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -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/<owner>/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/<owner>/qinglong3-local-application@sha256:...`,且同一 digest 的
|
||||
双架构 manifest、Cosign、SLSA、CycloneDX 远端 verify 全部成功后,才可写入
|
||||
D-184 Compose 私有输入;永远不得替换成 mutable tag。
|
||||
本地 tag/image ID 不是可发布 authority。只有 release workflow 返回的
|
||||
`ghcr.io/<owner>/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/<uid>/docker.sock`:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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<LocalDeploymentPrepareCommand> = {
|
||||
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),
|
||||
|
||||
@@ -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<ComposeImageSelection>): string {
|
||||
@@ -44,12 +49,30 @@ function selectionContents(selection: Readonly<ComposeImageSelection>): 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<ComposeImageSelection> {
|
||||
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<LocalDeploymentComposeRevisionCommand>,
|
||||
command: Readonly<NormalizedLocalDeploymentComposeRevisionCommand>,
|
||||
): string {
|
||||
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 {
|
||||
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<LocalDeploymentPrepareCommand>,
|
||||
command: Readonly<NormalizedLocalDeploymentPrepareCommand>,
|
||||
): 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<LocalComposeReleaseAuthority>;
|
||||
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(
|
||||
|
||||
@@ -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 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<LocalComposeReleaseSelectionInput>;
|
||||
readonly allowRootService: boolean;
|
||||
}
|
||||
|
||||
export interface NormalizedLocalDeploymentComposeService
|
||||
extends LocalDeploymentComposeService {
|
||||
readonly releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
|
||||
}
|
||||
|
||||
export type LocalDeploymentService =
|
||||
| LocalDeploymentProcessService
|
||||
| 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 {
|
||||
readonly schemaVersion: 1;
|
||||
readonly status: 'prepared' | 'existing';
|
||||
@@ -121,12 +140,23 @@ export interface LocalDeploymentComposeUpgradeCommand {
|
||||
}>;
|
||||
readonly request: Readonly<{
|
||||
expectedGeneration: number;
|
||||
image: string;
|
||||
releaseSelection: Readonly<LocalComposeReleaseSelectionInput>;
|
||||
mutationId: string;
|
||||
changedAtMs: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface NormalizedLocalDeploymentComposeUpgradeCommand
|
||||
extends Omit<LocalDeploymentComposeUpgradeCommand, 'request'> {
|
||||
readonly request: Omit<
|
||||
LocalDeploymentComposeUpgradeCommand['request'],
|
||||
'releaseSelection'
|
||||
> &
|
||||
Readonly<{
|
||||
releaseSelection: Readonly<ResolvedLocalComposeReleaseSelection>;
|
||||
}>;
|
||||
}
|
||||
|
||||
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<LocalDeploymentService> {
|
||||
):
|
||||
| Readonly<LocalDeploymentProcessService>
|
||||
| Readonly<NormalizedLocalDeploymentComposeService> {
|
||||
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<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({
|
||||
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<LocalDeploymentPrepareCommand> {
|
||||
): Readonly<NormalizedLocalDeploymentPrepareCommand> {
|
||||
const command = object(value, 'command');
|
||||
exact(
|
||||
command,
|
||||
@@ -659,7 +717,7 @@ export function normalizeLocalDeploymentStatusCommand(
|
||||
|
||||
export function normalizeLocalDeploymentComposeRevisionCommand(
|
||||
value: unknown,
|
||||
): Readonly<LocalDeploymentComposeRevisionCommand> {
|
||||
): Readonly<NormalizedLocalDeploymentComposeRevisionCommand> {
|
||||
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<LocalDeploymentComposeService>;
|
||||
) as Readonly<NormalizedLocalDeploymentComposeService>;
|
||||
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,
|
||||
|
||||
@@ -108,6 +108,10 @@ export {
|
||||
type LocalDeploymentStatusCommand,
|
||||
type LocalDeploymentStatusResult,
|
||||
} from './foundation/contract';
|
||||
export {
|
||||
type LocalComposeReleaseAuthority,
|
||||
type LocalComposeReleaseSelectionInput,
|
||||
} from './compose/releaseSelection';
|
||||
export {
|
||||
normalizeLocalDeploymentLegacyStopCommand,
|
||||
type LocalDeploymentLegacyStopCommand,
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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 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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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' },
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user