From 75dbe7a4c4619b3e87c0b4405c9560401635ebf0 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 14 Aug 2026 01:49:54 +0800 Subject: [PATCH] feat(ql3): isolate plugin secret action execution --- .../base/cron-job.yaml | 10 - .../base/network-policy.yaml | 1 - .../cloudnative-pg/cron-job-patch.yaml | 2 - .../config.example.yaml | 5 +- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- ...ransition-plugin-package-secret-binding.md | 2 + packages/ql3-cluster-admin/package.json | 5 + .../executor/pluginPackageApprovedAction.ts | 28 +- .../executor/pluginPackageExecutorProcess.ts | 79 ++- .../pluginPackageKubernetesSecretActionJob.ts | 504 ++++++++++++++++++ .../pluginPackageExecutorProcess.test.cjs | 97 ++++ ...nPackageKubernetesSecretActionJob.test.cjs | 246 +++++++++ .../approvedActionDispatcher.ts | 52 ++ .../test/approvedActionDispatcher.test.cjs | 40 ++ scripts/ql3-cluster-deployment-audit.cjs | 25 +- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- 16 files changed, 1044 insertions(+), 58 deletions(-) create mode 100644 packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageKubernetesSecretActionJob.ts create mode 100644 packages/ql3-cluster-admin/test/pluginPackageKubernetesSecretActionJob.test.cjs diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml index 1676b7e3..a4e69c42 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/cron-job.yaml @@ -65,8 +65,6 @@ spec: value: '16' - name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES value: '16' - - name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT - value: /var/run/secrets/qinglong3/plugin-package-values - name: QL3_POSTGRES_TLS_MODE value: verify-full - name: QL3_POSTGRES_TLS_CA_FILE @@ -98,9 +96,6 @@ spec: - name: postgres-package-executor-ca mountPath: /var/run/secrets/qinglong3/postgres-package-executor readOnly: true - - name: plugin-package-values - mountPath: /var/run/secrets/qinglong3/plugin-package-values - readOnly: true volumes: - name: tmp emptyDir: @@ -113,8 +108,3 @@ spec: items: - key: postgres-ca.crt path: ca.crt - - name: plugin-package-values - secret: - secretName: ql3-cluster-plugin-package-values - optional: true - defaultMode: 288 diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/network-policy.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/network-policy.yaml index 7dfe7ae7..54681523 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/network-policy.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/base/network-policy.yaml @@ -10,7 +10,6 @@ metadata: spec: podSelector: matchLabels: - app.kubernetes.io/name: ql3-plugin-package-executor app.kubernetes.io/component: plugin-package-executor policyTypes: - Ingress diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml index b2610bd0..a4d6bf52 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/cloudnative-pg/cron-job-patch.yaml @@ -17,8 +17,6 @@ value: '16' - name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES value: '16' - - name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT - value: /var/run/secrets/qinglong3/plugin-package-values - name: QL3_POSTGRES_TLS_MODE value: verify-full - name: QL3_POSTGRES_TLS_CA_FILE diff --git a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml index 044c9369..90cef1df 100644 --- a/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml +++ b/deploy/kubernetes/ql3-cluster/operations/plugin-package-executor/config.example.yaml @@ -11,8 +11,9 @@ stringData: postgres-tls-servername: REPLACE_WITH_POSTGRES_DNS_NAME postgres-ca.crt: REPLACE_WITH_POSTGRES_CA_CERTIFICATE --- -# Optional SecretRef projection. Each key is the lowercase SHA-256 of the -# canonical qlsecret:v1 reference; the executor checks metadata only. +# Source for action-scoped Secret projection Jobs. The batch executor never +# mounts this Secret. Each exact Job selects only the reviewed lowercase +# SHA-256 key(s) for one durable dispatch and checks metadata only. apiVersion: v1 kind: Secret metadata: diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 6d3811c3..3538843b 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -29,7 +29,7 @@ - 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 已形成 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-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。SQLite capability v49(0097/0098)已完成 immutable transition receipt ledger、Local Owner staged `plan→execute`、单事务 binding/audit/receipt 和 activation prerequisite。PostgreSQL capability v62(pg-0063)具有 receipt ledger,Cluster management 与 package-executor 也已完成 separation-of-duty 产品编排:executor 在一个 SERIALIZABLE transaction 中复验 current staged head、上一 active lineage、durable 最大 generation 与可选上一 binding,并原子提交可选目标 binding 和 immutable receipt;数据库 trigger 和最小角色 readiness 防止绕过,recovery/直接 activation 缺 receipt 均失败关闭。Cluster startup recovery 现把 binding/transition receipt 编译为 content-blind Kubernetes active pointer v3:只保存 source Secret 名、不可逆 SHA-256 key/path、`0440`、binding/receipt/projection digest 和逻辑 assignment,不保存 SecretRef 或明文;同一次 ConfigMap `resourceVersion` CAS 同时切换 Package generation 与投影声明。rotate 只投影下一代 exact key;revoke 生成显式空 projection,Pod volume renderer 对空项返回不挂载,避免 Kubernetes 空 `items` 被解释为投影全部 key。无 Secret generation 继续发布兼容 v2;publisher 不获得 Secret `get/list`,不新增 watcher/controller。响应丢失通过 durable pointer inspect 精确收敛,projection source 不可用或 digest 漂移时旧 active pointer 保持不变。真实三节点 K3s `v1.34.3+k3s1` 已完成两个受限 actor 同一 resourceVersion 的 v3 rotation 竞争:1 成功/1 冲突、最终恰好 1 pointer、1 个 exact projection item、Secret API read denied,projection digest `22add8965accf3f736935167963b9dbdeab8fba05f739f24d39dec941aae9680`、transition receipt digest `355b89ecd54422af33fa573780c8b70ed41da4df226ec301bdd5b6c71de609e1`;随后生产 renderer 驱动 2 副本 workload 分布到 2 个节点,源 Secret 同时保留目标 key 与 decoy key,而 Pod 只看到 exact path、文件模式 `0440`。第三个同权限 actor 以 generation 3 transition receipt `252e8cd1d0f8a2b63f8c0af6247861c08e5b7ae8886c0d65cd90537be3e9f9ac` 发布显式空 projection;Deployment 滚动产生全新 Pod UID,两个新 Pod 均无 Secret volume/mount 和投影根目录,源 Secret 保留,actor 的 Secret `get` 仍为 403;临时容器/网络已清理。实现没有新增 workspace package,Secret projection/renderer 内聚在既有 `cluster-admin/plugin-package/secret-binding`,18-package boundary 仍为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`,cluster dependency 与 edge import 审计无 finding。阶段提交 `9d7431c2` 后,完整 18-package 串行 build/test 退出 0,backend 1,192 pass/2 skip/0 fail;PostgreSQL 18.4 physical HA 125 gate、timeline `1→2` 通过,报告 SHA-256 为 `2f0d1107cc6d447868bfaeb3650284acd803924c6cd3cdee978b3eb5882eb26c`;Edge 本机观测的模块加载 RSS 增量约 6.0 MiB、1 万行输出峰值增量约 3.9 MiB,但尚无固定物理低配门。该 K3s workload 仍由 live harness 显式应用,不能冒充生产 rollout controller;现有 opt-in Package executor CronJob 的无 `items` optional Secret mount 也尚未收敛为 action-scoped exact projection。生产升级失败自动回滚、executor 精确投影重构和低配物理证据仍待完成。 +- D-306B2/ADR-0396(进行中):Secret rebind/rotation/revocation 不更新历史 binding,而是作为下一 Package generation 的 activation 前置事实。共享 transition plan v1 同时绑定上一 active target、可选的上一 binding、durable install history 的最后尝试 generation、新 target、可选下一 binding plan、逐 requirement 与 SecretRef 差异及独立 digest;上一 active Manifest 没有 Secret requirement 时 binding 可空,但 target/lock/generation lineage 不可省略。失败 install 也永久消耗 generation,重试必须使用 `lastAttemptGeneration + 1`,active lineage 继续由 `previousActiveLockDigest` 指回旧代。SQLite capability v49(0097/0098)已完成 immutable transition receipt ledger、Local Owner staged `plan→execute`、单事务 binding/audit/receipt 和 activation prerequisite。PostgreSQL capability v62(pg-0063)具有 receipt ledger,Cluster management 与 package-executor 也已完成 separation-of-duty 产品编排:executor 在一个 SERIALIZABLE transaction 中复验 current staged head、上一 active lineage、durable 最大 generation 与可选上一 binding,并原子提交可选目标 binding 和 immutable receipt;数据库 trigger 和最小角色 readiness 防止绕过,recovery/直接 activation 缺 receipt 均失败关闭。Cluster startup recovery 现把 binding/transition receipt 编译为 content-blind Kubernetes active pointer v3:只保存 source Secret 名、不可逆 SHA-256 key/path、`0440`、binding/receipt/projection digest 和逻辑 assignment,不保存 SecretRef 或明文;同一次 ConfigMap `resourceVersion` CAS 同时切换 Package generation 与投影声明。rotate 只投影下一代 exact key;revoke 生成显式空 projection,Pod volume renderer 对空项返回不挂载,避免 Kubernetes 空 `items` 被解释为投影全部 key。无 Secret generation 继续发布兼容 v2;publisher 不获得 Secret `get/list`,不新增 watcher/controller。响应丢失通过 durable pointer inspect 精确收敛,projection source 不可用或 digest 漂移时旧 active pointer 保持不变。真实三节点 K3s `v1.34.3+k3s1` 已完成两个受限 actor 同一 resourceVersion 的 v3 rotation 竞争:1 成功/1 冲突、最终恰好 1 pointer、1 个 exact projection item、Secret API read denied,projection digest `22add8965accf3f736935167963b9dbdeab8fba05f739f24d39dec941aae9680`、transition receipt digest `355b89ecd54422af33fa573780c8b70ed41da4df226ec301bdd5b6c71de609e1`;随后生产 renderer 驱动 2 副本 workload 分布到 2 个节点,源 Secret 同时保留目标 key 与 decoy key,而 Pod 只看到 exact path、文件模式 `0440`。第三个同权限 actor 以 generation 3 transition receipt `252e8cd1d0f8a2b63f8c0af6247861c08e5b7ae8886c0d65cd90537be3e9f9ac` 发布显式空 projection;Deployment 滚动产生全新 Pod UID,两个新 Pod 均无 Secret volume/mount 和投影根目录,源 Secret 保留,actor 的 Secret `get` 仍为 403;临时容器/网络已清理。实现没有新增 workspace package,Secret projection/renderer 内聚在既有 `cluster-admin/plugin-package/secret-binding`,18-package boundary 仍为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`,cluster dependency 与 edge import 审计无 finding。阶段提交 `9d7431c2` 后,完整 18-package 串行 build/test 退出 0,backend 1,192 pass/2 skip/0 fail;PostgreSQL 18.4 physical HA 125 gate、timeline `1→2` 通过,报告 SHA-256 为 `2f0d1107cc6d447868bfaeb3650284acd803924c6cd3cdee978b3eb5882eb26c`;Edge 本机观测的模块加载 RSS 增量约 6.0 MiB、1 万行输出峰值增量约 3.9 MiB,但尚无固定物理低配门。executor 精确投影重构已开始落地:共享 dispatcher 增加不扫描队列的 `dispatchById`,在租约领取前拒绝未配置 handler 的动作;executor 的 exact mode 跳过全部 Approval consumer,只执行一个 durable dispatch。既有 `cluster-admin/plugin-package/executor` 内新增 immutable Kubernetes Job renderer,以 dispatch 与 approval plan 联合校验生成确定性名称,只接受 digest-pinned 镜像,并只挂载去重后的 exact SHA-256 `items`(`0440`、`optional:false`、无 API token);零 SecretRef 的 revoke 使用 1 KiB `emptyDir`,绝不以空 `items` 误挂全量 Secret。常规 batch CronJob 已移除 Package values volume、Secret root 和 dispatch-id authority;未挂 Secret root 时 dispatcher 根本不注册 binding/transition handler,因此相关 durable execution 保持 pending,不会被错误领取后 blocked。该切片不增加 package、依赖或常驻进程,并把 action Job 内存 request/limit 固定为 48/192 MiB、数据库池固定 1,适合低配节点。生产 rollout controller 尚不能直接取得不受约束的 `jobs.create`:该权限可通过自定义 PodSpec 间接放大到任意 Secret/镜像,必须先用 admission policy 把 ServiceAccount、digest 镜像、command、source Secret、exact item/path 与数据库 SecretRef 固定,再接入 create/get-only adapter 和恢复状态机。本阶段完整 18-package clean build/test 退出 0;backend 1195 项为 1193 pass、2 条条件 skip、0 fail;package boundary 确认为 18 个 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,cluster dependency、edge import 和 cluster deployment 审计均无 finding。Edge arm64 本机观测模块加载 RSS 增量 8,945,664 bytes,1 万行输出峰值增量 5,226,496 bytes;仍仅为观测而非固定物理低配门。PostgreSQL `18.4` arm64 physical HA 125 项 gate、timeline `1→2` 通过,报告 SHA-256 为 `45fab400eb449774d50429103dd766a2755166530ac54ddc1056f777bc16c15f`,临时 Docker 资源已清理。升级失败自动回滚、真实 controller/RBAC/admission 现场门和固定低配物理证据仍待完成。 - 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-0396-generation-transition-plugin-package-secret-binding.md b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md index cca5bbd5..8a6d8242 100644 --- a/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md +++ b/docs/adr/ADR-0396-generation-transition-plugin-package-secret-binding.md @@ -40,3 +40,5 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首 - 阶段完整性门已串行复验:18 个 `ql3-*` workspace package 的统一 clean build/test 退出 0;backend 共 1194 项,1192 pass、2 条条件 skip、0 fail;PostgreSQL 18.4 arm64 physical HA 完成 timeline `1→2` 提升并通过 125 项门禁,报告 SHA-256 为 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`。package boundary、cluster dependency 与 edge import 审计均无新增越界;这些证据证明当前 authority 与既有运行路径兼容,不替代尚未完成的真实 Kubernetes rotation/revoke 和固定低配物理设备验证。 - Runtime Core 534/534、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1072 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。 - Cluster management/executor 的 separation-of-duty 产品编排仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片把既有 package-manager Approval 与 package-executor 接入这条 durable authority,再进入 Kubernetes/低配现场门。 +- 后续切片已经完成 Cluster transition Approval/执行、Kubernetes active pointer v3 与三节点 rotation/revoke rollout 原语现场门;该历史未完成描述由本条取代。当前 executor 重构进一步增加 exact `dispatchById` start-barrier 路径和由 durable dispatch + approval plan 联合绑定的 action-scoped Job renderer。常规 batch CronJob 不再挂载 `ql3-cluster-plugin-package-values`,也不注册需要 Secret projection 的 handler;action Job 只投影单动作所需的去重 SHA-256 key,固定 `0440`、`optional:false`、digest 镜像、tokenless ServiceAccount、单连接 PostgreSQL 与 48 MiB request。零 Secret transition 使用空目录而不是空 Secret `items`。因此“整个 optional Secret 暴露给批处理 executor”的生产缺口已经失败关闭,但自动 controller 仍未启用:Kubernetes `jobs.create` 是可间接选择镜像/ServiceAccount/Secret 的放大权限,必须由 ValidatingAdmissionPolicy(或同等级外部 admission)把 renderer 契约固定后,才允许 create/get-only RBAC、响应丢失收敛与失败 Job 恢复。升级失败自动回滚、controller/admission/RBAC 真实门及固定物理低配证据继续阻断 ADR Accepted。 +- 本阶段完整性门已闭合:18-package clean build/test 退出 0;backend 1195 项为 1193 pass、2 条条件 skip、0 fail;package boundary 仍为 18 个 package 且无 single-source/shallow-source package,cluster dependency、edge import、cluster deployment 均零 finding。Edge arm64 本机观测模块加载 RSS `+8,945,664` bytes、1 万行输出峰值 `+5,226,496` bytes,不冒充固定物理低配门。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `45fab400eb449774d50429103dd766a2755166530ac54ddc1056f777bc16c15f`,临时 Docker 资源已清理。 diff --git a/packages/ql3-cluster-admin/package.json b/packages/ql3-cluster-admin/package.json index ff56bbc6..5c9e440f 100644 --- a/packages/ql3-cluster-admin/package.json +++ b/packages/ql3-cluster-admin/package.json @@ -220,6 +220,11 @@ "require": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js", "default": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js" }, + "./plugin-package-kubernetes-secret-action-job": { + "types": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.d.ts", + "require": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js", + "default": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js" + }, "./prompt-output-gc-process": { "types": "./dist/prompt-output/retention/promptOutputGcProcess.d.ts", "require": "./dist/prompt-output/retention/promptOutputGcProcess.js", diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts index 13855818..a57b2e34 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageApprovedAction.ts @@ -40,7 +40,7 @@ export interface ClusterPluginPackageApprovedActionDispatcherOptions readonly defaultBatchSize?: number; readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort; readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort; - readonly secretExistenceInspector: PluginPackageSecretExistenceInspector; + readonly secretExistenceInspector?: PluginPackageSecretExistenceInspector; } export function createClusterPluginPackageApprovedActionDispatcher( @@ -64,16 +64,22 @@ export function createClusterPluginPackageApprovedActionDispatcher( ); const handlers = [ installHandler, - new ClusterPluginPackageSecretBindingApprovedActionHandler( - new PostgresPluginPackageSecretBindingApprovalPlanReader(pool), - new PostgresPluginPackageSecretBindingRepository(pool), - secretExistenceInspector, - ), - new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( - new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(pool), - new PostgresPluginPackageSecretBindingTransitionRepository(pool), - secretExistenceInspector, - ), + ...(secretExistenceInspector + ? [ + new ClusterPluginPackageSecretBindingApprovedActionHandler( + new PostgresPluginPackageSecretBindingApprovalPlanReader(pool), + new PostgresPluginPackageSecretBindingRepository(pool), + secretExistenceInspector, + ), + new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler( + new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader( + pool, + ), + new PostgresPluginPackageSecretBindingTransitionRepository(pool), + secretExistenceInspector, + ), + ] + : []), ...(['overlap_add', 'safe_retire'] as const).map( (mode) => new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler( diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts index 6b640d42..1c6270c8 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageExecutorProcess.ts @@ -64,6 +64,7 @@ export type ClusterPluginPackageExecutorProcessConfig = leaseDurationMs: number; revocationPageSize: number; revocationMaxPages: number; + dispatchId: string | null; secretProjectionRoot: string | null; database: Readonly<{ connection: PostgresConnectionOptions; @@ -111,6 +112,9 @@ export interface RunClusterPluginPackageExecutorProcessOptions { readonly createDispatcher?: ( options: ClusterPluginPackageApprovedActionDispatcherOptions, ) => ApprovedActionDispatcher; + readonly assertReady?: ( + pool: PostgresPool, + ) => Promise; readonly now?: () => number; } @@ -126,6 +130,7 @@ export class ClusterPluginPackageExecutorProcessConfigError extends TypeError { } const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const SAFE_DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; function enabledValue( environment: ClusterPluginPackageExecutorProcessEnvironment, @@ -319,6 +324,17 @@ export function loadClusterPluginPackageExecutorProcessConfig( 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER is invalid', ); } + const dispatchId = + boundedValue( + environment, + 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID', + 128, + ) ?? null; + if (dispatchId !== null && !SAFE_DISPATCH_ID.test(dispatchId)) { + throw new ClusterPluginPackageExecutorProcessConfigError( + 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID is invalid', + ); + } return Object.freeze({ enabled: true, owner, @@ -364,6 +380,7 @@ export function loadClusterPluginPackageExecutorProcessConfig( 1, 64, ), + dispatchId, secretProjectionRoot: secretProjectionRoot(environment), database: databaseConfig(environment), }); @@ -391,6 +408,22 @@ function isIdleBatch( ); } +function emptyApprovalSummary(): Readonly<{ + scanned: 0; + consumed: 0; + existing: 0; + expired: 0; + blocked: 0; +}> { + return Object.freeze({ + scanned: 0, + consumed: 0, + existing: 0, + expired: 0, + blocked: 0, + }); +} + export async function runClusterPluginPackageExecutorProcess( options: RunClusterPluginPackageExecutorProcessOptions, ): Promise { @@ -411,6 +444,8 @@ export async function runClusterPluginPackageExecutorProcess( typeof options.consumeSecretBindingTransitionApprovals !== 'function') || (options.createDispatcher !== undefined && typeof options.createDispatcher !== 'function') || + (options.assertReady !== undefined && + typeof options.assertReady !== 'function') || (options.now !== undefined && typeof options.now !== 'function') ) { throw new TypeError('Plugin Package executor process options are invalid'); @@ -430,9 +465,9 @@ export async function runClusterPluginPackageExecutorProcess( const database = await openDatabase(); let failure: unknown; try { - const evidence = await assertPostgresPackageExecutorSchemaReady( - database.pool, - ); + const evidence = await ( + options.assertReady ?? assertPostgresPackageExecutorSchemaReady + )(database.pool); const dispatcherFactory = options.createDispatcher ?? createClusterPluginPackageApprovedActionDispatcher; @@ -453,18 +488,14 @@ export async function runClusterPluginPackageExecutorProcess( owner: config.owner, leaseDurationMs: config.leaseDurationMs, defaultBatchSize: config.dispatchBatchSize, - secretExistenceInspector: - config.secretProjectionRoot === null - ? Object.freeze({ - async assertExists(): Promise { - throw new Error( - 'Plugin Package Secret projection is not configured', - ); - }, - }) - : new ProjectedPluginPackageSecretExistenceInspector({ - rootDirectory: config.secretProjectionRoot, - }), + ...(config.secretProjectionRoot === null + ? {} + : { + secretExistenceInspector: + new ProjectedPluginPackageSecretExistenceInspector({ + rootDirectory: config.secretProjectionRoot, + }), + }), ...(options.now ? { clock: options.now } : {}), publisherRevocations: { async run(receipt) { @@ -486,6 +517,24 @@ export async function runClusterPluginPackageExecutorProcess( }, }); const batches: Readonly[] = []; + if (config.dispatchId !== null) { + const dispatch = await dispatcher.dispatchById({ + dispatchId: config.dispatchId, + }); + return Object.freeze({ + status: 'completed', + database: evidence, + batches: Object.freeze([ + Object.freeze({ + approvals: emptyApprovalSummary(), + trustTransitionApprovals: emptyApprovalSummary(), + secretBindingApprovals: emptyApprovalSummary(), + secretBindingTransitionApprovals: emptyApprovalSummary(), + dispatch, + }), + ]), + }); + } for (let index = 0; index < config.maxBatches; index += 1) { const approvals = await consumeApprovals({ pool: database.pool, diff --git a/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageKubernetesSecretActionJob.ts b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageKubernetesSecretActionJob.ts new file mode 100644 index 00000000..4c4e7ec9 --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/executor/pluginPackageKubernetesSecretActionJob.ts @@ -0,0 +1,504 @@ +import { createHash } from 'node:crypto'; + +import { + normalizeApprovedActionDispatchRecord, + type ApprovedActionDispatchRecord, +} from '@qinglong/runtime-core/approved-action'; +import { + PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA, + normalizePluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, + type PluginPackageSecretBindingApprovalPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'; +import { + normalizePluginPackageSecretBindingTransitionApprovalPlan, + pluginPackageSecretBindingTransitionApprovedAction, + type PluginPackageSecretBindingTransitionApprovalPlan, +} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan'; +import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; + +import { + isPluginPackageKubernetesSecretName, + PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE, +} from '../secret-binding/pluginPackageKubernetesSecretProjection'; + +export const PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA = + 'qinglong/plugin-package-kubernetes-secret-action-job@v1' as const; +export const PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT = + '/var/run/secrets/qinglong3/plugin-package-values' as const; + +const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/; +const SECRET_KEY = /^[A-Za-z0-9._-]{1,253}$/; +const DATABASE_NAME = /^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/; +const DNS_NAME = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/; +const IMAGE_DIGEST = + /^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$/; +const JOB_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-kubernetes-secret-action-job-digest@v1\0', + 'utf8', +); + +type SecretActionApprovalPlan = + | PluginPackageSecretBindingApprovalPlan + | PluginPackageSecretBindingTransitionApprovalPlan; + +export type PluginPackageKubernetesPostgresConnection = + | Readonly<{ + mode: 'url'; + secretName: string; + urlKey: string; + }> + | Readonly<{ + mode: 'fields'; + authSecretName: string; + host: string; + port: number; + database: string; + usernameKey: string; + passwordKey: string; + }>; + +export interface PluginPackageKubernetesSecretActionJobOptions { + readonly namespace: string; + readonly serviceAccountName: string; + readonly sourceSecretName: string; + readonly image: string; + readonly postgres: Readonly<{ + connection: PluginPackageKubernetesPostgresConnection; + caSecretName: string; + caKey: string; + servername: string; + }>; +} + +export interface PluginPackageKubernetesSecretActionJobInput { + readonly dispatch: Readonly; + readonly approvalPlan: Readonly; + readonly options: Readonly; +} + +export class InvalidPluginPackageKubernetesSecretActionJobError extends TypeError { + readonly code = 'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_INVALID'; + + constructor(message: string) { + super(`Plugin Package Kubernetes Secret action Job is invalid: ${message}`); + this.name = 'InvalidPluginPackageKubernetesSecretActionJobError'; + } +} + +function invalid(message: string): never { + throw new InvalidPluginPackageKubernetesSecretActionJobError(message); +} + +function record(value: unknown, label: string): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return invalid(`${label} must be an object`); + } + return value as Record; +} + +function exactKeys( + value: Record, + keys: readonly string[], + label: string, +): void { + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if ( + actual.length !== expected.length || + actual.some((key, index) => key !== expected[index]) + ) { + invalid(`${label} shape is invalid`); + } +} + +function dnsLabel(value: unknown, label: string): string { + if (typeof value !== 'string' || !DNS_LABEL.test(value)) { + return invalid(`${label} is invalid`); + } + return value; +} + +function secretKey(value: unknown, label: string): string { + if (typeof value !== 'string' || !SECRET_KEY.test(value)) { + return invalid(`${label} is invalid`); + } + return value; +} + +function normalizeOptions( + value: PluginPackageKubernetesSecretActionJobOptions, +): PluginPackageKubernetesSecretActionJobOptions { + const options = record(value, 'options'); + exactKeys( + options, + [ + 'image', + 'namespace', + 'postgres', + 'serviceAccountName', + 'sourceSecretName', + ], + 'options', + ); + const postgres = record(value.postgres, 'postgres'); + exactKeys( + postgres, + ['caKey', 'caSecretName', 'connection', 'servername'], + 'postgres', + ); + const connection = record(value.postgres.connection, 'connection'); + if (connection.mode === 'url') { + exactKeys(connection, ['mode', 'secretName', 'urlKey'], 'connection'); + } else if (connection.mode === 'fields') { + exactKeys( + connection, + [ + 'authSecretName', + 'database', + 'host', + 'mode', + 'passwordKey', + 'port', + 'usernameKey', + ], + 'connection', + ); + } else { + return invalid('connection mode is invalid'); + } + if ( + !isPluginPackageKubernetesSecretName(value.sourceSecretName) || + typeof value.image !== 'string' || + !IMAGE_DIGEST.test(value.image) || + !isPluginPackageKubernetesSecretName(value.postgres.caSecretName) || + !DNS_NAME.test(value.postgres.servername) + ) { + return invalid('options contain an invalid Kubernetes identity'); + } + const normalizedConnection = + value.postgres.connection.mode === 'url' + ? Object.freeze({ + mode: 'url' as const, + secretName: dnsLabel( + value.postgres.connection.secretName, + 'connection Secret name', + ), + urlKey: secretKey(value.postgres.connection.urlKey, 'URL key'), + }) + : Object.freeze({ + mode: 'fields' as const, + authSecretName: dnsLabel( + value.postgres.connection.authSecretName, + 'authentication Secret name', + ), + host: DNS_NAME.test(value.postgres.connection.host) + ? value.postgres.connection.host + : invalid('PostgreSQL host is invalid'), + port: + Number.isSafeInteger(value.postgres.connection.port) && + value.postgres.connection.port >= 1 && + value.postgres.connection.port <= 65_535 + ? value.postgres.connection.port + : invalid('PostgreSQL port is invalid'), + database: DATABASE_NAME.test(value.postgres.connection.database) + ? value.postgres.connection.database + : invalid('PostgreSQL database is invalid'), + usernameKey: secretKey( + value.postgres.connection.usernameKey, + 'username key', + ), + passwordKey: secretKey( + value.postgres.connection.passwordKey, + 'password key', + ), + }); + return Object.freeze({ + namespace: dnsLabel(value.namespace, 'namespace'), + serviceAccountName: dnsLabel( + value.serviceAccountName, + 'ServiceAccount name', + ), + sourceSecretName: value.sourceSecretName, + image: value.image, + postgres: Object.freeze({ + connection: normalizedConnection, + caSecretName: value.postgres.caSecretName, + caKey: secretKey(value.postgres.caKey, 'CA key'), + servername: value.postgres.servername, + }), + }); +} + +function normalizePlan(value: SecretActionApprovalPlan): Readonly<{ + plan: Readonly; + action: ReturnType; + secretRefs: readonly string[]; +}> { + if ( + record(value, 'approval plan').schema === + PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA + ) { + const plan = normalizePluginPackageSecretBindingApprovalPlan( + value as PluginPackageSecretBindingApprovalPlan, + ); + return Object.freeze({ + plan, + action: pluginPackageSecretBindingApprovedAction(plan), + secretRefs: Object.freeze( + plan.bindingPlan.entries.flatMap((entry) => + entry.secretRef === null ? [] : [entry.secretRef], + ), + ), + }); + } + const plan = normalizePluginPackageSecretBindingTransitionApprovalPlan( + value as PluginPackageSecretBindingTransitionApprovalPlan, + ); + return Object.freeze({ + plan, + action: pluginPackageSecretBindingTransitionApprovedAction(plan), + secretRefs: Object.freeze( + plan.transitionPlan.nextBindingPlan?.entries.flatMap((entry) => + entry.secretRef === null ? [] : [entry.secretRef], + ) ?? [], + ), + }); +} + +function uniqueItems(secretRefs: readonly string[]): readonly Readonly<{ + key: string; + path: string; +}>[] { + const keys = [...new Set(secretRefs.map(secretProjectionFileName))].sort(); + return Object.freeze( + keys.map((key) => Object.freeze({ key, path: key })), + ); +} + +function valueFromSecret(name: string, key: string): object { + return { + valueFrom: { secretKeyRef: { name, key, optional: false } }, + }; +} + +function connectionEnvironment( + connection: PluginPackageKubernetesPostgresConnection, +): readonly object[] { + if (connection.mode === 'url') { + return [ + { + name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL', + ...valueFromSecret(connection.secretName, connection.urlKey), + }, + ]; + } + return [ + { name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST', value: connection.host }, + { + name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT', + value: String(connection.port), + }, + { + name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE', + value: connection.database, + }, + { + name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER', + ...valueFromSecret(connection.authSecretName, connection.usernameKey), + }, + { + name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD', + ...valueFromSecret(connection.authSecretName, connection.passwordKey), + }, + ]; +} + +function deepFreeze(value: T): Readonly { + if (!value || typeof value !== 'object' || Object.isFrozen(value)) { + return value as Readonly; + } + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + return Object.freeze(value); +} + +export function createPluginPackageKubernetesSecretActionJob( + input: PluginPackageKubernetesSecretActionJobInput, +): Readonly> { + const candidate = record(input, 'input'); + exactKeys(candidate, ['approvalPlan', 'dispatch', 'options'], 'input'); + const dispatch = normalizeApprovedActionDispatchRecord(input.dispatch); + const approved = normalizePlan(input.approvalPlan); + if ( + JSON.stringify(dispatch.action) !== JSON.stringify(approved.action) || + dispatch.projectId !== + ('bindingPlan' in approved.plan + ? approved.plan.bindingPlan.target.projectId + : approved.plan.transitionPlan.nextTarget.projectId) || + dispatch.requestedBy.type !== approved.plan.requestedBy.type || + dispatch.requestedBy.id !== approved.plan.requestedBy.id || + dispatch.createdAtMs > approved.plan.expiresAtMs + ) { + return invalid('dispatch does not match the approved plan'); + } + const options = normalizeOptions(input.options); + const items = uniqueItems(approved.secretRefs); + const unsigned = { + schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA, + dispatch, + approvalPlanDigest: approved.plan.approvalPlanDigest, + namespace: options.namespace, + serviceAccountName: options.serviceAccountName, + sourceSecretName: options.sourceSecretName, + image: options.image, + postgres: options.postgres, + items, + }; + const jobDigest = createHash('sha256') + .update(JOB_DIGEST_DOMAIN) + .update(JSON.stringify(unsigned), 'utf8') + .digest('hex'); + const name = `ql3-package-secret-${jobDigest.slice(0, 32)}`; + const valueVolume = + items.length === 0 + ? { name: 'plugin-package-values', emptyDir: { sizeLimit: '1Ki' } } + : { + name: 'plugin-package-values', + secret: { + secretName: options.sourceSecretName, + optional: false, + defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE, + items, + }, + }; + return deepFreeze({ + apiVersion: 'batch/v1', + kind: 'Job', + metadata: { + name, + namespace: options.namespace, + labels: { + 'app.kubernetes.io/name': 'ql3-plugin-package-secret-action', + 'app.kubernetes.io/component': 'plugin-package-executor', + 'app.kubernetes.io/part-of': 'qinglong3', + }, + annotations: { + 'qinglong.io/secret-action-job-schema': + PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA, + 'qinglong.io/secret-action-job-digest': jobDigest, + 'qinglong.io/approved-action-type': dispatch.action.actionType, + 'qinglong.io/approved-action-digest': dispatch.action.actionDigest, + }, + }, + spec: { + backoffLimit: 2, + activeDeadlineSeconds: 600, + ttlSecondsAfterFinished: 3600, + template: { + metadata: { + labels: { + 'app.kubernetes.io/name': 'ql3-plugin-package-secret-action', + 'app.kubernetes.io/component': 'plugin-package-executor', + 'app.kubernetes.io/part-of': 'qinglong3', + 'qinglong.io/secret-action-job': name, + }, + }, + spec: { + serviceAccountName: options.serviceAccountName, + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + fsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'executor', + image: options.image, + imagePullPolicy: 'IfNotPresent', + command: [ + 'node', + '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/executor/pluginPackageExecutorCli.js', + ], + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + env: [ + { name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED', value: 'true' }, + { + name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER', + value: `package_secret_${jobDigest.slice(0, 24)}`, + }, + { + name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID', + value: dispatch.id, + }, + { + name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT', + value: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT, + }, + { + name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', + value: '600000', + }, + { name: 'QL3_POSTGRES_TLS_MODE', value: 'verify-full' }, + { + name: 'QL3_POSTGRES_TLS_CA_FILE', + value: '/var/run/secrets/qinglong3/postgres/ca.crt', + }, + { + name: 'QL3_POSTGRES_TLS_SERVERNAME', + value: options.postgres.servername, + }, + { + name: 'QL3_POSTGRES_APPLICATION_NAME', + value: 'qinglong3-package-secret-action', + }, + { name: 'QL3_POSTGRES_MAX_CONNECTIONS', value: '1' }, + ...connectionEnvironment(options.postgres.connection), + ], + resources: { + requests: { cpu: '25m', memory: '48Mi' }, + limits: { cpu: '250m', memory: '192Mi' }, + }, + volumeMounts: [ + { name: 'tmp', mountPath: '/tmp' }, + { + name: 'postgres-ca', + mountPath: '/var/run/secrets/qinglong3/postgres', + readOnly: true, + }, + { + name: 'plugin-package-values', + mountPath: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT, + readOnly: true, + }, + ], + }, + ], + volumes: [ + { name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '8Mi' } }, + { + name: 'postgres-ca', + secret: { + secretName: options.postgres.caSecretName, + optional: false, + defaultMode: 0o444, + items: [{ key: options.postgres.caKey, path: 'ca.crt' }], + }, + }, + valueVolume, + ], + }, + }, + }, + }); +} diff --git a/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs index 2da8fe02..0a360e03 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageExecutorProcess.test.cjs @@ -49,6 +49,7 @@ test('loads bounded low-footprint Package-executor configuration', () => { assert.equal(config.maxBatches, 2); assert.equal(config.revocationPageSize, 8); assert.equal(config.revocationMaxPages, 4); + assert.equal(config.dispatchId, null); assert.equal( config.secretProjectionRoot, '/var/run/secrets/qinglong3/plugin-package-values', @@ -57,6 +58,99 @@ test('loads bounded low-footprint Package-executor configuration', () => { assert.equal(config.database.connection.tls.mode, 'disable'); }); +test('loads one bounded action-scoped dispatch without widening batch limits', () => { + const config = loadClusterPluginPackageExecutorProcessConfig( + environment({ + QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch.secret-binding.42', + QL3_PLUGIN_PACKAGE_EXECUTOR_APPROVAL_BATCH_SIZE: undefined, + QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_BATCH_SIZE: undefined, + QL3_PLUGIN_PACKAGE_EXECUTOR_MAX_BATCHES: undefined, + }), + ); + assert.equal(config.enabled, true); + assert.equal(config.dispatchId, 'dispatch.secret-binding.42'); + assert.equal(config.approvalBatchSize, 8); + assert.equal(config.dispatchBatchSize, 8); + assert.equal(config.maxBatches, 4); +}); + +test('action-scoped mode skips every Approval consumer and shared queue scan', async () => { + const calls = []; + const pool = {}; + const readiness = { + ready: true, + writablePrimary: true, + serverVersionNum: 180004, + serverMajor: 18, + currentUser: 'ql3_package_executor', + contractName: 'control-core', + contractVersion: 62, + migrationIds: ['pg-0063-plugin-package-secret-binding-transition-receipts'], + }; + const rejectConsumer = async () => { + throw new Error('action-scoped executor must not consume approvals'); + }; + const result = await runClusterPluginPackageExecutorProcess({ + environment: environment({ + QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch.secret-binding.42', + }), + async openDatabase() { + calls.push('open'); + return { + pool, + async close() { + calls.push('close'); + }, + }; + }, + async assertReady(candidate) { + assert.equal(candidate, pool); + calls.push('ready'); + return readiness; + }, + consumeApprovals: rejectConsumer, + consumeTrustTransitionApprovals: rejectConsumer, + consumeSecretBindingApprovals: rejectConsumer, + consumeSecretBindingTransitionApprovals: rejectConsumer, + createDispatcher(options) { + assert.equal(options.pool, pool); + assert.equal(typeof options.secretExistenceInspector.assertExists, 'function'); + return { + async dispatchBatch() { + throw new Error('action-scoped executor must not scan the queue'); + }, + async dispatchById({ dispatchId }) { + calls.push(`dispatch:${dispatchId}`); + return { + scanned: 1, + claimed: 1, + started: 1, + succeeded: 1, + failed: 0, + blocked: 0, + retrying: 0, + deferred: 0, + recoveryRequired: 0, + alreadyTerminal: 0, + unavailable: 0, + truncated: false, + }; + }, + }; + }, + }); + assert.deepEqual(calls, [ + 'open', + 'ready', + 'dispatch:dispatch.secret-binding.42', + 'close', + ]); + assert.equal(result.status, 'completed'); + assert.equal(result.batches.length, 1); + assert.equal(result.batches[0].approvals.scanned, 0); + assert.equal(result.batches[0].dispatch.succeeded, 1); +}); + test('rejects implicit insecure PostgreSQL and unbounded work', () => { for (const invalid of [ environment({ QL3_POSTGRES_ALLOW_INSECURE: undefined }), @@ -64,6 +158,9 @@ test('rejects implicit insecure PostgreSQL and unbounded work', () => { environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE: '129' }), environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER: 'not safe' }), environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: 'relative/path' }), + environment({ + QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch id with spaces', + }), ]) { assert.throws( () => loadClusterPluginPackageExecutorProcessConfig(invalid), diff --git a/packages/ql3-cluster-admin/test/pluginPackageKubernetesSecretActionJob.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageKubernetesSecretActionJob.test.cjs new file mode 100644 index 00000000..a334b089 --- /dev/null +++ b/packages/ql3-cluster-admin/test/pluginPackageKubernetesSecretActionJob.test.cjs @@ -0,0 +1,246 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + consumeApprovalRequest, + createApprovalRequest, + decideApprovalRequest, +} = require('@qinglong/runtime-core/approved-action'); +const { + createPluginPackageResourceGeneration, +} = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingApprovalPlan, + pluginPackageSecretBindingApprovedAction, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan'); +const { + createPluginPackageSecretBindingPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); +const { + InvalidPluginPackageKubernetesSecretActionJobError, + createPluginPackageKubernetesSecretActionJob, +} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-job'); + +const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' }); +const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' }); +const CONSUMER = Object.freeze({ + type: 'system', + id: 'cluster_package_executor', +}); +const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 }); + +function fixture({ withoutValues = false } = {}) { + const manifest = { + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version: '1.0.0', + description: 'Action-scoped Job fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '32Mi' }, + disk: { install: '4Mi', working: '8Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets: [ + { name: 'TOKEN', required: !withoutValues }, + { name: 'TOKEN_ALIAS', required: !withoutValues }, + ], + tools: ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; + const generation = createPluginPackageResourceGeneration({ + installationId: 'install-secret-action-1', + projectId: 'project-1', + packageName: 'example-monitor', + lockDigest: 'a'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: 'b'.repeat(64), + contents: manifest.spec.contents, + }); + const secretRef = createSecretRef({ + projectId: 'project-1', + name: 'runtime-token', + version: 2, + }); + const bindingPlan = createPluginPackageSecretBindingPlan({ + generation, + manifest, + assignments: [ + { name: 'TOKEN', secretRef: withoutValues ? null : secretRef }, + { name: 'TOKEN_ALIAS', secretRef: withoutValues ? null : secretRef }, + ], + plannedAtMs: 100, + }); + const approvalPlan = createPluginPackageSecretBindingApprovalPlan({ + actionRef: 'secret-binding:example-monitor-v1', + bindingPlan, + requestedBy: REQUESTER, + expiresAtMs: 1_000, + }); + const action = pluginPackageSecretBindingApprovedAction(approvalPlan); + const pending = createApprovalRequest({ + id: 'approval-secret-action-1', + projectId: 'project-1', + action, + risk: 'high', + decisionMode: 'separation_of_duty', + requestedBy: REQUESTER, + requestedAtMs: 110, + expiresAtMs: 900, + requestFence: FENCE, + }); + const approved = decideApprovalRequest(pending, { + expectedVersion: 1, + decisionId: 'decision-secret-action-1', + decision: 'approved', + reasonCode: 'reviewed', + principal: { + subject: REVIEWER, + authenticationId: 'auth-reviewer', + authenticatedAtMs: 100, + expiresAtMs: 800, + assurance: 'multi_factor', + }, + decidedAtMs: 120, + authorizationFence: FENCE, + }); + const dispatch = consumeApprovalRequest(approved, { + expectedVersion: 2, + consumptionId: 'consume-secret-action-1', + dispatchId: 'dispatch-secret-action-1', + action, + requestedBy: REQUESTER, + consumedBy: CONSUMER, + consumedAtMs: 130, + authorizationFence: FENCE, + }).dispatch; + return { approvalPlan, dispatch, secretRef }; +} + +function options(overrides = {}) { + return { + namespace: 'qinglong3-system', + serviceAccountName: 'ql3-plugin-package-secret-action', + sourceSecretName: 'ql3-cluster-plugin-package-values', + image: + 'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' + + 'c'.repeat(64), + postgres: { + connection: { + mode: 'fields', + authSecretName: 'ql3-postgres-package-executor-auth', + host: 'ql3-postgres-rw.qinglong3-system.svc', + port: 5432, + database: 'qinglong', + usernameKey: 'username', + passwordKey: 'password', + }, + caSecretName: 'ql3-postgres-ca', + caKey: 'ca.crt', + servername: 'ql3-postgres-rw.qinglong3-system.svc', + }, + ...overrides, + }; +} + +test('renders one deterministic exact-key Secret action Job', () => { + const { approvalPlan, dispatch, secretRef } = fixture(); + const job = createPluginPackageKubernetesSecretActionJob({ + dispatch, + approvalPlan, + options: options(), + }); + const replay = createPluginPackageKubernetesSecretActionJob({ + dispatch, + approvalPlan, + options: options(), + }); + assert.deepEqual(replay, job); + assert.match(job.metadata.name, /^ql3-package-secret-[0-9a-f]{32}$/); + assert.equal(Object.isFrozen(job), true); + assert.equal(Object.isFrozen(job.spec.template.spec.volumes), true); + assert.equal(job.spec.template.spec.automountServiceAccountToken, false); + + const values = job.spec.template.spec.volumes.find( + (volume) => volume.name === 'plugin-package-values', + ); + assert.deepEqual(values.secret.items, [ + { + key: secretProjectionFileName(secretRef), + path: secretProjectionFileName(secretRef), + }, + ]); + assert.equal(values.secret.optional, false); + assert.equal(values.secret.defaultMode, 0o440); + + const container = job.spec.template.spec.containers[0]; + assert.equal( + container.env.find( + (entry) => entry.name === 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID', + ).value, + dispatch.id, + ); + assert.equal(container.resources.requests.memory, '48Mi'); + assert.equal(JSON.stringify(job).includes('qlsecret:'), false); + assert.equal(JSON.stringify(job).includes('runtime-token'), false); +}); + +test('rejects a tag-only image and a dispatch bound to another plan', () => { + const { approvalPlan, dispatch } = fixture(); + assert.throws( + () => + createPluginPackageKubernetesSecretActionJob({ + dispatch, + approvalPlan, + options: options({ image: 'qinglong3-cluster-admin:latest' }), + }), + InvalidPluginPackageKubernetesSecretActionJobError, + ); + assert.throws( + () => + createPluginPackageKubernetesSecretActionJob({ + dispatch: { ...dispatch, id: 'dispatch-secret-action-drift' }, + approvalPlan: { ...approvalPlan, actionRef: 'other-action' }, + options: options(), + }), + TypeError, + ); +}); + +test('uses an empty directory for a reviewed action with no Secret values', () => { + const { approvalPlan, dispatch } = fixture({ withoutValues: true }); + const job = createPluginPackageKubernetesSecretActionJob({ + dispatch, + approvalPlan, + options: options(), + }); + const values = job.spec.template.spec.volumes.find( + (volume) => volume.name === 'plugin-package-values', + ); + assert.deepEqual(values, { + name: 'plugin-package-values', + emptyDir: { sizeLimit: '1Ki' }, + }); + assert.equal(values.secret, undefined); +}); diff --git a/packages/ql3-runtime-core/src/approved-action/approvedActionDispatcher.ts b/packages/ql3-runtime-core/src/approved-action/approvedActionDispatcher.ts index 9bd2babd..edf0f6c9 100644 --- a/packages/ql3-runtime-core/src/approved-action/approvedActionDispatcher.ts +++ b/packages/ql3-runtime-core/src/approved-action/approvedActionDispatcher.ts @@ -74,6 +74,10 @@ export interface ApprovedActionDispatchBatchSummary { readonly nextCursor?: Readonly; } +export interface ApprovedActionDispatchByIdOptions { + readonly dispatchId: string; +} + interface MutableSummary { scanned: number; claimed: number; @@ -262,6 +266,54 @@ export class ApprovedActionDispatcher { return Object.freeze({ ...summary }); } + /** + * Execute one durable dispatch without scanning the shared due queue. + * + * This is the entry point for an action-scoped executor (for example a + * Kubernetes Job with an exact Secret projection). The handler check happens + * before the claim so a narrowly configured executor cannot lease and block + * an action outside its authority. + */ + async dispatchById( + options: Readonly, + ): Promise> { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + !exactKeys(options, ['dispatchId']) + ) { + throw new TypeError('Approved Action exact dispatch is invalid'); + } + const dispatchId = identifier(options.dispatchId, 'dispatch id'); + const summary: MutableSummary = { + scanned: 0, + claimed: 0, + started: 0, + succeeded: 0, + failed: 0, + blocked: 0, + retrying: 0, + deferred: 0, + recoveryRequired: 0, + alreadyTerminal: 0, + unavailable: 0, + truncated: false, + }; + const snapshot = await this.#find(dispatchId); + if (!snapshot) { + summary.unavailable = 1; + return Object.freeze({ ...summary }); + } + summary.scanned = 1; + if (!this.#handlers.has(snapshot.dispatch.action.actionType)) { + summary.unavailable = 1; + return Object.freeze({ ...summary }); + } + await this.#dispatchOne(dispatchId, summary); + return Object.freeze({ ...summary }); + } + async #dispatchOne( dispatchId: string, summary: MutableSummary, diff --git a/packages/ql3-runtime-core/test/approvedActionDispatcher.test.cjs b/packages/ql3-runtime-core/test/approvedActionDispatcher.test.cjs index 2afe798e..e2ab8827 100644 --- a/packages/ql3-runtime-core/test/approvedActionDispatcher.test.cjs +++ b/packages/ql3-runtime-core/test/approvedActionDispatcher.test.cjs @@ -302,3 +302,43 @@ test('does not claim an action without a matching handler', async () => { assert.equal(repository.execution.status, 'pending'); assert.equal(repository.startCalls, 0); }); + +test('dispatches only the requested durable action without a queue scan', async () => { + const repository = new InMemoryExecutionRepository(dispatch()); + repository.listDueExecutions = async () => { + throw new Error('exact dispatch must not scan'); + }; + const summary = await createDispatcher(repository, { + actionType: 'plugin_package.install', + async inspect(value) { + return { status: 'ready', actionDigest: value.action.actionDigest }; + }, + async execute() { + return { + outcome: 'succeeded', + resultCode: 'package_admitted', + resultDigest: RESULT_DIGEST, + }; + }, + }).dispatchById({ dispatchId: 'dispatch-dispatcher-v1' }); + assert.equal(summary.scanned, 1); + assert.equal(summary.claimed, 1); + assert.equal(summary.succeeded, 1); + assert.equal(summary.truncated, false); +}); + +test('exact dispatch does not claim an action outside configured authority', async () => { + const repository = new InMemoryExecutionRepository(dispatch()); + const dispatcher = new ApprovedActionDispatcher(repository, [], { + owner: 'dispatcher_instance_1', + clock: () => 100, + createId: () => 'dispatcher-exact-id', + }); + const summary = await dispatcher.dispatchById({ + dispatchId: 'dispatch-dispatcher-v1', + }); + assert.equal(summary.scanned, 1); + assert.equal(summary.claimed, 0); + assert.equal(summary.unavailable, 1); + assert.equal(repository.execution.status, 'pending'); +}); diff --git a/scripts/ql3-cluster-deployment-audit.cjs b/scripts/ql3-cluster-deployment-audit.cjs index c89f7524..af82bd86 100644 --- a/scripts/ql3-cluster-deployment-audit.cjs +++ b/scripts/ql3-cluster-deployment-audit.cjs @@ -3254,10 +3254,6 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { ['QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', '600000'], ['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE', '16'], ['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES', '16'], - [ - 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT', - '/var/run/secrets/qinglong3/plugin-package-values', - ], ['QL3_POSTGRES_TLS_MODE', 'verify-full'], [ 'QL3_POSTGRES_TLS_CA_FILE', @@ -3311,13 +3307,14 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { caVolume?.secret?.defaultMode !== 0o444 || JSON.stringify(caVolume?.secret?.items) !== JSON.stringify([{ key: 'postgres-ca.crt', path: 'ca.crt' }]) || - secretMount?.readOnly !== true || - secretMount?.mountPath !== - '/var/run/secrets/qinglong3/plugin-package-values' || - secretVolume?.secret?.secretName !== - 'ql3-cluster-plugin-package-values' || - secretVolume?.secret?.optional !== true || - secretVolume?.secret?.defaultMode !== 0o440 || + secretMount !== undefined || + secretVolume !== undefined || + env.has('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT') || + env.has('QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID') || + JSON.stringify(networkPolicy?.spec?.podSelector?.matchLabels) !== + JSON.stringify({ + 'app.kubernetes.io/component': 'plugin-package-executor', + }) || JSON.stringify(networkPolicy?.spec?.policyTypes) !== JSON.stringify(['Ingress', 'Egress']) || JSON.stringify(networkPolicy?.spec?.ingress) !== JSON.stringify([]) || @@ -3326,7 +3323,7 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { findings.push( finding( 'QL3_CLUSTER_PLUGIN_EXECUTOR_BOUNDARY', - 'Plugin Package executor must have private read-only CA and optional SecretRef projections, no ingress and DNS-only base egress', + 'Plugin Package batch executor must have private read-only CA, no Package value projection, no ingress and DNS-only base egress', ), ); } @@ -3382,8 +3379,8 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) { }, ]) || cloudNativeEnv.has('QL3_POSTGRES_PACKAGE_EXECUTOR_URL') || - cloudNativeEnv.get('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT')?.value !== - '/var/run/secrets/qinglong3/plugin-package-values' || + cloudNativeEnv.has('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT') || + cloudNativeEnv.has('QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID') || cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_HOST')?.value !== 'ql3-postgres-rw.qinglong3-system.svc' || cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_USER')?.valueFrom diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 298e3014..bdd04a72 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: clusterAdmin.rootSourceFileRoles, }, { - sourceFiles: 107, + sourceFiles: 108, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 106, + nestedSourceFiles: 107, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', },