From 22eea3ac717c0645dda594e3712c3ff9d4a142cb Mon Sep 17 00:00:00 2001 From: whyour Date: Thu, 13 Aug 2026 17:44:05 +0800 Subject: [PATCH] test(ql3): prove Kubernetes secret binding projection --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- ...confirmed-plugin-package-secret-binding.md | 7 +- package.json | 2 + ...e-secret-binding-kubernetes-live-audit.cjs | 259 ++++ ...cret-binding-kubernetes-live-bootstrap.cjs | 343 +++++ ...ecret-binding-kubernetes-live-contract.cjs | 1366 +++++++++++++++++ ...eSecretBindingKubernetesLiveAudit.test.cjs | 99 ++ ...cretBindingKubernetesLiveContract.test.cjs | 46 + 8 files changed, 2120 insertions(+), 4 deletions(-) create mode 100644 scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs create mode 100644 scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs create mode 100644 scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs create mode 100644 test/back/ql3PluginPackageSecretBindingKubernetesLiveAudit.test.cjs create mode 100644 test/back/ql3PluginPackageSecretBindingKubernetesLiveContract.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 4b341962..23a1fa13 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -28,7 +28,7 @@ - D-304/ADR-0392(已接受):Plugin Package 进入安全 quarantine 时,Workflow/Prompt automation publication 不再仅依赖运行时 start guard 间接拒绝,而是与 quarantine event、Package-owned Task disabled revisions、Project Tool snapshot 和 withdrawal receipt 在同一 SQLite/PostgreSQL 事务中收敛为 `withdrawn`。原先仅能引用普通 lifecycle event 的外键升级为 append-only disposition-event 联合引用,历史 migration 与 publication digest schema 保持不变;SQLite edge/standalone 崩溃矩阵覆盖 automation publication insert 后、event/task/receipt/COMMIT 前后,PostgreSQL 通过触发器在既有 `SECURITY DEFINER` quarantine commit 内登记 disposition,再由同一外层 SERIALIZABLE transaction CAS publication head。能力位为 `plugin_package_automation_security_withdrawal@1`;不新增 package、daemon、timer、连接或常驻缓存,适用于低配路由设备和集群节点。SQLite 全量 228/228;PostgreSQL package 311 pass/1 条外部 URL 条件 skip;完整 18-package build/test 退出 0,backend 1,188 pass/2 skip,package/dependency boundary 零 finding;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `ab156901b9c96ec5a62259c44d83d24ded011e0616dc827d928f3e13efd11786`。 - D-305/ADR-0393(已接受):Plugin Package Manifest 的逻辑 Secret requirement 获得按 resource generation 固定的不可变 binding。binding 精确覆盖 Manifest requirements,只保存同 Project、显式 version 的 `qlsecret://` 引用与 `approved-action-execution|local-owner-confirmation` authority evidence digest,不保存 Secret 明文;required 不可为空,optional 可显式为 `null`。发布由当前 active installation head、lock、generation 与 Manifest digest 联合 fencing,相同事实幂等、不同事实冲突;domain-separated digest、最多 64 项和 64 KiB 单行预算同时约束 Local 与 Cluster。SQLite 追加 `0091` ledger 与 capability v46,PostgreSQL 追加 `pg-0059`、capability v58,并只向 package executor 授予 `SELECT, INSERT`。不新增 package、daemon、timer、watcher、连接、缓存或集群 workload,低配路由设备只承担一个有界表和三个索引。D-305 不冒充 Secret 已进入执行路径:现有 materialization 拒绝仍保留,D-306 再完成用户授权、Secret resolution、runtime consumption 与 lifecycle/rebinding 语义。core 509/509、SQLite 232/232、PostgreSQL 316 pass/1 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 skip,五项边界审计零 finding,workspace 仍无 single-source/shallow-source package。PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `acf0fea7ca7699989dfe70f5dd0061cdf5fb1968c691094331fea06ce01b96dc`。 - D-306A/ADR-0394(已接受):Package Task source 以 `package-secret` placeholder 引用逻辑 requirement,materialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`;Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digest,Local/Cluster 启动发布复用既有 repository/pool,Task dispatch、Local 短时解密和 Cluster offer/lease-fenced delivery 不另造协议。SQLite/PostgreSQL INSERT trigger 同时防止直接写库绕过;Local 只读 readiness 继续不加载 DDL。Local contract v47、Cluster v59;不新增 package、表、索引、连接、daemon、watcher、timer、cache 或 workload。完整 18-package clean build/test 退出 0;backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 finding,workspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 source;PostgreSQL 18.4 arm64 HA 125 项 gate 全绿、timeline `1→2`,报告 SHA-256 为 `f9107e8e54892a788779758f0573ac8d6a80f6d086516a1f5f5bbacb59bbb4be`。D-306A 不冒充产品闭环:Local bind/rebind 命令、Cluster Approved Action/API 与新 generation rotation/revocation 编排属于 D-306B。 -- D-306B1/ADR-0395(进行中):先完成当前 active、尚未绑定 Package generation 的首次 Secret binding,不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建,调用方不能注入 generation 或 digest。Local 已复用短生命周期 `ql3-package` 私有 command-file 和单 SQLite authority:强 `local-console` User 必须同时具备 `secret.manage` 与当前 Project Owner,human confirmation 后在一个 `BEGIN IMMEDIATE` transaction 内复验 Owner fence、Package provenance、精确 Secret version、content-free audit 与 immutable binding;plan 后撤权原子回滚,exact replay 收敛,不同 audit identity 或当前代重复绑定失败关闭。Cluster 核心 authority 已补齐 package-manager `plan→propose→separation-of-duty decide`、package-executor 有界 approved queue consumer、Approved Action handler 与 immutable PostgreSQL binding publish;executor 只对可选 read-only Kubernetes Secret 投影执行 metadata-only existence check,不读取 Secret value,缺投影失败关闭。产品入口已复用既有 `/api/v3/plugin-packages/management`、mTLS/OIDC 强 User 身份、同一个 package-manager Pool、分布式 quota 与 `ql3-cluster-admin package` 一次性 client,新增严格 exact-shape 的 `plugin-package.secret-binding.plan|propose|decide|inspect` 四命令;成功响应仅含 target、固定 version SecretRef、审批与 digest 摘要,客户端会拒绝跨 Project SecretRef、重复 requirement、错误 action/approval/digest 绑定和扩展响应。该入口不把 Secret value、executor、Kubernetes 或 binding 表直读 authority 装进 management 进程,也不增加 listener、连接或 workload。PostgreSQL 18 三角色真实纵切面已从正式 transport 完整执行 install/activate→plan/replay/propose/decide/inspect→consume/dispatch/bind,并证明 manager 不能读取最终 binding 表。完整 18-package clean build/test 退出 0,cluster-admin 315 pass/3 条件 skip,backend 1,188 pass/2 条件 skip/0 fail;package/dependency/deployment/CloudNativePG/edge/service-manager/local-image 七项审计零 finding。最新 PostgreSQL 18.4 arm64 physical HA 通过 125 gate、timeline `1→2`,报告 SHA-256 为 `6506b74721891cfd5709b1661562c204a310e99ed06131a25c464128f4ded4c6`。未新增 package、migration、表、索引、依赖、常驻进程、连接、listener、timer、watcher、cache 或 workload;18 个 workspace package仍无 single-source/shallow-source package。Kubernetes live exercise 与 B2 rebind/rotation/revocation 仍待完成,所以 ADR 保持 Proposed。 +- D-306B1/ADR-0395(已接受):当前 active、尚未绑定 Package generation 的首次 Secret binding 已形成 Local 与 Cluster 产品闭环,且不允许原地 rebind。共享 content-free plan 由服务端从 installation/proposal/lock/Manifest/generation 重建;Local 使用短生命周期 `ql3-package`、Owner human confirmation 与单 SQLite transaction,Cluster 使用既有 package-management HTTPS/CLI、package-manager separation-of-duty Approval 和短生命周期 package-executor。三节点 K3s `v1.34.3+k3s1` arm64 现场门已在真实 PostgreSQL `18.4` 上完成两个 management Pod 跨节点部署、正式 client `plan→跨副本 replay→propose→双人 decide→inspect`、真实 executor Job 与只读 Kubernetes Secret projection。management/executor 均无 Secret API 读取权和 ServiceAccount token;management 不挂载 Package value,executor 只验证投影元数据;最终恰好一条 immutable binding,Approval consumed、execution succeeded,数据库敏感值扫描为 0。16/16 gate 的 owner-private、低敏报告通过独立 exact-shape 审计,SHA-256 为 `aaabb5ebea77c50bce671f91dd3051671fd20875c11a8f787fe8933f29dbfa4d`。完整 18-package clean build/test、backend 与七项边界审计,以及 PostgreSQL 18.4 physical HA 125 gate/timeline `1→2` 证据继续有效;没有新增 workspace package、migration、表、索引、依赖或常驻 workload。B2 rebind/rotation/revocation 必须通过新 Package generation 独立推进。 - 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、 diff --git a/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md b/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md index 756c69e4..71d8d002 100644 --- a/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0395-owner-confirmed-plugin-package-secret-binding.md @@ -1,6 +1,6 @@ # ADR-0395:Owner 确认的 Plugin Package Secret 首次绑定 -- 状态:Proposed +- 状态:Accepted - 日期:2026-08-13 - 关联 RFC:QL-RFC-0001 D-306B1 - 关联 ADR:ADR-0364、ADR-0393、ADR-0394 @@ -40,6 +40,7 @@ QingLong 3.0 同时面向低配路由设备和集群节点。本机产品入口 - Runtime Core、Local SQLite、Local Owner CLI closure type-check 已通过;Local 产品定向纵切面 3/3。 - 18-package clean build/test 中除受限沙箱禁止绑定 `127.0.0.1` 导致的 Worker TLS 三项外,其余已执行测试均为 0 fail;同一 Worker 完整包在允许回环监听的环境为 133/133。 - Cluster 核心 authority 已完成:package-manager durable plan/proposal/decision、package-executor 有界 consumer/dispatch/handler、只读可选 Secret 投影与 immutable binding publish。`cluster-admin` 与 `cluster-postgres` 最终全包合计 638 pass/2 条件 skip;PostgreSQL 18 三角色真实纵切面 1/1,覆盖真实 install/activate、plan/propose/decide、consume/dispatch/bind、exact replay、manager 对 binding 表拒绝和 content-free 断言,并发现、修复 INSERT placeholder typmod 缺失。 -- Cluster 产品入口已完成:复用既有 `/api/v3/plugin-packages/management` HTTPS route、mTLS/OIDC 强 User 身份、package-manager Pool、分布式 quota 与 `ql3-cluster-admin package` 一次性 client,开放 exact-shape 的 `plugin-package.secret-binding.plan|propose|decide|inspect`。响应仅含 content-free target、固定 version SecretRef、审批和 digest 摘要;client 会拒绝跨 Project、重复 requirement、action/approval/digest 漂移和扩展响应。management 进程不取得 Secret value、Kubernetes、executor 或最终 binding 表 authority,也不新增 listener、连接、进程或 workload。 +- Cluster 产品入口已完成:复用既有 `/api/v3/plugin-packages/management` HTTPS route、TLS 1.3 服务端认证与 OIDC 强 User 身份、package-manager Pool、分布式 quota 与 `ql3-cluster-admin package` 一次性 client,开放 exact-shape 的 `plugin-package.secret-binding.plan|propose|decide|inspect`。响应仅含 content-free target、固定 version SecretRef、审批和 digest 摘要;client 会拒绝跨 Project、重复 requirement、action/approval/digest 漂移和扩展响应。management 进程不取得 Secret value、Kubernetes、executor 或最终 binding 表 authority,也不新增 listener、连接、进程或 workload。 - 当前 Cluster Admin runtime 镜像的统一产品 facade 已在 UID `10001:10001`、128 MiB、0.25 CPU、32 PIDs、read-only root、无网络、全 capability drop 和 `no-new-privileges` 下完成 live contract;七个子命令、operator context preflight/readiness 与 `package` 分派均通过。这是制品/CLI 资源门,不冒充 Kubernetes Secret projection 或多 Pod live exercise。 -- 正式 transport 经 PostgreSQL 18 三角色真实纵切面 1/1,覆盖 install/activate、plan/exact replay/propose/separation-of-duty decide/authorized inspect、consumer/dispatch/bind、manager 最终表拒绝与 content-free 输出。`cluster-admin` 315 pass/3 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 条件 skip/0 fail。package boundary、cluster dependency、cluster deployment、CloudNativePG、edge import、service-manager bridge、local image 七项审计零 finding;workspace 仍为 18 包且没有 single-source/shallow-source package。PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 已通过,报告 SHA-256 为 `6506b74721891cfd5709b1661562c204a310e99ed06131a25c464128f4ded4c6`。Kubernetes live exercise 仍待完成,因此本 ADR 保持 Proposed。 +- 正式 transport 经 PostgreSQL 18 三角色真实纵切面 1/1,覆盖 install/activate、plan/exact replay/propose/separation-of-duty decide/authorized inspect、consumer/dispatch/bind、manager 最终表拒绝与 content-free 输出。`cluster-admin` 315 pass/3 条件 skip;完整 18-package clean build/test 退出 0,backend 1,188 pass/2 条件 skip/0 fail。package boundary、cluster dependency、cluster deployment、CloudNativePG、edge import、service-manager bridge、local image 七项审计零 finding;workspace 仍为 18 包且没有 single-source/shallow-source package。PostgreSQL 18.4 arm64 physical HA 125 gate、timeline `1→2` 已通过,报告 SHA-256 为 `6506b74721891cfd5709b1661562c204a310e99ed06131a25c464128f4ded4c6`。 +- 三节点 K3s `v1.34.3+k3s1` arm64 现场门已完成真实 PostgreSQL `18.4` migration、两个 management Pod 跨节点反亲和、五次正式 HTTPS client `plan→跨副本 replay→propose→双人 decide→inspect`、真实 package-executor Job 与只读 Kubernetes Secret projection。management 与 executor ServiceAccount 均不能 `get/list secrets`、均不挂载 API token;management 不挂载 Package value,executor 只做 projection metadata existence check。数据库恰好发布一条 `approved-action-execution` binding,Approval consumed、execution succeeded,敏感值在 binding/plan/approval/execution JSON 中命中数为 0。低敏私有报告通过 16/16 gate 和独立 exact-shape 审计,SHA-256 为 `aaabb5ebea77c50bce671f91dd3051671fd20875c11a8f787fe8933f29dbfa4d`。本门不冒充 Kubernetes control-plane HA;PostgreSQL physical failover 继续由独立 125-gate 合约负责。B1 接受条件已满足,B2 rebind/rotation/revocation 作为后续新 generation 状态机继续推进。 diff --git a/package.json b/package.json index c90a774d..861fcec5 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "test:prompt-output-projection-kubernetes-live:ql3": "pnpm --filter @qinglong/ai build && node scripts/ql3-prompt-output-projection-kubernetes-live-contract.cjs", "audit:prompt-output-projection-kubernetes-live:ql3": "node scripts/ql3-prompt-output-projection-kubernetes-live-audit.cjs", "test:plugin-package-kubernetes-live:ql3": "node scripts/ql3-plugin-package-kubernetes-live-contract.cjs", + "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", "benchmark:edge": "node scripts/ql3-edge-benchmark.cjs", "benchmark:cluster-control": "node scripts/ql3-cluster-control-benchmark.cjs", diff --git a/scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs new file mode 100644 index 00000000..1973d0ac --- /dev/null +++ b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs @@ -0,0 +1,259 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const FIXTURE = 'qinglong/plugin-package-secret-binding-kubernetes-live@v1'; +const SHA256 = /^sha256:[a-f0-9]{64}$/; +const FORBIDDEN_KEY = + /(secretRef|secretValue|material|assertion|jwt|password|dsn|privateKey|certificate|kubeconfig|podUid|nodeUid|podName|nodeName)/i; +const REQUIRED_GATES = Object.freeze([ + 'realThreeNodeKubernetes', + 'twoManagementReplicasOnDistinctNodes', + 'formalHttpsClientCommands', + 'planReplayedAcrossReplicas', + 'separationOfDutyDecision', + 'authorizedInspection', + 'realExecutorJob', + 'projectedSecretMetadataAccepted', + 'bindingPublishedExactlyOnce', + 'managementCannotReadSecrets', + 'managementDoesNotMountPackageValues', + 'executorCannotReadSecrets', + 'executorHasNoServiceAccountToken', + 'executorProjectionReadOnly', + 'databaseContainsNoSensitiveValue', + 'passed', +]); + +function exact(value, keys) { + return Boolean( + value && + typeof value === 'object' && + !Array.isArray(value) && + JSON.stringify(Object.keys(value).sort()) === + JSON.stringify([...keys].sort()), + ); +} + +function scan(value, findings, location = 'report') { + if (Array.isArray(value)) { + value.forEach((entry, index) => + scan(entry, findings, `${location}[${index}]`), + ); + return; + } + if (!value || typeof value !== 'object') return; + for (const [key, entry] of Object.entries(value)) { + if (FORBIDDEN_KEY.test(key)) + findings.push(`${location}.${key} is forbidden`); + if ( + typeof entry === 'string' && + /(qlsecret:v1:|-----BEGIN |postgres(?:ql)?:\/\/|eyJ[A-Za-z0-9_-]+\.)/.test( + entry, + ) + ) { + findings.push(`${location}.${key} contains sensitive material`); + } + scan(entry, findings, `${location}.${key}`); + } +} + +function validatePluginPackageSecretBindingKubernetesLiveReport(report) { + const findings = []; + if ( + !exact(report, [ + 'schemaVersion', + 'fixture', + 'observedAtMs', + 'platform', + 'management', + 'review', + 'executor', + 'persistence', + 'gates', + 'limitations', + ]) || + report.schemaVersion !== 1 || + report.fixture !== FIXTURE || + !Number.isSafeInteger(report.observedAtMs) || + report.observedAtMs < 1 + ) { + findings.push('report envelope is invalid'); + } + if ( + !exact(report.platform, [ + 'architecture', + 'kubernetesVersion', + 'nodeCount', + 'postgresVersionNumber', + 'adminImageId', + ]) || + !['amd64', 'arm64'].includes(report.platform?.architecture) || + typeof report.platform?.kubernetesVersion !== 'string' || + report.platform?.nodeCount !== 3 || + report.platform?.postgresVersionNumber !== 180004 || + !SHA256.test(report.platform?.adminImageId ?? '') + ) { + findings.push('platform evidence is invalid'); + } + if ( + !exact(report.management, [ + 'replicas', + 'distinctNodeHashes', + 'serviceAccountTokenMounted', + 'packageValueVolumeMounted', + 'canGetSecrets', + 'canListSecrets', + ]) || + report.management?.replicas !== 2 || + !Array.isArray(report.management?.distinctNodeHashes) || + report.management.distinctNodeHashes.length !== 2 || + new Set(report.management.distinctNodeHashes).size !== 2 || + !report.management.distinctNodeHashes.every((value) => + SHA256.test(value), + ) || + report.management.serviceAccountTokenMounted !== false || + report.management.packageValueVolumeMounted !== false || + report.management.canGetSecrets !== false || + report.management.canListSecrets !== false + ) { + findings.push('management isolation evidence is invalid'); + } + if ( + !exact(report.review, [ + 'commands', + 'requesterSubjectHash', + 'reviewerSubjectHash', + 'distinctUsers', + 'planStatus', + 'replayStatus', + 'decisionStatus', + 'inspectionStale', + 'actionDigest', + 'planDigest', + ]) || + JSON.stringify(report.review?.commands) !== + JSON.stringify([ + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.propose', + 'plugin-package.secret-binding.decide', + 'plugin-package.secret-binding.inspect', + ]) || + !SHA256.test(report.review?.requesterSubjectHash ?? '') || + !SHA256.test(report.review?.reviewerSubjectHash ?? '') || + report.review?.requesterSubjectHash === + report.review?.reviewerSubjectHash || + report.review?.distinctUsers !== true || + report.review?.planStatus !== 'created' || + report.review?.replayStatus !== 'existing' || + report.review?.decisionStatus !== 'decided' || + report.review?.inspectionStale !== false || + !SHA256.test(`sha256:${report.review?.actionDigest ?? ''}`) || + !SHA256.test(`sha256:${report.review?.planDigest ?? ''}`) + ) { + findings.push('review ceremony evidence is invalid'); + } + if ( + !exact(report.executor, [ + 'jobSucceeded', + 'serviceAccountTokenMounted', + 'canGetSecrets', + 'canListSecrets', + 'projectionReadOnly', + 'projectionFileCount', + 'projectionKeyHash', + 'outputSensitiveFree', + ]) || + report.executor?.jobSucceeded !== true || + report.executor?.serviceAccountTokenMounted !== false || + report.executor?.canGetSecrets !== false || + report.executor?.canListSecrets !== false || + report.executor?.projectionReadOnly !== true || + report.executor?.projectionFileCount !== 1 || + !SHA256.test(report.executor?.projectionKeyHash ?? '') || + report.executor?.outputSensitiveFree !== true + ) { + findings.push('executor projection evidence is invalid'); + } + if ( + !exact(report.persistence, [ + 'bindingCount', + 'authorityKind', + 'evidenceDigest', + 'entryCount', + 'approvalConsumed', + 'executionSucceeded', + 'sensitiveMatchCount', + ]) || + report.persistence?.bindingCount !== 1 || + report.persistence?.authorityKind !== 'approved-action-execution' || + !SHA256.test(`sha256:${report.persistence?.evidenceDigest ?? ''}`) || + report.persistence?.entryCount !== 1 || + report.persistence?.approvalConsumed !== true || + report.persistence?.executionSucceeded !== true || + report.persistence?.sensitiveMatchCount !== 0 + ) { + findings.push('durable binding evidence is invalid'); + } + if ( + !exact(report.gates, REQUIRED_GATES) || + REQUIRED_GATES.some((gate) => report.gates?.[gate] !== true) + ) { + findings.push('one or more required gates are false or missing'); + } + if ( + !Array.isArray(report.limitations) || + report.limitations.length !== 2 || + report.limitations.some( + (value) => + typeof value !== 'string' || value.length < 16 || value.length > 512, + ) + ) { + findings.push('limitations are invalid'); + } + scan(report, findings); + return Object.freeze({ + schemaVersion: 1, + fixture: FIXTURE, + findings: Object.freeze(findings), + compatible: findings.length === 0, + }); +} + +function main(argv) { + if (argv.length !== 1 || !argv[0].startsWith('--report=/')) { + throw new Error( + 'usage: ql3-plugin-package-secret-binding-kubernetes-live-audit --report=/absolute/report.json', + ); + } + const reportPath = argv[0].slice('--report='.length); + if (path.resolve(reportPath) !== reportPath) + throw new Error('report path is invalid'); + const report = JSON.parse(fs.readFileSync(reportPath, 'utf8')); + const result = validatePluginPackageSecretBindingKubernetesLiveReport(report); + 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 Secret binding Kubernetes live audit failed: ${ + error instanceof Error ? error.message : String(error) + }\n`, + ); + process.exitCode = 1; + } +} + +module.exports = { + FIXTURE, + REQUIRED_GATES, + validatePluginPackageSecretBindingKubernetesLiveReport, +}; diff --git a/scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs new file mode 100644 index 00000000..4d318dc2 --- /dev/null +++ b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const { randomUUID } = require('node:crypto'); +const fs = require('node:fs'); + +const { + createPostgresDatabaseOpener, +} = require('@qinglong/cluster-postgres/package-executor'); +const { + PostgresApprovalRequestRepository, +} = require('@qinglong/cluster-postgres/approved-action'); +const { + PostgresPluginPackageInstallRepository, +} = require('@qinglong/cluster-postgres/plugin-package-install'); +const { + PLUGIN_PACKAGE_API_VERSION, + PLUGIN_PACKAGE_KIND, + planPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package'); +const { + pluginPackageActivationIntentDigest, + pluginPackageInstallCommit, + transitionPluginPackageInstall, +} = require('@qinglong/runtime-core/plugin-package-install'); +const { + createPluginPackagePublisherProvenance, +} = require('@qinglong/runtime-core/plugin-package-publisher-provenance'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { + createClusterPluginPackageManagementService, +} = require('@qinglong/cluster-admin/plugin-package-management'); +const { + createClusterPluginPackageApprovedActionDispatcher, +} = require('@qinglong/cluster-admin/plugin-package-approved-action'); + +const PROJECT_ID = 'secret-binding-kubernetes-live'; +const PACKAGE_NAME = 'secret-binding-live'; +const REQUESTER_ID = 'secret-binding-requester'; +const REVIEWER_ID = 'secret-binding-reviewer'; +const MIGRATION_URL = process.env.QL3_TEST_POSTGRES_MIGRATION_URL; +const MANAGER_URL = process.env.QL3_TEST_POSTGRES_PACKAGE_MANAGER_URL; +const EXECUTOR_URL = process.env.QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL; + +function opener(role, connectionString) { + assert.ok(connectionString, `${role} URL is required`); + return createPostgresDatabaseOpener({ + role, + connection: { connectionString, tls: { mode: 'disable' } }, + pool: { maxConnections: 2, applicationName: `ql3-live-${role}` }, + onPoolError(error) { + throw error; + }, + }); +} + +function principal(id, authenticationId, now) { + return Object.freeze({ + subject: Object.freeze({ type: 'user', id }), + authenticationId, + authenticatedAtMs: now - 1, + expiresAtMs: now + 120_000, + assurance: 'multi_factor', + }); +} + +function audit(eventId, requestId, projectId, now, fence) { + return Object.freeze({ + eventId, + requestId, + operationId: 'approval.consume', + projectId, + subject: Object.freeze({ type: 'system', id: 'cluster_package_executor' }), + authenticationId: 'secret-binding-kubernetes-live-bootstrap', + outcome: 'allowed', + reasons: Object.freeze(['package_review']), + fence, + occurredAtMs: now, + }); +} + +async function main() { + let now = Date.now(); + const migration = await opener('migration', MIGRATION_URL)(); + const manager = await opener('package-manager', MANAGER_URL)(); + const executor = await opener('package-executor', EXECUTOR_URL)(); + try { + await migration.pool.query( + `INSERT INTO "ql3"."projects" ( + id, name, slug, status, version, created_at_ms, updated_at_ms + ) VALUES ($1, $1, $1, 'active', 1, $2, $2)`, + [PROJECT_ID, now], + ); + await migration.pool.query( + `INSERT INTO "ql3"."project_role_bindings" ( + project_id, subject_type, subject_id, version, state, role, + mutation_id, changed_by_type, changed_by_id, created_at_ms + ) VALUES + ($1, 'user', $2, 1, 'active', 'owner', $4, 'system', 'live', $6), + ($1, 'user', $3, 1, 'active', 'admin', $5, 'system', 'live', $6)`, + [ + PROJECT_ID, + REQUESTER_ID, + REVIEWER_ID, + 'secret-binding-live-owner', + 'secret-binding-live-reviewer', + now, + ], + ); + + const manifest = Object.freeze({ + apiVersion: PLUGIN_PACKAGE_API_VERSION, + kind: PLUGIN_PACKAGE_KIND, + metadata: { + name: PACKAGE_NAME, + displayName: 'Kubernetes Secret binding live fixture', + version: '1.0.0', + description: 'Content-free Secret binding Kubernetes evidence fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64', 'amd64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '16Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [{ name: 'TOKEN', required: true }], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }); + const environment = Object.freeze({ + qinglongVersion: '3.0.0-alpha.0', + architecture: process.arch === 'x64' ? 'amd64' : 'arm64', + deploymentProfile: 'cluster-control', + runtimes: [], + availableMemoryBytes: 128 * 1024 * 1024, + availableDiskBytes: 256 * 1024 * 1024, + }); + const plan = planPluginPackageInstall(manifest, environment); + const actionInput = Object.freeze({ + lockId: 'secret-binding-live-lock', + projectId: PROJECT_ID, + manifest, + plan, + environment, + source: { + kind: 'offline', + locator: `offline:sha256:${'a'.repeat(64)}`, + artifactDigest: 'a'.repeat(64), + artifactBytes: 2048, + contentDigest: 'b'.repeat(64), + }, + architecture: environment.architecture, + deploymentProfile: 'cluster-control', + targetGeneration: 1, + }); + const actionRef = `install:${PACKAGE_NAME}:v1`; + const approvalId = 'secret-binding-live-install-approval'; + const management = createClusterPluginPackageManagementService({ + pool: manager.pool, + now: () => now, + approvalLifetimeMs: 120_000, + }); + const proposed = await management.propose({ + actionRef, + approvalRequestId: approvalId, + proposalAuditEventId: randomUUID(), + approvalAuditEventId: randomUUID(), + requestedAtMs: now, + actionInput, + principal: principal(REQUESTER_ID, 'bootstrap-requester', now), + }); + now += 10; + const decided = await management.decide({ + approvalRequestId: approvalId, + expectedVersion: proposed.approvalRequest.version, + decisionId: 'secret-binding-live-install-decision', + auditEventId: randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + decidedAtMs: now, + principal: principal(REVIEWER_ID, 'bootstrap-reviewer', now), + }); + const fence = Object.freeze({ projectVersion: 1, bindingVersion: 1 }); + now += 10; + const consumed = await new PostgresApprovalRequestRepository( + executor.pool, + ).consume({ + requestId: approvalId, + expectedVersion: decided.request.version, + consumptionId: 'secret-binding-live-install-consume', + dispatchId: 'secret-binding-live-install-dispatch', + action: decided.request.action, + requestedBy: Object.freeze({ type: 'user', id: REQUESTER_ID }), + consumedBy: Object.freeze({ + type: 'system', + id: 'cluster_package_executor', + }), + consumedAtMs: now, + authorizationFence: fence, + audit: audit(randomUUID(), approvalId, PROJECT_ID, now, fence), + }); + assert.equal(consumed.status, 'consumed'); + let id = 0; + now += 10; + const dispatched = await createClusterPluginPackageApprovedActionDispatcher( + { + pool: executor.pool, + owner: 'secret-binding-live-bootstrap', + clock: () => now, + createId: () => `secret-binding-live-bootstrap-${++id}`, + secretExistenceInspector: { async assertExists() {} }, + }, + ).dispatchBatch({ limit: 4 }); + assert.equal(dispatched.succeeded, 1); + + const installs = new PostgresPluginPackageInstallRepository(executor.pool); + const queued = await installs.find(PROJECT_ID, PACKAGE_NAME); + assert.ok(queued); + const lock = await installs.findLock(queued.lockDigest); + assert.ok(lock); + now += 10; + const staged = transitionPluginPackageInstall(lock, queued, { + type: 'stage_completed', + mutationId: 'secret-binding-live-stage', + occurredAtMs: now, + stageRef: `stage:${lock.lockDigest}`, + artifactDigest: lock.source.artifactDigest, + manifestDigest: lock.manifestDigest, + contentDigest: lock.source.contentDigest, + evidenceDigest: 'c'.repeat(64), + }); + const provenance = createPluginPackagePublisherProvenance({ + projectId: PROJECT_ID, + packageName: PACKAGE_NAME, + installationId: queued.installationId, + lockDigest: lock.lockDigest, + artifactDigest: staged.stageReceipt.artifactDigest, + manifestDigest: staged.stageReceipt.manifestDigest, + contentDigest: staged.stageReceipt.contentDigest, + stageEvidenceDigest: staged.stageReceipt.evidenceDigest, + signature: { + publisher: 'live.qinglong.test', + keyId: 'live-key-1', + signatureDigest: 'd'.repeat(64), + keyNotBeforeMs: now - 1, + keyNotAfterMs: now + 120_000, + verifiedAtMs: now, + }, + }); + await executor.pool.query( + `INSERT INTO "ql3"."plugin_package_publisher_provenance" ( + installation_id, project_id, package_name, lock_digest, + artifact_digest, manifest_digest, content_digest, + stage_evidence_digest, publisher, key_id, signature_digest, + key_not_before_ms, key_not_after_ms, verified_at_ms, + provenance_digest, provenance_json + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16::jsonb)`, + [ + provenance.installationId, + provenance.projectId, + provenance.packageName, + provenance.lockDigest, + provenance.artifactDigest, + provenance.manifestDigest, + provenance.contentDigest, + provenance.stageEvidenceDigest, + provenance.publisher, + provenance.keyId, + provenance.signatureDigest, + provenance.keyNotBeforeMs, + provenance.keyNotAfterMs, + provenance.verifiedAtMs, + provenance.provenanceDigest, + JSON.stringify(provenance), + ], + ); + await installs.commit(pluginPackageInstallCommit(queued, staged)); + now += 10; + const activating = transitionPluginPackageInstall(lock, staged, { + type: 'activation_started', + mutationId: 'secret-binding-live-activate', + occurredAtMs: now, + }); + await installs.commit(pluginPackageInstallCommit(staged, activating)); + now += 10; + const active = transitionPluginPackageInstall(lock, activating, { + type: 'activation_committed', + mutationId: 'secret-binding-live-commit', + occurredAtMs: now, + activationRef: `active:${lock.lockDigest}`, + intentDigest: pluginPackageActivationIntentDigest(lock, activating), + generation: 1, + contentDigest: lock.source.contentDigest, + }); + await installs.commit(pluginPackageInstallCommit(activating, active)); + + const secretRef = createSecretRef({ + projectId: PROJECT_ID, + name: 'runtime-token', + version: 1, + }); + const evidence = `${JSON.stringify({ + schemaVersion: 1, + event: 'secret_binding_prerequisite_ready', + projectId: PROJECT_ID, + packageName: PACKAGE_NAME, + requesterId: REQUESTER_ID, + reviewerId: REVIEWER_ID, + secretRef, + projectionKey: secretProjectionFileName(secretRef), + installationId: active.installationId, + generation: active.targetGeneration, + })}\n`; + fs.writeFileSync('/dev/termination-log', evidence); + process.stdout.write(evidence); + } finally { + await Promise.all([migration.close(), manager.close(), executor.close()]); + } +} + +main().catch((error) => { + process.stderr.write( + `QL3 Secret binding Kubernetes bootstrap failed: ${ + error instanceof Error ? error.stack || error.message : String(error) + }\n`, + ); + process.exitCode = 1; +}); diff --git a/scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs new file mode 100644 index 00000000..596f344c --- /dev/null +++ b/scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs @@ -0,0 +1,1366 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); +const yaml = require('js-yaml'); + +const { + K3sDockerLiveFixture, + run, + waitFor, +} = require('./lib/ql3-k3s-docker-live.cjs'); +const { createMutualTlsPki } = require('./lib/ql3-live-pki.cjs'); +const { + podReady, + readyManagementPods, +} = require('./lib/ql3-management-kubernetes-live.cjs'); +const { + createManagementIdentityCeremony, +} = require('./lib/ql3-management-live-identity.cjs'); +const { + validatePluginPackageSecretBindingKubernetesLiveReport, +} = require('./ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const NAMESPACE = 'qinglong3-system'; +const POSTGRES = 'ql3-secret-binding-postgres'; +const POSTGRES_IMAGE = 'postgres:18.4-bookworm'; +const POSTGRES_DIGEST = + 'sha256:1961f96e6029a02c3812d7cb329a3b03a3ac2bb067058dec17b0f5596aca9296'; +const DATABASE = 'qinglong'; +const MANAGEMENT = 'ql3-plugin-package-management'; +const SERVERNAME = MANAGEMENT + '.' + NAMESPACE + '.svc'; +const MANAGEMENT_PATH = '/api/v3/plugin-packages/management'; +const PROJECT_ID = 'secret-binding-kubernetes-live'; +const PACKAGE_NAME = 'secret-binding-live'; +const REQUESTER_ID = 'secret-binding-requester'; +const REVIEWER_ID = 'secret-binding-reviewer'; +const ACTION_REF = 'secret-binding:secret-binding-live:v1'; +const APPROVAL_ID = 'secret-binding-live-approval'; +const ADMIN_IMAGE_BASE = 'ql3-secret-binding-kubernetes-live'; +const ZERO_DIGEST = 'sha256:' + '0'.repeat(64); +const ISSUER = 'https://identity.qinglong.test/'; +const AUDIENCE = 'qinglong3-plugin-package-management'; +const ROLE_NAMES = Object.freeze([ + 'ql3_migration', + 'ql3_runtime', + 'ql3_admin', + 'ql3_package_manager', + 'ql3_package_executor', + 'ql3_automation_manager', + 'ql3_approval_manager', + 'ql3_run_manager', + 'ql3_worker_credential_manager', + 'ql3_worker_credential_executor', + 'ql3_worker_ingress', +]); +const identity = createManagementIdentityCeremony({ + issuer: ISSUER, + audience: AUDIENCE, + purpose: 'plugin-package-management', + tokenType: 'ql3-plugin-package-management+jwt', + subject: REQUESTER_ID, + jtiPrefix: 'ql3-secret-binding-live', +}); + +function sha256(value) { + return 'sha256:' + crypto.createHash('sha256').update(value).digest('hex'); +} + +function randomSecret() { + return crypto.randomBytes(32).toString('base64url'); +} + +function sqlString(value) { + assert.equal(typeof value, 'string'); + return "'" + value.replaceAll("'", "''") + "'"; +} + +function privateReportPath(argv) { + if ( + argv.length !== 1 || + !argv[0].startsWith('--report=') || + !path.isAbsolute(argv[0].slice('--report='.length)) + ) { + throw new Error( + 'usage: ql3-plugin-package-secret-binding-kubernetes-live-contract ' + + '--report=/absolute/private-report.json', + ); + } + const report = argv[0].slice('--report='.length); + if (fs.existsSync(report)) throw new Error('refusing to overwrite report'); + const parent = fs.lstatSync(path.dirname(report)); + if (!parent.isDirectory() || parent.isSymbolicLink()) { + throw new Error('report parent must be a real directory'); + } + return report; +} + +function writePrivateReport(reportFile, report) { + const temporary = path.join( + path.dirname(reportFile), + '.' + path.basename(reportFile) + '.' + process.pid + '.tmp', + ); + let descriptor; + try { + descriptor = fs.openSync(temporary, 'wx', 0o600); + fs.writeFileSync(descriptor, JSON.stringify(report, null, 2) + '\n'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + fs.linkSync(temporary, reportFile); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + fs.rmSync(temporary, { force: true }); + } +} + +function imageId(image) { + assert.match(image.Id, /^sha256:[a-f0-9]{64}$/); + return image.Id; +} + +function ensurePostgresImage(fixture) { + let inspected = fixture.dockerRun(['image', 'inspect', POSTGRES_IMAGE], { + capture: true, + quiet: true, + allowFailure: true, + }); + if (inspected.status !== 0) { + run(fixture.docker, ['pull', POSTGRES_IMAGE + '@' + POSTGRES_DIGEST]); + inspected = fixture.dockerRun(['image', 'inspect', POSTGRES_IMAGE], { + capture: true, + quiet: true, + }); + } + const image = JSON.parse(inspected.stdout)[0]; + assert.ok( + image.RepoDigests?.includes('postgres@' + POSTGRES_DIGEST), + 'PostgreSQL image does not retain the reviewed digest', + ); + fixture.loadImage(POSTGRES_IMAGE, 'secret-binding-postgres.tar'); +} + +function applySecret(fixture, name, stringData, type = 'Opaque') { + fixture.apply({ + apiVersion: 'v1', + kind: 'Secret', + metadata: { name, namespace: NAMESPACE }, + immutable: true, + type, + stringData, + }); +} + +function postgresResources(superuserPassword) { + return [ + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { name: POSTGRES + '-superuser', namespace: NAMESPACE }, + immutable: true, + type: 'Opaque', + stringData: { password: superuserPassword }, + }, + { + apiVersion: 'v1', + kind: 'Service', + metadata: { name: POSTGRES, namespace: NAMESPACE }, + spec: { + selector: { 'app.kubernetes.io/name': POSTGRES }, + ports: [{ name: 'postgres', port: 5432, targetPort: 5432 }], + }, + }, + { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: POSTGRES, + namespace: NAMESPACE, + labels: { 'app.kubernetes.io/name': POSTGRES }, + }, + spec: { + automountServiceAccountToken: false, + restartPolicy: 'Never', + securityContext: { seccompProfile: { type: 'RuntimeDefault' } }, + containers: [ + { + name: 'postgres', + image: POSTGRES_IMAGE, + imagePullPolicy: 'Never', + env: [ + { name: 'POSTGRES_USER', value: 'postgres' }, + { name: 'POSTGRES_DB', value: 'postgres' }, + { + name: 'POSTGRES_PASSWORD', + valueFrom: { + secretKeyRef: { + name: POSTGRES + '-superuser', + key: 'password', + }, + }, + }, + ], + ports: [{ name: 'postgres', containerPort: 5432 }], + readinessProbe: { + exec: { + command: [ + 'pg_isready', + '--username', + 'postgres', + '--dbname', + 'postgres', + ], + }, + periodSeconds: 2, + failureThreshold: 60, + }, + resources: { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '1', memory: '512Mi' }, + }, + volumeMounts: [{ name: 'data', mountPath: '/var/lib/postgresql' }], + }, + ], + volumes: [{ name: 'data', emptyDir: { sizeLimit: '2Gi' } }], + }, + }, + ]; +} + +function psql(fixture, database, sql, options = {}) { + const result = fixture.kubectl( + [ + '-n', + NAMESPACE, + 'exec', + '-i', + POSTGRES, + '--', + ...(options.password ? ['env', 'PGPASSWORD=' + options.password] : []), + 'psql', + '--username', + options.user ?? 'postgres', + '--dbname', + database, + '--no-psqlrc', + '--tuples-only', + '--no-align', + '--set', + 'ON_ERROR_STOP=1', + ], + { + input: sql + '\n', + capture: true, + quiet: true, + allowFailure: options.allowFailure === true, + }, + ); + return result; +} + +function createDatabaseRoles(fixture, passwords) { + const statements = ROLE_NAMES.map( + (role) => + 'CREATE ROLE ' + + role + + ' LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE ' + + 'NOREPLICATION NOBYPASSRLS PASSWORD ' + + sqlString(passwords[role]) + + ';', + ); + psql(fixture, 'postgres', statements.join('\n')); + psql( + fixture, + 'postgres', + 'CREATE DATABASE ' + DATABASE + ' OWNER ql3_migration;', + ); +} + +function roleEnvironment(role, passwordKey, applicationName) { + const prefix = 'QL3_POSTGRES_' + role; + return [ + { name: 'QL3_POSTGRES_TLS_MODE', value: 'disable' }, + { name: 'QL3_POSTGRES_ALLOW_INSECURE', value: 'true' }, + { name: 'QL3_POSTGRES_APPLICATION_NAME', value: applicationName }, + { name: prefix + '_HOST', value: POSTGRES }, + { name: prefix + '_PORT', value: '5432' }, + { name: prefix + '_DATABASE', value: DATABASE }, + { + name: prefix + '_USER', + value: 'ql3_' + role.toLowerCase(), + }, + { + name: prefix + '_PASSWORD', + valueFrom: { + secretKeyRef: { name: 'ql3-secret-binding-db-auth', key: passwordKey }, + }, + }, + ]; +} + +function migrationJob(adminImage) { + return { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name: 'ql3-secret-binding-migration', namespace: NAMESPACE }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 600, + ttlSecondsAfterFinished: 600, + template: { + metadata: { + labels: { 'app.kubernetes.io/name': 'ql3-secret-binding-migration' }, + }, + spec: { + automountServiceAccountToken: false, + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'migration', + image: adminImage, + imagePullPolicy: 'Never', + command: [ + 'node', + '/opt/qinglong/node_modules/@qinglong/cluster-postgres/' + + 'dist/migration/migrationCli.js', + ], + terminationMessagePolicy: 'FallbackToLogsOnError', + env: roleEnvironment( + 'MIGRATION', + 'migration-password', + 'ql3-secret-binding-migration', + ), + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '50m', memory: '64Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }, + volumeMounts: [{ name: 'tmp', mountPath: '/tmp' }], + }, + ], + volumes: [ + { name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '8Mi' } }, + ], + }, + }, + }, + }; +} + +async function waitJob(fixture, name, timeoutMs = 600_000) { + const result = await waitFor(name + ' completion', timeoutMs, () => { + const job = fixture.kubectlJson(['-n', NAMESPACE, 'get', 'job', name]); + const complete = job.status?.conditions?.some( + (condition) => + condition.type === 'Complete' && condition.status === 'True', + ); + const failed = job.status?.conditions?.some( + (condition) => condition.type === 'Failed' && condition.status === 'True', + ); + return complete || failed + ? { ready: true, value: { complete, failed } } + : { ready: false, fact: JSON.stringify(job.status ?? {}) }; + }); + if (!result.value.complete) { + const pods = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + 'batch.kubernetes.io/job-name=' + name, + ]).items; + let logs = fixture.kubectl( + ['-n', NAMESPACE, 'logs', 'job/' + name, '--all-containers=true'], + { capture: true, quiet: true, allowFailure: true }, + ); + if (logs.status !== 0 && pods[0]) { + logs = fixture.kubectl( + [ + '-n', + NAMESPACE, + 'logs', + pods[0].metadata.name, + '--all-containers=true', + ], + { capture: true, quiet: true, allowFailure: true }, + ); + } + const facts = pods.map((pod) => ({ + name: pod.metadata.name, + nodeName: pod.spec.nodeName, + phase: pod.status?.phase, + reason: pod.status?.reason, + message: pod.status?.message, + containers: pod.status?.containerStatuses?.map((status) => ({ + name: status.name, + state: status.state, + lastState: status.lastState, + message: status.state?.terminated?.message, + })), + })); + throw new Error( + name + + ' failed: pods=' + + JSON.stringify(facts) + + '\nstdout=' + + logs.stdout + + '\nstderr=' + + logs.stderr, + ); + } + return result.value; +} + +function jobLog(fixture, name) { + const logs = fixture.kubectl( + ['-n', NAMESPACE, 'logs', 'job/' + name, '--all-containers=true'], + { capture: true, quiet: true, allowFailure: true }, + ); + if (logs.status === 0) return logs.stdout; + const pod = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + 'batch.kubernetes.io/job-name=' + name, + ]).items[0]; + const messages = pod?.status?.containerStatuses + ?.map((status) => status.state?.terminated?.message) + .filter(Boolean); + if (!messages?.length) { + throw new Error(name + ' output is unavailable: ' + logs.stderr); + } + return messages.join('\n'); +} + +function lastJsonLine(output, predicate) { + const found = output + .split('\n') + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean) + .findLast(predicate); + assert.ok(found, 'expected JSON evidence was absent from output'); + return found; +} + +function bootstrapJob(adminImage, bootstrapSource, urls) { + return [ + { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'ql3-secret-binding-bootstrap', namespace: NAMESPACE }, + data: { 'bootstrap.cjs': bootstrapSource }, + }, + { + apiVersion: 'v1', + kind: 'Secret', + metadata: { + name: 'ql3-secret-binding-bootstrap-db', + namespace: NAMESPACE, + }, + immutable: true, + type: 'Opaque', + stringData: urls, + }, + { + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name: 'ql3-secret-binding-bootstrap', namespace: NAMESPACE }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 600, + ttlSecondsAfterFinished: 600, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': 'ql3-secret-binding-bootstrap', + }, + }, + spec: { + automountServiceAccountToken: false, + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'bootstrap', + image: adminImage, + imagePullPolicy: 'Never', + command: ['node', '/opt/ql3-live/bootstrap.cjs'], + terminationMessagePolicy: 'FallbackToLogsOnError', + env: [ + { name: 'NODE_PATH', value: '/opt/qinglong/node_modules' }, + ...[ + 'QL3_TEST_POSTGRES_MIGRATION_URL', + 'QL3_TEST_POSTGRES_PACKAGE_MANAGER_URL', + 'QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL', + ].map((name) => ({ + name, + valueFrom: { + secretKeyRef: { + name: 'ql3-secret-binding-bootstrap-db', + key: name, + }, + }, + })), + ], + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '25m', memory: '64Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }, + volumeMounts: [ + { + name: 'source', + mountPath: '/opt/ql3-live', + readOnly: true, + }, + { name: 'tmp', mountPath: '/tmp' }, + ], + }, + ], + volumes: [ + { + name: 'source', + configMap: { + name: 'ql3-secret-binding-bootstrap', + defaultMode: 292, + }, + }, + { name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '8Mi' } }, + ], + }, + }, + }, + }, + ]; +} + +function yamlDocuments(value) { + const documents = []; + yaml.loadAll(value, (document) => { + if (document) documents.push(document); + }); + return documents; +} + +function envIndex(container) { + return new Map(container.env.map((entry, index) => [entry.name, index])); +} + +function setEnvironment(container, entry) { + const index = envIndex(container).get(entry.name); + if (index === undefined) container.env.push(entry); + else container.env[index] = entry; +} + +function renderManagement(fixture, adminImage) { + const rendered = fixture.kubectl( + [ + 'kustomize', + 'deploy/kubernetes/ql3-cluster/operations/plugin-package-management/base', + ], + { capture: true, quiet: true }, + ).stdout; + const resources = yamlDocuments(rendered); + const deployment = resources.find((value) => value.kind === 'Deployment'); + assert.ok(deployment); + const container = deployment.spec.template.spec.containers[0]; + container.image = adminImage; + container.imagePullPolicy = 'Never'; + container.env = container.env.filter( + (entry) => + ![ + 'QL3_POSTGRES_PACKAGE_MANAGER_URL', + 'QL3_POSTGRES_PACKAGE_MANAGER_TLS_SERVERNAME', + 'QL3_POSTGRES_PACKAGE_MANAGER_TLS_CA_FILE', + ].includes(entry.name), + ); + setEnvironment(container, { + name: 'QL3_POSTGRES_PACKAGE_MANAGER_TLS_MODE', + value: 'disable', + }); + setEnvironment(container, { + name: 'QL3_POSTGRES_PACKAGE_MANAGER_ALLOW_INSECURE', + value: 'true', + }); + for (const entry of roleEnvironment( + 'PACKAGE_MANAGER', + 'package-manager-password', + 'ql3-secret-binding-manager', + )) { + if ( + !['QL3_POSTGRES_TLS_MODE', 'QL3_POSTGRES_ALLOW_INSECURE'].includes( + entry.name, + ) + ) { + setEnvironment(container, entry); + } + } + container.volumeMounts = container.volumeMounts.filter( + (entry) => entry.name !== 'postgres-package-manager-ca', + ); + deployment.spec.template.spec.volumes = + deployment.spec.template.spec.volumes.filter( + (entry) => entry.name !== 'postgres-package-manager-ca', + ); + const networkPolicy = resources.find( + (value) => value.kind === 'NetworkPolicy', + ); + networkPolicy.spec.egress.push({ + to: [ + { podSelector: { matchLabels: { 'app.kubernetes.io/name': POSTGRES } } }, + ], + ports: [{ protocol: 'TCP', port: 5432 }], + }); + return resources; +} + +function managementConfiguration(fixture, pki, identityKey) { + const publisher = crypto.generateKeyPairSync('ed25519').publicKey.export({ + type: 'spki', + format: 'pem', + }); + applySecret( + fixture, + MANAGEMENT + '-tls', + { + 'tls.crt': pki.serverCertificate, + 'tls.key': pki.serverKey, + }, + 'kubernetes.io/tls', + ); + applySecret(fixture, MANAGEMENT + '-identity', { + 'keyset.json': JSON.stringify(identity.keyset(1, [identityKey])) + '\n', + }); + fixture.apply({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { name: 'ql3-plugin-publisher-trust', namespace: NAMESPACE }, + data: { + 'publishers.json': + JSON.stringify({ + schema: 'qinglong/plugin-package-publisher-trust@v1', + keys: [ + { + publisher: 'live.qinglong.test', + keyId: 'live-key-1', + publicKeyPem: publisher, + notBeforeMs: Date.now() - 60_000, + notAfterMs: Date.now() + 86_400_000, + }, + ], + }) + '\n', + }, + }); +} + +async function executeClient(fixture, options) { + const inputName = options.name + '-input'; + const config = { + schemaVersion: 1, + endpoint: 'https://' + SERVERNAME + ':8443' + MANAGEMENT_PATH, + servername: SERVERNAME, + caFile: '/tmp/ca.crt', + requestTimeoutMs: 5_000, + }; + applySecret(fixture, inputName, { + 'client.json': JSON.stringify(config) + '\n', + 'command.json': JSON.stringify(options.command) + '\n', + 'assertion.jwt': options.bearer, + 'ca.crt': options.ca, + }); + fixture.create({ + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { name: options.name, namespace: NAMESPACE }, + spec: { + backoffLimit: 0, + activeDeadlineSeconds: 240, + ttlSecondsAfterFinished: 600, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': 'ql3-secret-binding-client', + 'qinglong.io/plugin-package-management-client': 'true', + }, + }, + spec: { + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: 'Never', + hostAliases: [ + { ip: options.target.status.podIP, hostnames: [SERVERNAME] }, + ], + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + fsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'client', + image: options.adminImage, + imagePullPolicy: 'Never', + command: ['/bin/sh', '-c'], + args: [ + [ + 'set -eu', + 'umask 077', + 'cp /var/run/ql3/client/client.json /tmp/client.json', + 'cp /var/run/ql3/client/command.json /tmp/command.json', + 'cp /var/run/ql3/client/assertion.jwt /tmp/assertion.jwt', + 'cp /var/run/ql3/client/ca.crt /tmp/ca.crt', + 'chmod 600 /tmp/client.json /tmp/command.json /tmp/assertion.jwt /tmp/ca.crt', + 'attempt=0', + 'while true; do', + ' attempt=$((attempt + 1))', + ' set +e', + ' output="$(node /opt/qinglong/node_modules/@qinglong/cluster-admin/' + + 'dist/plugin-package/management/pluginPackageManagementClientCli.js ' + + '--config=/tmp/client.json --command=/tmp/command.json ' + + '--assertion=/tmp/assertion.jwt 2>&1)"', + ' status=$?', + ' set -e', + ' if [ "$status" -eq 0 ] || [ "$attempt" -ge 60 ]; then break; fi', + ' sleep 1', + 'done', + 'printf \'%s\\n\' "$output" > /dev/termination-log', + 'printf \'%s\\n\' "$output"', + 'exit "$status"', + ].join('\n'), + ], + terminationMessagePolicy: 'File', + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '10m', memory: '32Mi' }, + limits: { cpu: '250m', memory: '128Mi' }, + }, + volumeMounts: [ + { name: 'tmp', mountPath: '/tmp' }, + { + name: 'input', + mountPath: '/var/run/ql3/client', + readOnly: true, + }, + ], + }, + ], + volumes: [ + { name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '4Mi' } }, + { + name: 'input', + secret: { secretName: inputName, defaultMode: 288 }, + }, + ], + }, + }, + }, + }); + await waitJob(fixture, options.name, 300_000); + const pod = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + 'batch.kubernetes.io/job-name=' + options.name, + ]).items[0]; + assert.ok(pod); + assert.equal(pod.spec.automountServiceAccountToken, false); + const terminated = pod.status.containerStatuses[0].state.terminated; + assert.ok(terminated); + assert.equal(terminated.message.includes(options.bearer), false); + const output = lastJsonLine( + terminated.message, + (value) => value.event === 'command_completed', + ); + assert.equal(output.result.operation, options.command.operation); + return Object.freeze({ output, pod }); +} + +function baseCommand(operation, request) { + return Object.freeze({ schemaVersion: 1, operation, request }); +} + +function canI(fixture, serviceAccount, verb) { + const result = fixture.kubectl( + [ + 'auth', + 'can-i', + verb, + 'secrets', + '--namespace', + NAMESPACE, + '--as', + 'system:serviceaccount:' + NAMESPACE + ':' + serviceAccount, + ], + { capture: true, quiet: true, allowFailure: true }, + ); + assert.ok(result.stdout === 'yes' || result.stdout === 'no'); + return result.stdout === 'yes'; +} + +function renderExecutor(fixture, adminImage) { + const rendered = fixture.kubectl( + [ + 'kustomize', + 'deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base', + ], + { capture: true, quiet: true }, + ).stdout; + const resources = yamlDocuments(rendered); + const cronJob = resources.find((value) => value.kind === 'CronJob'); + assert.ok(cronJob); + cronJob.spec.suspend = true; + cronJob.spec.jobTemplate.spec.backoffLimit = 0; + const template = cronJob.spec.jobTemplate.spec.template.spec; + const container = template.containers[0]; + container.image = adminImage; + container.imagePullPolicy = 'Never'; + const executorCommand = container.command; + assert.deepEqual(executorCommand.slice(0, 1), ['node']); + container.command = ['/bin/sh', '-c']; + container.args = [ + [ + 'set +e', + 'output="$(' + executorCommand.join(' ') + ' 2>&1)"', + 'status=$?', + 'printf \'%s\\n\' "$output" > /dev/termination-log', + 'printf \'%s\\n\' "$output"', + 'exit "$status"', + ].join('\n'), + ]; + container.terminationMessagePolicy = 'File'; + container.env = container.env.filter( + (entry) => + ![ + 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL', + 'QL3_POSTGRES_TLS_SERVERNAME', + 'QL3_POSTGRES_TLS_CA_FILE', + ].includes(entry.name), + ); + setEnvironment(container, { + name: 'QL3_POSTGRES_TLS_MODE', + value: 'disable', + }); + setEnvironment(container, { + name: 'QL3_POSTGRES_ALLOW_INSECURE', + value: 'true', + }); + for (const entry of roleEnvironment( + 'PACKAGE_EXECUTOR', + 'package-executor-password', + 'ql3-secret-binding-executor', + )) { + if ( + !['QL3_POSTGRES_TLS_MODE', 'QL3_POSTGRES_ALLOW_INSECURE'].includes( + entry.name, + ) + ) { + setEnvironment(container, entry); + } + } + container.volumeMounts = container.volumeMounts.filter( + (entry) => entry.name !== 'postgres-package-executor-ca', + ); + template.volumes = template.volumes.filter( + (entry) => entry.name !== 'postgres-package-executor-ca', + ); + const networkPolicy = resources.find( + (value) => value.kind === 'NetworkPolicy', + ); + networkPolicy.spec.egress.push({ + to: [ + { podSelector: { matchLabels: { 'app.kubernetes.io/name': POSTGRES } } }, + ], + ports: [{ protocol: 'TCP', port: 5432 }], + }); + return resources; +} + +function persistenceEvidence(fixture, sensitiveValue) { + const sql = ` +SELECT json_build_object( + 'bindingCount', (SELECT count(*)::integer + FROM ql3.plugin_package_secret_bindings + WHERE project_id = ${sqlString(PROJECT_ID)} + AND package_name = ${sqlString(PACKAGE_NAME)}), + 'authorityKind', (SELECT authority_kind + FROM ql3.plugin_package_secret_bindings + WHERE project_id = ${sqlString(PROJECT_ID)} + AND package_name = ${sqlString(PACKAGE_NAME)}), + 'evidenceDigest', (SELECT evidence_digest + FROM ql3.plugin_package_secret_bindings + WHERE project_id = ${sqlString(PROJECT_ID)} + AND package_name = ${sqlString(PACKAGE_NAME)}), + 'entryCount', (SELECT jsonb_array_length(binding_json -> 'entries') + FROM ql3.plugin_package_secret_bindings + WHERE project_id = ${sqlString(PROJECT_ID)} + AND package_name = ${sqlString(PACKAGE_NAME)}), + 'approvalConsumed', (SELECT state = 'consumed' + FROM ql3.approval_requests WHERE request_id = ${sqlString(APPROVAL_ID)}), + 'executionSucceeded', (SELECT execution.status = 'succeeded' + FROM ql3.approval_requests AS approval + JOIN ql3.approved_action_executions AS execution + ON execution.dispatch_id = approval.dispatch_id + WHERE approval.request_id = ${sqlString(APPROVAL_ID)}), + 'sensitiveMatchCount', (SELECT count(*)::integer FROM ( + SELECT binding_json::text AS payload FROM ql3.plugin_package_secret_bindings + UNION ALL SELECT plan_json::text FROM ql3.plugin_package_secret_binding_approval_plans + UNION ALL SELECT request_json::text FROM ql3.approval_requests + UNION ALL SELECT execution_json::text FROM ql3.approved_action_executions + ) AS durable WHERE strpos(payload, ${sqlString(sensitiveValue)}) > 0) +)::text; + `.trim(); + return JSON.parse(psql(fixture, DATABASE, sql).stdout); +} + +async function main(argv = process.argv.slice(2)) { + const reportFile = privateReportPath(argv); + if (process.env.QL3_PLUGIN_PACKAGE_SECRET_BINDING_KUBERNETES_LIVE !== '1') { + throw new Error( + 'Refusing to mutate Docker/Kubernetes without ' + + 'QL3_PLUGIN_PACKAGE_SECRET_BINDING_KUBERNETES_LIVE=1', + ); + } + const fixture = new K3sDockerLiveFixture({ + prefix: 'ql3-secret-binding-live', + }); + const suffix = + process.pid.toString(36) + '-' + crypto.randomBytes(3).toString('hex'); + const adminImage = ADMIN_IMAGE_BASE + ':' + suffix; + let adminImageBuilt = false; + try { + const nodes = await fixture.start(); + assert.equal(nodes.length, 3); + ensurePostgresImage(fixture); + const sourceRevision = run('git', ['rev-parse', 'HEAD'], { + capture: true, + quiet: true, + }).stdout; + run(fixture.docker, [ + 'build', + '--file', + 'deploy/containers/ql3-cluster-admin/Dockerfile', + '--tag', + adminImage, + '--build-arg', + 'SOURCE_REVISION=' + sourceRevision, + '.', + ]); + adminImageBuilt = true; + fixture.loadImage(adminImage, 'secret-binding-admin.tar'); + const adminImageInfo = fixture.inspectImage(adminImage); + assert.ok(['amd64', 'arm64'].includes(adminImageInfo.Architecture)); + + fixture.apply({ + apiVersion: 'v1', + kind: 'Namespace', + metadata: { name: NAMESPACE }, + }); + const superuserPassword = randomSecret(); + for (const resource of postgresResources(superuserPassword)) { + fixture.apply(resource); + } + await waitFor('PostgreSQL readiness', 300_000, () => { + const pod = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pod', + POSTGRES, + ]); + return podReady(pod) + ? { ready: true, value: pod } + : { ready: false, fact: pod.status?.phase ?? 'unknown' }; + }); + const passwords = Object.fromEntries( + ROLE_NAMES.map((role) => [role, randomSecret()]), + ); + createDatabaseRoles(fixture, passwords); + applySecret(fixture, 'ql3-secret-binding-db-auth', { + 'migration-password': passwords.ql3_migration, + 'package-manager-password': passwords.ql3_package_manager, + 'package-executor-password': passwords.ql3_package_executor, + }); + fixture.create(migrationJob(adminImage)); + await waitJob(fixture, 'ql3-secret-binding-migration'); + const postgresVersionNumber = Number( + psql(fixture, DATABASE, 'SHOW server_version_num;').stdout, + ); + assert.equal(postgresVersionNumber, 180004); + + const databaseUrl = (role) => + 'postgresql://' + + role + + ':' + + passwords[role] + + '@' + + POSTGRES + + ':5432/' + + DATABASE; + const bootstrapResources = bootstrapJob( + adminImage, + fs.readFileSync( + path.join( + ROOT, + 'scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs', + ), + 'utf8', + ), + { + QL3_TEST_POSTGRES_MIGRATION_URL: databaseUrl('ql3_migration'), + QL3_TEST_POSTGRES_PACKAGE_MANAGER_URL: databaseUrl( + 'ql3_package_manager', + ), + QL3_TEST_POSTGRES_PACKAGE_EXECUTOR_URL: databaseUrl( + 'ql3_package_executor', + ), + }, + ); + for (const resource of bootstrapResources) fixture.create(resource); + await waitJob(fixture, 'ql3-secret-binding-bootstrap'); + const prerequisite = lastJsonLine( + jobLog(fixture, 'ql3-secret-binding-bootstrap'), + (value) => value.event === 'secret_binding_prerequisite_ready', + ); + assert.equal(prerequisite.projectId, PROJECT_ID); + assert.equal(prerequisite.packageName, PACKAGE_NAME); + assert.match(prerequisite.secretRef, /^qlsecret:v1:/); + assert.match(prerequisite.projectionKey, /^[a-f0-9]{64}$/); + + const pki = createMutualTlsPki({ + directory: fixture.temporary, + servername: SERVERNAME, + label: 'QL3 Secret Binding Live', + run, + crypto, + }).read(); + const identityKey = identity.reviewedKey('secret-binding-live-key-1'); + managementConfiguration(fixture, pki, identityKey); + for (const resource of renderManagement(fixture, adminImage)) { + fixture.apply(resource); + } + fixture.kubectl([ + '-n', + NAMESPACE, + 'rollout', + 'status', + 'deployment/' + MANAGEMENT, + '--timeout=5m', + ]); + const managementPods = await readyManagementPods({ + fixture, + namespace: NAMESPACE, + deployment: MANAGEMENT, + description: 'two Secret binding management replicas', + }); + assert.equal(managementPods.length, 2); + const managementDeployment = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'deployment', + MANAGEMENT, + ]); + const managementTemplate = managementDeployment.spec.template.spec; + const managementMounts = managementTemplate.containers[0].volumeMounts; + assert.equal(managementTemplate.automountServiceAccountToken, false); + assert.equal( + managementMounts.some((entry) => entry.name === 'plugin-package-values'), + false, + ); + assert.equal(canI(fixture, MANAGEMENT, 'get'), false); + assert.equal(canI(fixture, MANAGEMENT, 'list'), false); + + const requester = () => + identity.assertionForSubject(identityKey, REQUESTER_ID); + const reviewer = () => + identity.assertionForSubject(identityKey, REVIEWER_ID); + const planCommand = baseCommand('plugin-package.secret-binding.plan', { + actionRef: ACTION_REF, + projectId: PROJECT_ID, + packageName: PACKAGE_NAME, + assignments: [{ name: 'TOKEN', secretRef: prerequisite.secretRef }], + }); + const planned = await executeClient(fixture, { + name: 'ql3-secret-binding-plan-a', + target: managementPods[0], + command: planCommand, + bearer: requester(), + ca: pki.ca, + adminImage, + }); + assert.equal(planned.output.result.status, 'created'); + const replayed = await executeClient(fixture, { + name: 'ql3-secret-binding-plan-b', + target: managementPods[1], + command: planCommand, + bearer: requester(), + ca: pki.ca, + adminImage, + }); + assert.equal(replayed.output.result.status, 'existing'); + const proposed = await executeClient(fixture, { + name: 'ql3-secret-binding-propose-a', + target: managementPods[0], + command: baseCommand('plugin-package.secret-binding.propose', { + actionRef: ACTION_REF, + approvalRequestId: APPROVAL_ID, + approvalAuditEventId: crypto.randomUUID(), + }), + bearer: requester(), + ca: pki.ca, + adminImage, + }); + assert.equal(proposed.output.result.approvalStatus, 'created'); + const decided = await executeClient(fixture, { + name: 'ql3-secret-binding-decide-b', + target: managementPods[1], + command: baseCommand('plugin-package.secret-binding.decide', { + actionRef: ACTION_REF, + approvalRequestId: APPROVAL_ID, + expectedVersion: proposed.output.result.approval.version, + decisionId: 'secret-binding-live-decision', + auditEventId: crypto.randomUUID(), + decision: 'approved', + reasonCode: 'reviewed', + }), + bearer: reviewer(), + ca: pki.ca, + adminImage, + }); + assert.equal(decided.output.result.status, 'decided'); + const inspected = await executeClient(fixture, { + name: 'ql3-secret-binding-inspect-a', + target: managementPods[0], + command: baseCommand('plugin-package.secret-binding.inspect', { + actionRef: ACTION_REF, + approvalRequestId: APPROVAL_ID, + inspectionId: 'secret-binding-live-inspection', + }), + bearer: requester(), + ca: pki.ca, + adminImage, + }); + assert.equal(inspected.output.result.stale, false); + + const sensitiveValue = 'ql3-live-' + randomSecret(); + applySecret(fixture, 'ql3-cluster-plugin-package-values', { + [prerequisite.projectionKey]: sensitiveValue, + }); + const executorResources = renderExecutor(fixture, adminImage); + for (const resource of executorResources) fixture.apply(resource); + fixture.kubectl([ + '-n', + NAMESPACE, + 'create', + 'job', + 'ql3-secret-binding-executor', + '--from=cronjob/ql3-plugin-package-executor', + ]); + await waitJob(fixture, 'ql3-secret-binding-executor'); + const executorOutput = jobLog(fixture, 'ql3-secret-binding-executor'); + assert.equal(executorOutput.includes(sensitiveValue), false); + assert.equal(executorOutput.includes(prerequisite.secretRef), false); + const executorPod = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + 'batch.kubernetes.io/job-name=ql3-secret-binding-executor', + ]).items[0]; + assert.ok(executorPod); + assert.equal(executorPod.spec.automountServiceAccountToken, false); + const executorContainer = executorPod.spec.containers[0]; + const projectionMount = executorContainer.volumeMounts.find( + (entry) => entry.name === 'plugin-package-values', + ); + assert.equal(projectionMount.readOnly, true); + const projectionVolume = executorPod.spec.volumes.find( + (entry) => entry.name === 'plugin-package-values', + ); + assert.equal( + projectionVolume.secret.secretName, + 'ql3-cluster-plugin-package-values', + ); + assert.equal(canI(fixture, 'ql3-plugin-package-executor', 'get'), false); + assert.equal(canI(fixture, 'ql3-plugin-package-executor', 'list'), false); + const persistence = persistenceEvidence(fixture, sensitiveValue); + assert.deepEqual(persistence, { + bindingCount: 1, + authorityKind: 'approved-action-execution', + evidenceDigest: planned.output.result.plan.approvalPlanDigest, + entryCount: 1, + approvalConsumed: true, + executionSucceeded: true, + sensitiveMatchCount: 0, + }); + const managerBindingRead = psql( + fixture, + DATABASE, + 'SELECT count(*) FROM ql3.plugin_package_secret_bindings;', + { + user: 'ql3_package_manager', + password: passwords.ql3_package_manager, + allowFailure: true, + }, + ); + assert.notEqual(managerBindingRead.status, 0); + + const report = { + schemaVersion: 1, + fixture: 'qinglong/plugin-package-secret-binding-kubernetes-live@v1', + observedAtMs: Date.now(), + platform: { + architecture: adminImageInfo.Architecture, + kubernetesVersion: fixture.kubectlJson(['version']).serverVersion + .gitVersion, + nodeCount: nodes.length, + postgresVersionNumber, + adminImageId: imageId(adminImageInfo), + }, + management: { + replicas: managementPods.length, + distinctNodeHashes: managementPods + .map((pod) => sha256(pod.spec.nodeName)) + .sort(), + serviceAccountTokenMounted: false, + packageValueVolumeMounted: false, + canGetSecrets: false, + canListSecrets: false, + }, + review: { + commands: [ + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.propose', + 'plugin-package.secret-binding.decide', + 'plugin-package.secret-binding.inspect', + ], + requesterSubjectHash: sha256(REQUESTER_ID), + reviewerSubjectHash: sha256(REVIEWER_ID), + distinctUsers: REQUESTER_ID !== REVIEWER_ID, + planStatus: planned.output.result.status, + replayStatus: replayed.output.result.status, + decisionStatus: decided.output.result.status, + inspectionStale: inspected.output.result.stale, + actionDigest: proposed.output.result.approval.actionDigest, + planDigest: planned.output.result.plan.planDigest, + }, + executor: { + jobSucceeded: true, + serviceAccountTokenMounted: false, + canGetSecrets: false, + canListSecrets: false, + projectionReadOnly: projectionMount.readOnly, + projectionFileCount: Object.keys( + fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'secret', + 'ql3-cluster-plugin-package-values', + ]).data, + ).length, + projectionKeyHash: sha256(prerequisite.projectionKey), + outputSensitiveFree: + !executorOutput.includes(sensitiveValue) && + !executorOutput.includes(prerequisite.secretRef), + }, + persistence, + gates: { + realThreeNodeKubernetes: true, + twoManagementReplicasOnDistinctNodes: true, + formalHttpsClientCommands: true, + planReplayedAcrossReplicas: true, + separationOfDutyDecision: true, + authorizedInspection: true, + realExecutorJob: true, + projectedSecretMetadataAccepted: true, + bindingPublishedExactlyOnce: true, + managementCannotReadSecrets: true, + managementDoesNotMountPackageValues: true, + executorCannotReadSecrets: true, + executorHasNoServiceAccountToken: true, + executorProjectionReadOnly: true, + databaseContainsNoSensitiveValue: true, + passed: true, + }, + limitations: [ + 'single-server k3s control plane is not Kubernetes control-plane HA evidence', + 'PostgreSQL physical failover is proven by the independent 125-gate HA contract', + ], + }; + const audit = + validatePluginPackageSecretBindingKubernetesLiveReport(report); + assert.deepEqual(audit.findings, []); + writePrivateReport(reportFile, report); + process.stdout.write( + JSON.stringify({ + schemaVersion: 1, + event: 'plugin_package_secret_binding_kubernetes_live_completed', + reportSha256: sha256(fs.readFileSync(reportFile)).slice( + 'sha256:'.length, + ), + gates: Object.keys(report.gates).length, + }) + '\n', + ); + } finally { + await fixture.cleanup(); + if (adminImageBuilt) { + run(fixture.docker, ['image', 'rm', '-f', adminImage], { + capture: true, + quiet: true, + }); + } + } +} + +main().catch((error) => { + process.stderr.write( + 'QL3 Secret binding Kubernetes live contract failed: ' + + (error instanceof Error ? error.stack || error.message : String(error)) + + '\n', + ); + process.exitCode = 1; +}); diff --git a/test/back/ql3PluginPackageSecretBindingKubernetesLiveAudit.test.cjs b/test/back/ql3PluginPackageSecretBindingKubernetesLiveAudit.test.cjs new file mode 100644 index 00000000..5a7e11fa --- /dev/null +++ b/test/back/ql3PluginPackageSecretBindingKubernetesLiveAudit.test.cjs @@ -0,0 +1,99 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + FIXTURE, + REQUIRED_GATES, + validatePluginPackageSecretBindingKubernetesLiveReport, +} = require('../../scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs'); + +function report() { + const digest = 'a'.repeat(64); + return { + schemaVersion: 1, + fixture: FIXTURE, + observedAtMs: 1, + platform: { + architecture: 'arm64', + kubernetesVersion: 'v1.34.3+k3s1', + nodeCount: 3, + postgresVersionNumber: 180004, + adminImageId: `sha256:${digest}`, + }, + management: { + replicas: 2, + distinctNodeHashes: [ + `sha256:${'1'.repeat(64)}`, + `sha256:${'2'.repeat(64)}`, + ], + serviceAccountTokenMounted: false, + packageValueVolumeMounted: false, + canGetSecrets: false, + canListSecrets: false, + }, + review: { + commands: [ + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.plan', + 'plugin-package.secret-binding.propose', + 'plugin-package.secret-binding.decide', + 'plugin-package.secret-binding.inspect', + ], + requesterSubjectHash: `sha256:${'3'.repeat(64)}`, + reviewerSubjectHash: `sha256:${'4'.repeat(64)}`, + distinctUsers: true, + planStatus: 'created', + replayStatus: 'existing', + decisionStatus: 'decided', + inspectionStale: false, + actionDigest: '5'.repeat(64), + planDigest: '6'.repeat(64), + }, + executor: { + jobSucceeded: true, + serviceAccountTokenMounted: false, + canGetSecrets: false, + canListSecrets: false, + projectionReadOnly: true, + projectionFileCount: 1, + projectionKeyHash: `sha256:${'7'.repeat(64)}`, + outputSensitiveFree: true, + }, + persistence: { + bindingCount: 1, + authorityKind: 'approved-action-execution', + evidenceDigest: '8'.repeat(64), + entryCount: 1, + approvalConsumed: true, + executionSucceeded: true, + sensitiveMatchCount: 0, + }, + gates: Object.fromEntries(REQUIRED_GATES.map((gate) => [gate, true])), + limitations: [ + 'single-server k3s control plane is not Kubernetes control-plane HA evidence', + 'PostgreSQL physical failover is proven by the independent 125-gate HA contract', + ], + }; +} + +test('accepts one exact low-sensitive Secret binding Kubernetes report', () => { + assert.deepEqual( + validatePluginPackageSecretBindingKubernetesLiveReport(report()).findings, + [], + ); +}); + +test('rejects false gates, topology drift and sensitive material', () => { + const invalid = report(); + invalid.gates.realExecutorJob = false; + invalid.management.distinctNodeHashes[1] = + invalid.management.distinctNodeHashes[0]; + invalid.executor.secretRef = 'qlsecret:v1:forbidden'; + const findings = + validatePluginPackageSecretBindingKubernetesLiveReport(invalid).findings; + assert.ok(findings.some((value) => value.includes('management'))); + assert.ok(findings.some((value) => value.includes('gates'))); + assert.ok(findings.some((value) => value.includes('forbidden'))); +}); diff --git a/test/back/ql3PluginPackageSecretBindingKubernetesLiveContract.test.cjs b/test/back/ql3PluginPackageSecretBindingKubernetesLiveContract.test.cjs new file mode 100644 index 00000000..431a8b8b --- /dev/null +++ b/test/back/ql3PluginPackageSecretBindingKubernetesLiveContract.test.cjs @@ -0,0 +1,46 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); + +const ROOT = path.resolve(__dirname, '../..'); +const CONTRACT = path.join( + ROOT, + 'scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs', +); +const BOOTSTRAP = path.join( + ROOT, + 'scripts/ql3-plugin-package-secret-binding-kubernetes-live-bootstrap.cjs', +); + +test('wires the Secret binding Kubernetes live gate as an explicit opt-in', () => { + const scripts = require(path.join(ROOT, 'package.json')).scripts; + assert.equal( + scripts['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', + ); + assert.equal( + scripts['audit:plugin-package-secret-binding-kubernetes-live:ql3'], + 'node scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs', + ); + const source = fs.readFileSync(CONTRACT, 'utf8'); + assert.match(source, /QL3_PLUGIN_PACKAGE_SECRET_BINDING_KUBERNETES_LIVE/); + assert.match(source, /K3sDockerLiveFixture/); + assert.match(source, /readyManagementPods/); + assert.match(source, /hostAliases/); + assert.match(source, /pluginPackageManagementClientCli\.js/); + assert.match(source, /ql3-cluster-plugin-package-values/); + assert.match(source, /automountServiceAccountToken: false/); + assert.match(source, /can-i/); + assert.match(source, /sensitiveMatchCount/); +}); + +test('keeps prerequisite construction in a one-shot cluster Job', () => { + const source = fs.readFileSync(BOOTSTRAP, 'utf8'); + assert.match(source, /createClusterPluginPackageManagementService/); + assert.match(source, /createClusterPluginPackageApprovedActionDispatcher/); + assert.match(source, /secretProjectionFileName/); + assert.doesNotMatch(source, /setInterval|setTimeout|watch\(/); +});