From 3979707f6d8e90a25479c9a4abd433b6d4c2c245 Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 20 Aug 2026 10:04:21 +0800 Subject: [PATCH] feat(ql3): gate console capacity evidence --- .github/workflows/ql3-ci.yml | 95 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 17 + ...pacity-and-assertion-lifecycle-evidence.md | 107 ++ ...ster-copilot-console-capacity-evidence.cjs | 1473 +++++++++++++++++ scripts/ql3-cluster-image-release-audit.cjs | 144 ++ ...terCopilotConsoleCapacityEvidence.test.cjs | 402 +++++ .../back/ql3ClusterImageReleaseAudit.test.cjs | 52 + 7 files changed, 2289 insertions(+), 1 deletion(-) create mode 100644 docs/adr/ADR-0463-native-console-capacity-and-assertion-lifecycle-evidence.md create mode 100644 scripts/ql3-cluster-copilot-console-capacity-evidence.cjs create mode 100644 test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index fb43c728..c99f972d 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -542,7 +542,7 @@ jobs: - name: Verify native runner architecture run: node -e "if (process.arch !== '${{ matrix.node_arch }}') throw new Error('unexpected architecture ' + process.arch)" - name: Test exact SBOM and release contract failures - run: node --test test/back/ql3ClusterImageSbom.test.cjs test/back/ql3ClusterImageReleaseAudit.test.cjs + run: node --test test/back/ql3ClusterImageSbom.test.cjs test/back/ql3ClusterImageReleaseAudit.test.cjs test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs - name: Audit deployment and image release contracts run: | pnpm audit:cluster-deployment:ql3 @@ -594,6 +594,40 @@ jobs: IMAGE: qinglong3-cluster-admin:ci-${{ matrix.image_arch }} QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '1' run: node scripts/ql3-cluster-admin-product-live-contract.cjs --image="${IMAGE}" + - name: Capture the fixed Cluster Copilot Console capacity envelope + if: matrix.image == 'admin' + timeout-minutes: 10 + env: + IMAGE: qinglong3-cluster-admin:ci-${{ matrix.image_arch }} + QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE: '1' + SOURCE_REPOSITORY: ${{ github.repository }} + SOURCE_REVISION: ${{ github.sha }} + SOURCE_WORKFLOW: ${{ github.workflow }} + SOURCE_RUN_ID: ${{ github.run_id }} + SOURCE_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + mkdir -p "${RUNNER_TEMP}/ql3-cluster-console-capacity" + node scripts/ql3-cluster-copilot-console-capacity-evidence.cjs \ + --mode=capture \ + --repository="${SOURCE_REPOSITORY}" \ + --revision="${SOURCE_REVISION}" \ + --workflow="${SOURCE_WORKFLOW}" \ + --run-id="${SOURCE_RUN_ID}" \ + --run-attempt="${SOURCE_RUN_ATTEMPT}" \ + --architecture="${{ matrix.node_arch }}" \ + --image="${IMAGE}" \ + --output="${RUNNER_TEMP}/ql3-cluster-console-capacity/${{ matrix.node_arch }}.json" + - name: Upload native Cluster Copilot Console capacity evidence + if: matrix.image == 'admin' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.node_arch }} + path: ${{ runner.temp }}/ql3-cluster-console-capacity/${{ matrix.node_arch }}.json + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + include-hidden-files: false - name: Generate the reviewed application SBOM run: >- node scripts/ql3-cluster-image-sbom.cjs @@ -614,6 +648,65 @@ jobs: --image=${{ matrix.image }} --inventory-root=/opt/qinglong/node_modules + cluster-console-capacity-release-evidence: + name: Cross-architecture Cluster Copilot Console capacity evidence + needs: cluster-image + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.18.0' + - name: Download native x64 Console capacity evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-x64 + path: ${{ runner.temp }}/ql3-cluster-console-capacity/x64 + - name: Download native arm64 Console capacity evidence + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-arm64 + path: ${{ runner.temp }}/ql3-cluster-console-capacity/arm64 + - name: Merge and audit the source-bound Console capacity evidence + env: + SOURCE_REPOSITORY: ${{ github.repository }} + SOURCE_REVISION: ${{ github.sha }} + SOURCE_WORKFLOW: ${{ github.workflow }} + SOURCE_RUN_ID: ${{ github.run_id }} + SOURCE_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + node scripts/ql3-cluster-copilot-console-capacity-evidence.cjs \ + --mode=merge \ + --repository="${SOURCE_REPOSITORY}" \ + --revision="${SOURCE_REVISION}" \ + --workflow="${SOURCE_WORKFLOW}" \ + --run-id="${SOURCE_RUN_ID}" \ + --run-attempt="${SOURCE_RUN_ATTEMPT}" \ + --x64="${RUNNER_TEMP}/ql3-cluster-console-capacity/x64/x64.json" \ + --arm64="${RUNNER_TEMP}/ql3-cluster-console-capacity/arm64/arm64.json" \ + --output="${RUNNER_TEMP}/ql3-cluster-console-capacity/cross-architecture.json" + node scripts/ql3-cluster-copilot-console-capacity-evidence.cjs \ + --mode=audit \ + --repository="${SOURCE_REPOSITORY}" \ + --revision="${SOURCE_REVISION}" \ + --workflow="${SOURCE_WORKFLOW}" \ + --run-id="${SOURCE_RUN_ID}" \ + --run-attempt="${SOURCE_RUN_ATTEMPT}" \ + --report="${RUNNER_TEMP}/ql3-cluster-console-capacity/cross-architecture.json" + - name: Upload cross-architecture Console capacity evidence + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ql3-cluster-console-capacity-release-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/ql3-cluster-console-capacity/cross-architecture.json + if-no-files-found: error + retention-days: 14 + compression-level: 0 + overwrite: false + include-hidden-files: false + image-oci: name: ${{ matrix.image }} multi-architecture OCI evidence runs-on: ubuntu-24.04 diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index a5b8c6ac..af0948b7 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,23 @@ 最新增量证据(2026-08-20): +- D-370/ADR-0463(实现与本地协议门已接受;首份原生 CI 双架构报告待实际 workflow 产生):为显式按需的 Cluster Admin + Copilot Console 增加固定 Linux x64/arm64 容量与 assertion 生命周期发布证据。两个原生 Admin image matrix job 分别在 + `192 MiB / swap 0 / 0.25 CPU / 32 PIDs`、只读 root、非 root、cap-drop ALL、no-new-privileges、默认 seccomp、8 MiB tmpfs 与 + loopback-only publication 下执行四次用户驱动读取,并从 Console 自身 cgroup v2 采集 memory max/peak/events、swap、CPU 与 PID;要求至少 + 32 MiB headroom 且 `max/oom/oom_kill/oom_group_kill` 不增加。隔离 synthetic TLS 1.3/mTLS management verifier 验证 + `initial accepted → atomic rotated accepted → expired rejected → rotated recovered`,Console 全程不重启、不轮询、不重试、不缓存、不 mutation,过期 + assertion 只投影为 502/`assertion_expired` 低敏事实。每架构报告绑定 repository/revision/workflow/run ID/attempt 和独立 image ID,使用 nofollow + bounded input、`wx/0600` output 与 domain-separated canonical SHA-256;独立只读 job 以 commit-pinned artifact actions 精确下载、合并并离线重审。 + 实现保持在单一 CI 脚本和既有 image release audit 中,没有新增 package、dependency、binary、服务、端口、数据库对象或部署 workload,避免制造单文件微包; + Edge/Standalone closure 不含 Console。D-370 聚焦证据与发布审计为 `108/108`,连同 image SBOM 的原生 job 聚焦集合为 `120/120`;18-package + clean build/逐包测试退出 0,完整 backend 工作区门为 `1,503 total / 1,501 pass / 2 conditional skip / 0 fail`(含一条不会提交的既有用户测试;D-370 + 提交范围为 `1,502 total / 1,500 pass / 2 skip`),六项架构审计和 14 档 Local artifact + audit 全部 compatible。基础 Edge/Standalone 仍为 `2,589,998 / 2,590,076` bytes,Application+AI 为 `4,493,151 / 4,493,283` bytes,MCP 为 + `7,315,930 / 7,316,038` bytes。本机只能验证协议、Docker inspect 契约和 workflow 装配,尚无本提交的原生 + GitHub x64/arm64 memory peak、artifact digest 或 run URL,因此不能把实现状态写成最终现场证据。192 MiB 只回答 workstation Console 的有界空载读取, + 不是物理路由器最低配置,也不是 Cluster 节点吞吐、故障恢复或容量规划。 + - D-369/ADR-0462(已接受):在既有 operator-workstation Copilot Console 内完成显式可选、默认关闭的 Run management 只读纵切,新增固定 `run_cancellation_status`、`run_cancellation_blocked_list`、`run_cancellation_inspect` 三种 browser/BFF operation。只有同时提供独立 `--run-management-config` 与 owner-private `--run-management-assertion` 才启用,Project API credential、浏览器 session 与 Run 专用 TLS 1.3/mTLS/OIDC diff --git a/docs/adr/ADR-0463-native-console-capacity-and-assertion-lifecycle-evidence.md b/docs/adr/ADR-0463-native-console-capacity-and-assertion-lifecycle-evidence.md new file mode 100644 index 00000000..568f9006 --- /dev/null +++ b/docs/adr/ADR-0463-native-console-capacity-and-assertion-lifecycle-evidence.md @@ -0,0 +1,107 @@ +# ADR-0463:原生双架构 Console 容量与 Assertion 生命周期证据 + +- 状态:Accepted(证据协议、原生采集与 CI 聚合门已实现;首份 GitHub Actions 双架构报告待实际运行产生) +- 日期:2026-08-20 +- 关联 RFC:QL-RFC-0001 D-370、PR-5、PR-7 +- 关联 ADR:ADR-0088、ADR-0281、ADR-0322、ADR-0329、ADR-0462 +- Amends:ADR-0462 中尚未取得的 compact Console Linux x64/arm64 实测边界 + +## 上下文 + +D-369 已把 Run cancellation status、blocked page 与 inspect 作为显式可选、用户驱动的只读纵切接入 +Cluster Copilot Console,并继续声明 compact workstation 容器预算为 `192 MiB / 0.25 CPU / 32 PIDs`。 +静态 limit、macOS Docker Desktop 或单架构开发机都不能证明生产镜像在原生 Linux x64 与 arm64 上确实满足该 +envelope,也不能证明 Console 在不重启时会为每次点击重新读取轮换后的短期 assertion,并低敏拒绝过期凭据。 + +部署用户跨度很大:Edge/Standalone 可能运行在小型路由设备,而 Cluster operator workstation 与集群节点有 +完全不同的资源和可用性要求。因此本门只能回答“按需启动的 Cluster Admin Console 是否在固定 CI 容器预算内 +完成四次有界管理读取”,不能把结果外推为物理 Edge 最低配置、Cluster 节点吞吐或多节点容量规划。 + +## 决策 + +1. 新增单一 CI/审核脚本 `scripts/ql3-cluster-copilot-console-capacity-evidence.cjs`,提供 `capture`、`merge`、 + `audit` 三种模式。它不新增 workspace package、生产 dependency、binary、服务、数据库对象或部署 workload; + 证据职责不足以成立独立 package,避免再次制造只有一个文件的微包。 +2. `capture` 只允许 Node `v24.18.0` 的原生 Linux `x64` 或 `arm64`,并要求显式 + `QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE=1`。镜像必须是当前 matrix 刚构建的独立 Admin image, + architecture、content ID、size 和 `10001:10001` runtime user 均从 Docker inspect 取得。 +3. Console 使用精确 compact envelope:`192 MiB` memory、memory-swap 与 memory 相同从而使 cgroup v2 + `memory.swap.max=0`、`0.25 CPU`、`32 PIDs`、只读 root、cap-drop ALL、no-new-privileges、默认 seccomp、 + `8 MiB` noexec/nosuid/nodev tmpfs、非 root user 和仅 `127.0.0.1` 发布端口。authority volume 只读挂载, + management fixture 只存在于隔离 Docker network 且不发布宿主端口。 +4. 在四次请求前后从 Console 自身 cgroup v2 读取 `memory.max`、`memory.peak`、`memory.swap.max`、 + `memory.events`、`cpu.max`、`pids.max/current`,并从 `/proc/self/status` 读取 NoNewPrivs/seccomp。peak 必须至少 + 保留 `32 MiB` headroom;`max/oom/oom_kill/oom_group_kill` 不能增加。container start identity 前后相同, + 因而不能通过重启掩盖累积峰值或 assertion reload 缺陷。 +5. synthetic management verifier 只接受 TLS 1.3 和受信 client certificate,固定 service SAN 与 + `/api/v3/runs/management`,且只记录 label、TLS、method、path、operation、mutation 等低敏事实。它不是外部 + IdP 或生产 authorization attestation。 +6. 用户驱动序列固定为 `initial_accepted → rotated_accepted → expired_rejected → rotated_recovered`。Assertion + 通过同一只读 volume 内的 `write wx → chown → atomic rename` 原位轮换;Console 每次 POST 重新读取文件, + 不能重启、轮询、重试、缓存或触发 mutation。过期 assertion 必须由上游 401 被 BFF 投影为 502 和 + `assertion_expired`,响应与日志不能泄露 assertion、authority path 或 service identity。 +7. 每个原生 matrix job 生成 exact-shape、source-bound 架构报告,绑定 repository、40 位 revision、workflow、 + run ID 与 run attempt。输入为有大小上限、nofollow 的普通 JSON;输出只以 `wx/0600` 新建。内容使用固定 + domain separator 的 canonical SHA-256,未知字段、非有限数字、过深或过多节点均失败关闭。 +8. Artifact 名精确包含 run ID、attempt 与 architecture,upload/download action 固定到完整 commit,禁止 + overwrite。独立只读 job 必须等待完整 `cluster-image` matrix,分别下载 x64/arm64,不使用 pattern 或 + merge-multiple;merge 重验同源与不同 image ID,随后在同一 job 离线 audit 合并报告。 +9. 合并报告显式保留四条 limitation:192 MiB workstation envelope 不是 Cluster 吞吐/容量规划;native CI + 不是物理 Edge 最低配置、断电、闪存、热环境或 soak 证明;synthetic mTLS verifier 不是外部 IdP 证明; + workflow source binding 不是密码学硬件 attestation。 + +## 被拒绝的替代方案 + +### 在 macOS arm64 上用 QEMU 生成双架构结果 + +拒绝。标签与模拟执行不能代替两个原生 Linux runner;本地只能验证协议、负向门禁和工作流装配。 + +### 把容量采集拆成多个 workspace package + +拒绝。capture、merge、audit 只服务一个 CI evidence contract,没有独立生产依赖、版本、入口或运行时生命周期。 +拆分会扩大 package 数和维护面,并重现 `packages/*/src` 只有一个平铺文件的问题。 + +### 把 192 MiB 写成路由设备最低配置 + +拒绝。Console 是显式按需的 Cluster operator workstation 工具,Edge/Standalone artifact closure 不包含它; +固定路由器仍需明确硬件、内核、文件系统、断电、闪存、热环境和长时间 soak 证据。 + +### 把空载 Console 峰值写成 Cluster 容量 + +拒绝。该门只有四次串行读,没有副本、数据库延迟、并发 operator、Worker、queue、failover 或恢复负载。 +Cluster 容量规划必须由独立多节点负载与故障恢复门完成。 + +### 为 assertion 轮换加入 watcher、polling 或重启 + +拒绝。既有 server 的每次点击读取已经提供最小、可审计的 reload 语义;watcher/timer 会增加 idle 成本,重启会 +破坏 session 并掩盖实际 reload 行为。 + +## 验证与当前证据状态 + +- D-370 证据协议与 CI 发布审计聚焦门为 `108/108`;原生 `cluster-image` 聚焦集合连同 SBOM 为 `120/120`。覆盖双架构正向 merge/audit、headroom、swap、OOM、PID、 + schema widening、assertion sequence/TLS/mTLS/mutation、digest 篡改、跨 run、重复 image、symlink、overwrite、 + live opt-in,以及 capture、matrix dependency、offline audit 的负向 workflow drift。 +- 本机 Docker inspect 探针确认 SecurityOpt 为 `no-new-privileges`,tmpfs 保留精确 `size=8m` 表示;探针容器已 + 删除。该探针只用于修正静态 inspect 契约,不冒充 native Linux x64/arm64 容量证据。 +- CI 脚本与 workflow 已实现,但当前提交尚未在 GitHub Actions 产生同一 run 的两个原生 architecture artifact。 + 因此本 ADR 接受的是实现和门禁设计,不能宣称已经取得最终 memory peak、image ID、artifact digest 或 run URL。 +- 该变化不改 package/dependency tree、Edge/Standalone runtime closure、PostgreSQL schema/role/Pool 或 HA 拓扑, + 不重新占有数据库 HA 证据。 +- 18-package clean build 与逐包测试在允许 loopback listener 的宿主环境退出 0;Worker Runtime 独立复核为 + `133/133`。当前完整 backend 工作区门为 `1,503 total / 1,501 pass / 2 conditional skip / 0 fail`;其中包含一项 + 与本切片无关、保持未跟踪且不会提交的用户测试,因此 D-370 提交范围对应 `1,502 total / 1,500 pass / 2 skip`。package boundary、dependency、Edge import、Cluster deployment、 + Console 与 Console distribution 六项审计均 compatible、零 finding,workspace 仍为 18 packages、无 single/shallow + package,Cluster Admin 为 `125 source / 124 nested`。 +- 14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 精确为 `2,589,998 / 2,590,076` bytes, + Application+AI 为 `4,493,151 / 4,493,283` bytes,MCP 为 `7,315,930 / 7,316,038` bytes,证明 CI-only evidence + 没有进入低配部署闭包。 + +## 后续边界 + +- 推送后记录首个成功 workflow run、x64/arm64 bundle digest、cross-architecture release digest、各架构 image ID/ + size/memory peak/headroom 与 artifact retention;若任何架构超过 envelope,必须修实现或调整经过 RFC 评审的 + budget,不能放宽 validator 伪造成功。 +- 物理 Edge 支持下限继续由 ADR-0088 类型的固定设备矩阵证明;Cluster 节点容量继续由多节点数据库、Worker、 + queue、failover 与恢复负载证明。 +- 后续若 Console 引入新的 read operation 或生产 dependency,必须重新采集双架构报告并证明仍无 mutation、 + polling、authority leak 和 Edge closure 变化。 diff --git a/scripts/ql3-cluster-copilot-console-capacity-evidence.cjs b/scripts/ql3-cluster-copilot-console-capacity-evidence.cjs new file mode 100644 index 00000000..45f5897e --- /dev/null +++ b/scripts/ql3-cluster-copilot-console-capacity-evidence.cjs @@ -0,0 +1,1473 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { createHash, randomBytes } = require('node:crypto'); +const { createServer } = require('node:net'); +const { request: httpRequest } = require('node:http'); + +const ARCHITECTURES = Object.freeze(['x64', 'arm64']); +const IMAGE_ARCHITECTURES = Object.freeze({ x64: 'amd64', arm64: 'arm64' }); +const NODE_VERSION = 'v24.18.0'; +const MEMORY_MAX_BYTES = 192 * 1024 * 1024; +const MINIMUM_MEMORY_HEADROOM_BYTES = 32 * 1024 * 1024; +const SWAP_MAX_BYTES = 0; +const CPU_QUOTA_MICROS = 25_000; +const CPU_PERIOD_MICROS = 100_000; +const PIDS_MAX = 32; +const TMPFS_BYTES = 8 * 1024 * 1024; +const MAX_EVIDENCE_BYTES = 1024 * 1024; +const MAX_CANONICAL_DEPTH = 24; +const MAX_CANONICAL_NODES = 20_000; +const REVISION_PATTERN = /^[0-9a-f]{40}$/u; +const REPOSITORY_PATTERN = + /^[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})\/[A-Za-z0-9](?:[A-Za-z0-9_.-]{0,99})$/u; +const RUN_ID_PATTERN = /^[1-9][0-9]{0,31}$/u; +const IMAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,255}$/u; +const IMAGE_ID_PATTERN = /^sha256:[0-9a-f]{64}$/u; +const SERVICE_NAME = 'ql3-plugin-package-management.qinglong3-system.svc'; +const MANAGEMENT_PORT = 8443; +const LIMITATIONS = Object.freeze([ + 'The 192 MiB workstation Console envelope is not Cluster throughput or capacity planning', + 'Native CI evidence is not a physical Edge minimum, power-loss, flash, thermal, or soak claim', + 'The management service is a bounded synthetic mTLS verifier, not an external IdP attestation', + 'GitHub workflow source binding is not a cryptographic hardware attestation', +]); +const ASSERTION_SEQUENCE = Object.freeze([ + 'initial_accepted', + 'rotated_accepted', + 'expired_rejected', + 'rotated_recovered', +]); + +class QingLong3ClusterCopilotConsoleCapacityEvidenceError extends Error { + constructor(message) { + super( + `QingLong 3.0 Cluster Copilot Console capacity evidence failed: ${message}`, + ); + this.name = 'QingLong3ClusterCopilotConsoleCapacityEvidenceError'; + } +} + +function fail(message) { + throw new QingLong3ClusterCopilotConsoleCapacityEvidenceError(message); +} + +function isRecord(value) { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + Object.getPrototypeOf(value) === Object.prototype + ); +} + +function assertRecord(value, label) { + if (!isRecord(value)) fail(`${label} must be a plain object`); + return value; +} + +function assertExactKeys(value, expected, label) { + const actual = Object.keys(assertRecord(value, label)).sort(); + const wanted = [...expected].sort(); + if (JSON.stringify(actual) !== JSON.stringify(wanted)) { + fail(`${label} fields are invalid`); + } +} + +function safeInteger(value, label, minimum = 0) { + if (!Number.isSafeInteger(value) || value < minimum) { + fail(`${label} must be an integer >= ${minimum}`); + } + return value; +} + +function boundedString(value, label, maximum = 128) { + if ( + typeof value !== 'string' || + value.length < 1 || + value.length > maximum || + /[\u0000-\u001f\u007f]/u.test(value) + ) { + fail(`${label} is invalid`); + } + return value; +} + +function canonicalize(value, depth = 0, budget = { nodes: 0 }) { + budget.nodes += 1; + if (budget.nodes > MAX_CANONICAL_NODES) fail('evidence node budget exceeded'); + if (depth > MAX_CANONICAL_DEPTH) fail('evidence depth budget exceeded'); + if ( + value === null || + typeof value === 'string' || + typeof value === 'boolean' + ) { + return value; + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) fail('evidence contains a non-finite number'); + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => canonicalize(entry, depth + 1, budget)); + } + if (!isRecord(value)) fail('evidence contains an unsupported value'); + const result = {}; + for (const key of Object.keys(value).sort()) { + if (key.length < 1 || key.length > 128) fail('evidence key is invalid'); + result[key] = canonicalize(value[key], depth + 1, budget); + } + return result; +} + +function evidenceDigest(value) { + return createHash('sha256') + .update('qinglong/cluster-console-capacity-evidence\0') + .update(JSON.stringify(canonicalize(value))) + .digest('hex'); +} + +function normalizeSource(source) { + assertExactKeys( + source, + ['repository', 'revision', 'workflow', 'runId', 'runAttempt'], + 'source', + ); + if ( + typeof source.repository !== 'string' || + !REPOSITORY_PATTERN.test(source.repository) + ) { + fail('source repository is invalid'); + } + if ( + typeof source.revision !== 'string' || + !REVISION_PATTERN.test(source.revision) + ) { + fail('source revision is invalid'); + } + boundedString(source.workflow, 'source workflow'); + if (typeof source.runId !== 'string' || !RUN_ID_PATTERN.test(source.runId)) { + fail('source runId is invalid'); + } + safeInteger(source.runAttempt, 'source runAttempt', 1); + return Object.freeze({ + repository: source.repository, + revision: source.revision, + workflow: source.workflow, + runId: source.runId, + runAttempt: source.runAttempt, + }); +} + +function validateMemoryEvents(value, label) { + assertExactKeys( + value, + ['low', 'high', 'max', 'oom', 'oomKill', 'oomGroupKill'], + label, + ); + return Object.freeze({ + low: safeInteger(value.low, `${label}.low`), + high: safeInteger(value.high, `${label}.high`), + max: safeInteger(value.max, `${label}.max`), + oom: safeInteger(value.oom, `${label}.oom`), + oomKill: safeInteger(value.oomKill, `${label}.oomKill`), + oomGroupKill: safeInteger(value.oomGroupKill, `${label}.oomGroupKill`), + }); +} + +function validateObservation(value, expectedArchitecture) { + assertExactKeys( + value, + [ + 'schemaVersion', + 'observedAtMs', + 'platform', + 'architecture', + 'image', + 'runtime', + 'envelope', + 'assertionLifecycle', + ], + 'observation', + ); + if ( + value.schemaVersion !== 1 || + value.platform !== 'linux' || + value.architecture !== expectedArchitecture || + !ARCHITECTURES.includes(value.architecture) + ) { + fail('observation native identity is invalid'); + } + safeInteger(value.observedAtMs, 'observation observedAtMs', 1); + + assertExactKeys( + value.image, + ['architecture', 'id', 'bytes', 'user'], + 'observation image', + ); + if ( + value.image.architecture !== IMAGE_ARCHITECTURES[expectedArchitecture] || + typeof value.image.id !== 'string' || + !IMAGE_ID_PATTERN.test(value.image.id) || + value.image.user !== '10001:10001' + ) { + fail('observation image identity is invalid'); + } + safeInteger(value.image.bytes, 'observation image bytes', 1); + + assertExactKeys(value.runtime, ['node', 'uid', 'gid'], 'observation runtime'); + if ( + value.runtime.node !== NODE_VERSION || + value.runtime.uid !== 10001 || + value.runtime.gid !== 10001 + ) { + fail('observation runtime identity is invalid'); + } + + assertExactKeys( + value.envelope, + [ + 'memoryMaxBytes', + 'memoryPeakBytes', + 'memoryHeadroomBytes', + 'swapMaxBytes', + 'cpuQuotaMicros', + 'cpuPeriodMicros', + 'pidsMax', + 'pidsCurrent', + 'noNewPrivileges', + 'seccompMode', + 'readOnlyRoot', + 'tmpfsBytes', + 'publishedHostAddress', + 'capabilityDrop', + 'memoryEventsBefore', + 'memoryEventsAfter', + ], + 'observation envelope', + ); + const memoryPeakBytes = safeInteger( + value.envelope.memoryPeakBytes, + 'observation memoryPeakBytes', + 1, + ); + const memoryHeadroomBytes = safeInteger( + value.envelope.memoryHeadroomBytes, + 'observation memoryHeadroomBytes', + ); + const pidsCurrent = safeInteger( + value.envelope.pidsCurrent, + 'observation pidsCurrent', + 1, + ); + if ( + value.envelope.memoryMaxBytes !== MEMORY_MAX_BYTES || + memoryPeakBytes + memoryHeadroomBytes !== MEMORY_MAX_BYTES || + memoryHeadroomBytes < MINIMUM_MEMORY_HEADROOM_BYTES || + value.envelope.swapMaxBytes !== SWAP_MAX_BYTES || + value.envelope.cpuQuotaMicros !== CPU_QUOTA_MICROS || + value.envelope.cpuPeriodMicros !== CPU_PERIOD_MICROS || + value.envelope.pidsMax !== PIDS_MAX || + pidsCurrent > PIDS_MAX || + value.envelope.noNewPrivileges !== 1 || + value.envelope.seccompMode !== 2 || + value.envelope.readOnlyRoot !== true || + value.envelope.tmpfsBytes !== TMPFS_BYTES || + value.envelope.publishedHostAddress !== '127.0.0.1' || + value.envelope.capabilityDrop !== 'ALL' + ) { + fail('observation resource envelope drifted'); + } + const before = validateMemoryEvents( + value.envelope.memoryEventsBefore, + 'observation memoryEventsBefore', + ); + const after = validateMemoryEvents( + value.envelope.memoryEventsAfter, + 'observation memoryEventsAfter', + ); + for (const key of ['max', 'oom', 'oomKill', 'oomGroupKill']) { + if (after[key] !== before[key]) fail(`memory event ${key} changed`); + } + + assertExactKeys( + value.assertionLifecycle, + [ + 'requestCount', + 'sequence', + 'tlsVersion', + 'mutualTls', + 'consoleRestarted', + 'mutation', + 'operation', + 'expiredConsoleStatus', + 'expiredCode', + ], + 'observation assertionLifecycle', + ); + if ( + value.assertionLifecycle.requestCount !== 4 || + JSON.stringify(value.assertionLifecycle.sequence) !== + JSON.stringify(ASSERTION_SEQUENCE) || + value.assertionLifecycle.tlsVersion !== 'TLSv1.3' || + value.assertionLifecycle.mutualTls !== true || + value.assertionLifecycle.consoleRestarted !== false || + value.assertionLifecycle.mutation !== false || + value.assertionLifecycle.operation !== 'run.cancellation.summary' || + value.assertionLifecycle.expiredConsoleStatus !== 502 || + value.assertionLifecycle.expiredCode !== 'assertion_expired' + ) { + fail('observation assertion lifecycle drifted'); + } + canonicalize(value); + return value; +} + +function architecturePayload(source, architecture, observation) { + return { + schemaVersion: 1, + fixture: 'qinglong/cluster-console-capacity-architecture-evidence@v1', + source, + architecture, + observation, + gates: { + nativeLinux: true, + exactImageIdentity: true, + compactEnvelope: true, + memoryHeadroom: true, + noSwapOrOom: true, + loopbackOnly: true, + assertionRotation: true, + assertionExpiryRejected: true, + assertionRecoveryWithoutRestart: true, + mutationAbsent: true, + sourceBound: true, + passed: true, + }, + limitations: LIMITATIONS, + }; +} + +function createArchitectureEvidence({ source, architecture, observation }) { + const normalizedSource = normalizeSource(source); + if (!ARCHITECTURES.includes(architecture)) fail('architecture is invalid'); + const normalizedObservation = validateObservation(observation, architecture); + const payload = architecturePayload( + normalizedSource, + architecture, + normalizedObservation, + ); + return Object.freeze({ + ...payload, + bundleDigest: evidenceDigest(payload), + }); +} + +function validateArchitectureEvidence(value, expectedSource, architecture) { + assertExactKeys( + value, + [ + 'schemaVersion', + 'fixture', + 'source', + 'architecture', + 'observation', + 'gates', + 'limitations', + 'bundleDigest', + ], + `${architecture} evidence`, + ); + const rebuilt = createArchitectureEvidence({ + source: value.source, + architecture: value.architecture, + observation: value.observation, + }); + if ( + value.schemaVersion !== rebuilt.schemaVersion || + value.fixture !== rebuilt.fixture || + value.architecture !== architecture || + JSON.stringify(value.gates) !== JSON.stringify(rebuilt.gates) || + JSON.stringify(value.limitations) !== JSON.stringify(rebuilt.limitations) || + value.bundleDigest !== rebuilt.bundleDigest + ) { + fail(`${architecture} evidence digest or gates drifted`); + } + if ( + JSON.stringify(rebuilt.source) !== + JSON.stringify(normalizeSource(expectedSource)) + ) { + fail(`${architecture} evidence belongs to another source`); + } + return rebuilt; +} + +function releasePayload(source, x64, arm64) { + return { + schemaVersion: 1, + fixture: 'qinglong/cluster-console-capacity-cross-architecture-evidence@v1', + source, + architectures: [x64, arm64].map((entry) => ({ + architecture: entry.architecture, + imageArchitecture: entry.observation.image.architecture, + imageId: entry.observation.image.id, + imageBytes: entry.observation.image.bytes, + memoryMaxBytes: entry.observation.envelope.memoryMaxBytes, + memoryPeakBytes: entry.observation.envelope.memoryPeakBytes, + memoryHeadroomBytes: entry.observation.envelope.memoryHeadroomBytes, + pidsCurrent: entry.observation.envelope.pidsCurrent, + bundleDigest: entry.bundleDigest, + })), + assertionLifecycle: { + sequence: ASSERTION_SEQUENCE, + tlsVersion: 'TLSv1.3', + mutualTls: true, + consoleRestarted: false, + mutation: false, + }, + gates: { + nativeX64Passed: true, + nativeArm64Passed: true, + sameSourceRevision: true, + sameWorkflowRun: true, + independentImages: true, + compactEnvelopeParity: true, + assertionLifecycleParity: true, + releaseEvidenceComplete: true, + passed: true, + }, + limitations: LIMITATIONS, + }; +} + +function mergeCrossArchitectureEvidence({ source, x64, arm64 }) { + const normalizedSource = normalizeSource(source); + const validatedX64 = validateArchitectureEvidence( + x64, + normalizedSource, + 'x64', + ); + const validatedArm64 = validateArchitectureEvidence( + arm64, + normalizedSource, + 'arm64', + ); + if ( + validatedX64.bundleDigest === validatedArm64.bundleDigest || + validatedX64.observation.image.id === validatedArm64.observation.image.id + ) { + fail('architecture evidence must use independently measured images'); + } + const payload = releasePayload( + normalizedSource, + validatedX64, + validatedArm64, + ); + return Object.freeze({ + ...payload, + releaseDigest: evidenceDigest(payload), + }); +} + +function validateReleaseEvidence(value, expectedSource) { + assertExactKeys( + value, + [ + 'schemaVersion', + 'fixture', + 'source', + 'architectures', + 'assertionLifecycle', + 'gates', + 'limitations', + 'releaseDigest', + ], + 'release evidence', + ); + if (!Array.isArray(value.architectures) || value.architectures.length !== 2) { + fail('release architecture summaries are invalid'); + } + const expectedArchitectures = ['x64', 'arm64']; + for (let index = 0; index < expectedArchitectures.length; index += 1) { + const entry = value.architectures[index]; + assertExactKeys( + entry, + [ + 'architecture', + 'imageArchitecture', + 'imageId', + 'imageBytes', + 'memoryMaxBytes', + 'memoryPeakBytes', + 'memoryHeadroomBytes', + 'pidsCurrent', + 'bundleDigest', + ], + `release architecture ${index}`, + ); + if ( + entry.architecture !== expectedArchitectures[index] || + entry.imageArchitecture !== IMAGE_ARCHITECTURES[entry.architecture] || + typeof entry.imageId !== 'string' || + !IMAGE_ID_PATTERN.test(entry.imageId) || + typeof entry.bundleDigest !== 'string' || + !/^[0-9a-f]{64}$/u.test(entry.bundleDigest) || + entry.memoryMaxBytes !== MEMORY_MAX_BYTES + ) { + fail('release architecture summary drifted'); + } + safeInteger(entry.imageBytes, 'release imageBytes', 1); + const peak = safeInteger( + entry.memoryPeakBytes, + 'release memoryPeakBytes', + 1, + ); + const headroom = safeInteger( + entry.memoryHeadroomBytes, + 'release memoryHeadroomBytes', + ); + if ( + peak + headroom !== MEMORY_MAX_BYTES || + headroom < MINIMUM_MEMORY_HEADROOM_BYTES + ) { + fail('release memory headroom drifted'); + } + const pids = safeInteger(entry.pidsCurrent, 'release pidsCurrent', 1); + if (pids > PIDS_MAX) fail('release pidsCurrent exceeded'); + } + if ( + value.architectures[0].imageId === value.architectures[1].imageId || + value.architectures[0].bundleDigest === + value.architectures[1].bundleDigest || + JSON.stringify(value.assertionLifecycle) !== + JSON.stringify({ + sequence: ASSERTION_SEQUENCE, + tlsVersion: 'TLSv1.3', + mutualTls: true, + consoleRestarted: false, + mutation: false, + }) || + JSON.stringify(value.gates) !== + JSON.stringify({ + nativeX64Passed: true, + nativeArm64Passed: true, + sameSourceRevision: true, + sameWorkflowRun: true, + independentImages: true, + compactEnvelopeParity: true, + assertionLifecycleParity: true, + releaseEvidenceComplete: true, + passed: true, + }) || + JSON.stringify(value.limitations) !== JSON.stringify(LIMITATIONS) + ) { + fail('release evidence gates drifted'); + } + if ( + JSON.stringify(normalizeSource(value.source)) !== + JSON.stringify(normalizeSource(expectedSource)) + ) { + fail('release evidence belongs to another source'); + } + const { releaseDigest, ...payload } = value; + if (releaseDigest !== evidenceDigest(payload)) { + fail('release evidence digest drifted'); + } + canonicalize(value); + return value; +} + +function readJsonFile(filePath, label) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + fail(`${label} path must be absolute`); + } + let descriptor = -1; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0), + ); + const stat = fs.fstatSync(descriptor); + if (!stat.isFile() || stat.size < 2 || stat.size > MAX_EVIDENCE_BYTES) { + fail(`${label} size is invalid`); + } + const bytes = fs.readFileSync(descriptor); + let value; + try { + value = JSON.parse(bytes.toString('utf8')); + } catch { + fail(`${label} must contain valid JSON`); + } + canonicalize(value); + return value; + } catch (error) { + if (error instanceof QingLong3ClusterCopilotConsoleCapacityEvidenceError) { + throw error; + } + fail(`${label} must be a readable non-symlink file`); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function writeJsonFile(filePath, value) { + if (typeof filePath !== 'string' || !path.isAbsolute(filePath)) { + fail('output path must be absolute'); + } + const parent = path.dirname(filePath); + let parentStat; + try { + parentStat = fs.lstatSync(parent); + if ( + !parentStat.isDirectory() || + parentStat.isSymbolicLink() || + fs.realpathSync(parent) !== parent + ) { + fail('output parent is invalid'); + } + } catch (error) { + if (error instanceof QingLong3ClusterCopilotConsoleCapacityEvidenceError) { + throw error; + } + fail('output parent is invalid'); + } + let descriptor = -1; + try { + descriptor = fs.openSync( + filePath, + fs.constants.O_WRONLY | + fs.constants.O_CREAT | + fs.constants.O_EXCL | + (fs.constants.O_NOFOLLOW ?? 0), + 0o600, + ); + fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); + } catch (error) { + if (error instanceof QingLong3ClusterCopilotConsoleCapacityEvidenceError) { + throw error; + } + fail('output must be a new private file'); + } finally { + if (descriptor >= 0) fs.closeSync(descriptor); + } +} + +function docker(args, options = {}) { + return execFileSync('docker', args, { + encoding: 'utf8', + maxBuffer: 4 * 1024 * 1024, + stdio: ['pipe', 'pipe', 'pipe'], + ...options, + }); +} + +function cleanupDocker(args) { + spawnSync('docker', args, { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + stdio: ['ignore', 'ignore', 'ignore'], + }); +} + +function dockerLogs(container) { + return docker(['logs', container]); +} + +function waitForLog(container, event) { + const waitArray = new Int32Array(new SharedArrayBuffer(4)); + for (let attempt = 0; attempt < 240; attempt += 1) { + const logs = dockerLogs(container); + for (const line of logs.trim().split('\n')) { + try { + const fact = JSON.parse(line); + if (fact?.event === event) return fact; + } catch {} + } + Atomics.wait(waitArray, 0, 0, 25); + } + fail(`${container} did not publish ${event}`); +} + +function syntheticJwt(subject, expiration, marker) { + const encode = (value) => + Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); + return `${encode({ alg: 'EdDSA', typ: 'JWT' })}.${encode({ + sub: subject, + exp: expiration, + assurance: 'strong', + })}.${Buffer.alloc(32, marker).toString('base64url')}`; +} + +const VOLUME_SEED_SOURCE = String.raw` +const fs = require('node:fs'); +const value = JSON.parse(fs.readFileSync(0, 'utf8')); +for (const [name, contents] of Object.entries(value.files)) { + if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name) || typeof contents !== 'string') process.exit(91); + const target = value.root + '/' + name; + fs.writeFileSync(target, contents, { mode: 0o600, flag: 'wx' }); + fs.chownSync(target, 10001, 10001); +} +`; + +const ASSERTION_ROTATE_SOURCE = String.raw` +const fs = require('node:fs'); +const value = JSON.parse(fs.readFileSync(0, 'utf8')); +if (typeof value.assertion !== 'string' || value.assertion.length > 8192) process.exit(92); +const next = '/authority/assertion.next'; +fs.writeFileSync(next, value.assertion, { mode: 0o600, flag: 'wx' }); +fs.chownSync(next, 10001, 10001); +fs.renameSync(next, '/authority/assertion.jwt'); +`; + +const MANAGEMENT_SERVER_SOURCE = String.raw` +const fs = require('node:fs'); +const https = require('node:https'); +const assertions = JSON.parse(fs.readFileSync('/server/assertions.json', 'utf8')); +const server = https.createServer({ + key: fs.readFileSync('/server/server-key.pem'), + cert: fs.readFileSync('/server/server-cert.pem'), + ca: fs.readFileSync('/server/client-ca.pem'), + requestCert: true, + rejectUnauthorized: true, + minVersion: 'TLSv1.3', + maxVersion: 'TLSv1.3', +}, (request, response) => { + const chunks = []; + request.on('data', (chunk) => chunks.push(chunk)); + request.once('end', () => { + let command; + try { command = JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { process.exit(93); } + const authorization = request.headers.authorization; + const token = typeof authorization === 'string' && authorization.startsWith('Bearer ') ? authorization.slice(7) : ''; + const label = token === assertions.initial ? 'initial' : token === assertions.rotated ? 'rotated' : token === assertions.expired ? 'expired' : 'unknown'; + const observation = { + event: 'management_request', + label, + tlsVersion: request.socket.getProtocol(), + mutualTls: request.client.authorized === true, + method: request.method, + path: request.url, + operation: command?.operation ?? null, + mutation: command?.operation !== 'run.cancellation.summary', + }; + process.stdout.write(JSON.stringify(observation) + '\n'); + const requestId = command?.request?.requestId ?? 'invalid-request'; + if (label === 'expired' || label === 'unknown') { + const body = Buffer.from(JSON.stringify({ schemaVersion: 1, requestId, error: { code: label === 'expired' ? 'assertion_expired' : 'assertion_invalid' } })); + response.writeHead(label === 'expired' ? 401 : 403, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(body.length) }); + response.end(body); + return; + } + const blocked = label === 'rotated'; + const summary = { + schema: 'qinglong/run-cancellation-dispatch-summary@v1', + projectId: command.request.projectId, + observedAtMs: 1700000000000, + assessment: blocked ? 'attention_required' : 'clear', + operatorAction: blocked ? 'inspect' : 'none', + dispatches: { total: blocked ? 1 : 0, pending: 0, leased: 0, retryWait: 0, dispatched: 0, blocked: blocked ? 1 : 0 }, + signals: { due: 0, expiredLease: 0 }, + blockingResults: { identityMismatch: blocked ? 1 : 0, pidMismatch: 0, unsupported: 0, invalid: 0 }, + ...(blocked ? { oldestBlockedAtMs: 1699999999000 } : {}), + }; + const body = Buffer.from(JSON.stringify({ schemaVersion: 1, requestId, result: { schemaVersion: 1, operation: 'run.cancellation.summary', summary } })); + response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'content-length': String(body.length) }); + response.end(body); + }); +}); +server.listen(8443, '0.0.0.0', () => process.stdout.write(JSON.stringify({ event: 'management_ready' }) + '\n')); +process.once('SIGTERM', () => server.close(() => process.exit(0))); +`; + +const CGROUP_SNAPSHOT_SOURCE = String.raw` +const fs = require('node:fs'); +function text(name) { return fs.readFileSync('/sys/fs/cgroup/' + name, 'utf8').trim(); } +function integer(name) { const value = text(name); if (!/^(?:0|[1-9][0-9]*)$/.test(value)) process.exit(94); return Number(value); } +const events = Object.fromEntries(text('memory.events').split('\n').map((line) => { const [key, value] = line.split(' '); return [key, Number(value)]; })); +const [cpuQuota, cpuPeriod] = text('cpu.max').split(' ').map(Number); +const status = fs.readFileSync('/proc/self/status', 'utf8'); +const field = (name) => Number(new RegExp('^' + name + ':\\s+([0-9]+)$', 'm').exec(status)?.[1]); +process.stdout.write(JSON.stringify({ + platform: process.platform, + architecture: process.arch, + node: process.version, + uid: process.getuid(), + gid: process.getgid(), + memoryMaxBytes: integer('memory.max'), + memoryPeakBytes: integer('memory.peak'), + swapMaxBytes: integer('memory.swap.max'), + cpuQuotaMicros: cpuQuota, + cpuPeriodMicros: cpuPeriod, + pidsMax: integer('pids.max'), + pidsCurrent: integer('pids.current'), + noNewPrivileges: field('NoNewPrivs'), + seccompMode: field('Seccomp'), + memoryEvents: { low: events.low, high: events.high, max: events.max, oom: events.oom, oomKill: events.oom_kill, oomGroupKill: events.oom_group_kill }, +})); +`; + +function seedVolume(image, volume, root, files) { + docker( + [ + 'run', + '--rm', + '--read-only', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '0:0', + '--volume', + `${volume}:${root}`, + '--entrypoint', + 'node', + image, + '-e', + VOLUME_SEED_SOURCE, + ], + { input: JSON.stringify({ root, files }) }, + ); +} + +function rotateAssertion(image, volume, assertion) { + docker( + [ + 'run', + '--rm', + '--read-only', + '--network', + 'none', + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '0:0', + '--volume', + `${volume}:/authority`, + '--entrypoint', + 'node', + image, + '-e', + ASSERTION_ROTATE_SOURCE, + ], + { input: JSON.stringify({ assertion }) }, + ); +} + +function parseCgroupSnapshot(container) { + let value; + try { + value = JSON.parse( + docker([ + 'exec', + '--user', + '10001:10001', + container, + 'node', + '-e', + CGROUP_SNAPSHOT_SOURCE, + ]), + ); + } catch { + fail('Console cgroup v2 snapshot is unavailable'); + } + return value; +} + +function unusedLoopbackPort() { + return new Promise((resolve, reject) => { + const probe = createServer(); + probe.once('error', reject); + probe.listen(0, '127.0.0.1', () => { + const address = probe.address(); + if (!address || typeof address === 'string') { + probe.close(); + reject(new Error('invalid address')); + return; + } + const port = address.port; + probe.close((error) => (error ? reject(error) : resolve(port))); + }); + }); +} + +function postConsole(origin, session, requestId) { + const target = new URL(origin); + const body = Buffer.from( + JSON.stringify({ + schema: 'qinglong/cluster-copilot-console-read-request@v1', + operation: 'run_cancellation_status', + projectId: 'capacity-project', + requestId, + }), + 'utf8', + ); + return new Promise((resolve, reject) => { + const request = httpRequest( + { + hostname: '127.0.0.1', + port: Number(target.port), + method: 'POST', + path: '/api/v1/run-management/cancellation-status', + agent: false, + headers: { + authorization: `QL3-Console ${session}`, + origin, + 'content-type': 'application/json; charset=utf-8', + 'content-length': String(body.length), + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.once('end', () => { + try { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }); + } catch (error) { + reject(error); + } + }); + }, + ); + request.once('error', reject); + request.end(body); + }); +} + +function parseJsonLines(text) { + const values = []; + for (const line of text.trim().split('\n')) { + if (!line) continue; + try { + values.push(JSON.parse(line)); + } catch { + fail('container emitted a non-JSON diagnostic'); + } + } + return values; +} + +async function captureLiveObservation(image, architecture) { + if (process.platform !== 'linux' || process.arch !== architecture) { + fail('capture requires the exact native Linux architecture'); + } + if (process.version !== NODE_VERSION) fail('capture requires Node v24.18.0'); + const inspectedImage = JSON.parse(docker(['image', 'inspect', image])); + if (!Array.isArray(inspectedImage) || inspectedImage.length !== 1) { + fail('image inspection shape is invalid'); + } + const imageFact = inspectedImage[0]; + if ( + imageFact?.Os !== 'linux' || + imageFact?.Architecture !== IMAGE_ARCHITECTURES[architecture] || + imageFact?.Config?.User !== '10001:10001' || + typeof imageFact?.Id !== 'string' || + !IMAGE_ID_PATTERN.test(imageFact.Id) || + !Number.isSafeInteger(imageFact?.Size) || + imageFact.Size < 1 + ) { + fail('image identity is invalid'); + } + + const suffix = `${process.pid}-${Date.now()}`; + const network = `ql3-console-capacity-${suffix}`; + const authorityVolume = `ql3-console-authority-${suffix}`; + const serverVolume = `ql3-console-server-${suffix}`; + const serverContainer = `ql3-console-manager-${suffix}`; + const consoleContainer = `ql3-console-capacity-${suffix}`; + const created = { + network: false, + authorityVolume: false, + serverVolume: false, + serverContainer: false, + consoleContainer: false, + }; + const fixtureRoot = path.resolve( + __dirname, + '../packages/ql3-cluster-control/test/fixtures/mtls', + ); + const managementFixtureRoot = path.resolve( + __dirname, + '../packages/ql3-cluster-admin/test/fixtures', + ); + const session = randomBytes(32).toString('base64url'); + const initialAssertion = syntheticJwt( + 'capacity-operator-a', + 4_102_444_800, + 1, + ); + const rotatedAssertion = syntheticJwt( + 'capacity-operator-b', + 4_102_444_800, + 2, + ); + const expiredAssertion = syntheticJwt('capacity-operator-expired', 1, 3); + const port = await unusedLoopbackPort(); + const runConfig = JSON.stringify({ + schemaVersion: 1, + endpoint: `https://${SERVICE_NAME}:${MANAGEMENT_PORT}/api/v3/runs/management`, + servername: SERVICE_NAME, + caFile: '/authority/management-service-cert.pem', + clientCertificateFile: '/authority/client-cert.pem', + clientPrivateKeyFile: '/authority/client-key.pem', + requestTimeoutMs: 2_000, + }); + const projectConfig = JSON.stringify({ + schema: 'qinglong/cluster-copilot-client-config@v1', + endpoint: `https://${SERVICE_NAME}:${MANAGEMENT_PORT}/`, + servername: SERVICE_NAME, + caFile: '/authority/management-service-cert.pem', + requestTimeoutMs: 2_000, + }); + + try { + docker(['network', 'create', '--driver', 'bridge', network]); + created.network = true; + docker(['volume', 'create', authorityVolume]); + created.authorityVolume = true; + docker(['volume', 'create', serverVolume]); + created.serverVolume = true; + seedVolume(image, authorityVolume, '/authority', { + 'management-service-cert.pem': fs.readFileSync( + path.join(managementFixtureRoot, 'management-service-cert.pem'), + 'utf8', + ), + 'client-cert.pem': fs.readFileSync( + path.join(fixtureRoot, 'client-cert.pem'), + 'utf8', + ), + 'client-key.pem': fs.readFileSync( + path.join(fixtureRoot, 'client-key.pem'), + 'utf8', + ), + 'project.json': projectConfig, + credential: `ql3c_console_${randomBytes(32).toString('base64url')}`, + session, + 'run.json': runConfig, + 'assertion.jwt': initialAssertion, + }); + seedVolume(image, serverVolume, '/server', { + 'server-cert.pem': fs.readFileSync( + path.join(managementFixtureRoot, 'management-service-cert.pem'), + 'utf8', + ), + 'server-key.pem': fs.readFileSync( + path.join(managementFixtureRoot, 'management-service-key.pem'), + 'utf8', + ), + 'client-ca.pem': fs.readFileSync( + path.join(fixtureRoot, 'ca-cert.pem'), + 'utf8', + ), + 'assertions.json': JSON.stringify({ + initial: initialAssertion, + rotated: rotatedAssertion, + expired: expiredAssertion, + }), + }); + + docker([ + 'run', + '--detach', + '--name', + serverContainer, + '--read-only', + '--network', + network, + '--network-alias', + SERVICE_NAME, + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + '16', + '--memory', + '96m', + '--memory-swap', + '96m', + '--cpus', + '0.25', + '--tmpfs', + '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001', + '--volume', + `${serverVolume}:/server:ro`, + '--entrypoint', + 'node', + image, + '-e', + MANAGEMENT_SERVER_SOURCE, + ]); + created.serverContainer = true; + waitForLog(serverContainer, 'management_ready'); + + docker([ + 'run', + '--detach', + '--name', + consoleContainer, + '--read-only', + '--network', + network, + '--cap-drop', + 'ALL', + '--security-opt', + 'no-new-privileges', + '--user', + '10001:10001', + '--pids-limit', + String(PIDS_MAX), + '--memory', + '192m', + '--memory-swap', + '192m', + '--cpus', + '0.25', + '--stop-timeout', + '3', + '--tmpfs', + '/tmp:rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001', + '--publish', + `127.0.0.1:${port}:${port}/tcp`, + '--volume', + `${authorityVolume}:/authority:ro`, + image, + 'copilot-console', + '--container-published-loopback', + `--port=${port}`, + '--config', + '/authority/project.json', + '--credential', + '/authority/credential', + '--session', + '/authority/session', + '--run-management-config', + '/authority/run.json', + '--run-management-assertion', + '/authority/assertion.jwt', + ]); + created.consoleContainer = true; + const started = waitForLog(consoleContainer, 'started'); + if ( + started?.origin !== `http://127.0.0.1:${port}` || + started?.networkBoundary !== 'container-published-loopback' || + started?.publishedHostAddress !== '127.0.0.1' || + started?.runManagementAuthority !== 'server_only' || + started?.mutation !== false + ) { + fail('Console start boundary drifted'); + } + const beforeInspect = JSON.parse(docker(['inspect', consoleContainer]))[0]; + const beforeSnapshot = parseCgroupSnapshot(consoleContainer); + const origin = `http://127.0.0.1:${port}`; + const initial = await postConsole(origin, session, 'capacity-initial'); + rotateAssertion(image, authorityVolume, rotatedAssertion); + const rotated = await postConsole(origin, session, 'capacity-rotated'); + rotateAssertion(image, authorityVolume, expiredAssertion); + const expired = await postConsole(origin, session, 'capacity-expired'); + rotateAssertion(image, authorityVolume, rotatedAssertion); + const recovered = await postConsole(origin, session, 'capacity-recovered'); + const afterSnapshot = parseCgroupSnapshot(consoleContainer); + const afterInspect = JSON.parse(docker(['inspect', consoleContainer]))[0]; + if ( + initial.statusCode !== 200 || + initial.body?.result?.result?.assessment !== 'clear' || + rotated.statusCode !== 200 || + rotated.body?.result?.result?.assessment !== 'attention_required' || + expired.statusCode !== 502 || + expired.body?.code !== 'assertion_expired' || + recovered.statusCode !== 200 || + recovered.body?.result?.result?.assessment !== 'attention_required' + ) { + fail('assertion lifecycle response drifted'); + } + const serializedResponses = JSON.stringify([ + initial, + rotated, + expired, + recovered, + ]); + if ( + serializedResponses.includes(initialAssertion) || + serializedResponses.includes(rotatedAssertion) || + serializedResponses.includes(expiredAssertion) || + serializedResponses.includes('/authority/') || + serializedResponses.includes(SERVICE_NAME) + ) { + fail('Console response leaked private authority'); + } + const managementRequests = parseJsonLines( + dockerLogs(serverContainer), + ).filter(({ event }) => event === 'management_request'); + if ( + JSON.stringify(managementRequests.map(({ label }) => label)) !== + JSON.stringify(['initial', 'rotated', 'expired', 'rotated']) || + managementRequests.some( + (entry) => + entry.tlsVersion !== 'TLSv1.3' || + entry.mutualTls !== true || + entry.method !== 'POST' || + entry.path !== '/api/v3/runs/management' || + entry.operation !== 'run.cancellation.summary' || + entry.mutation !== false, + ) + ) { + fail('management assertion observations drifted'); + } + const binding = + afterInspect?.HostConfig?.PortBindings?.[`${port}/tcp`]?.[0]; + const authorityMount = afterInspect?.Mounts?.find( + ({ Destination }) => Destination === '/authority', + ); + if ( + beforeInspect?.State?.StartedAt !== afterInspect?.State?.StartedAt || + afterInspect?.State?.Running !== true || + afterInspect?.Config?.User !== '10001:10001' || + afterInspect?.HostConfig?.ReadonlyRootfs !== true || + afterInspect?.HostConfig?.Memory !== MEMORY_MAX_BYTES || + afterInspect?.HostConfig?.MemorySwap !== MEMORY_MAX_BYTES || + afterInspect?.HostConfig?.NanoCpus !== 250_000_000 || + afterInspect?.HostConfig?.PidsLimit !== PIDS_MAX || + afterInspect?.HostConfig?.NetworkMode !== network || + binding?.HostIp !== '127.0.0.1' || + !afterInspect?.HostConfig?.CapDrop?.includes('ALL') || + !afterInspect?.HostConfig?.SecurityOpt?.includes('no-new-privileges') || + authorityMount?.RW !== false || + afterInspect?.HostConfig?.Tmpfs?.['/tmp'] !== + 'rw,noexec,nosuid,nodev,size=8m,mode=700,uid=10001,gid=10001' + ) { + fail('Console container envelope drifted'); + } + if ( + beforeSnapshot.platform !== 'linux' || + beforeSnapshot.architecture !== architecture || + beforeSnapshot.node !== NODE_VERSION || + beforeSnapshot.uid !== 10001 || + beforeSnapshot.gid !== 10001 || + afterSnapshot.platform !== beforeSnapshot.platform || + afterSnapshot.architecture !== beforeSnapshot.architecture || + afterSnapshot.node !== beforeSnapshot.node || + afterSnapshot.uid !== beforeSnapshot.uid || + afterSnapshot.gid !== beforeSnapshot.gid + ) { + fail('Console runtime identity drifted'); + } + + return { + schemaVersion: 1, + observedAtMs: Date.now(), + platform: 'linux', + architecture, + image: { + architecture: imageFact.Architecture, + id: imageFact.Id, + bytes: imageFact.Size, + user: imageFact.Config.User, + }, + runtime: { + node: afterSnapshot.node, + uid: afterSnapshot.uid, + gid: afterSnapshot.gid, + }, + envelope: { + memoryMaxBytes: afterSnapshot.memoryMaxBytes, + memoryPeakBytes: afterSnapshot.memoryPeakBytes, + memoryHeadroomBytes: + afterSnapshot.memoryMaxBytes - afterSnapshot.memoryPeakBytes, + swapMaxBytes: afterSnapshot.swapMaxBytes, + cpuQuotaMicros: afterSnapshot.cpuQuotaMicros, + cpuPeriodMicros: afterSnapshot.cpuPeriodMicros, + pidsMax: afterSnapshot.pidsMax, + pidsCurrent: afterSnapshot.pidsCurrent, + noNewPrivileges: afterSnapshot.noNewPrivileges, + seccompMode: afterSnapshot.seccompMode, + readOnlyRoot: true, + tmpfsBytes: TMPFS_BYTES, + publishedHostAddress: '127.0.0.1', + capabilityDrop: 'ALL', + memoryEventsBefore: beforeSnapshot.memoryEvents, + memoryEventsAfter: afterSnapshot.memoryEvents, + }, + assertionLifecycle: { + requestCount: managementRequests.length, + sequence: ASSERTION_SEQUENCE, + tlsVersion: 'TLSv1.3', + mutualTls: true, + consoleRestarted: false, + mutation: false, + operation: 'run.cancellation.summary', + expiredConsoleStatus: expired.statusCode, + expiredCode: expired.body.code, + }, + }; + } finally { + if (created.consoleContainer) { + cleanupDocker(['stop', '--time', '3', consoleContainer]); + cleanupDocker(['rm', '--force', consoleContainer]); + } + if (created.serverContainer) { + cleanupDocker(['stop', '--time', '3', serverContainer]); + cleanupDocker(['rm', '--force', serverContainer]); + } + if (created.authorityVolume) + cleanupDocker(['volume', 'rm', authorityVolume]); + if (created.serverVolume) cleanupDocker(['volume', 'rm', serverVolume]); + if (created.network) cleanupDocker(['network', 'rm', network]); + } +} + +function parseArguments(argv) { + const options = {}; + for (const argument of argv) { + const separator = argument.indexOf('='); + if (!argument.startsWith('--') || separator < 3) { + fail(`unsupported argument ${argument}`); + } + const key = argument.slice(2, separator); + if (Object.hasOwn(options, key)) fail(`duplicate argument --${key}`); + options[key] = argument.slice(separator + 1); + } + const common = [ + 'mode', + 'repository', + 'revision', + 'workflow', + 'run-id', + 'run-attempt', + ]; + const modeKeys = + options.mode === 'capture' + ? [...common, 'architecture', 'image', 'output'] + : options.mode === 'merge' + ? [...common, 'x64', 'arm64', 'output'] + : options.mode === 'audit' + ? [...common, 'report'] + : fail('--mode must be capture, merge, or audit'); + if ( + JSON.stringify(Object.keys(options).sort()) !== + JSON.stringify(modeKeys.sort()) + ) { + fail(`${options.mode} arguments are incomplete or widened`); + } + const source = normalizeSource({ + repository: options.repository, + revision: options.revision, + workflow: options.workflow, + runId: options['run-id'], + runAttempt: Number(options['run-attempt']), + }); + if ( + options.mode === 'capture' && + (typeof options.image !== 'string' || !IMAGE_PATTERN.test(options.image)) + ) { + fail('image is invalid'); + } + return Object.freeze({ ...options, source }); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.mode === 'capture') { + if (process.env.QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE !== '1') { + fail('QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE=1 is required'); + } + if (!ARCHITECTURES.includes(options.architecture)) { + fail('architecture is invalid'); + } + const observation = await captureLiveObservation( + options.image, + options.architecture, + ); + const result = createArchitectureEvidence({ + source: options.source, + architecture: options.architecture, + observation, + }); + writeJsonFile(options.output, result); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + mode: 'capture', + architecture: result.architecture, + bundleDigest: result.bundleDigest, + passed: true, + })}\n`, + ); + return; + } + if (options.mode === 'merge') { + const result = mergeCrossArchitectureEvidence({ + source: options.source, + x64: readJsonFile(options.x64, 'x64 evidence'), + arm64: readJsonFile(options.arm64, 'arm64 evidence'), + }); + writeJsonFile(options.output, result); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + mode: 'merge', + releaseDigest: result.releaseDigest, + passed: true, + })}\n`, + ); + return; + } + const report = readJsonFile(options.report, 'release evidence'); + validateReleaseEvidence(report, options.source); + process.stdout.write( + `${JSON.stringify({ + schemaVersion: 1, + mode: 'audit', + releaseDigest: report.releaseDigest, + passed: true, + })}\n`, + ); +} + +module.exports = { + ARCHITECTURES, + ASSERTION_SEQUENCE, + CPU_PERIOD_MICROS, + CPU_QUOTA_MICROS, + LIMITATIONS, + MAX_EVIDENCE_BYTES, + MEMORY_MAX_BYTES, + MINIMUM_MEMORY_HEADROOM_BYTES, + NODE_VERSION, + PIDS_MAX, + QingLong3ClusterCopilotConsoleCapacityEvidenceError, + SWAP_MAX_BYTES, + TMPFS_BYTES, + createArchitectureEvidence, + evidenceDigest, + mergeCrossArchitectureEvidence, + normalizeSource, + parseArguments, + readJsonFile, + validateArchitectureEvidence, + validateObservation, + validateReleaseEvidence, +}; + +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/ql3-cluster-image-release-audit.cjs b/scripts/ql3-cluster-image-release-audit.cjs index 22397e44..18b6e0bd 100644 --- a/scripts/ql3-cluster-image-release-audit.cjs +++ b/scripts/ql3-cluster-image-release-audit.cjs @@ -51,6 +51,8 @@ function auditClusterImageCiWorkflow( const workflow = yaml.load(source); const clusterImageJob = workflow?.jobs?.['cluster-image']; const localImageJob = workflow?.jobs?.['local-image']; + const consoleCapacityJob = + workflow?.jobs?.['cluster-console-capacity-release-evidence']; const expectedNativeMatrix = [ { runner: 'ubuntu-24.04', @@ -282,6 +284,11 @@ function auditClusterImageCiWorkflow( /pnpm audit:image-release:ql3/, 'image CI must audit the shared release workflow contract', ); + requirePattern( + source, + /test\/back\/ql3ClusterCopilotConsoleCapacityEvidence\.test\.cjs/, + 'native image CI must run the Console capacity evidence protocol tests', + ); requirePattern( source, /pnpm audit:deployment-lock-surfaces:ql3/, @@ -341,6 +348,135 @@ function auditClusterImageCiWorkflow( /name: Run the bounded Cluster Admin product facade\s+if: matrix\.image == 'admin'\s+env:\s+IMAGE: qinglong3-cluster-admin:ci-\$\{\{ matrix\.image_arch \}\}\s+QL3_CLUSTER_ADMIN_PRODUCT_LIVE: '1'\s+run: node scripts\/ql3-cluster-admin-product-live-contract\.cjs --image="\$\{IMAGE\}"/, 'native admin image CI must run the bounded product facade contract', ); + const capacityCapture = clusterImageJob?.steps?.find( + ({ name }) => + name === 'Capture the fixed Cluster Copilot Console capacity envelope', + ); + const nativeCapacityUpload = clusterImageJob?.steps?.find( + ({ name }) => + name === 'Upload native Cluster Copilot Console capacity evidence', + ); + const expectedSourceEnvironment = { + SOURCE_REPOSITORY: '${{ github.repository }}', + SOURCE_REVISION: '${{ github.sha }}', + SOURCE_WORKFLOW: '${{ github.workflow }}', + SOURCE_RUN_ID: '${{ github.run_id }}', + SOURCE_RUN_ATTEMPT: '${{ github.run_attempt }}', + }; + if ( + capacityCapture?.if !== "matrix.image == 'admin'" || + capacityCapture?.['timeout-minutes'] !== 10 || + JSON.stringify(capacityCapture?.env) !== + JSON.stringify({ + IMAGE: 'qinglong3-cluster-admin:ci-${{ matrix.image_arch }}', + QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE: '1', + ...expectedSourceEnvironment, + }) || + typeof capacityCapture?.run !== 'string' || + !capacityCapture.run.includes( + 'node scripts/ql3-cluster-copilot-console-capacity-evidence.cjs', + ) || + !capacityCapture.run.includes('--mode=capture') || + !capacityCapture.run.includes('--architecture="${{ matrix.node_arch }}"') || + !capacityCapture.run.includes('--image="${IMAGE}"') || + !capacityCapture.run.includes( + '--output="${RUNNER_TEMP}/ql3-cluster-console-capacity/${{ matrix.node_arch }}.json"', + ) || + nativeCapacityUpload?.if !== "matrix.image == 'admin'" || + nativeCapacityUpload?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(nativeCapacityUpload?.with) !== + JSON.stringify({ + name: 'ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.node_arch }}', + path: '${{ runner.temp }}/ql3-cluster-console-capacity/${{ matrix.node_arch }}.json', + 'if-no-files-found': 'error', + 'retention-days': 14, + 'compression-level': 0, + overwrite: false, + 'include-hidden-files': false, + }) + ) { + throw new Error( + 'native admin image CI must capture and retain the exact source-bound Console capacity envelope', + ); + } + const capacitySteps = consoleCapacityJob?.steps; + const x64Download = capacitySteps?.find( + ({ name }) => name === 'Download native x64 Console capacity evidence', + ); + const arm64Download = capacitySteps?.find( + ({ name }) => name === 'Download native arm64 Console capacity evidence', + ); + const capacityMerge = capacitySteps?.find( + ({ name }) => + name === 'Merge and audit the source-bound Console capacity evidence', + ); + const capacityUpload = capacitySteps?.find( + ({ name }) => + name === 'Upload cross-architecture Console capacity evidence', + ); + const downloadAction = + 'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c'; + if ( + consoleCapacityJob?.needs !== 'cluster-image' || + consoleCapacityJob?.['runs-on'] !== 'ubuntu-24.04' || + consoleCapacityJob?.['timeout-minutes'] !== 5 || + JSON.stringify(consoleCapacityJob?.permissions) !== + JSON.stringify({ contents: 'read' }) || + !Array.isArray(capacitySteps) || + capacitySteps.length !== 6 || + capacitySteps[0]?.uses !== 'actions/checkout@v6' || + capacitySteps[1]?.uses !== 'actions/setup-node@v6' || + JSON.stringify(capacitySteps[1]?.with) !== + JSON.stringify({ 'node-version': '24.18.0' }) || + x64Download?.uses !== downloadAction || + JSON.stringify(x64Download?.with) !== + JSON.stringify({ + name: 'ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-x64', + path: '${{ runner.temp }}/ql3-cluster-console-capacity/x64', + }) || + arm64Download?.uses !== downloadAction || + JSON.stringify(arm64Download?.with) !== + JSON.stringify({ + name: 'ql3-cluster-console-capacity-${{ github.run_id }}-${{ github.run_attempt }}-arm64', + path: '${{ runner.temp }}/ql3-cluster-console-capacity/arm64', + }) || + JSON.stringify(capacityMerge?.env) !== + JSON.stringify(expectedSourceEnvironment) || + typeof capacityMerge?.run !== 'string' || + ( + capacityMerge.run.match( + /node scripts\/ql3-cluster-copilot-console-capacity-evidence\.cjs/g, + ) || [] + ).length !== 2 || + !capacityMerge.run.includes('--mode=merge') || + !capacityMerge.run.includes('--mode=audit') || + !capacityMerge.run.includes( + '--x64="${RUNNER_TEMP}/ql3-cluster-console-capacity/x64/x64.json"', + ) || + !capacityMerge.run.includes( + '--arm64="${RUNNER_TEMP}/ql3-cluster-console-capacity/arm64/arm64.json"', + ) || + !capacityMerge.run.includes( + '--report="${RUNNER_TEMP}/ql3-cluster-console-capacity/cross-architecture.json"', + ) || + capacityUpload?.uses !== + 'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a' || + JSON.stringify(capacityUpload?.with) !== + JSON.stringify({ + name: 'ql3-cluster-console-capacity-release-${{ github.run_id }}-${{ github.run_attempt }}', + path: '${{ runner.temp }}/ql3-cluster-console-capacity/cross-architecture.json', + 'if-no-files-found': 'error', + 'retention-days': 14, + 'compression-level': 0, + overwrite: false, + 'include-hidden-files': false, + }) + ) { + throw new Error( + 'Console capacity release evidence must merge and audit exact native x64 and arm64 reports with read-only authority', + ); + } requirePattern( adminProductLiveContract, /runOperatorContextContract\(image\);[\s\S]*operatorContext: true,[\s\S]*contextPreflight: true,[\s\S]*contextReadiness: true/, @@ -390,6 +526,14 @@ function auditClusterImageCiWorkflow( clusterAdminOperatorContext: true, clusterAdminContextPreflight: true, clusterAdminContextReadiness: true, + clusterCopilotConsoleCapacityEvidence: { + nativeArchitectures: ['x64', 'arm64'], + memoryLimitMiB: 192, + minimumHeadroomMiB: 32, + assertionRotation: true, + assertionExpiryRejected: true, + sourceBound: true, + }, releaseVersionAudit: true, deploymentLockMaterialization: true, ociAttestations: true, diff --git a/test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs b/test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs new file mode 100644 index 00000000..2d4ba0cc --- /dev/null +++ b/test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs @@ -0,0 +1,402 @@ +'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 { test } = require('node:test'); + +const { + ASSERTION_SEQUENCE, + LIMITATIONS, + MEMORY_MAX_BYTES, + MINIMUM_MEMORY_HEADROOM_BYTES, + createArchitectureEvidence, + evidenceDigest, + mergeCrossArchitectureEvidence, + normalizeSource, + readJsonFile, + validateArchitectureEvidence, + validateObservation, + validateReleaseEvidence, +} = require('../../scripts/ql3-cluster-copilot-console-capacity-evidence.cjs'); + +const root = path.resolve(__dirname, '../..'); +const scriptPath = path.join( + root, + 'scripts/ql3-cluster-copilot-console-capacity-evidence.cjs', +); +const scriptSource = fs.readFileSync(scriptPath, 'utf8'); + +function fixtureSource(overrides = {}) { + return { + repository: 'whyour/qinglong', + revision: 'a'.repeat(40), + workflow: 'QingLong 3.0 CI', + runId: '123456', + runAttempt: 1, + ...overrides, + }; +} + +function memoryEvents(overrides = {}) { + return { + low: 0, + high: 0, + max: 0, + oom: 0, + oomKill: 0, + oomGroupKill: 0, + ...overrides, + }; +} + +function fixtureObservation(architecture, overrides = {}) { + const peak = 96 * 1024 * 1024; + return { + schemaVersion: 1, + observedAtMs: 1_700_000_000_000, + platform: 'linux', + architecture, + image: { + architecture: architecture === 'x64' ? 'amd64' : 'arm64', + id: `sha256:${architecture === 'x64' ? '1' : '2'}`.padEnd( + 71, + architecture === 'x64' ? '1' : '2', + ), + bytes: architecture === 'x64' ? 120_000_000 : 119_000_000, + user: '10001:10001', + }, + runtime: { node: 'v24.18.0', uid: 10001, gid: 10001 }, + envelope: { + memoryMaxBytes: MEMORY_MAX_BYTES, + memoryPeakBytes: peak, + memoryHeadroomBytes: MEMORY_MAX_BYTES - peak, + swapMaxBytes: 0, + cpuQuotaMicros: 25_000, + cpuPeriodMicros: 100_000, + pidsMax: 32, + pidsCurrent: 5, + noNewPrivileges: 1, + seccompMode: 2, + readOnlyRoot: true, + tmpfsBytes: 8 * 1024 * 1024, + publishedHostAddress: '127.0.0.1', + capabilityDrop: 'ALL', + memoryEventsBefore: memoryEvents(), + memoryEventsAfter: memoryEvents(), + }, + assertionLifecycle: { + requestCount: 4, + sequence: [...ASSERTION_SEQUENCE], + tlsVersion: 'TLSv1.3', + mutualTls: true, + consoleRestarted: false, + mutation: false, + operation: 'run.cancellation.summary', + expiredConsoleStatus: 502, + expiredCode: 'assertion_expired', + }, + ...overrides, + }; +} + +function fixtureArchitecture(architecture, source = fixtureSource()) { + return createArchitectureEvidence({ + source, + architecture, + observation: fixtureObservation(architecture), + }); +} + +function sourceArguments(source = fixtureSource()) { + return [ + `--repository=${source.repository}`, + `--revision=${source.revision}`, + `--workflow=${source.workflow}`, + `--run-id=${source.runId}`, + `--run-attempt=${source.runAttempt}`, + ]; +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value)}\n`, 'utf8'); +} + +function runCli(arguments_, env = {}) { + return spawnSync(process.execPath, [scriptPath, ...arguments_], { + encoding: 'utf8', + env: { ...process.env, ...env }, + }); +} + +test('creates exact native reports and a source-bound cross-architecture release report', () => { + const source = fixtureSource(); + const x64 = fixtureArchitecture('x64', source); + const arm64 = fixtureArchitecture('arm64', source); + const release = mergeCrossArchitectureEvidence({ source, x64, arm64 }); + + assert.equal( + x64.fixture, + 'qinglong/cluster-console-capacity-architecture-evidence@v1', + ); + assert.equal( + release.fixture, + 'qinglong/cluster-console-capacity-cross-architecture-evidence@v1', + ); + assert.deepEqual( + release.architectures.map(({ architecture }) => architecture), + ['x64', 'arm64'], + ); + assert.equal(release.gates.releaseEvidenceComplete, true); + assert.equal(release.gates.passed, true); + assert.deepEqual(release.assertionLifecycle.sequence, ASSERTION_SEQUENCE); + assert.deepEqual(release.limitations, LIMITATIONS); + assert.equal(release.releaseDigest.length, 64); + assert.notEqual(x64.bundleDigest, arm64.bundleDigest); + assert.equal(validateReleaseEvidence(release, source), release); +}); + +test('rejects memory pressure, OOM, swap, PID and widened envelope observations', () => { + const architecture = 'x64'; + const base = fixtureObservation(architecture); + const cases = [ + [ + { + envelope: { + ...base.envelope, + memoryPeakBytes: MEMORY_MAX_BYTES - MINIMUM_MEMORY_HEADROOM_BYTES + 1, + memoryHeadroomBytes: MINIMUM_MEMORY_HEADROOM_BYTES - 1, + }, + }, + /resource envelope drifted/, + ], + [ + { + envelope: { + ...base.envelope, + memoryEventsAfter: memoryEvents({ oomKill: 1 }), + }, + }, + /memory event oomKill changed/, + ], + [ + { envelope: { ...base.envelope, swapMaxBytes: 1024 } }, + /resource envelope drifted/, + ], + [ + { envelope: { ...base.envelope, pidsCurrent: 33 } }, + /resource envelope drifted/, + ], + [ + { envelope: { ...base.envelope, unexpected: true } }, + /envelope fields are invalid/, + ], + ]; + for (const [override, expected] of cases) { + assert.throws( + () => + validateObservation( + fixtureObservation(architecture, override), + architecture, + ), + expected, + ); + } +}); + +test('rejects assertion lifecycle, native identity and mutation drift', () => { + const base = fixtureObservation('arm64'); + for (const assertionLifecycle of [ + { ...base.assertionLifecycle, requestCount: 3 }, + { + ...base.assertionLifecycle, + sequence: ['initial_accepted', 'expired_rejected'], + }, + { ...base.assertionLifecycle, tlsVersion: 'TLSv1.2' }, + { ...base.assertionLifecycle, mutualTls: false }, + { ...base.assertionLifecycle, consoleRestarted: true }, + { ...base.assertionLifecycle, mutation: true }, + { ...base.assertionLifecycle, operation: 'run.cancellation.rearm' }, + { ...base.assertionLifecycle, expiredCode: 'assertion_invalid' }, + ]) { + assert.throws( + () => + validateObservation( + fixtureObservation('arm64', { assertionLifecycle }), + 'arm64', + ), + /assertion lifecycle drifted/, + ); + } + assert.throws( + () => validateObservation(fixtureObservation('arm64'), 'x64'), + /native identity is invalid/, + ); +}); + +test('rejects tampering, cross-run mixing and duplicate image identity', () => { + const source = fixtureSource(); + const x64 = fixtureArchitecture('x64', source); + const arm64 = fixtureArchitecture('arm64', source); + assert.throws( + () => + validateArchitectureEvidence( + { ...x64, bundleDigest: '0'.repeat(64) }, + source, + 'x64', + ), + /digest or gates drifted/, + ); + assert.throws( + () => + mergeCrossArchitectureEvidence({ + source, + x64, + arm64: fixtureArchitecture( + 'arm64', + fixtureSource({ revision: 'b'.repeat(40) }), + ), + }), + /belongs to another source/, + ); + + const duplicateObservation = fixtureObservation('arm64'); + duplicateObservation.image.id = x64.observation.image.id; + const duplicate = createArchitectureEvidence({ + source, + architecture: 'arm64', + observation: duplicateObservation, + }); + assert.throws( + () => mergeCrossArchitectureEvidence({ source, x64, arm64: duplicate }), + /independently measured images/, + ); +}); + +test('rejects coercible source fields and oversized canonical evidence', () => { + for (const source of [ + fixtureSource({ repository: 123 }), + fixtureSource({ revision: 123 }), + fixtureSource({ runId: 123456 }), + ]) { + assert.throws( + () => normalizeSource(source), + /source (repository|revision|runId)/, + ); + } + assert.throws( + () => evidenceDigest(Array.from({ length: 100_000 }, () => null)), + /node budget exceeded/, + ); +}); + +test('CLI merges, audits and refuses overwrite, symlink and source drift', (t) => { + const temporaryDirectory = fs.realpathSync( + fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-console-capacity-evidence-')), + ); + t.after(() => + fs.rmSync(temporaryDirectory, { recursive: true, force: true }), + ); + const source = fixtureSource(); + const x64Path = path.join(temporaryDirectory, 'x64.json'); + const arm64Path = path.join(temporaryDirectory, 'arm64.json'); + const outputPath = path.join(temporaryDirectory, 'cross.json'); + writeJson(x64Path, fixtureArchitecture('x64', source)); + writeJson(arm64Path, fixtureArchitecture('arm64', source)); + + const merged = runCli([ + '--mode=merge', + ...sourceArguments(source), + `--x64=${x64Path}`, + `--arm64=${arm64Path}`, + `--output=${outputPath}`, + ]); + assert.equal(merged.status, 0, merged.stderr); + assert.equal(fs.statSync(outputPath).mode & 0o777, 0o600); + const report = readJsonFile(outputPath, 'release evidence'); + validateReleaseEvidence(report, source); + + const audit = runCli([ + '--mode=audit', + ...sourceArguments(source), + `--report=${outputPath}`, + ]); + assert.equal(audit.status, 0, audit.stderr); + assert.equal(JSON.parse(audit.stdout).passed, true); + + const overwrite = runCli([ + '--mode=merge', + ...sourceArguments(source), + `--x64=${x64Path}`, + `--arm64=${arm64Path}`, + `--output=${outputPath}`, + ]); + assert.notEqual(overwrite.status, 0); + assert.match(overwrite.stderr, /output must be a new private file/); + + const drift = runCli([ + '--mode=audit', + ...sourceArguments(fixtureSource({ revision: 'b'.repeat(40) })), + `--report=${outputPath}`, + ]); + assert.notEqual(drift.status, 0); + assert.match(drift.stderr, /belongs to another source/); + + const linkPath = path.join(temporaryDirectory, 'report-link.json'); + fs.symlinkSync(outputPath, linkPath); + const symlink = runCli([ + '--mode=audit', + ...sourceArguments(source), + `--report=${linkPath}`, + ]); + assert.notEqual(symlink.status, 0); + assert.match(symlink.stderr, /readable non-symlink file/); +}); + +test('capture fails closed before Docker without explicit live opt-in', () => { + const result = runCli([ + '--mode=capture', + ...sourceArguments(), + `--architecture=${process.arch === 'arm64' ? 'arm64' : 'x64'}`, + '--image=qinglong3-cluster-admin:ci-test', + '--output=/tmp/ql3-console-capacity-should-not-exist.json', + ]); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE=1 is required/, + ); +}); + +test('live source freezes native cgroup v2, isolation and assertion rotation mechanics', () => { + for (const contract of [ + "process.platform !== 'linux' || process.arch !== architecture", + 'process.version !== NODE_VERSION', + "'--memory',\n '192m'", + "'--memory-swap',\n '192m'", + "'--cpus',\n '0.25'", + "'--pids-limit',\n String(PIDS_MAX)", + "'--read-only'", + "'--cap-drop',\n 'ALL'", + "'--security-opt',\n 'no-new-privileges'", + '`127.0.0.1:${port}:${port}/tcp`', + "memoryPeakBytes: integer('memory.peak')", + 'memoryEvents: { low: events.low', + "swapMaxBytes: integer('memory.swap.max')", + 'requestCert: true', + 'rejectUnauthorized: true', + "minVersion: 'TLSv1.3'", + "fs.renameSync(next, '/authority/assertion.jwt')", + "operation: 'run.cancellation.summary'", + 'consoleRestarted: false', + 'mutation: false', + 'created.network = true', + "cleanupDocker(['network', 'rm', network])", + ]) { + assert.ok(scriptSource.includes(contract), `missing ${contract}`); + } + assert.doesNotMatch(scriptSource, /run\.cancellation\.(?:rearm|stop|retry)/); + assert.doesNotMatch(scriptSource, /--privileged|--network[= ]host/); +}); diff --git a/test/back/ql3ClusterImageReleaseAudit.test.cjs b/test/back/ql3ClusterImageReleaseAudit.test.cjs index 3140ddd5..60636e39 100644 --- a/test/back/ql3ClusterImageReleaseAudit.test.cjs +++ b/test/back/ql3ClusterImageReleaseAudit.test.cjs @@ -30,6 +30,14 @@ test('accepts the reviewed native CI and digest release contracts', () => { clusterAdminOperatorContext: true, clusterAdminContextPreflight: true, clusterAdminContextReadiness: true, + clusterCopilotConsoleCapacityEvidence: { + nativeArchitectures: ['x64', 'arm64'], + memoryLimitMiB: 192, + minimumHeadroomMiB: 32, + assertionRotation: true, + assertionExpiryRejected: true, + sourceBound: true, + }, releaseVersionAudit: true, deploymentLockMaterialization: true, ociAttestations: true, @@ -315,6 +323,50 @@ test('rejects removal of the native Cluster Admin product facade gate', () => { ); }); +test('rejects a Console capacity capture without the exact native live opt-in', () => { + const mutated = ciSource.replace( + "QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE: '1'", + "QL3_CLUSTER_COPILOT_CONSOLE_CAPACITY_LIVE: '0'", + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /exact source-bound Console capacity envelope/, + ); +}); + +test('rejects removal of the Console capacity evidence protocol tests', () => { + const mutated = ciSource.replace( + 'test/back/ql3ClusterCopilotConsoleCapacityEvidence.test.cjs', + 'test/back/ql3ClusterCopilotConsoleCapacityEvidence.removed.cjs', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /Console capacity evidence protocol tests/, + ); +}); + +test('rejects Console capacity evidence that is not gated by the native image matrix', () => { + const mutated = ciSource.replace( + 'cluster-console-capacity-release-evidence:\n name: Cross-architecture Cluster Copilot Console capacity evidence\n needs: cluster-image', + 'cluster-console-capacity-release-evidence:\n name: Cross-architecture Cluster Copilot Console capacity evidence\n needs: image-oci', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /exact native x64 and arm64 reports/, + ); +}); + +test('rejects removal of the offline Console capacity release audit', () => { + const mutated = ciSource.replace( + ' --mode=audit \\\n', + ' --mode=merge \\\n', + ); + assert.throws( + () => auditClusterImageCiWorkflow(mutated), + /exact native x64 and arm64 reports/, + ); +}); + test('rejects a Cluster Admin live gate that omits operator context injection', () => { const contract = fs.readFileSync( path.join(root, 'scripts/ql3-cluster-admin-product-live-contract.cjs'),