mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
test(ql3): audit plugin recovery evidence
This commit is contained in:
@@ -1061,7 +1061,21 @@ jobs:
|
||||
- name: Prove migration, durable OCI recovery and rollout ordering
|
||||
env:
|
||||
QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE: '1'
|
||||
QL3_SOURCE_REVISION: ${{ github.sha }}
|
||||
QL3_KIND_BIN: ${{ github.workspace }}/kind-linux-amd64
|
||||
QL3_KUBECTL_BIN: ${{ github.workspace }}/kubectl
|
||||
QL3_KIND_CLUSTER: ql3-plugin-recovery-e2e-ci
|
||||
run: pnpm test:plugin-package-recovery-e2e:ql3
|
||||
run: |
|
||||
install -d -m 0700 "${RUNNER_TEMP}/ql3-plugin-package-recovery-e2e"
|
||||
pnpm test:plugin-package-recovery-e2e:ql3 -- \
|
||||
--report="${RUNNER_TEMP}/ql3-plugin-package-recovery-e2e/report.json"
|
||||
pnpm audit:plugin-package-recovery-e2e:ql3 -- \
|
||||
--report="${RUNNER_TEMP}/ql3-plugin-package-recovery-e2e/report.json"
|
||||
- name: Upload Plugin Package recovery E2E evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ql3-plugin-package-recovery-e2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/ql3-plugin-package-recovery-e2e/report.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
- 2026-08-14 终态恢复更新:Secret Action controller 不再把所有终态 Job 仅计为瞬时 `recoveryRequired`。Job 到达 Complete/Failed 后已停止执行,controller 会用 started execution 的原 lease fence 复验不可变业务结果:首次 binding 必须与 approval plan、`startedAtMs` 推导出的 binding 完全一致;transition 必须与 plan、authority evidence、commit time 推导出的 receipt 完全一致。精确 durable result 存在时补写 `succeeded`,即使 Job 已被 TTL 清理也能收敛;Failed 且无 durable mutation 时写 `failed`;Complete 但无 receipt 时以 `indeterminate` 写 `blocked`。Job 在 start barrier 前终态或审批过期且尚未创建时,controller 复用既有 claim→release fence 写 `blocked`,不让坏 Job 永久占据 reconciler 页首。任何 stored result 漂移继续抛出 conflict,`executing + Job 缺失 + receipt 缺失` 继续要求人工处理,绝不自动重建可能已产生副作用的动作。该切片不修改共享 execution schema、PostgreSQL migration 或角色权限,不新增 package、连接与常驻进程;Cluster controller 复用现有 package-executor Pool,Edge 零变化。controller/process 定向 21/21,cluster-admin 全包 348 项为 345 pass/3 条件 skip/0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass/2 条件 skip/0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 为 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。
|
||||
- 2026-08-14 人工恢复更新(ADR-0397,已接受):上述唯一保留的 `executing + Job/receipt 均缺失` 不确定窗口现在具有显式 Cluster 产品处置路径。既有 Approval management mTLS/OIDC endpoint 新增 `approval.recover.inspect|resolve`,只接受五分钟内 `multi_factor|hardware` User、独立 `approval.recover` 权限、二次认证、exact execution version/digest 和外部 evidence SHA-256。只允许 Secret binding/transition action;`confirm_failed` 写 failed,`abandon_unknown` 写 blocked,永远禁止人工 succeeded、Job 重建或 execution 重置。PostgreSQL `pg-0065`/capability v64 新增不可变 resolution ledger 与单个 SECURITY DEFINER resolver,在同一事务内锁 Policy/execution fence、写 allowed audit、推进终态并写 receipt;Approval manager 只有 dispatch/execution/resolution SELECT 与函数 EXECUTE,没有 execution UPDATE。通用 execution repository 与 Worker Credential 调用链保持不变。真实 PostgreSQL 18.4 已从空库完成 65 migration,证明原子提交、exact replay 不重复审计和 direct UPDATE `42501`;实现不新增 package、依赖、Pod、Pool、daemon、timer、watcher 或 Edge/Standalone 负担。18-package clean build/test 退出 0,backend 1,194 pass/2 skip/0 fail,package/dependency/edge/deployment 审计零 finding;新 migration 与 repository 内聚到 `approved-action` 领域,migration ledger 直属源码保持审定上限 65。PostgreSQL 18.4 arm64 physical HA 125 项 gate、timeline `1→2` 通过,报告 SHA-256 为 `6d4921cba74475d15722a13c6a8034793c0ee25681bc7dcaf91024927c5752fe`,临时 Docker 资源已清理。
|
||||
- 2026-08-14 升级失败收口(ADR-0398,进行中):已存在旧 active 的 `upgrade|reinstall|rollback` 不再先切 active pointer 再物化 Package 资源。共享 activation prerequisite sequence 固定为 Secret binding/transition receipt 就绪后,从 staged install 与 immutable lock 构建候选 generation,按既有字节上限完成 Manifest、Task、Workflow、Prompt、Tool 语义物化,并以 generation digest 预发布 immutable revision;只有成功后才进入 active pointer CAS。确定性候选错误把本次 install 写为 `failed(activation_fact_conflict)`,`activeLockDigest` 保持 `previousActiveLockDigest` 且 publisher 不被调用;瞬时文件/OCI/数据库故障保持 staged 重试。generation 1 没有旧版本可保留,Secret-aware 首次安装继续使用 ADR-0395 的 post-activation B1 binding ceremony,不被候选门错误拒绝。激活后的 publication recovery 只复用 revision 做 generation-fenced reconciliation。实现复用既有 18 个 package、双方言 repository、Local 单 SQLite authority 与 Cluster caller-driven recovery Job/单 Pool,不新增 migration、表、依赖、daemon、timer、watcher、listener、连接池或常驻 cache。定向 Runtime Core 21/21、Runtime Core 全量 548/548、Local Application 47 pass/4 条件 skip、Cluster Admin 347 pass/3 条件 skip;18-package clean build/test 退出 0,backend 1196 项为 1194 pass/2 条件 skip/0 fail,package boundary、cluster dependency、cluster deployment 与 edge import 审计均通过。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `75d7a52be75c22b2aacf32f2d7e2c432a467ebaab4d639668ff3a4b98767a17e`。真实 Kubernetes 失败升级未移动 active pointer/head 的现场门与固定物理低配设备证据仍待闭合。
|
||||
- 2026-08-14 失败升级现场门更新:既有 PostgreSQL/OCI/Kubernetes recovery E2E 已升级为 report v2。门先激活 signed OCI generation 1,再排入包含合法 Task 与循环 Workflow 的 generation 2;第一次 recovery 必须因 transition receipt 缺失而失败并保持 staged,提交 content-free receipt 后,第二次 recovery 必须确定性写入 `failed(activation_fact_conflict)`,且 generation 2 materialized revision 数量为 0。门在升级前后逐字比较 active ConfigMap UID、`resourceVersion` 与完整 `active.json`,从而排除“先切 pointer、再补偿”的假安全;OCI v1 六路径各取一次、v2 六路径各取两次,全部要求 HTTPS、exact Basic authentication 与零 redirect。runtime rollout 只绑定最终成功 recovery Job,ConfigMap-only RBAC 与 runtime 数据库隔离保持不变。源码契约 7/7、18-package clean build/test、backend 1194 pass/2 skip/0 fail、package/dependency/deployment/edge 审计均通过;PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 通过,报告 SHA-256 `8560469694c67776e5e4c70977f8bde8d4f5635f8e7d1c293ef449dc6da59f72`。本机 admin/control 镜像构建成功,但固定 Kind 1.32.8 节点镜像不在缓存且受限网络无法取得,门在创建节点前中止并确认无遗留集群/容器;因此远端 Kubernetes 成功记录与固定物理低配设备证据仍阻断 ADR Accepted。
|
||||
- 2026-08-14 失败升级现场门更新:既有 PostgreSQL/OCI/Kubernetes recovery E2E 已升级为 report v2。门先激活 signed OCI generation 1,再排入包含合法 Task 与循环 Workflow 的 generation 2;第一次 recovery 必须因 transition receipt 缺失而失败并保持 staged,提交 content-free receipt 后,第二次 recovery 必须确定性写入 `failed(activation_fact_conflict)`,且 generation 2 materialized revision 数量为 0。门在升级前后逐字比较 active ConfigMap UID、`resourceVersion` 与完整 `active.json`,从而排除“先切 pointer、再补偿”的假安全;OCI v1 六路径各取一次、v2 六路径各取两次,全部要求 HTTPS、exact Basic authentication 与零 redirect。runtime rollout 只绑定最终成功 recovery Job,ConfigMap-only RBAC 与 runtime 数据库隔离保持不变。证据链现强制使用 canonical absolute path 原子写入 `0600` no-replace 私有报告,绑定 40-hex source revision 与 admin/control OCI revision label;持久报告只保存 active JSON digest,不保存原始 pointer、credential、DSN、kubeconfig、证书或 Secret material。独立离线审计以 `O_NOFOLLOW` 和 inode/mode/size 复验文件,并 exact-shape 校验 provenance、ordering、数据库/OCI/RBAC/runtime 事实、11 个 gate 和 limitation;CI 审计成功后上传固定 14 天的 evidence artifact。该链路只增加验收代码,不增加产品 package、依赖或低配/集群运行时常驻开销。本轮 producer/离线审计契约 14/14、18-package clean build/test 退出 0、backend 1201 pass/2 skip/0 fail,package/dependency/deployment/edge 审计均通过;PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 通过,报告 SHA-256 `8560469694c67776e5e4c70977f8bde8d4f5635f8e7d1c293ef449dc6da59f72`。本机 admin/control 镜像构建成功,但固定 Kind 1.32.8 节点镜像不在缓存且受限网络无法取得,门在创建节点前中止并确认无遗留集群/容器;因此远端 Kubernetes 成功记录与固定物理低配设备证据仍阻断 ADR Accepted。
|
||||
- D-302/ADR-0390(已接受)
|
||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||
|
||||
@@ -70,12 +70,19 @@ ConfigMap 和数据库 head 之间会出现第二次分布式提交窗口,并
|
||||
`resourceVersion` 与完整 `active.json`,因此不能用“错误切换后再补偿回来”冒充旧版本未移动;OCI v1 六个路径各读取一次,v2 六个路径
|
||||
各读取两次,全部要求 HTTPS、exact Basic authentication、200 且无 redirect。最终 runtime rollout 仍只绑定最后一个成功 recovery Job,
|
||||
recovery ServiceAccount 继续只有 ConfigMap `get|create|update`,runtime 角色仍不能读取安装 authority。
|
||||
- 18-package clean build/test 在允许 loopback TLS 的环境退出 0;backend 1196 项为
|
||||
1194 pass/2 条件 skip/0 fail。新增/更新的 recovery E2E 源码契约 7/7,Runtime Core 定向 21/21。package boundary 保持 18 个 package 且
|
||||
- v2 现场门现在强制接收 canonical absolute `--report`,以 `0600` 临时文件、`fsync` 与 no-replace hard link 原子发布
|
||||
owner-private 报告;目标已存在、父目录为 symlink、缺少显式 opt-in 或缺少 40-hex `QL3_SOURCE_REVISION` 时,均在访问
|
||||
Docker/Kind 前失败关闭。admin/control 镜像的 OCI revision label 必须与报告源码 revision 相同;报告只保存 active JSON 的
|
||||
SHA-256,不保存原始 pointer、Registry credential、数据库 DSN、kubeconfig、证书或 Secret material。独立离线审计对 envelope、
|
||||
镜像 provenance、六段单调 ordering、数据库精确计数、OCI 18 次认证请求、ConfigMap-only RBAC、双节点 runtime 绑定、全部
|
||||
11 个 gate 与三项 limitation 做 exact-shape 校验,并以 `O_NOFOLLOW`/inode/mode/size 复验私有报告。CI 在独立 job 内先审计,
|
||||
再上传固定 14 天的 source-bound evidence artifact;这一证据链只属于验收,不新增产品 package、依赖或运行时常驻开销。
|
||||
- 18-package clean build/test 在允许 loopback TLS 的环境退出 0;backend 1203 项为
|
||||
1201 pass/2 条件 skip/0 fail。新增/更新的 recovery E2E producer/离线审计契约 14/14,Runtime Core 定向 21/21。package boundary 保持 18 个 package 且
|
||||
`singleSourcePackages=[]`、`shallowSourcePackages=[]`;cluster dependency、cluster deployment
|
||||
与 edge import 审计均无 finding。
|
||||
- PostgreSQL `18.4` arm64 physical HA 通过 125 项门,timeline `1→2`,报告 SHA-256
|
||||
`8560469694c67776e5e4c70977f8bde8d4f5635f8e7d1c293ef449dc6da59f72`,临时 Docker
|
||||
资源已清理。本机已成功构建现场门所需 admin/control 镜像,但固定 `kindest/node:v1.32.8` 不在本地缓存,受限网络拉取数分钟无进度;
|
||||
门在创建任何 Kind 节点前被中止,并确认没有遗留集群或容器。因此 v2 门的代码与离线契约已完成,但仍不能计为真实 Kubernetes
|
||||
门在创建任何 Kind 节点前被中止,并确认没有遗留集群或容器。因此 v2 门、私有报告与离线审计代码已完成,但仍不能计为真实 Kubernetes
|
||||
现场通过;远端 CI 成功记录与固定物理低配设备证据仍待完成,本 ADR 保持 Proposed。
|
||||
|
||||
+1
-1
@@ -401,7 +401,7 @@
|
||||
| [ADR-0395](./ADR-0395-owner-confirmed-plugin-package-secret-binding.md) | Owner 确认的 Plugin Package Secret 首次绑定 | Proposed |
|
||||
| [ADR-0396](./ADR-0396-generation-transition-plugin-package-secret-binding.md) | 按 Package Generation 切换 Plugin Package Secret Binding | Proposed |
|
||||
| [ADR-0397](./ADR-0397-explicit-cluster-secret-action-manual-recovery.md) | Cluster Secret Action 显式人工恢复 | Accepted(实现、单节点 PostgreSQL、完整 workspace/后端/边界与 physical HA 门完成) |
|
||||
| [ADR-0398](./ADR-0398-pre-activation-plugin-package-candidate-qualification.md) | Plugin Package 激活前候选资格校验与自动保留旧版本 | Proposed(v2 现场门已编排、全量/HA 门完成;远端 Kubernetes 成功记录与物理低配门待闭合) |
|
||||
| [ADR-0398](./ADR-0398-pre-activation-plugin-package-candidate-qualification.md) | Plugin Package 激活前候选资格校验与自动保留旧版本 | Proposed(v2 私有报告/离线审计已编排、全量/HA 门完成;远端 Kubernetes 成功记录与物理低配门待闭合) |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@
|
||||
"test:plugin-package-secret-binding-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs",
|
||||
"audit:plugin-package-secret-binding-kubernetes-live:ql3": "node scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs",
|
||||
"test:plugin-package-recovery-e2e:ql3": "pnpm --filter @qinglong/cluster-admin check && pnpm --filter @qinglong/cluster-control check && node scripts/ql3-plugin-package-recovery-e2e-live-contract.cjs",
|
||||
"audit:plugin-package-recovery-e2e:ql3": "node scripts/ql3-plugin-package-recovery-e2e-live-audit.cjs",
|
||||
"benchmark:edge": "node scripts/ql3-edge-benchmark.cjs",
|
||||
"benchmark:cluster-control": "node scripts/ql3-cluster-control-benchmark.cjs",
|
||||
"benchmark:worker": "node scripts/ql3-worker-resource-benchmark.cjs",
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const FIXTURE = 'qinglong/plugin-package-recovery-e2e-live-contract@v2';
|
||||
const LIMITATIONS = Object.freeze([
|
||||
'isolated PostgreSQL uses explicit TLS disable; production manifests remain verify-full',
|
||||
'the authenticated HTTPS OCI Distribution fixture implements the immutable GET/referrers surface used by the resolver, not a production registry storage implementation',
|
||||
'the disposable Kind control plane is single-replica; this gate proves workload ordering, not Kubernetes control-plane HA',
|
||||
]);
|
||||
const GATE_KEYS = Object.freeze([
|
||||
'healthyInitialActivation',
|
||||
'missingTransitionFailedClosed',
|
||||
'invalidUpgradeRejectedBeforeActivation',
|
||||
'activePointerUidUnchanged',
|
||||
'activePointerResourceVersionUnchanged',
|
||||
'activePointerJsonUnchanged',
|
||||
'candidateRevisionAbsent',
|
||||
'exactAuthenticatedOciRequests',
|
||||
'recoveryRbacLeastPrivilege',
|
||||
'runtimeRolledOutAfterRecovery',
|
||||
'passed',
|
||||
]);
|
||||
const BANNED_KEYS = new Set([
|
||||
'activejson',
|
||||
'assertion',
|
||||
'authorization',
|
||||
'bearer',
|
||||
'certificate',
|
||||
'connectionstring',
|
||||
'credential',
|
||||
'dsn',
|
||||
'kubeconfig',
|
||||
'password',
|
||||
'privatekey',
|
||||
'secret',
|
||||
'tlskey',
|
||||
'token',
|
||||
]);
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
const SHA256_ID = /^sha256:[a-f0-9]{64}$/;
|
||||
const UUID =
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
|
||||
|
||||
function finding(code, detail) {
|
||||
return Object.freeze({ code, detail });
|
||||
}
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return Boolean(
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...expected].sort()),
|
||||
);
|
||||
}
|
||||
|
||||
function imageDigest(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
return value.match(/sha256:[a-f0-9]{64}$/)?.[0];
|
||||
}
|
||||
|
||||
function safeToken(value, maximum = 256) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length > 0 &&
|
||||
value.length <= maximum &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function sensitiveMaterial(value, key = '') {
|
||||
if (BANNED_KEYS.has(key.toLowerCase())) return true;
|
||||
if (typeof value === 'string') {
|
||||
return (
|
||||
/-----BEGIN (?:CERTIFICATE|(?:RSA |EC |OPENSSH )?PRIVATE KEY)-----/.test(
|
||||
value,
|
||||
) ||
|
||||
/postgres(?:ql)?:\/\/[^/\s]+:[^@\s]+@/i.test(value) ||
|
||||
/\bBasic\s+[A-Za-z0-9+/=_-]{8,}/i.test(value) ||
|
||||
/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{8,}\./.test(value) ||
|
||||
/\bqlsecret(?::|\/\/)/i.test(value)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((entry) => sensitiveMaterial(entry));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value).some(([entryKey, entry]) =>
|
||||
sensitiveMaterial(entry, entryKey),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validIso(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) &&
|
||||
Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
function validRuntime(report) {
|
||||
const runtime = report.runtime;
|
||||
const ordering = report.ordering;
|
||||
return (
|
||||
exactKeys(runtime, [
|
||||
'replicas',
|
||||
'creationTimestamp',
|
||||
'recoveryJobUid',
|
||||
'recoveryCompletedAt',
|
||||
'nodes',
|
||||
'imageIds',
|
||||
]) &&
|
||||
runtime.replicas === 2 &&
|
||||
runtime.creationTimestamp === ordering?.runtimeCreatedAt &&
|
||||
runtime.recoveryJobUid === ordering?.rejectionRecoveryJobUid &&
|
||||
runtime.recoveryCompletedAt === ordering?.rejectionRecoveryCompletedAt &&
|
||||
Array.isArray(runtime.nodes) &&
|
||||
runtime.nodes.length === 2 &&
|
||||
new Set(runtime.nodes).size === 2 &&
|
||||
runtime.nodes.every((value) => safeToken(value)) &&
|
||||
Array.isArray(runtime.imageIds) &&
|
||||
runtime.imageIds.length === 1 &&
|
||||
imageDigest(runtime.imageIds[0]) === report.images?.controlBuildId
|
||||
);
|
||||
}
|
||||
|
||||
function validatePluginPackageRecoveryE2ELiveReport(report) {
|
||||
const findings = [];
|
||||
if (
|
||||
!exactKeys(report, [
|
||||
'schema',
|
||||
'observedAt',
|
||||
'sourceRevision',
|
||||
'passed',
|
||||
'cluster',
|
||||
'architecture',
|
||||
'elapsedMs',
|
||||
'images',
|
||||
'ordering',
|
||||
'failedUpgrade',
|
||||
'database',
|
||||
'oci',
|
||||
'kubernetes',
|
||||
'runtime',
|
||||
'gates',
|
||||
'limitations',
|
||||
]) ||
|
||||
report?.schema !== FIXTURE ||
|
||||
report?.passed !== true ||
|
||||
!validIso(report?.observedAt) ||
|
||||
!/^[a-f0-9]{40}$/.test(report?.sourceRevision ?? '') ||
|
||||
!/^ql3-plugin-recovery-e2e(?:-[a-z0-9](?:[-a-z0-9]{0,24}[a-z0-9])?)?$/.test(
|
||||
report?.cluster ?? '',
|
||||
) ||
|
||||
!['amd64', 'arm64'].includes(report?.architecture) ||
|
||||
!Number.isSafeInteger(report?.elapsedMs) ||
|
||||
report.elapsedMs < 1 ||
|
||||
report.elapsedMs > 60 * 60 * 1000
|
||||
) {
|
||||
findings.push(
|
||||
finding('QL3_PLUGIN_RECOVERY_E2E_ENVELOPE', 'report envelope is invalid'),
|
||||
);
|
||||
}
|
||||
|
||||
const images = report?.images;
|
||||
if (
|
||||
!exactKeys(images, [
|
||||
'adminBuildId',
|
||||
'adminSourceRevision',
|
||||
'controlBuildId',
|
||||
'controlSourceRevision',
|
||||
'postgresRepositoryDigest',
|
||||
'migrationImageId',
|
||||
'initialRecoveryImageId',
|
||||
'stageRecoveryImageId',
|
||||
'rejectionRecoveryImageId',
|
||||
'postgresImageId',
|
||||
]) ||
|
||||
!SHA256_ID.test(images?.adminBuildId ?? '') ||
|
||||
!SHA256_ID.test(images?.controlBuildId ?? '') ||
|
||||
images?.adminSourceRevision !== report?.sourceRevision ||
|
||||
images?.controlSourceRevision !== report?.sourceRevision ||
|
||||
images?.postgresRepositoryDigest !==
|
||||
'postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296' ||
|
||||
!imageDigest(images?.migrationImageId) ||
|
||||
imageDigest(images?.initialRecoveryImageId) !==
|
||||
imageDigest(images?.migrationImageId) ||
|
||||
imageDigest(images?.stageRecoveryImageId) !==
|
||||
imageDigest(images?.migrationImageId) ||
|
||||
imageDigest(images?.rejectionRecoveryImageId) !==
|
||||
imageDigest(images?.migrationImageId) ||
|
||||
!imageDigest(images?.postgresImageId)
|
||||
) {
|
||||
findings.push(
|
||||
finding('QL3_PLUGIN_RECOVERY_E2E_IMAGES', 'image provenance is invalid'),
|
||||
);
|
||||
}
|
||||
|
||||
const ordering = report?.ordering;
|
||||
const timeKeys = [
|
||||
'migrationCompletedAt',
|
||||
'initialRecoveryCompletedAt',
|
||||
'upgradeStageFailedAt',
|
||||
'transitionCompletedAt',
|
||||
'rejectionRecoveryCompletedAt',
|
||||
'runtimeCreatedAt',
|
||||
];
|
||||
const uidKeys = [
|
||||
'migrationJobUid',
|
||||
'initialRecoveryJobUid',
|
||||
'upgradeStageJobUid',
|
||||
'transitionJobUid',
|
||||
'rejectionRecoveryJobUid',
|
||||
'runtimeBoundRecoveryJobUid',
|
||||
];
|
||||
const times = timeKeys.map((key) => Date.parse(ordering?.[key]));
|
||||
if (
|
||||
!exactKeys(ordering, [...uidKeys, ...timeKeys]) ||
|
||||
uidKeys.some((key) => !UUID.test(ordering?.[key] ?? '')) ||
|
||||
ordering?.runtimeBoundRecoveryJobUid !==
|
||||
ordering?.rejectionRecoveryJobUid ||
|
||||
timeKeys.some((key) => !validIso(ordering?.[key])) ||
|
||||
times.some((value, index) => index > 0 && value < times[index - 1])
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_ORDERING',
|
||||
'Job and rollout ordering is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const failed = report?.failedUpgrade;
|
||||
if (
|
||||
!exactKeys(failed, [
|
||||
'stageFailure',
|
||||
'transitionReceiptDigest',
|
||||
'rejectionReason',
|
||||
'candidateRevisionCount',
|
||||
'activePointerUnchanged',
|
||||
]) ||
|
||||
!exactKeys(failed?.stageFailure, ['jobUid', 'reason', 'durableState']) ||
|
||||
failed?.stageFailure?.jobUid !== ordering?.upgradeStageJobUid ||
|
||||
failed?.stageFailure?.reason !==
|
||||
'ClusterPluginPackageRecoveryRequiredError' ||
|
||||
failed?.stageFailure?.durableState !== 'staged' ||
|
||||
!SHA256.test(failed?.transitionReceiptDigest ?? '') ||
|
||||
failed?.rejectionReason !== 'activation_fact_conflict' ||
|
||||
failed?.candidateRevisionCount !== 0 ||
|
||||
failed?.activePointerUnchanged !== true
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_FAILED_UPGRADE',
|
||||
'failed upgrade evidence is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const database = report?.database;
|
||||
const databaseKeys = [
|
||||
'migrationCount',
|
||||
'capabilityVersion',
|
||||
'initialState',
|
||||
'initialActiveLockDigest',
|
||||
'upgradeState',
|
||||
'upgradePreviousActiveLockDigest',
|
||||
'upgradeActiveLockDigest',
|
||||
'upgradeFailureReason',
|
||||
'initialMutationCount',
|
||||
'upgradeMutationCount',
|
||||
'headInstallationId',
|
||||
'transitionReceiptCount',
|
||||
'initialRevisionCount',
|
||||
'upgradeRevisionCount',
|
||||
'recoverableCount',
|
||||
];
|
||||
if (
|
||||
!exactKeys(database, databaseKeys) ||
|
||||
database?.migrationCount !== 65 ||
|
||||
database?.capabilityVersion !== 64 ||
|
||||
database?.initialState !== 'active' ||
|
||||
database?.upgradeState !== 'failed' ||
|
||||
!SHA256.test(database?.initialActiveLockDigest ?? '') ||
|
||||
database?.upgradePreviousActiveLockDigest !==
|
||||
database?.initialActiveLockDigest ||
|
||||
database?.upgradeActiveLockDigest !== database?.initialActiveLockDigest ||
|
||||
database?.upgradeFailureReason !== 'activation_fact_conflict' ||
|
||||
database?.initialMutationCount !== 4 ||
|
||||
database?.upgradeMutationCount !== 3 ||
|
||||
database?.headInstallationId !== 'install-plugin-recovery-e2e-upgrade' ||
|
||||
database?.transitionReceiptCount !== 1 ||
|
||||
database?.initialRevisionCount !== 1 ||
|
||||
database?.upgradeRevisionCount !== 0 ||
|
||||
database?.recoverableCount !== 0
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_DATABASE',
|
||||
'durable database evidence is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const oci = report?.oci;
|
||||
if (
|
||||
!exactKeys(oci, [
|
||||
'https',
|
||||
'authentication',
|
||||
'authenticatedRequestCount',
|
||||
'requestCount',
|
||||
'uniquePaths',
|
||||
'initialRequestCount',
|
||||
'upgradeRequestCount',
|
||||
'redirects',
|
||||
]) ||
|
||||
oci?.https !== true ||
|
||||
oci?.authentication !== 'exact-registry-basic' ||
|
||||
oci?.authenticatedRequestCount !== 18 ||
|
||||
oci?.requestCount !== 18 ||
|
||||
oci?.uniquePaths !== 12 ||
|
||||
oci?.initialRequestCount !== 6 ||
|
||||
oci?.upgradeRequestCount !== 12 ||
|
||||
oci?.redirects !== 0
|
||||
) {
|
||||
findings.push(
|
||||
finding('QL3_PLUGIN_RECOVERY_E2E_OCI', 'OCI request evidence is invalid'),
|
||||
);
|
||||
}
|
||||
|
||||
const kubernetes = report?.kubernetes;
|
||||
const pointer = kubernetes?.activePointer;
|
||||
const rbac = kubernetes?.rbac;
|
||||
if (
|
||||
!exactKeys(kubernetes, ['activePointer', 'rbac']) ||
|
||||
!exactKeys(pointer, [
|
||||
'name',
|
||||
'uid',
|
||||
'resourceVersion',
|
||||
'activeJsonDigest',
|
||||
'intentDigest',
|
||||
'activationRef',
|
||||
]) ||
|
||||
!safeToken(pointer?.name) ||
|
||||
!UUID.test(pointer?.uid ?? '') ||
|
||||
!/^[1-9][0-9]*$/.test(pointer?.resourceVersion ?? '') ||
|
||||
!SHA256.test(pointer?.activeJsonDigest ?? '') ||
|
||||
!SHA256.test(pointer?.intentDigest ?? '') ||
|
||||
!safeToken(pointer?.activationRef) ||
|
||||
!exactKeys(rbac, [
|
||||
'getConfigMaps',
|
||||
'createConfigMaps',
|
||||
'updateConfigMaps',
|
||||
'listConfigMaps',
|
||||
'deleteConfigMaps',
|
||||
'getSecrets',
|
||||
]) ||
|
||||
rbac?.getConfigMaps !== true ||
|
||||
rbac?.createConfigMaps !== true ||
|
||||
rbac?.updateConfigMaps !== true ||
|
||||
rbac?.listConfigMaps !== false ||
|
||||
rbac?.deleteConfigMaps !== false ||
|
||||
rbac?.getSecrets !== false
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_KUBERNETES',
|
||||
'active pointer or RBAC evidence is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!validRuntime(report ?? {})) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_RUNTIME',
|
||||
'runtime rollout evidence is invalid',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
!exactKeys(report?.gates, GATE_KEYS) ||
|
||||
GATE_KEYS.some((key) => report?.gates?.[key] !== true)
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_GATES',
|
||||
'every gate must be explicitly true',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
!Array.isArray(report?.limitations) ||
|
||||
JSON.stringify([...report.limitations].sort()) !==
|
||||
JSON.stringify([...LIMITATIONS].sort())
|
||||
) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_LIMITATIONS',
|
||||
'limitations must remain exact',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (sensitiveMaterial(report)) {
|
||||
findings.push(
|
||||
finding(
|
||||
'QL3_PLUGIN_RECOVERY_E2E_SENSITIVE',
|
||||
'report contains a forbidden key or sensitive material',
|
||||
),
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
fixture: FIXTURE,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function readPrivateReport(filePath) {
|
||||
if (!path.isAbsolute(filePath) || path.resolve(filePath) !== filePath) {
|
||||
throw new Error('report path must be absolute and canonical');
|
||||
}
|
||||
const before = fs.lstatSync(filePath);
|
||||
if (
|
||||
!before.isFile() ||
|
||||
before.isSymbolicLink() ||
|
||||
before.size < 2 ||
|
||||
before.size > 1024 * 1024 ||
|
||||
(before.mode & 0o777) !== 0o600
|
||||
) {
|
||||
throw new Error(
|
||||
'report must be an owner-private regular file between 2 bytes and 1 MiB',
|
||||
);
|
||||
}
|
||||
const descriptor = fs.openSync(
|
||||
filePath,
|
||||
fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0),
|
||||
);
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor);
|
||||
if (
|
||||
opened.dev !== before.dev ||
|
||||
opened.ino !== before.ino ||
|
||||
opened.size !== before.size ||
|
||||
(opened.mode & 0o777) !== 0o600
|
||||
) {
|
||||
throw new Error('report identity or mode changed while opening');
|
||||
}
|
||||
const text = fs.readFileSync(descriptor, 'utf8');
|
||||
const after = fs.fstatSync(descriptor);
|
||||
const currentPath = fs.lstatSync(filePath);
|
||||
if (
|
||||
after.size !== opened.size ||
|
||||
after.mtimeMs !== opened.mtimeMs ||
|
||||
currentPath.dev !== opened.dev ||
|
||||
currentPath.ino !== opened.ino ||
|
||||
currentPath.size !== opened.size ||
|
||||
(currentPath.mode & 0o777) !== 0o600
|
||||
) {
|
||||
throw new Error('report changed while reading');
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
if (argv.length !== 1 || !argv[0].startsWith('--report=')) {
|
||||
throw new Error(
|
||||
'usage: ql3-plugin-package-recovery-e2e-live-audit --report=/absolute/report.json',
|
||||
);
|
||||
}
|
||||
const result = validatePluginPackageRecoveryE2ELiveReport(
|
||||
readPrivateReport(argv[0].slice('--report='.length)),
|
||||
);
|
||||
process.stdout.write(`${JSON.stringify(result)}\n`);
|
||||
if (!result.compatible) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`QL3 Plugin Package recovery E2E live audit failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FIXTURE,
|
||||
GATE_KEYS,
|
||||
LIMITATIONS,
|
||||
readPrivateReport,
|
||||
validatePluginPackageRecoveryE2ELiveReport,
|
||||
};
|
||||
@@ -6,7 +6,7 @@ const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { createHash, randomBytes } = require('node:crypto');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
@@ -181,7 +181,62 @@ function imageExists(image) {
|
||||
);
|
||||
}
|
||||
|
||||
function buildImages() {
|
||||
function sourceRevision() {
|
||||
const value = process.env.QL3_SOURCE_REVISION ?? '';
|
||||
if (!/^[a-f0-9]{40}$/.test(value)) {
|
||||
fail(
|
||||
'QL3_SOURCE_REVISION must be the exact lowercase 40-hex source revision',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function privateReportPath(argv) {
|
||||
if (
|
||||
argv.length !== 1 ||
|
||||
!argv[0].startsWith('--report=') ||
|
||||
!path.isAbsolute(argv[0].slice('--report='.length))
|
||||
) {
|
||||
fail(
|
||||
'usage: ql3-plugin-package-recovery-e2e-live-contract ' +
|
||||
'--report=/absolute/private-report.json',
|
||||
);
|
||||
}
|
||||
const reportFile = argv[0].slice('--report='.length);
|
||||
if (path.resolve(reportFile) !== reportFile) {
|
||||
fail('Plugin Package recovery E2E report path must be canonical');
|
||||
}
|
||||
if (fs.existsSync(reportFile)) {
|
||||
fail('refusing to overwrite the Plugin Package recovery E2E report');
|
||||
}
|
||||
const parent = fs.lstatSync(path.dirname(reportFile));
|
||||
if (!parent.isDirectory() || parent.isSymbolicLink()) {
|
||||
fail('Plugin Package recovery E2E report parent must be a real directory');
|
||||
}
|
||||
return reportFile;
|
||||
}
|
||||
|
||||
function writePrivateReport(reportFile, report) {
|
||||
const temporaryReport = path.join(
|
||||
path.dirname(reportFile),
|
||||
`.${path.basename(reportFile)}.${process.pid}.` +
|
||||
`${randomBytes(6).toString('hex')}.tmp`,
|
||||
);
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(temporaryReport, 'wx', 0o600);
|
||||
fs.writeFileSync(descriptor, `${JSON.stringify(report, null, 2)}\n`);
|
||||
fs.fsyncSync(descriptor);
|
||||
fs.closeSync(descriptor);
|
||||
descriptor = undefined;
|
||||
fs.linkSync(temporaryReport, reportFile);
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
fs.rmSync(temporaryReport, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function buildImages(revision) {
|
||||
if (process.env.QL3_SKIP_IMAGE_BUILD === '1') {
|
||||
assert.equal(imageExists(ADMIN_IMAGE), true, `${ADMIN_IMAGE} is absent`);
|
||||
assert.equal(
|
||||
@@ -200,7 +255,7 @@ function buildImages() {
|
||||
'--tag',
|
||||
ADMIN_IMAGE,
|
||||
'--build-arg',
|
||||
'SOURCE_REVISION=ql3-plugin-recovery-e2e-live',
|
||||
`SOURCE_REVISION=${revision}`,
|
||||
'.',
|
||||
],
|
||||
{ label: 'build current QingLong 3.0 cluster-admin image' },
|
||||
@@ -214,7 +269,7 @@ function buildImages() {
|
||||
'--tag',
|
||||
CONTROL_IMAGE,
|
||||
'--build-arg',
|
||||
'SOURCE_REVISION=ql3-plugin-recovery-e2e-live',
|
||||
`SOURCE_REVISION=${revision}`,
|
||||
'.',
|
||||
],
|
||||
{ label: 'build current QingLong 3.0 cluster-control image' },
|
||||
@@ -248,6 +303,15 @@ function imageId(image) {
|
||||
)[0].Id;
|
||||
}
|
||||
|
||||
function imageSourceRevision(image) {
|
||||
return JSON.parse(
|
||||
run(DOCKER, ['image', 'inspect', image], {
|
||||
capture: true,
|
||||
quiet: true,
|
||||
}).stdout,
|
||||
)[0].Config?.Labels?.['org.opencontainers.image.revision'];
|
||||
}
|
||||
|
||||
function createRegistryCertificate(root) {
|
||||
const caKey = path.join(root, 'registry-ca.key');
|
||||
const caCert = path.join(root, 'registry-ca.crt');
|
||||
@@ -1264,6 +1328,19 @@ function activePointerEvidence(fixture) {
|
||||
});
|
||||
}
|
||||
|
||||
function reportActivePointer(pointer) {
|
||||
return Object.freeze({
|
||||
name: pointer.name,
|
||||
uid: pointer.uid,
|
||||
resourceVersion: pointer.resourceVersion,
|
||||
activeJsonDigest: createHash('sha256')
|
||||
.update(pointer.activeJson, 'utf8')
|
||||
.digest('hex'),
|
||||
intentDigest: pointer.intentDigest,
|
||||
activationRef: pointer.activationRef,
|
||||
});
|
||||
}
|
||||
|
||||
function registryEvidence(fixture) {
|
||||
const output = kubectl(
|
||||
['-n', NAMESPACE, 'logs', REGISTRY_NAME, '-c', 'registry'],
|
||||
@@ -1542,12 +1619,14 @@ function diagnostics() {
|
||||
if (snapshot.stdout) process.stderr.write(`${snapshot.stdout}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
async function main(argv = process.argv.slice(2)) {
|
||||
const reportFile = privateReportPath(argv);
|
||||
if (process.env.QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE !== '1') {
|
||||
fail(
|
||||
'Refusing to create a live cluster without QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE=1',
|
||||
);
|
||||
}
|
||||
const revision = sourceRevision();
|
||||
const clusterName = exactClusterName();
|
||||
const existing = kind(['get', 'clusters'], {
|
||||
capture: true,
|
||||
@@ -1605,7 +1684,13 @@ async function main() {
|
||||
let created = false;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
buildImages();
|
||||
buildImages(revision);
|
||||
const adminSourceRevision = imageSourceRevision(ADMIN_IMAGE);
|
||||
const controlSourceRevision = imageSourceRevision(CONTROL_IMAGE);
|
||||
assert.equal(adminSourceRevision, revision);
|
||||
assert.equal(controlSourceRevision, revision);
|
||||
const adminBuildId = imageId(ADMIN_IMAGE);
|
||||
const controlBuildId = imageId(CONTROL_IMAGE);
|
||||
ensurePostgresImage();
|
||||
fs.writeFileSync(
|
||||
kindConfig,
|
||||
@@ -1829,13 +1914,17 @@ async function main() {
|
||||
]);
|
||||
const report = Object.freeze({
|
||||
schema: REPORT_SCHEMA,
|
||||
observedAt: new Date().toISOString(),
|
||||
sourceRevision: revision,
|
||||
passed: true,
|
||||
cluster: clusterName,
|
||||
architecture: fixtureArchitecture,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
images: Object.freeze({
|
||||
adminBuildId: imageId(ADMIN_IMAGE),
|
||||
controlBuildId: imageId(CONTROL_IMAGE),
|
||||
adminBuildId,
|
||||
adminSourceRevision,
|
||||
controlBuildId,
|
||||
controlSourceRevision,
|
||||
postgresRepositoryDigest: POSTGRES_REPOSITORY_DIGEST,
|
||||
migrationImageId,
|
||||
initialRecoveryImageId,
|
||||
@@ -1871,17 +1960,38 @@ async function main() {
|
||||
database,
|
||||
oci,
|
||||
kubernetes: Object.freeze({
|
||||
activePointer: pointerAfterRejection,
|
||||
activePointer: reportActivePointer(pointerAfterRejection),
|
||||
rbac,
|
||||
}),
|
||||
runtime,
|
||||
gates: Object.freeze({
|
||||
healthyInitialActivation: true,
|
||||
missingTransitionFailedClosed: true,
|
||||
invalidUpgradeRejectedBeforeActivation: true,
|
||||
activePointerUidUnchanged: true,
|
||||
activePointerResourceVersionUnchanged: true,
|
||||
activePointerJsonUnchanged: true,
|
||||
candidateRevisionAbsent: true,
|
||||
exactAuthenticatedOciRequests: true,
|
||||
recoveryRbacLeastPrivilege: true,
|
||||
runtimeRolledOutAfterRecovery: true,
|
||||
passed: true,
|
||||
}),
|
||||
limitations: Object.freeze([
|
||||
'isolated PostgreSQL uses explicit TLS disable; production manifests remain verify-full',
|
||||
'the authenticated HTTPS OCI Distribution fixture implements the immutable GET/referrers surface used by the resolver, not a production registry storage implementation',
|
||||
'the disposable Kind control plane is single-replica; this gate proves workload ordering, not Kubernetes control-plane HA',
|
||||
]),
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
writePrivateReport(reportFile, report);
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
fixture: REPORT_SCHEMA,
|
||||
reportWritten: true,
|
||||
passed: true,
|
||||
})}\n`,
|
||||
);
|
||||
} catch (error) {
|
||||
diagnostics();
|
||||
throw error;
|
||||
@@ -1902,13 +2012,21 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-recovery-e2e-live-failure@v1',
|
||||
name: error?.name ?? 'Error',
|
||||
message: error?.message ?? 'unknown failure',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
`${JSON.stringify({
|
||||
schema: 'qinglong/plugin-package-recovery-e2e-live-failure@v1',
|
||||
name: error?.name ?? 'Error',
|
||||
message: error?.message ?? 'unknown failure',
|
||||
})}\n`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REPORT_SCHEMA,
|
||||
privateReportPath,
|
||||
writePrivateReport,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
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 root = path.resolve(__dirname, '../..');
|
||||
const producerPath = path.join(
|
||||
root,
|
||||
'scripts/ql3-plugin-package-recovery-e2e-live-contract.cjs',
|
||||
);
|
||||
const {
|
||||
FIXTURE,
|
||||
GATE_KEYS,
|
||||
readPrivateReport,
|
||||
validatePluginPackageRecoveryE2ELiveReport,
|
||||
} = require('../../scripts/ql3-plugin-package-recovery-e2e-live-audit.cjs');
|
||||
const {
|
||||
writePrivateReport,
|
||||
} = require('../../scripts/ql3-plugin-package-recovery-e2e-live-contract.cjs');
|
||||
|
||||
function validReport() {
|
||||
const admin = `sha256:${'a'.repeat(64)}`;
|
||||
const control = `sha256:${'b'.repeat(64)}`;
|
||||
const sourceRevision = 'c'.repeat(40);
|
||||
const lock = 'd'.repeat(64);
|
||||
return {
|
||||
schema: FIXTURE,
|
||||
observedAt: '2026-08-14T08:00:07.000Z',
|
||||
sourceRevision,
|
||||
passed: true,
|
||||
cluster: 'ql3-plugin-recovery-e2e-ci',
|
||||
architecture: 'arm64',
|
||||
elapsedMs: 420000,
|
||||
images: {
|
||||
adminBuildId: admin,
|
||||
adminSourceRevision: sourceRevision,
|
||||
controlBuildId: control,
|
||||
controlSourceRevision: sourceRevision,
|
||||
postgresRepositoryDigest:
|
||||
'postgres@sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296',
|
||||
migrationImageId: `docker://${admin}`,
|
||||
initialRecoveryImageId: `docker://${admin}`,
|
||||
stageRecoveryImageId: `docker://${admin}`,
|
||||
rejectionRecoveryImageId: `docker://${admin}`,
|
||||
postgresImageId: `containerd://sha256:${'e'.repeat(64)}`,
|
||||
},
|
||||
ordering: {
|
||||
migrationJobUid: '00000000-0000-4000-8000-000000000001',
|
||||
migrationCompletedAt: '2026-08-14T08:00:01.000Z',
|
||||
initialRecoveryJobUid: '00000000-0000-4000-8000-000000000002',
|
||||
initialRecoveryCompletedAt: '2026-08-14T08:00:02.000Z',
|
||||
upgradeStageJobUid: '00000000-0000-4000-8000-000000000003',
|
||||
upgradeStageFailedAt: '2026-08-14T08:00:03.000Z',
|
||||
transitionJobUid: '00000000-0000-4000-8000-000000000004',
|
||||
transitionCompletedAt: '2026-08-14T08:00:04.000Z',
|
||||
rejectionRecoveryJobUid: '00000000-0000-4000-8000-000000000005',
|
||||
rejectionRecoveryCompletedAt: '2026-08-14T08:00:05.000Z',
|
||||
runtimeCreatedAt: '2026-08-14T08:00:06.000Z',
|
||||
runtimeBoundRecoveryJobUid: '00000000-0000-4000-8000-000000000005',
|
||||
},
|
||||
failedUpgrade: {
|
||||
stageFailure: {
|
||||
jobUid: '00000000-0000-4000-8000-000000000003',
|
||||
reason: 'ClusterPluginPackageRecoveryRequiredError',
|
||||
durableState: 'staged',
|
||||
},
|
||||
transitionReceiptDigest: 'f'.repeat(64),
|
||||
rejectionReason: 'activation_fact_conflict',
|
||||
candidateRevisionCount: 0,
|
||||
activePointerUnchanged: true,
|
||||
},
|
||||
database: {
|
||||
migrationCount: 65,
|
||||
capabilityVersion: 64,
|
||||
initialState: 'active',
|
||||
initialActiveLockDigest: lock,
|
||||
upgradeState: 'failed',
|
||||
upgradePreviousActiveLockDigest: lock,
|
||||
upgradeActiveLockDigest: lock,
|
||||
upgradeFailureReason: 'activation_fact_conflict',
|
||||
initialMutationCount: 4,
|
||||
upgradeMutationCount: 3,
|
||||
headInstallationId: 'install-plugin-recovery-e2e-upgrade',
|
||||
transitionReceiptCount: 1,
|
||||
initialRevisionCount: 1,
|
||||
upgradeRevisionCount: 0,
|
||||
recoverableCount: 0,
|
||||
},
|
||||
oci: {
|
||||
https: true,
|
||||
authentication: 'exact-registry-basic',
|
||||
authenticatedRequestCount: 18,
|
||||
requestCount: 18,
|
||||
uniquePaths: 12,
|
||||
initialRequestCount: 6,
|
||||
upgradeRequestCount: 12,
|
||||
redirects: 0,
|
||||
},
|
||||
kubernetes: {
|
||||
activePointer: {
|
||||
name: 'ql3-plugin-package-active-e2e-monitor',
|
||||
uid: '00000000-0000-4000-8000-000000000006',
|
||||
resourceVersion: '42',
|
||||
activeJsonDigest: '1'.repeat(64),
|
||||
intentDigest: '2'.repeat(64),
|
||||
activationRef: 'activation-e2e-monitor-1',
|
||||
},
|
||||
rbac: {
|
||||
getConfigMaps: true,
|
||||
createConfigMaps: true,
|
||||
updateConfigMaps: true,
|
||||
listConfigMaps: false,
|
||||
deleteConfigMaps: false,
|
||||
getSecrets: false,
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
replicas: 2,
|
||||
creationTimestamp: '2026-08-14T08:00:06.000Z',
|
||||
recoveryJobUid: '00000000-0000-4000-8000-000000000005',
|
||||
recoveryCompletedAt: '2026-08-14T08:00:05.000Z',
|
||||
nodes: ['worker-a', 'worker-b'],
|
||||
imageIds: [`docker://${control}`],
|
||||
},
|
||||
gates: Object.fromEntries(GATE_KEYS.map((key) => [key, true])),
|
||||
limitations: [
|
||||
'isolated PostgreSQL uses explicit TLS disable; production manifests remain verify-full',
|
||||
'the authenticated HTTPS OCI Distribution fixture implements the immutable GET/referrers surface used by the resolver, not a production registry storage implementation',
|
||||
'the disposable Kind control plane is single-replica; this gate proves workload ordering, not Kubernetes control-plane HA',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('offline audit accepts one exact low-sensitive recovery report', () => {
|
||||
const result = validatePluginPackageRecoveryE2ELiveReport(validReport());
|
||||
assert.equal(result.compatible, true);
|
||||
assert.deepEqual(result.findings, []);
|
||||
});
|
||||
|
||||
test('offline audit rejects broken upgrade, ordering and image relationships', () => {
|
||||
const report = validReport();
|
||||
report.database.upgradeRevisionCount = 1;
|
||||
report.ordering.transitionCompletedAt = '2026-08-14T07:59:59.000Z';
|
||||
report.runtime.imageIds = [`docker://sha256:${'9'.repeat(64)}`];
|
||||
report.gates.activePointerJsonUnchanged = false;
|
||||
const codes = validatePluginPackageRecoveryE2ELiveReport(report).findings.map(
|
||||
(value) => value.code,
|
||||
);
|
||||
assert.ok(codes.includes('QL3_PLUGIN_RECOVERY_E2E_DATABASE'));
|
||||
assert.ok(codes.includes('QL3_PLUGIN_RECOVERY_E2E_ORDERING'));
|
||||
assert.ok(codes.includes('QL3_PLUGIN_RECOVERY_E2E_RUNTIME'));
|
||||
assert.ok(codes.includes('QL3_PLUGIN_RECOVERY_E2E_GATES'));
|
||||
});
|
||||
|
||||
test('offline audit rejects forbidden keys and credential-shaped material', () => {
|
||||
const report = validReport();
|
||||
report.password = 'not-allowed';
|
||||
report.limitations[0] =
|
||||
'postgresql://operator:credential@database.example/qinglong';
|
||||
const result = validatePluginPackageRecoveryE2ELiveReport(report);
|
||||
assert.equal(result.compatible, false);
|
||||
assert.ok(
|
||||
result.findings.some(
|
||||
(value) => value.code === 'QL3_PLUGIN_RECOVERY_E2E_SENSITIVE',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('producer writes an atomic owner-private no-replace report', (context) => {
|
||||
const temporary = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-recovery-report-'),
|
||||
);
|
||||
context.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
const reportFile = path.join(temporary, 'report.json');
|
||||
writePrivateReport(reportFile, validReport());
|
||||
assert.equal(fs.statSync(reportFile).mode & 0o777, 0o600);
|
||||
assert.equal(readPrivateReport(reportFile).schema, FIXTURE);
|
||||
assert.deepEqual(
|
||||
fs.readdirSync(temporary).filter((value) => value.endsWith('.tmp')),
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() => writePrivateReport(reportFile, validReport()),
|
||||
/EEXIST|exist/i,
|
||||
);
|
||||
});
|
||||
|
||||
test('private report reader rejects relative, symlinked and broad-mode reports', (context) => {
|
||||
const temporary = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-recovery-audit-'),
|
||||
);
|
||||
context.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
const reportFile = path.join(temporary, 'report.json');
|
||||
fs.writeFileSync(reportFile, '{}\n', { mode: 0o600 });
|
||||
assert.throws(() => readPrivateReport('report.json'), /absolute/);
|
||||
const link = path.join(temporary, 'link.json');
|
||||
fs.symlinkSync(reportFile, link);
|
||||
assert.throws(() => readPrivateReport(link), /owner-private/);
|
||||
fs.chmodSync(reportFile, 0o640);
|
||||
assert.throws(() => readPrivateReport(reportFile), /owner-private/);
|
||||
});
|
||||
|
||||
test('producer refuses missing authority before invoking Docker or Kind', (context) => {
|
||||
const temporary = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), 'ql3-recovery-producer-'),
|
||||
);
|
||||
context.after(() => fs.rmSync(temporary, { recursive: true, force: true }));
|
||||
const report = path.join(temporary, 'report.json');
|
||||
const noReport = spawnSync(process.execPath, [producerPath], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
assert.notEqual(noReport.status, 0);
|
||||
assert.match(noReport.stderr, /--report=\/absolute/);
|
||||
const noOptIn = spawnSync(
|
||||
process.execPath,
|
||||
[producerPath, `--report=${report}`],
|
||||
{
|
||||
cwd: root,
|
||||
env: { ...process.env, QL3_DOCKER_BIN: '/not/invoked/docker' },
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.notEqual(noOptIn.status, 0);
|
||||
assert.match(noOptIn.stderr, /QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE=1/);
|
||||
const noRevision = spawnSync(
|
||||
process.execPath,
|
||||
[producerPath, `--report=${report}`],
|
||||
{
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE: '1',
|
||||
QL3_KIND_BIN: '/not/invoked/kind',
|
||||
},
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.notEqual(noRevision.status, 0);
|
||||
assert.match(noRevision.stderr, /QL3_SOURCE_REVISION/);
|
||||
});
|
||||
@@ -122,9 +122,33 @@ test('package script and independent CI job execute the full gate', () => {
|
||||
const job = workflow.jobs['cluster-plugin-package-recovery-e2e'];
|
||||
assert.ok(job);
|
||||
assert.equal(job['timeout-minutes'], 35);
|
||||
assert.equal(
|
||||
packageJson.scripts['audit:plugin-package-recovery-e2e:ql3'],
|
||||
'node scripts/ql3-plugin-package-recovery-e2e-live-audit.cjs',
|
||||
);
|
||||
assert.ok(
|
||||
job.steps.some(
|
||||
(step) => step.run === 'pnpm test:plugin-package-recovery-e2e:ql3',
|
||||
(step) =>
|
||||
String(step.run).includes(
|
||||
'pnpm test:plugin-package-recovery-e2e:ql3',
|
||||
) &&
|
||||
String(step.run).includes('--report=') &&
|
||||
String(step.run).includes('pnpm audit:plugin-package-recovery-e2e:ql3'),
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
job.steps.some(
|
||||
(step) =>
|
||||
step.env?.QL3_SOURCE_REVISION === '${{ github.sha }}' &&
|
||||
step.env?.QL3_PLUGIN_PACKAGE_RECOVERY_E2E_LIVE === '1',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
job.steps.some(
|
||||
(step) =>
|
||||
step.if === 'always()' &&
|
||||
String(step.uses).startsWith('actions/upload-artifact@') &&
|
||||
step.with?.['retention-days'] === 14,
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
@@ -136,6 +160,16 @@ test('package script and independent CI job execute the full gate', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('gate persists only a source-bound low-sensitive private report', () => {
|
||||
assert.match(live, /privateReportPath\(argv\)/);
|
||||
assert.match(live, /writePrivateReport\(reportFile, report\)/);
|
||||
assert.match(live, /QL3_SOURCE_REVISION/);
|
||||
assert.match(live, /org\.opencontainers\.image\.revision/);
|
||||
assert.match(live, /activeJsonDigest/);
|
||||
assert.doesNotMatch(live, /activePointer: pointerAfterRejection/);
|
||||
assert.match(live, /reportWritten: true/);
|
||||
});
|
||||
|
||||
test('private Registry evidence uses one exact Secret file and authenticated requests', () => {
|
||||
assert.match(fixture, /request\.headers\.authorization !== authorization/);
|
||||
assert.match(fixture, /www-authenticate/);
|
||||
|
||||
Reference in New Issue
Block a user