mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
test(ql3): accept local cancellation live gate
This commit is contained in:
@@ -435,6 +435,21 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge
|
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge
|
||||||
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone
|
node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone
|
||||||
|
- name: Run authenticated Local API cancellation through real Linux processes
|
||||||
|
env:
|
||||||
|
QL3_LOCAL_API_CANCELLATION_LIVE: '1'
|
||||||
|
run: |
|
||||||
|
install -d -m 0700 "${{ runner.temp }}/ql3-local-api-cancellation"
|
||||||
|
node scripts/ql3-local-api-cancellation-live-contract.cjs \
|
||||||
|
--profile=edge \
|
||||||
|
--report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json"
|
||||||
|
node scripts/ql3-local-api-cancellation-live-contract.cjs \
|
||||||
|
--profile=standalone \
|
||||||
|
--report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json"
|
||||||
|
node scripts/ql3-local-api-cancellation-live-audit.cjs \
|
||||||
|
--report="${{ runner.temp }}/ql3-local-api-cancellation/edge.json"
|
||||||
|
node scripts/ql3-local-api-cancellation-live-audit.cjs \
|
||||||
|
--report="${{ runner.temp }}/ql3-local-api-cancellation/standalone.json"
|
||||||
|
|
||||||
cluster-image:
|
cluster-image:
|
||||||
name: Cluster ${{ matrix.image }} image (${{ matrix.image_arch }})
|
name: Cluster ${{ matrix.image }} image (${{ matrix.image_arch }})
|
||||||
|
|||||||
@@ -11,6 +11,24 @@
|
|||||||
|
|
||||||
最新增量证据(2026-08-12):
|
最新增量证据(2026-08-12):
|
||||||
|
|
||||||
|
- D-299/ADR-0387(已接受)
|
||||||
|
Local `run.cancel` 已从“HTTP durable intent 与 `/proc` stop 分别验证”推进为同一次真实 Linux 组合门。门先用既有离线 pack/prune
|
||||||
|
生成可选 `edge/standalone-application-api` 最终制品,再在锁定 Node 24 arm64 Linux 容器内以 non-root、read-only root、network none、
|
||||||
|
capabilities none、0.5 CPU、Edge 128 MiB/64 PIDs 或 Standalone 256 MiB/256 PIDs 运行单进程 Local API。真实 HTTP `task.start`
|
||||||
|
拉起长期子进程,SQLite 持久 PID 后由 `/proc/<pid>/stat` 绑定 start ticks;真实 HTTP cancellation 必须得到
|
||||||
|
`accepted → already_requested`,最终只有 1 条 intent Event、1 条 cancelled Event、2 条 allowed audit,Run/Attempt 均 cancelled,精确
|
||||||
|
PID/start identity 消失,SQLite integrity `ok`,API 有序重启后仍通过 HTTP 观察 cancelled。Edge/Standalone 可选 API 制品为
|
||||||
|
3,668,052/3,668,196 bytes、429 files、85 modules,距 6 MiB 保留 2,623,404/2,623,260 bytes;API RSS 为
|
||||||
|
80,736,256/78,868,480 bytes,低于对应 envelope。两份 `0600` 私有报告经独立 audit 零 finding,SHA-256 分别为
|
||||||
|
`056f8f1c07f0c5dfe4552fcb605d6b55b194cd826dbf1c720b21fdba4bd55e53` 与
|
||||||
|
`223b7241ec3af8edea824dd802f24d573d0987c2ec55fd82b380c95acdf46ba7`。报告固定声明
|
||||||
|
`linux_virtualized_live_contract/physicalDevice:false`,因此关闭自动化组合缝隙但不冒充固定型号路由器报告,ADR-0372 继续保持
|
||||||
|
Proposed。实现只增强 artifact auditor 的可选临时输出并新增 scripts/test/CI,不新增 package、生产依赖、migration、表、默认 listener、
|
||||||
|
timer、watcher、连接、cache 或 sidecar;默认 API-excluded Edge/Standalone 产品闭包不变。完整 backend 为 1,180 tests、1,178 pass/2
|
||||||
|
conditional skip/0 fail,完整 18-package clean build/test 退出 0;package/dependency/local-image boundary 全绿,仍无 single-source/shallow
|
||||||
|
package。14 个 Profile artifact 全部 compatible,最小 Edge 为 2,467,343 bytes/295 files/53 modules,最重 Standalone MCP 为
|
||||||
|
7,168,978 bytes/778 files/213 modules,RSS delta 38,158,336 bytes,均低于各自预算。PostgreSQL 18.4 arm64 HA 干净重跑通过 123 gates、
|
||||||
|
timeline `1→2`,报告 SHA-256 `4bf01be43b6eaa0bb6b2d5a2510e6a701c7d02a0fe4a0f246e207cc2c63dc003`。
|
||||||
- D-298/ADR-0386(已接受)
|
- D-298/ADR-0386(已接受)
|
||||||
Cluster `run.retry | run.stop` 已建立人工触发的真实三节点 Kubernetes 组合门:1 control-plane + 2 worker K3s/Flannel、3 实例
|
Cluster `run.retry | run.stop` 已建立人工触发的真实三节点 Kubernetes 组合门:1 control-plane + 2 worker K3s/Flannel、3 实例
|
||||||
CloudNativePG 1.30.0/PostgreSQL 18.4、2 个跨节点 Run manager Pod、TLS 1.3 mTLS、purpose-bound OIDC strong User、identity
|
CloudNativePG 1.30.0/PostgreSQL 18.4、2 个跨节点 Run manager Pod、TLS 1.3 mTLS、purpose-bound OIDC strong User、identity
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ D-283 后,Local `/api/v3` 已能按 Project 读取 Run、列表、RunEvent 与
|
|||||||
- Runtime Core 覆盖 profile-neutral canonical schema、严格 body/result、compat export 与非法状态;
|
- Runtime Core 覆盖 profile-neutral canonical schema、严格 body/result、compat export 与非法状态;
|
||||||
- Local admission/transport 覆盖认证与持久审计早于 body、512-byte hard cap、content-type/content-length/UTF-8/JSON 严格性、GET 零 body parser;
|
- Local admission/transport 覆盖认证与持久审计早于 body、512-byte hard cap、content-type/content-length/UTF-8/JSON 严格性、GET 零 body parser;
|
||||||
- SQLite repository 覆盖 accepted、response-loss replay、already-requested、already-terminal、跨 Project、Role revoke/version drift、counter overflow、Event collision、rollback 与 bounded authority queue;
|
- SQLite repository 覆盖 accepted、response-loss replay、already-requested、already-terminal、跨 Project、Role revoke/version drift、counter overflow、Event collision、rollback 与 bounded authority queue;
|
||||||
- 真实 SQLite HTTP 覆盖 Owner/Operator allow、Viewer deny、credential confirm、durable Run/Event、取消 lifecycle 收敛与重启后可观察状态;
|
- 真实 SQLite HTTP 覆盖 Owner/Operator allow、Viewer deny、credential confirm、durable Run/Event、取消 lifecycle 收敛与重启后可观察状态;Linux 虚拟化组合门必须在同一进程链证明 API→intent→真实 PID stop,固定物理设备报告仍独立采集;
|
||||||
- Cluster canonical schema、完整源码、制品、Local image 与 PostgreSQL HA 门全绿,且 MCP Tool 清单和默认 Edge/Standalone import closure 不获得写 authority。
|
- Cluster canonical schema、完整源码、制品、Local image 与 PostgreSQL HA 门全绿,且 MCP Tool 清单和默认 Edge/Standalone import closure 不获得写 authority。
|
||||||
|
|
||||||
## 当前验证证据
|
## 当前验证证据
|
||||||
@@ -45,4 +45,4 @@ D-283 后,Local `/api/v3` 已能按 Project 读取 Run、列表、RunEvent 与
|
|||||||
- 14 个 Profile artifact 全部 compatible。默认 Edge/Standalone 仅包含 SQLite/Runtime Core/SemVer,为 3,694,042/3,694,096 bytes、375 files、50 loaded modules;API 组合为 5,113,425/5,113,569 bytes。最紧 Application+AI 为 6,281,428/6,281,560 bytes,距 6 MiB 只余 10,028/9,896 bytes,后续增量必须先恢复包内可达文件裁剪余量,不得提高 cap。
|
- 14 个 Profile artifact 全部 compatible。默认 Edge/Standalone 仅包含 SQLite/Runtime Core/SemVer,为 3,694,042/3,694,096 bytes、375 files、50 loaded modules;API 组合为 5,113,425/5,113,569 bytes。最紧 Application+AI 为 6,281,428/6,281,560 bytes,距 6 MiB 只余 10,028/9,896 bytes,后续增量必须先恢复包内可达文件裁剪余量,不得提高 cap。
|
||||||
- AI/API-excluded arm64 Local image 为 478 files/4,717,459 bytes;Edge 128 MiB/64 PIDs 与 Standalone 256 MiB/256 PIDs 均在只读根、无网络、非 root 条件下 active→graceful stop,SQLite integrity 为 `ok`。
|
- AI/API-excluded arm64 Local image 为 478 files/4,717,459 bytes;Edge 128 MiB/64 PIDs 与 Standalone 256 MiB/256 PIDs 均在只读根、无网络、非 root 条件下 active→graceful stop,SQLite integrity 为 `ok`。
|
||||||
- PostgreSQL 18.4 arm64 HA 112/112 gates、timeline 1→2;私有报告 SHA-256 为 `8416a26aa6220210961a40e22aec897215a55e59423ad48638ca203a8cb488e6`,离线审计 `compatible=true/findings=[]`,Docker 资源零残留。
|
- PostgreSQL 18.4 arm64 HA 112/112 gates、timeline 1→2;私有报告 SHA-256 为 `8416a26aa6220210961a40e22aec897215a55e59423ad48638ca203a8cb488e6`,离线审计 `compatible=true/findings=[]`,Docker 资源零残留。
|
||||||
- HTTP→SQLite durable intent/exact replay、重启前 cancellation intent 收敛,以及 Linux `/proc` 真实进程 stop 已由相邻集成门分别证明;固定型号低配路由器上的同一 API→进程 stop 链尚无物理报告,因此本 ADR 保持 Proposed。
|
- D-299/ADR-0387 已把相邻证据收敛为同一真实 arm64 Linux 链:可选 `edge/standalone-application-api` 制品经 HTTP `task.start` 拉起真实子进程,再经 HTTP cancellation 写入唯一 durable intent/Event,exact replay 不重复,execution-control 收敛 `run/attempt=cancelled`,`/proc/<pid>` 的精确 start identity 消失,重启后仍可读 cancelled。Edge 128 MiB/64 PIDs 与 Standalone 256 MiB/256 PIDs 报告均通过独立离线审计;两份报告明确标记 `physicalDevice:false`,因此不能替代固定型号低配路由器上的物理报告,本 ADR 保持 Proposed。
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# ADR-0387:Local API Cancellation Linux 组合实证门
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-12
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-299
|
||||||
|
- 前置决策:ADR-0364、ADR-0366、ADR-0367、ADR-0372、ADR-0373、ADR-0376、ADR-0380
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
ADR-0372 已分别证明认证 HTTP 写入 SQLite durable cancellation intent、execution-control 收敛和 Linux `/proc` 子进程退出,但不同测试之间仍存在组合缝隙:HTTP 使用的 Run 可能不是 runtime 实际拉起的 Run,进程 stop 也可能来自命令文件或测试内直接调用,而非同一个 API mutation。
|
||||||
|
|
||||||
|
部署用户同时包含 128 MiB 路由设备和 Standalone 主机。默认 Local image 刻意排除 API,不能为了取证把 HTTP listener、认证依赖与 Owner Pepper provider 强塞进所有低配部署;但可选 `edge-application-api | standalone-application-api` 制品已经定义了单进程产品闭包。因此组合门应验证可选 API 制品,同时保持默认 Edge/Standalone 零增量。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 新增 opt-in `ql3-local-api-cancellation-live-contract`。它只接受 fresh absolute `0600` report path,并要求显式 `QL3_LOCAL_API_CANCELLATION_LIVE=1` 后才允许调用 Docker。
|
||||||
|
2. live gate 复用 `ql3-local-profile-artifact-audit` 的离线 pack、精确 import closure 与 runtime JavaScript pruning;auditor 新增 `--output-directory`,只允许复制一个此前不存在的 absolute 目录,不改变默认 stdout schema、预算或已有 14 个 profile 行为。
|
||||||
|
3. 每个 profile 在 `node:24.18.0-bookworm-slim` 锁定镜像内以当前非 root UID、read-only root、network none、capabilities none、no-new-privileges、0.5 CPU、16 MiB noexec tmpfs 运行;Edge 固定 128 MiB/64 PIDs,Standalone 固定 256 MiB/256 PIDs。
|
||||||
|
4. 同一 Local API 进程经真实 loopback HTTP `task.start` 创建并调度一个长期 command Run。gate 从 SQLite 读取 runtime 持久化的 PID,并从 `/proc/<pid>/stat` 绑定 start ticks;随后真实 HTTP `run.cancel` 必须得到 `accepted → already_requested`。
|
||||||
|
5. gate 必须观察唯一 `run.cancel_requested`、唯一 `run.cancelled`、两条 allowed audit、Run/Attempt 均 cancelled、精确 PID/start identity 消失、SQLite integrity `ok`;Local API 有序停止并重启后,同一 Run 仍必须通过 HTTP 观察为 cancelled。
|
||||||
|
6. 私有 report 只保存 profile、平台、resource envelope、artifact size/files/modules 和低敏布尔/计数事实;不保存 token、Pepper、路径、命令、Run ID、PID 或业务内容。独立 audit 对缺失 replay、PID exit、durability、资源预算或 qualification 的报告失败关闭。
|
||||||
|
7. report 的资格固定为 `linux_virtualized_live_contract`、`physicalDevice:false`。它关闭自动化组合缝隙,但不伪装成固定型号路由器证据,不能单独把 ADR-0372 转为 Accepted。
|
||||||
|
8. 不新增 workspace package、生产依赖、migration、表、索引、listener、timer、watcher、连接、cache、sidecar 或默认产品能力。代码放在现有 `scripts/` live/audit 分层中,不制造只有一个文件的微包。
|
||||||
|
|
||||||
|
## Package 与低配影响
|
||||||
|
|
||||||
|
workspace 仍为 18 个领域 package。新增文件都是发布/证据脚本,不进入任何 runtime artifact;默认 Edge/Standalone image 仍不包含 API。可选 API 制品的 Edge/Standalone 实测为 3,668,052/3,668,196 bytes、429 files、85 loaded modules,分别距 6 MiB 上限保留 2,623,404/2,623,260 bytes。真实运行 API RSS 为 80,736,256/78,868,480 bytes,均低于对应 128/256 MiB envelope。
|
||||||
|
|
||||||
|
## 验收状态
|
||||||
|
|
||||||
|
- report validator 与 opt-in/no-overwrite 失败关闭测试 3/3 通过;脚本均通过 Node syntax check。
|
||||||
|
- 完整 backend 回归 1,180 tests、1,178 pass/2 conditional skip/0 fail;完整 18-package clean build/test 退出 0。
|
||||||
|
- package boundary 保持 18 个 workspace package,`singleSourcePackages=[]`、`shallowSourcePackages=[]`;dependency 与 Local image static audit 均为 `compatible:true/findings:[]`。
|
||||||
|
- 14 个 Edge/Standalone Profile artifact 全部 compatible。最小 Edge 为 2,467,343 bytes/295 files/53 loaded modules,RSS delta 11,157,504 bytes;最重 Standalone MCP 为 7,168,978 bytes/778 files/213 loaded modules,RSS delta 38,158,336 bytes,均低于各自预算。
|
||||||
|
- arm64 Linux Edge 组合门通过:task start/cancellation/exact replay、durable intent/cancelled Event、两条 allowed audit、PID/start identity exit、重启观察、SQLite integrity 全绿;私有报告权限 `0600`,SHA-256 `056f8f1c07f0c5dfe4552fcb605d6b55b194cd826dbf1c720b21fdba4bd55e53`,离线审计零 finding。
|
||||||
|
- arm64 Linux Standalone 组合门同样通过;私有报告权限 `0600`,SHA-256 `223b7241ec3af8edea824dd802f24d573d0987c2ec55fd82b380c95acdf46ba7`,离线审计零 finding。
|
||||||
|
- PostgreSQL 18.4 arm64 HA 干净重跑通过 123 gates、timeline `1→2`;报告 SHA-256 `4bf01be43b6eaa0bb6b2d5a2510e6a701c7d02a0fe4a0f246e207cc2c63dc003`。
|
||||||
|
- CI 在 local image 的 amd64/arm64 matrix 中分别执行 Edge 与 Standalone gate;live 资源为短生命周期,完成后 artifact/evidence 临时目录和容器被清理。
|
||||||
|
- 固定型号物理 Edge 设备尚未产生 `physicalDevice:true` 的独立资格报告;ADR-0372 因此继续保持 Proposed。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
Local cancellation 的自动化证据不再依赖“两个相邻测试看起来可以拼接”的推断,路由器与 Standalone profile 都有同构、可复现的真实 Linux 子进程门。代价是 local-image CI 每个架构额外组装两次可选 API 制品;这是 release evidence 成本,不进入产品常驻面。后续物理 recorder 应复用相同低敏事实和 PID/start identity 语义,但必须增加设备 manifest、反虚拟化检查与签名/导入流程,不能修改本报告的 `physicalDevice:false`。
|
||||||
+2
-1
@@ -375,7 +375,7 @@
|
|||||||
| [ADR-0369](./ADR-0369-profile-exact-runtime-export-projection.md) | Profile 精确 Runtime Export 投影 | Accepted |
|
| [ADR-0369](./ADR-0369-profile-exact-runtime-export-projection.md) | Profile 精确 Runtime Export 投影 | Accepted |
|
||||||
| [ADR-0370](./ADR-0370-bounded-project-run-event-timeline-http-api.md) | 有界、Project-scoped 的 RunEvent 时间线 HTTP API | Accepted |
|
| [ADR-0370](./ADR-0370-bounded-project-run-event-timeline-http-api.md) | 有界、Project-scoped 的 RunEvent 时间线 HTTP API | Accepted |
|
||||||
| [ADR-0371](./ADR-0371-bounded-project-run-step-list-http-and-mcp-api.md) | 有界、Project-scoped 的 Run StepRun HTTP 与 MCP API | Accepted |
|
| [ADR-0371](./ADR-0371-bounded-project-run-step-list-http-and-mcp-api.md) | 有界、Project-scoped 的 Run StepRun HTTP 与 MCP API | Accepted |
|
||||||
| [ADR-0372](./ADR-0372-two-phase-authenticated-local-run-cancellation-api.md) | 两阶段认证的 Local Run Cancellation API | Proposed(实现完成,固定实机 API/进程 stop 门待补) |
|
| [ADR-0372](./ADR-0372-two-phase-authenticated-local-run-cancellation-api.md) | 两阶段认证的 Local Run Cancellation API | Proposed(Linux 组合门已完成,固定物理设备报告待补) |
|
||||||
| [ADR-0373](./ADR-0373-profile-reachable-runtime-javascript-projection.md) | Profile 可达的 Runtime JavaScript 投影 | Accepted |
|
| [ADR-0373](./ADR-0373-profile-reachable-runtime-javascript-projection.md) | Profile 可达的 Runtime JavaScript 投影 | Accepted |
|
||||||
| [ADR-0374](./ADR-0374-shared-bounded-task-discovery-http-api.md) | 共享、有界的 Task Discovery HTTP API | Accepted |
|
| [ADR-0374](./ADR-0374-shared-bounded-task-discovery-http-api.md) | 共享、有界的 Task Discovery HTTP API | Accepted |
|
||||||
| [ADR-0375](./ADR-0375-shared-current-task-point-read-api.md) | 共享的 current Task point-read API | Proposed(设计冻结,实现中) |
|
| [ADR-0375](./ADR-0375-shared-current-task-point-read-api.md) | 共享的 current Task point-read API | Proposed(设计冻结,实现中) |
|
||||||
@@ -390,6 +390,7 @@
|
|||||||
| [ADR-0384](./ADR-0384-strong-cluster-run-stop-management.md) | 强认证的 Cluster Run Stop Management 与列级数据库权限 | Accepted |
|
| [ADR-0384](./ADR-0384-strong-cluster-run-stop-management.md) | 强认证的 Cluster Run Stop Management 与列级数据库权限 | Accepted |
|
||||||
| [ADR-0385](./ADR-0385-strong-local-run-stop-product-entry.md) | 强认证的 Local Run Stop 产品入口与原子审计 | Accepted |
|
| [ADR-0385](./ADR-0385-strong-local-run-stop-product-entry.md) | 强认证的 Local Run Stop 产品入口与原子审计 | Accepted |
|
||||||
| [ADR-0386](./ADR-0386-cluster-run-management-kubernetes-live-evidence.md) | Cluster Run Management Kubernetes 多节点实证 | Accepted |
|
| [ADR-0386](./ADR-0386-cluster-run-management-kubernetes-live-evidence.md) | Cluster Run Management Kubernetes 多节点实证 | Accepted |
|
||||||
|
| [ADR-0387](./ADR-0387-local-api-cancellation-linux-live-evidence.md) | Local API Cancellation Linux 组合实证门 | Accepted |
|
||||||
|
|
||||||
## 规则
|
## 规则
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@
|
|||||||
"audit:approval-management-kubernetes-live:ql3": "node scripts/ql3-approval-management-kubernetes-live-audit.cjs",
|
"audit:approval-management-kubernetes-live:ql3": "node scripts/ql3-approval-management-kubernetes-live-audit.cjs",
|
||||||
"test:run-management-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && pnpm --filter @qinglong/cluster-control build && node scripts/ql3-run-management-kubernetes-live-contract.cjs",
|
"test:run-management-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && pnpm --filter @qinglong/cluster-control build && node scripts/ql3-run-management-kubernetes-live-contract.cjs",
|
||||||
"audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs",
|
"audit:run-management-kubernetes-live:ql3": "node scripts/ql3-run-management-kubernetes-live-audit.cjs",
|
||||||
|
"test:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-contract.cjs",
|
||||||
|
"audit:local-api-cancellation-live:ql3": "node scripts/ql3-local-api-cancellation-live-audit.cjs",
|
||||||
"test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs",
|
"test:provider-credential-test-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-provider-credential-test-kubernetes-live-contract.cjs",
|
||||||
"audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs",
|
"audit:provider-credential-test-kubernetes-live:ql3": "node scripts/ql3-provider-credential-test-kubernetes-live-audit.cjs",
|
||||||
"test:prompt-output-key-retirement-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-prompt-output-key-retirement-kubernetes-live-contract.cjs",
|
"test:prompt-output-key-retirement-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-prompt-output-key-retirement-kubernetes-live-contract.cjs",
|
||||||
|
|||||||
@@ -0,0 +1,706 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('node:crypto');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const http = require('node:http');
|
||||||
|
const net = require('node:net');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createRequire } = require('node:module');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { DatabaseSync } = require('node:sqlite');
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 45_000;
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(
|
||||||
|
`QingLong Local API cancellation scenario failed: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(argv) {
|
||||||
|
if (argv.length !== 3)
|
||||||
|
fail('usage: artifact-root evidence-root edge|standalone');
|
||||||
|
const [artifactRoot, evidenceRoot, profile] = argv;
|
||||||
|
for (const [value, label] of [
|
||||||
|
[artifactRoot, 'artifact root'],
|
||||||
|
[evidenceRoot, 'evidence root'],
|
||||||
|
]) {
|
||||||
|
if (!path.isAbsolute(value) || path.normalize(value) !== value) {
|
||||||
|
fail(`${label} must be absolute and normalized`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!['edge', 'standalone'].includes(profile)) fail('profile is invalid');
|
||||||
|
return Object.freeze({ artifactRoot, evidenceRoot, profile });
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateDirectory(directory) {
|
||||||
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||||
|
fs.chmodSync(directory, 0o700);
|
||||||
|
const stat = fs.lstatSync(directory);
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
(stat.mode & 0o777) !== 0o700
|
||||||
|
) {
|
||||||
|
fail(`private directory is invalid: ${directory}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function privateJson(filePath, value) {
|
||||||
|
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function procStartTicks(pid) {
|
||||||
|
const fields = fs.readFileSync(`/proc/${pid}/stat`, 'utf8').trim().split(' ');
|
||||||
|
const ticks = fields[21];
|
||||||
|
if (!/^[1-9][0-9]*$/.test(ticks ?? ''))
|
||||||
|
fail('process start ticks are invalid');
|
||||||
|
return ticks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameProcessExists(pid, startTicks) {
|
||||||
|
try {
|
||||||
|
return procStartTicks(pid) === startTicks;
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.code === 'ENOENT' || error?.code === 'ESRCH') return false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rssBytes(pid) {
|
||||||
|
const status = fs.readFileSync(`/proc/${pid}/status`, 'utf8');
|
||||||
|
const match = /^VmRSS:\s+(\d+) kB$/m.exec(status);
|
||||||
|
if (!match) fail('process RSS is unavailable');
|
||||||
|
return Number(match[1]) * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(port, token, requestPath, values = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const outgoing = http.request(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: requestPath,
|
||||||
|
method: values.method ?? 'GET',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
connection: 'close',
|
||||||
|
...(values.headers ?? {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
outgoing.once('error', reject);
|
||||||
|
if (values.body !== undefined) outgoing.write(values.body);
|
||||||
|
outgoing.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reservePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) reject(error);
|
||||||
|
else if (!address || typeof address === 'string') {
|
||||||
|
reject(new Error('dynamic loopback port is unavailable'));
|
||||||
|
} else resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(probe, label) {
|
||||||
|
const deadline = Date.now() + TIMEOUT_MS;
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
const value = await probe();
|
||||||
|
if (value) return value;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
fail(`${label} did not converge${lastError ? `: ${lastError.message}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function query(databasePath, sql, ...parameters) {
|
||||||
|
const database = new DatabaseSync(databasePath, {
|
||||||
|
readOnly: true,
|
||||||
|
timeout: 100,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return database.prepare(sql).get(...parameters);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertTaskAndIdentity(
|
||||||
|
modules,
|
||||||
|
databasePath,
|
||||||
|
pepperKeyId,
|
||||||
|
pepper,
|
||||||
|
now,
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
apiCredentialSecretDigest,
|
||||||
|
createBuiltInTaskSpecSemanticRegistry,
|
||||||
|
createTaskDefinitionRecord,
|
||||||
|
compileLocalCommandTaskDefinition,
|
||||||
|
formatApiCredentialToken,
|
||||||
|
} = modules;
|
||||||
|
const credentialId = 'local-live-operator';
|
||||||
|
const subjectId = 'local-live-user';
|
||||||
|
const secret = crypto.randomBytes(32).toString('base64url');
|
||||||
|
const taskSemantics = createBuiltInTaskSpecSemanticRegistry();
|
||||||
|
const taskCommand = {
|
||||||
|
projectId: 'default',
|
||||||
|
taskId: 'live-cancellation-task',
|
||||||
|
expectedRevision: null,
|
||||||
|
mutationId: '019f8700-0000-7000-8000-000000000001',
|
||||||
|
name: 'Local API cancellation live task',
|
||||||
|
kind: 'command',
|
||||||
|
spec: {
|
||||||
|
schema: 'qinglong/command@v1',
|
||||||
|
config: {
|
||||||
|
command: {
|
||||||
|
kind: 'argv',
|
||||||
|
file: '/bin/sh',
|
||||||
|
args: ['-c', 'trap "exit 0" TERM INT; while :; do sleep 1; done'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labels: {},
|
||||||
|
enabled: true,
|
||||||
|
occurredAtMs: now,
|
||||||
|
};
|
||||||
|
const definition = createTaskDefinitionRecord(
|
||||||
|
{
|
||||||
|
...taskCommand,
|
||||||
|
spec: taskSemantics.normalize({
|
||||||
|
projectId: taskCommand.projectId,
|
||||||
|
taskId: taskCommand.taskId,
|
||||||
|
kind: taskCommand.kind,
|
||||||
|
spec: taskCommand.spec,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const execution = compileLocalCommandTaskDefinition(
|
||||||
|
definition,
|
||||||
|
taskSemantics,
|
||||||
|
);
|
||||||
|
const database = new DatabaseSync(databasePath, { timeout: 100 });
|
||||||
|
try {
|
||||||
|
database.exec('PRAGMA foreign_keys = ON; BEGIN IMMEDIATE');
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3IdentitySubjects" (
|
||||||
|
"subject_type", "subject_id", "status", "version",
|
||||||
|
"created_at_ms", "updated_at_ms"
|
||||||
|
) VALUES ('user', ?, 'active', 1, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(subjectId, now, now);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3ApiCredentials" (
|
||||||
|
"credential_id", "version", "state", "subject_type", "subject_id",
|
||||||
|
"secret_digest", "created_at_ms", "not_before_at_ms", "expires_at_ms"
|
||||||
|
) VALUES (?, 1, 'active', 'user', ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
credentialId,
|
||||||
|
subjectId,
|
||||||
|
apiCredentialSecretDigest(pepper, credentialId, secret),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
now + 3_600_000,
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3ApiCredentialPepperBindings" (
|
||||||
|
"credential_id", "credential_version", "pepper_key_id"
|
||||||
|
) VALUES (?, 1, ?)`,
|
||||||
|
)
|
||||||
|
.run(credentialId, pepperKeyId);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3ProjectRoleBindings" (
|
||||||
|
"project_id", "subject_type", "subject_id", "version", "state",
|
||||||
|
"role", "mutation_id", "changed_by_type", "changed_by_id",
|
||||||
|
"created_at_ms"
|
||||||
|
) VALUES ('default', 'user', ?, 1, 'active', 'operator', ?, 'system',
|
||||||
|
'local-live-gate', ?)`,
|
||||||
|
)
|
||||||
|
.run(subjectId, 'local-live-role-binding', now);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3TaskDefinitions" (
|
||||||
|
"project_id", "task_id", "current_revision", "created_at_ms",
|
||||||
|
"updated_at_ms"
|
||||||
|
) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
definition.projectId,
|
||||||
|
definition.taskId,
|
||||||
|
definition.revision,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3TaskDefinitionRevisions" (
|
||||||
|
"project_id", "task_id", "revision", "mutation_id", "name",
|
||||||
|
"description", "kind", "spec_json", "labels_json", "enabled",
|
||||||
|
"content_digest", "created_at_ms"
|
||||||
|
) VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, 1, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
definition.projectId,
|
||||||
|
definition.taskId,
|
||||||
|
definition.revision,
|
||||||
|
definition.mutationId,
|
||||||
|
definition.name,
|
||||||
|
definition.kind,
|
||||||
|
JSON.stringify(definition.spec),
|
||||||
|
JSON.stringify(definition.labels),
|
||||||
|
definition.contentDigest,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LocalExecutionContextRecipes" (
|
||||||
|
"context_ref", "environment_json", "content_digest", "created_at_ms"
|
||||||
|
) VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
execution.contextRecipe.contextRef,
|
||||||
|
JSON.stringify(execution.contextRecipe.environment),
|
||||||
|
execution.contextRecipe.contentDigest,
|
||||||
|
execution.contextRecipe.createdAtMs,
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LocalTaskExecutionRevisions" (
|
||||||
|
"project_id", "task_id", "task_revision", "executor_type",
|
||||||
|
"command_json", "working_directory", "timeout_ms", "context_ref",
|
||||||
|
"content_digest", "created_at_ms"
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
execution.executionRevision.projectId,
|
||||||
|
execution.executionRevision.taskId,
|
||||||
|
execution.executionRevision.taskRevision,
|
||||||
|
execution.executionRevision.executorType,
|
||||||
|
JSON.stringify(execution.executionRevision.command),
|
||||||
|
execution.executionRevision.workingDirectory ?? null,
|
||||||
|
execution.executionRevision.timeoutMs ?? null,
|
||||||
|
execution.executionRevision.contextRef,
|
||||||
|
execution.executionRevision.contentDigest,
|
||||||
|
execution.executionRevision.createdAtMs,
|
||||||
|
);
|
||||||
|
database.exec('COMMIT');
|
||||||
|
} catch (error) {
|
||||||
|
if (database.isTransaction) database.exec('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
return Object.freeze({
|
||||||
|
definition,
|
||||||
|
token: formatApiCredentialToken(credentialId, secret),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startApi(executable, configPath) {
|
||||||
|
const child = spawn(process.execPath, [executable, '--config', configPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, NODE_ENV: 'production' },
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
let stdout = '';
|
||||||
|
let stderr = '';
|
||||||
|
child.stdout.setEncoding('utf8');
|
||||||
|
child.stderr.setEncoding('utf8');
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout += chunk;
|
||||||
|
while (stdout.includes('\n')) {
|
||||||
|
const index = stdout.indexOf('\n');
|
||||||
|
const line = stdout.slice(0, index);
|
||||||
|
stdout = stdout.slice(index + 1);
|
||||||
|
if (line) events.push(JSON.parse(line));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr = (stderr + chunk).slice(-16_384);
|
||||||
|
});
|
||||||
|
const exit = new Promise((resolve, reject) => {
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||||||
|
});
|
||||||
|
return Object.freeze({ child, events, exit, stderr: () => stderr });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopApi(active) {
|
||||||
|
active.child.kill('SIGTERM');
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
active.exit,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('API stop timeout')), 30_000),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
if (outcome.code !== 0 || outcome.signal !== null) {
|
||||||
|
fail(
|
||||||
|
`API process did not stop cleanly: ${JSON.stringify({
|
||||||
|
...outcome,
|
||||||
|
stderr: active.stderr(),
|
||||||
|
})}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(argv = process.argv.slice(2)) {
|
||||||
|
const value = options(argv);
|
||||||
|
if (process.platform !== 'linux' || !fs.existsSync('/proc/self/stat')) {
|
||||||
|
fail('a real Linux /proc runtime is required');
|
||||||
|
}
|
||||||
|
privateDirectory(value.evidenceRoot);
|
||||||
|
const deploymentRoot = path.join(value.evidenceRoot, 'deployment');
|
||||||
|
for (const directory of [
|
||||||
|
deploymentRoot,
|
||||||
|
path.join(deploymentRoot, 'owner-peppers'),
|
||||||
|
path.join(deploymentRoot, 'receipts'),
|
||||||
|
path.join(deploymentRoot, 'artifacts'),
|
||||||
|
path.join(deploymentRoot, 'plugin-staging'),
|
||||||
|
path.join(deploymentRoot, 'plugin-activation'),
|
||||||
|
])
|
||||||
|
privateDirectory(directory);
|
||||||
|
|
||||||
|
const artifactRequire = createRequire(
|
||||||
|
path.join(value.artifactRoot, 'package.json'),
|
||||||
|
);
|
||||||
|
const { migrateLocalSqlitePath } = artifactRequire(
|
||||||
|
'@qinglong/local-sqlite/migration',
|
||||||
|
);
|
||||||
|
const { LocalOwnerPepperKeyringFileProvider, provisionLocalOwnerPepperKey } =
|
||||||
|
artifactRequire('@qinglong/local-owner-console/pepper-custody');
|
||||||
|
const { provisionLocalSecretKeyring } = artifactRequire(
|
||||||
|
'@qinglong/local-secret',
|
||||||
|
);
|
||||||
|
const tokenModule = artifactRequire(
|
||||||
|
'@qinglong/runtime-core/api-credential-token',
|
||||||
|
);
|
||||||
|
const definitionModule = artifactRequire(
|
||||||
|
'@qinglong/runtime-core/task-definition',
|
||||||
|
);
|
||||||
|
const compilerModule = artifactRequire(
|
||||||
|
'@qinglong/runtime-core/task-definition-execution-compiler',
|
||||||
|
);
|
||||||
|
const semanticModule = artifactRequire(
|
||||||
|
'@qinglong/runtime-core/task-spec-semantic',
|
||||||
|
);
|
||||||
|
const databasePath = path.join(deploymentRoot, 'qinglong3.sqlite');
|
||||||
|
const pepperKeyId = 'owner-v1';
|
||||||
|
const now = Date.now();
|
||||||
|
const pepperSummary = provisionLocalOwnerPepperKey({
|
||||||
|
keyringDirectory: path.join(deploymentRoot, 'owner-peppers'),
|
||||||
|
pepperKeyId,
|
||||||
|
});
|
||||||
|
await migrateLocalSqlitePath({ databasePath, profile: value.profile });
|
||||||
|
await provisionLocalSecretKeyring(
|
||||||
|
path.join(deploymentRoot, 'local-secret-keyring.json'),
|
||||||
|
);
|
||||||
|
const database = new DatabaseSync(databasePath);
|
||||||
|
try {
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LocalOwnerPepperKeys" (
|
||||||
|
"pepper_key_id", "material_digest", "backup_digest", "state",
|
||||||
|
"version", "register_mutation_id", "activate_mutation_id",
|
||||||
|
"registered_at_ms", "activated_at_ms"
|
||||||
|
) VALUES (?, ?, ?, 'active', 2, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
pepperKeyId,
|
||||||
|
pepperSummary.digest,
|
||||||
|
'b'.repeat(64),
|
||||||
|
'019f8700-0000-4000-8000-000000000002',
|
||||||
|
'019f8700-0000-4000-8000-000000000003',
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
database
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO "QingLong3LocalOwnerPepperActivations" (
|
||||||
|
"generation", "mutation_id", "expected_generation",
|
||||||
|
"previous_pepper_key_id", "active_pepper_key_id", "material_digest",
|
||||||
|
"backup_digest", "activated_at_ms"
|
||||||
|
) VALUES (1, ?, 0, NULL, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
'019f8700-0000-4000-8000-000000000003',
|
||||||
|
pepperKeyId,
|
||||||
|
pepperSummary.digest,
|
||||||
|
'b'.repeat(64),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
database.close();
|
||||||
|
}
|
||||||
|
const pepper = new LocalOwnerPepperKeyringFileProvider(
|
||||||
|
path.join(deploymentRoot, 'owner-peppers'),
|
||||||
|
).resolve(pepperKeyId).pepper;
|
||||||
|
const seeded = insertTaskAndIdentity(
|
||||||
|
{
|
||||||
|
...tokenModule,
|
||||||
|
...definitionModule,
|
||||||
|
...compilerModule,
|
||||||
|
...semanticModule,
|
||||||
|
},
|
||||||
|
databasePath,
|
||||||
|
pepperKeyId,
|
||||||
|
pepper,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const port = await reservePort();
|
||||||
|
const applicationConfigPath = path.join(
|
||||||
|
deploymentRoot,
|
||||||
|
'local-application.json',
|
||||||
|
);
|
||||||
|
const apiConfigPath = path.join(deploymentRoot, 'local-api.json');
|
||||||
|
privateJson(applicationConfigPath, {
|
||||||
|
schema: 'qinglong/local-application-process@v2',
|
||||||
|
instanceId: `local-api-cancellation-${value.profile}`,
|
||||||
|
profile: value.profile,
|
||||||
|
storage: { mode: 'fresh', databasePath, busyTimeoutMs: 100 },
|
||||||
|
runtime: {
|
||||||
|
receiptRoot: path.join(deploymentRoot, 'receipts'),
|
||||||
|
artifactRoot: path.join(deploymentRoot, 'artifacts'),
|
||||||
|
secretKeyringPath: path.join(deploymentRoot, 'local-secret-keyring.json'),
|
||||||
|
},
|
||||||
|
pluginPackages: {
|
||||||
|
stagingRoot: path.join(deploymentRoot, 'plugin-staging'),
|
||||||
|
activationRoot: path.join(deploymentRoot, 'plugin-activation'),
|
||||||
|
recoverySource: { mode: 'disabled' },
|
||||||
|
pageSize: value.profile === 'edge' ? 4 : 16,
|
||||||
|
maxPages: 1,
|
||||||
|
taskPublicationPageSize: value.profile === 'edge' ? 4 : 16,
|
||||||
|
taskPublicationMaxPages: 1,
|
||||||
|
},
|
||||||
|
ai: { deployment: 'excluded' },
|
||||||
|
});
|
||||||
|
privateJson(apiConfigPath, {
|
||||||
|
schema: 'qinglong/local-api-process@v1',
|
||||||
|
deploymentRoot,
|
||||||
|
applicationConfigFilePath: applicationConfigPath,
|
||||||
|
ownerPepperKeyringDirectory: path.join(deploymentRoot, 'owner-peppers'),
|
||||||
|
listener: { host: '127.0.0.1', port },
|
||||||
|
});
|
||||||
|
const executable = path.join(
|
||||||
|
value.artifactRoot,
|
||||||
|
'node_modules/@qinglong/local-api/dist/cli.js',
|
||||||
|
);
|
||||||
|
let active = startApi(executable, apiConfigPath);
|
||||||
|
try {
|
||||||
|
await waitFor(
|
||||||
|
() => active.events.some((event) => event.event === 'listening'),
|
||||||
|
'Local API listener',
|
||||||
|
);
|
||||||
|
const startBody = JSON.stringify({
|
||||||
|
schema: 'qinglong/task-start@v1',
|
||||||
|
mutationId: '019f8700-0000-7000-8000-000000000004',
|
||||||
|
expectedRevision: seeded.definition.revision,
|
||||||
|
expectedContentDigest: seeded.definition.contentDigest,
|
||||||
|
});
|
||||||
|
const started = await request(
|
||||||
|
port,
|
||||||
|
seeded.token,
|
||||||
|
'/api/v3/projects/default/tasks/live-cancellation-task/runs',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': String(Buffer.byteLength(startBody)),
|
||||||
|
},
|
||||||
|
body: startBody,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (started.statusCode !== 202 || started.body.status !== 'accepted') {
|
||||||
|
fail(`task start was rejected: ${JSON.stringify(started)}`);
|
||||||
|
}
|
||||||
|
const running = await waitFor(() => {
|
||||||
|
const row = query(
|
||||||
|
databasePath,
|
||||||
|
`SELECT run.status, attempt.status AS attemptStatus, attempt.pid
|
||||||
|
FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id
|
||||||
|
WHERE run.id = ?`,
|
||||||
|
started.body.runId,
|
||||||
|
);
|
||||||
|
return row?.status === 'running' &&
|
||||||
|
row?.attemptStatus === 'running' &&
|
||||||
|
row?.pid
|
||||||
|
? row
|
||||||
|
: null;
|
||||||
|
}, 'task process start');
|
||||||
|
const taskPid = Number(running.pid);
|
||||||
|
const taskStartTicks = procStartTicks(taskPid);
|
||||||
|
const apiRssBytes = rssBytes(active.child.pid);
|
||||||
|
const cancellationBody = JSON.stringify({
|
||||||
|
schema: 'qinglong/run-cancellation@v1',
|
||||||
|
mutationId: 'local-live-cancellation-1',
|
||||||
|
});
|
||||||
|
const cancellationPath = `/api/v3/projects/default/runs/${started.body.runId}/cancellation`;
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'content-length': String(Buffer.byteLength(cancellationBody)),
|
||||||
|
},
|
||||||
|
body: cancellationBody,
|
||||||
|
};
|
||||||
|
const accepted = await request(
|
||||||
|
port,
|
||||||
|
seeded.token,
|
||||||
|
cancellationPath,
|
||||||
|
requestOptions,
|
||||||
|
);
|
||||||
|
const replay = await request(
|
||||||
|
port,
|
||||||
|
seeded.token,
|
||||||
|
cancellationPath,
|
||||||
|
requestOptions,
|
||||||
|
);
|
||||||
|
if (accepted.statusCode !== 202 || accepted.body.status !== 'accepted') {
|
||||||
|
fail(`cancellation was rejected: ${JSON.stringify(accepted)}`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
replay.statusCode !== 200 ||
|
||||||
|
replay.body.status !== 'already_requested'
|
||||||
|
) {
|
||||||
|
fail(`cancellation replay drifted: ${JSON.stringify(replay)}`);
|
||||||
|
}
|
||||||
|
const terminal = await waitFor(() => {
|
||||||
|
const row = query(
|
||||||
|
databasePath,
|
||||||
|
`SELECT run.status, attempt.status AS attemptStatus
|
||||||
|
FROM Runs AS run JOIN RunAttempts AS attempt ON attempt.run_id = run.id
|
||||||
|
WHERE run.id = ?`,
|
||||||
|
started.body.runId,
|
||||||
|
);
|
||||||
|
return row?.status === 'cancelled' && row?.attemptStatus === 'cancelled'
|
||||||
|
? row
|
||||||
|
: null;
|
||||||
|
}, 'durable cancellation');
|
||||||
|
await waitFor(
|
||||||
|
() => !sameProcessExists(taskPid, taskStartTicks),
|
||||||
|
'task process identity exit',
|
||||||
|
);
|
||||||
|
await stopApi(active);
|
||||||
|
active = startApi(executable, apiConfigPath);
|
||||||
|
await waitFor(
|
||||||
|
() => active.events.some((event) => event.event === 'listening'),
|
||||||
|
'restarted Local API listener',
|
||||||
|
);
|
||||||
|
const observed = await request(
|
||||||
|
port,
|
||||||
|
seeded.token,
|
||||||
|
`/api/v3/projects/default/runs/${started.body.runId}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
observed.statusCode !== 200 ||
|
||||||
|
observed.body.run.status !== 'cancelled'
|
||||||
|
) {
|
||||||
|
fail(`restart observation drifted: ${JSON.stringify(observed)}`);
|
||||||
|
}
|
||||||
|
await stopApi(active);
|
||||||
|
const facts = query(
|
||||||
|
databasePath,
|
||||||
|
`SELECT
|
||||||
|
(SELECT COUNT(*) FROM RunEvents WHERE run_id = ? AND type = 'run.cancel_requested') AS cancelEvents,
|
||||||
|
(SELECT COUNT(*) FROM RunEvents WHERE run_id = ? AND type = 'run.cancelled') AS cancelledEvents,
|
||||||
|
(SELECT COUNT(*) FROM QingLong3SecurityAuditEvents WHERE operation_id = 'run.cancel' AND outcome = 'allowed') AS cancelAudits,
|
||||||
|
(SELECT integrity_check FROM pragma_integrity_check LIMIT 1) AS integrity`,
|
||||||
|
started.body.runId,
|
||||||
|
started.body.runId,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
facts.cancelEvents !== 1 ||
|
||||||
|
facts.cancelledEvents !== 1 ||
|
||||||
|
facts.cancelAudits !== 2 ||
|
||||||
|
facts.integrity !== 'ok'
|
||||||
|
)
|
||||||
|
fail(`durable facts drifted: ${JSON.stringify(facts)}`);
|
||||||
|
const report = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
profile: value.profile,
|
||||||
|
platform: { os: 'linux', architecture: process.arch, procfs: true },
|
||||||
|
resourceEnvelope: {
|
||||||
|
memoryBytes:
|
||||||
|
value.profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024,
|
||||||
|
pids: value.profile === 'edge' ? 64 : 256,
|
||||||
|
apiRssBytes,
|
||||||
|
},
|
||||||
|
observations: {
|
||||||
|
taskStartAccepted: true,
|
||||||
|
cancellationAccepted: true,
|
||||||
|
exactReplay: true,
|
||||||
|
durableIntentEvents: facts.cancelEvents,
|
||||||
|
durableCancellationEvents: facts.cancelledEvents,
|
||||||
|
durableAllowedAudits: facts.cancelAudits,
|
||||||
|
processIdentityObserved: true,
|
||||||
|
processIdentityGone: !sameProcessExists(taskPid, taskStartTicks),
|
||||||
|
restartObservedCancelled: true,
|
||||||
|
sqliteIntegrity: facts.integrity,
|
||||||
|
},
|
||||||
|
qualification: {
|
||||||
|
evidenceClass: 'linux_virtualized_live_contract',
|
||||||
|
physicalDevice: false,
|
||||||
|
passed: true,
|
||||||
|
},
|
||||||
|
compatible: terminal.status === 'cancelled',
|
||||||
|
};
|
||||||
|
privateJson(path.join(value.evidenceRoot, 'report.json'), report);
|
||||||
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||||
|
} finally {
|
||||||
|
if (active.child.exitCode === null && active.child.signalCode === null) {
|
||||||
|
active.child.kill('SIGKILL');
|
||||||
|
await active.exit.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main().catch((error) => {
|
||||||
|
process.stderr.write(
|
||||||
|
`${
|
||||||
|
error instanceof Error ? error.stack || error.message : String(error)
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { options };
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const PROFILES = new Set(['edge', 'standalone']);
|
||||||
|
|
||||||
|
function exactKeys(value, expected) {
|
||||||
|
return (
|
||||||
|
value !== null &&
|
||||||
|
typeof value === 'object' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
JSON.stringify(Object.keys(value).sort()) ===
|
||||||
|
JSON.stringify([...expected].sort())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateLocalApiCancellationLiveReport(value) {
|
||||||
|
const findings = [];
|
||||||
|
const record = exactKeys(value, [
|
||||||
|
'artifact',
|
||||||
|
'compatible',
|
||||||
|
'observations',
|
||||||
|
'platform',
|
||||||
|
'profile',
|
||||||
|
'qualification',
|
||||||
|
'resourceEnvelope',
|
||||||
|
'schemaVersion',
|
||||||
|
]);
|
||||||
|
if (!record || value.schemaVersion !== 1 || !PROFILES.has(value.profile)) {
|
||||||
|
return Object.freeze({
|
||||||
|
compatible: false,
|
||||||
|
findings: ['report identity is invalid'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const expectedMemory =
|
||||||
|
value.profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024;
|
||||||
|
const expectedPids = value.profile === 'edge' ? 64 : 256;
|
||||||
|
if (
|
||||||
|
!exactKeys(value.platform, ['architecture', 'os', 'procfs']) ||
|
||||||
|
value.platform?.os !== 'linux' ||
|
||||||
|
!['arm64', 'x64'].includes(value.platform?.architecture) ||
|
||||||
|
value.platform?.procfs !== true
|
||||||
|
)
|
||||||
|
findings.push('Linux /proc platform observation is invalid');
|
||||||
|
if (
|
||||||
|
!exactKeys(value.resourceEnvelope, [
|
||||||
|
'apiRssBytes',
|
||||||
|
'memoryBytes',
|
||||||
|
'pids',
|
||||||
|
]) ||
|
||||||
|
value.resourceEnvelope?.memoryBytes !== expectedMemory ||
|
||||||
|
value.resourceEnvelope?.pids !== expectedPids ||
|
||||||
|
!Number.isSafeInteger(value.resourceEnvelope?.apiRssBytes) ||
|
||||||
|
value.resourceEnvelope.apiRssBytes < 1 ||
|
||||||
|
value.resourceEnvelope.apiRssBytes > expectedMemory
|
||||||
|
)
|
||||||
|
findings.push('resource envelope is invalid');
|
||||||
|
if (
|
||||||
|
!exactKeys(value.artifact, [
|
||||||
|
'bytes',
|
||||||
|
'compatible',
|
||||||
|
'files',
|
||||||
|
'loadedModules',
|
||||||
|
'profile',
|
||||||
|
]) ||
|
||||||
|
value.artifact?.profile !== `${value.profile}-application-api` ||
|
||||||
|
!Number.isSafeInteger(value.artifact?.bytes) ||
|
||||||
|
value.artifact.bytes < 1 ||
|
||||||
|
value.artifact.bytes > 6 * 1024 * 1024 ||
|
||||||
|
!Number.isSafeInteger(value.artifact?.files) ||
|
||||||
|
value.artifact.files < 1 ||
|
||||||
|
value.artifact.files > 640 ||
|
||||||
|
!Number.isSafeInteger(value.artifact?.loadedModules) ||
|
||||||
|
value.artifact.loadedModules < 1 ||
|
||||||
|
value.artifact.loadedModules > 256 ||
|
||||||
|
value.artifact.compatible !== true
|
||||||
|
)
|
||||||
|
findings.push('optional API artifact evidence is invalid');
|
||||||
|
const observed = value.observations;
|
||||||
|
if (
|
||||||
|
!exactKeys(observed, [
|
||||||
|
'cancellationAccepted',
|
||||||
|
'durableAllowedAudits',
|
||||||
|
'durableCancellationEvents',
|
||||||
|
'durableIntentEvents',
|
||||||
|
'exactReplay',
|
||||||
|
'processIdentityGone',
|
||||||
|
'processIdentityObserved',
|
||||||
|
'restartObservedCancelled',
|
||||||
|
'sqliteIntegrity',
|
||||||
|
'taskStartAccepted',
|
||||||
|
]) ||
|
||||||
|
observed?.taskStartAccepted !== true ||
|
||||||
|
observed?.cancellationAccepted !== true ||
|
||||||
|
observed?.exactReplay !== true ||
|
||||||
|
observed?.durableIntentEvents !== 1 ||
|
||||||
|
observed?.durableCancellationEvents !== 1 ||
|
||||||
|
observed?.durableAllowedAudits !== 2 ||
|
||||||
|
observed?.processIdentityObserved !== true ||
|
||||||
|
observed?.processIdentityGone !== true ||
|
||||||
|
observed?.restartObservedCancelled !== true ||
|
||||||
|
observed?.sqliteIntegrity !== 'ok'
|
||||||
|
)
|
||||||
|
findings.push('API to durable process-stop observations are incomplete');
|
||||||
|
if (
|
||||||
|
!exactKeys(value.qualification, [
|
||||||
|
'evidenceClass',
|
||||||
|
'passed',
|
||||||
|
'physicalDevice',
|
||||||
|
]) ||
|
||||||
|
value.qualification?.evidenceClass !== 'linux_virtualized_live_contract' ||
|
||||||
|
value.qualification?.physicalDevice !== false ||
|
||||||
|
value.qualification?.passed !== true
|
||||||
|
)
|
||||||
|
findings.push('qualification boundary is invalid');
|
||||||
|
if (value.compatible !== true) findings.push('report is not compatible');
|
||||||
|
return Object.freeze({
|
||||||
|
compatible: findings.length === 0,
|
||||||
|
findings: Object.freeze(findings),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportPath(argv) {
|
||||||
|
if (
|
||||||
|
argv.length !== 1 ||
|
||||||
|
!argv[0].startsWith('--report=') ||
|
||||||
|
!path.isAbsolute(argv[0].slice('--report='.length))
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'usage: ql3-local-api-cancellation-live-audit --report=/absolute/private-report.json',
|
||||||
|
);
|
||||||
|
return argv[0].slice('--report='.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
const filePath = reportPath(process.argv.slice(2));
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (
|
||||||
|
!stat.isFile() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
(typeof process.getuid === 'function' && stat.uid !== process.getuid()) ||
|
||||||
|
(stat.mode & 0o077) !== 0 ||
|
||||||
|
stat.size > 32 * 1024
|
||||||
|
) {
|
||||||
|
throw new Error('report must be a private bounded regular file');
|
||||||
|
}
|
||||||
|
const audit = validateLocalApiCancellationLiveReport(
|
||||||
|
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||||
|
);
|
||||||
|
process.stdout.write(`${JSON.stringify(audit)}\n`);
|
||||||
|
if (!audit.compatible) process.exitCode = 1;
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(
|
||||||
|
`${error instanceof Error ? error.message : String(error)}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { validateLocalApiCancellationLiveReport };
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateLocalApiCancellationLiveReport,
|
||||||
|
} = require('./ql3-local-api-cancellation-live-audit.cjs');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..');
|
||||||
|
const NODE_IMAGE =
|
||||||
|
'node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d';
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(
|
||||||
|
`QingLong Local API cancellation live contract failed: ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function argumentsOf(argv) {
|
||||||
|
if (argv.length !== 2)
|
||||||
|
fail(
|
||||||
|
'usage: --profile=edge|standalone --report=/absolute/private-report.json',
|
||||||
|
);
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
argv.map((argument) => {
|
||||||
|
const match = /^--(profile|report)=(.+)$/.exec(argument);
|
||||||
|
if (!match) fail(`unsupported argument ${argument}`);
|
||||||
|
return [match[1], match[2]];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!['edge', 'standalone'].includes(values.profile))
|
||||||
|
fail('profile is invalid');
|
||||||
|
if (
|
||||||
|
!path.isAbsolute(values.report ?? '') ||
|
||||||
|
path.normalize(values.report) !== values.report ||
|
||||||
|
path.parse(values.report).root === values.report ||
|
||||||
|
fs.existsSync(values.report)
|
||||||
|
)
|
||||||
|
fail('report must be a fresh normalized absolute non-root path');
|
||||||
|
const parent = fs.lstatSync(path.dirname(values.report));
|
||||||
|
if (!parent.isDirectory() || parent.isSymbolicLink())
|
||||||
|
fail('report parent must be a real directory');
|
||||||
|
return Object.freeze(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, options = {}) {
|
||||||
|
const result = spawnSync(command, args, {
|
||||||
|
cwd: ROOT,
|
||||||
|
encoding: 'utf8',
|
||||||
|
maxBuffer: 4 * 1024 * 1024,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
if (result.status !== 0) {
|
||||||
|
fail(
|
||||||
|
`${command} ${args[0]} failed: ${(result.stderr || result.stdout)
|
||||||
|
.trim()
|
||||||
|
.slice(0, 4096)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.stdout.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function main(argv = process.argv.slice(2)) {
|
||||||
|
const selected = argumentsOf(argv);
|
||||||
|
if (process.env.QL3_LOCAL_API_CANCELLATION_LIVE !== '1') {
|
||||||
|
fail('refusing to run Docker without QL3_LOCAL_API_CANCELLATION_LIVE=1');
|
||||||
|
}
|
||||||
|
const temporaryRoot = fs.realpathSync(
|
||||||
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-api-cancel-live-')),
|
||||||
|
);
|
||||||
|
fs.chmodSync(temporaryRoot, 0o700);
|
||||||
|
const artifactRoot = path.join(temporaryRoot, 'artifact');
|
||||||
|
const evidenceRoot = path.join(temporaryRoot, 'evidence');
|
||||||
|
fs.mkdirSync(evidenceRoot, { mode: 0o700 });
|
||||||
|
try {
|
||||||
|
const artifactOutput = run(process.execPath, [
|
||||||
|
path.join(ROOT, 'scripts/ql3-local-profile-artifact-audit.cjs'),
|
||||||
|
`${selected.profile}-application-api`,
|
||||||
|
`--output-directory=${artifactRoot}`,
|
||||||
|
]);
|
||||||
|
const artifact = JSON.parse(artifactOutput.split(/\r?\n/).at(-1));
|
||||||
|
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
||||||
|
const gid = typeof process.getgid === 'function' ? process.getgid() : null;
|
||||||
|
if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid))
|
||||||
|
fail('a POSIX identity is required');
|
||||||
|
const memory = selected.profile === 'edge' ? '128m' : '256m';
|
||||||
|
const pids = selected.profile === 'edge' ? '64' : '256';
|
||||||
|
run('docker', [
|
||||||
|
'run',
|
||||||
|
'--rm',
|
||||||
|
'--read-only',
|
||||||
|
'--user',
|
||||||
|
`${uid}:${gid}`,
|
||||||
|
'--network',
|
||||||
|
'none',
|
||||||
|
'--cap-drop',
|
||||||
|
'ALL',
|
||||||
|
'--security-opt',
|
||||||
|
'no-new-privileges',
|
||||||
|
'--memory',
|
||||||
|
memory,
|
||||||
|
'--memory-swap',
|
||||||
|
memory,
|
||||||
|
'--cpus',
|
||||||
|
'0.5',
|
||||||
|
'--pids-limit',
|
||||||
|
pids,
|
||||||
|
'--tmpfs',
|
||||||
|
'/tmp:rw,nosuid,nodev,noexec,size=16m',
|
||||||
|
'--volume',
|
||||||
|
`${artifactRoot}:/opt/ql3-artifact:ro`,
|
||||||
|
'--volume',
|
||||||
|
`${path.join(ROOT, 'scripts')}:/opt/ql3-scripts:ro`,
|
||||||
|
'--volume',
|
||||||
|
`${evidenceRoot}:/evidence`,
|
||||||
|
NODE_IMAGE,
|
||||||
|
'node',
|
||||||
|
'/opt/ql3-scripts/lib/ql3-local-api-cancellation-live-scenario.cjs',
|
||||||
|
'/opt/ql3-artifact',
|
||||||
|
'/evidence',
|
||||||
|
selected.profile,
|
||||||
|
]);
|
||||||
|
const scenario = JSON.parse(
|
||||||
|
fs.readFileSync(path.join(evidenceRoot, 'report.json'), 'utf8'),
|
||||||
|
);
|
||||||
|
const report = Object.freeze({
|
||||||
|
...scenario,
|
||||||
|
artifact: Object.freeze({
|
||||||
|
profile: artifact.profile,
|
||||||
|
bytes: artifact.artifactBytes,
|
||||||
|
files: artifact.artifactFiles,
|
||||||
|
loadedModules: artifact.loadedModuleCount,
|
||||||
|
compatible: artifact.compatible,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const audit = validateLocalApiCancellationLiveReport(report);
|
||||||
|
assert.deepEqual(audit.findings, []);
|
||||||
|
fs.writeFileSync(selected.report, `${JSON.stringify(report, null, 2)}\n`, {
|
||||||
|
flag: 'wx',
|
||||||
|
mode: 0o600,
|
||||||
|
});
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
profile: selected.profile,
|
||||||
|
reportWritten: true,
|
||||||
|
compatible: true,
|
||||||
|
})}\n`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(temporaryRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
main();
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(
|
||||||
|
`${
|
||||||
|
error instanceof Error ? error.stack || error.message : String(error)
|
||||||
|
}\n`,
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { argumentsOf };
|
||||||
@@ -284,8 +284,8 @@ function auditWorkflow(contents, findings) {
|
|||||||
'scripts/ql3-local-image-inventory.cjs',
|
'scripts/ql3-local-image-inventory.cjs',
|
||||||
'--inventory-root=/opt/qinglong/node_modules',
|
'--inventory-root=/opt/qinglong/node_modules',
|
||||||
'node ../../scripts/ql3-build-package-closure.cjs',
|
'node ../../scripts/ql3-build-package-closure.cjs',
|
||||||
'--profile=edge',
|
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=edge',
|
||||||
'--profile=standalone',
|
'node scripts/ql3-local-image-live-contract.cjs --image="${IMAGE}" --profile=standalone',
|
||||||
];
|
];
|
||||||
for (const value of required) {
|
for (const value of required) {
|
||||||
if (!job.includes(value)) {
|
if (!job.includes(value)) {
|
||||||
|
|||||||
@@ -48,6 +48,35 @@ function boundedPositiveInteger(value, fallback, label) {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function outputDirectoryArgument(value) {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (
|
||||||
|
value.length < 1 ||
|
||||||
|
Buffer.byteLength(value, 'utf8') > 4_096 ||
|
||||||
|
value.includes('\0') ||
|
||||||
|
!path.isAbsolute(value) ||
|
||||||
|
path.normalize(value) !== value ||
|
||||||
|
path.parse(value).root === value
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
'output directory must be a normalized bounded absolute non-root path',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fs.existsSync(value)) {
|
||||||
|
fail('output directory must not already exist');
|
||||||
|
}
|
||||||
|
const parent = path.dirname(value);
|
||||||
|
const stat = fs.lstatSync(parent);
|
||||||
|
if (
|
||||||
|
!stat.isDirectory() ||
|
||||||
|
stat.isSymbolicLink() ||
|
||||||
|
fs.realpathSync(parent) !== parent
|
||||||
|
) {
|
||||||
|
fail('output directory parent must be a canonical real directory');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function parseArguments(argv) {
|
function parseArguments(argv) {
|
||||||
const profile = argv[0];
|
const profile = argv[0];
|
||||||
if (
|
if (
|
||||||
@@ -70,13 +99,23 @@ function parseArguments(argv) {
|
|||||||
'Profile must be edge, standalone, an adopted/application variant, an AI/API variant, or an MCP variant',
|
'Profile must be edge, standalone, an adopted/application variant, an AI/API variant, or an MCP variant',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const values = Object.fromEntries(
|
const values = {};
|
||||||
argv.slice(1).map((argument) => {
|
for (const argument of argv.slice(1)) {
|
||||||
const match = /^--([a-z-]+)=(\d+)$/.exec(argument);
|
const match = /^--([a-z-]+)=(.+)$/.exec(argument);
|
||||||
if (!match) fail(`unsupported argument ${argument}`);
|
if (!match || Object.hasOwn(values, match[1])) {
|
||||||
return [match[1], match[2]];
|
fail(`unsupported argument ${argument}`);
|
||||||
}),
|
}
|
||||||
);
|
values[match[1]] = match[2];
|
||||||
|
}
|
||||||
|
const supported = new Set([
|
||||||
|
'max-artifact-files',
|
||||||
|
'max-artifact-bytes',
|
||||||
|
'max-rss-delta-bytes',
|
||||||
|
'output-directory',
|
||||||
|
]);
|
||||||
|
for (const name of Object.keys(values)) {
|
||||||
|
if (!supported.has(name)) fail(`unsupported argument --${name}`);
|
||||||
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
profile,
|
profile,
|
||||||
runtimeProfile: profile.startsWith('edge') ? 'edge' : 'standalone',
|
runtimeProfile: profile.startsWith('edge') ? 'edge' : 'standalone',
|
||||||
@@ -128,6 +167,7 @@ function parseArguments(argv) {
|
|||||||
profile.includes('-application') && profile.endsWith('-ai')
|
profile.includes('-application') && profile.endsWith('-ai')
|
||||||
? MIN_APPLICATION_AI_ARTIFACT_HEADROOM_BYTES
|
? MIN_APPLICATION_AI_ARTIFACT_HEADROOM_BYTES
|
||||||
: 0,
|
: 0,
|
||||||
|
outputDirectory: outputDirectoryArgument(values['output-directory']),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +261,7 @@ function auditImportClosure(
|
|||||||
}
|
}
|
||||||
const forbidden = result.loaded.filter(
|
const forbidden = result.loaded.filter(
|
||||||
(filePath) =>
|
(filePath) =>
|
||||||
(!allowSemver &&
|
(!allowSemver && /node_modules[\\/]semver(?:[\\/]|$)/i.test(filePath)) ||
|
||||||
/node_modules[\\/]semver(?:[\\/]|$)/i.test(filePath)) ||
|
|
||||||
/(?:node_modules[\\/](?:croner|pg|drizzle-orm|sequelize|sqlite3)(?:[\\/]|$)|node_modules[\\/]@qinglong[\\/]cluster-|local-sqlite[\\/]dist[\\/]migration\.js$|local-sqlite[\\/]dist[\\/]migrations[\\/])/i.test(
|
/(?:node_modules[\\/](?:croner|pg|drizzle-orm|sequelize|sqlite3)(?:[\\/]|$)|node_modules[\\/]@qinglong[\\/]cluster-|local-sqlite[\\/]dist[\\/]migration\.js$|local-sqlite[\\/]dist[\\/]migrations[\\/])/i.test(
|
||||||
filePath,
|
filePath,
|
||||||
),
|
),
|
||||||
@@ -309,8 +348,7 @@ function auditLocalApiExecutable(artifactDirectory) {
|
|||||||
artifactDirectory,
|
artifactDirectory,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
output !==
|
output !== 'Usage: ql3-local-api --config /absolute/private-config.json'
|
||||||
'Usage: ql3-local-api --config /absolute/private-config.json'
|
|
||||||
) {
|
) {
|
||||||
fail('local API executable help output is invalid');
|
fail('local API executable help output is invalid');
|
||||||
}
|
}
|
||||||
@@ -670,6 +708,13 @@ function main() {
|
|||||||
`import RSS delta is ${closure.rssDeltaBytes} bytes, budget is ${options.maxRssDeltaBytes}`,
|
`import RSS delta is ${closure.rssDeltaBytes} bytes, budget is ${options.maxRssDeltaBytes}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (options.outputDirectory !== undefined) {
|
||||||
|
fs.cpSync(artifactDirectory, options.outputDirectory, {
|
||||||
|
recursive: true,
|
||||||
|
errorOnExist: true,
|
||||||
|
force: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`${JSON.stringify({
|
`${JSON.stringify({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
@@ -680,8 +725,7 @@ function main() {
|
|||||||
maxArtifactFiles: options.maxArtifactFiles,
|
maxArtifactFiles: options.maxArtifactFiles,
|
||||||
maxArtifactBytes: options.maxArtifactBytes,
|
maxArtifactBytes: options.maxArtifactBytes,
|
||||||
artifactHeadroomBytes,
|
artifactHeadroomBytes,
|
||||||
minimumArtifactHeadroomBytes:
|
minimumArtifactHeadroomBytes: options.minimumArtifactHeadroomBytes,
|
||||||
options.minimumArtifactHeadroomBytes,
|
|
||||||
prunedRuntimeDevelopmentFiles: prunedRuntimeArtifact.development.files,
|
prunedRuntimeDevelopmentFiles: prunedRuntimeArtifact.development.files,
|
||||||
prunedRuntimeDevelopmentBytes: prunedRuntimeArtifact.development.bytes,
|
prunedRuntimeDevelopmentBytes: prunedRuntimeArtifact.development.bytes,
|
||||||
runtimeJavaScriptFilesBefore:
|
runtimeJavaScriptFilesBefore:
|
||||||
@@ -716,10 +760,8 @@ function main() {
|
|||||||
prunedRuntimeArtifact.packageManifests.runtimeExports
|
prunedRuntimeArtifact.packageManifests.runtimeExports
|
||||||
.excludedSpecifiers,
|
.excludedSpecifiers,
|
||||||
prunedRuntimeArtifactBytes: prunedRuntimeArtifact.savedBytes,
|
prunedRuntimeArtifactBytes: prunedRuntimeArtifact.savedBytes,
|
||||||
prunedMcpExternalDevelopmentFiles:
|
prunedMcpExternalDevelopmentFiles: prunedMcpExternalDevelopment.files,
|
||||||
prunedMcpExternalDevelopment.files,
|
prunedMcpExternalDevelopmentBytes: prunedMcpExternalDevelopment.bytes,
|
||||||
prunedMcpExternalDevelopmentBytes:
|
|
||||||
prunedMcpExternalDevelopment.bytes,
|
|
||||||
loadedModuleCount: closure.loadedModuleCount,
|
loadedModuleCount: closure.loadedModuleCount,
|
||||||
rssDeltaBytes: closure.rssDeltaBytes,
|
rssDeltaBytes: closure.rssDeltaBytes,
|
||||||
maxRssDeltaBytes: options.maxRssDeltaBytes,
|
maxRssDeltaBytes: options.maxRssDeltaBytes,
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const os = require('node:os');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawnSync } = require('node:child_process');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateLocalApiCancellationLiveReport,
|
||||||
|
} = require('../../scripts/ql3-local-api-cancellation-live-audit.cjs');
|
||||||
|
|
||||||
|
function report(profile = 'edge') {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
profile,
|
||||||
|
platform: { os: 'linux', architecture: 'arm64', procfs: true },
|
||||||
|
resourceEnvelope: {
|
||||||
|
memoryBytes: profile === 'edge' ? 128 * 1024 * 1024 : 256 * 1024 * 1024,
|
||||||
|
pids: profile === 'edge' ? 64 : 256,
|
||||||
|
apiRssBytes: 80 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
observations: {
|
||||||
|
taskStartAccepted: true,
|
||||||
|
cancellationAccepted: true,
|
||||||
|
exactReplay: true,
|
||||||
|
durableIntentEvents: 1,
|
||||||
|
durableCancellationEvents: 1,
|
||||||
|
durableAllowedAudits: 2,
|
||||||
|
processIdentityObserved: true,
|
||||||
|
processIdentityGone: true,
|
||||||
|
restartObservedCancelled: true,
|
||||||
|
sqliteIntegrity: 'ok',
|
||||||
|
},
|
||||||
|
qualification: {
|
||||||
|
evidenceClass: 'linux_virtualized_live_contract',
|
||||||
|
physicalDevice: false,
|
||||||
|
passed: true,
|
||||||
|
},
|
||||||
|
compatible: true,
|
||||||
|
artifact: {
|
||||||
|
profile: `${profile}-application-api`,
|
||||||
|
bytes: 4 * 1024 * 1024,
|
||||||
|
files: 429,
|
||||||
|
loadedModules: 85,
|
||||||
|
compatible: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('accepts exact Edge and Standalone API to process-stop evidence', () => {
|
||||||
|
for (const profile of ['edge', 'standalone']) {
|
||||||
|
assert.deepEqual(validateLocalApiCancellationLiveReport(report(profile)), {
|
||||||
|
compatible: true,
|
||||||
|
findings: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects missing process, replay, resource, artifact or qualification facts', () => {
|
||||||
|
for (const mutate of [
|
||||||
|
(value) => {
|
||||||
|
value.observations.processIdentityGone = false;
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
value.observations.exactReplay = false;
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
value.resourceEnvelope.memoryBytes += 1;
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
value.artifact.bytes = 6 * 1024 * 1024 + 1;
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
value.qualification.physicalDevice = true;
|
||||||
|
},
|
||||||
|
(value) => {
|
||||||
|
value.unreviewed = true;
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
const value = report();
|
||||||
|
mutate(value);
|
||||||
|
assert.equal(
|
||||||
|
validateLocalApiCancellationLiveReport(value).compatible,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requires a fresh private report before Docker opt-in is checked', () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-local-cancel-test-'));
|
||||||
|
try {
|
||||||
|
const script = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
'../../scripts/ql3-local-api-cancellation-live-contract.cjs',
|
||||||
|
);
|
||||||
|
const reportPath = path.join(root, 'report.json');
|
||||||
|
const result = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[script, '--profile=edge', `--report=${reportPath}`],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, QL3_LOCAL_API_CANCELLATION_LIVE: '0' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(result.status, 1);
|
||||||
|
assert.match(result.stderr, /refusing to run Docker/);
|
||||||
|
assert.equal(fs.existsSync(reportPath), false);
|
||||||
|
fs.writeFileSync(reportPath, '{}', { mode: 0o600 });
|
||||||
|
const existing = spawnSync(
|
||||||
|
process.execPath,
|
||||||
|
[script, '--profile=edge', `--report=${reportPath}`],
|
||||||
|
{
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: { ...process.env, QL3_LOCAL_API_CANCELLATION_LIVE: '1' },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(existing.status, 1);
|
||||||
|
assert.match(existing.stderr, /fresh normalized absolute/);
|
||||||
|
assert.doesNotMatch(existing.stderr, /docker/i);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user