From 9d7431c2cd4b79c8cca4c119c99c5d97a86d31f3 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 14 Aug 2026 00:34:49 +0800 Subject: [PATCH] feat(ql3): project plugin secrets into kubernetes --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 2 +- package.json | 3 +- .../pluginPackageKubernetesActivation.ts | 258 +++++++++++-- .../recovery/pluginPackageRecovery.ts | 11 + ...pluginPackageKubernetesSecretProjection.ts | 315 +++++++++++++++ ...pluginPackageKubernetesActivation.test.cjs | 360 ++++++++++++++++++ .../test/application.test.cjs | 6 + .../test/bootstrap.test.cjs | 6 + ...n-package-kubernetes-k3s-live-contract.cjs | 314 +++++++++++++++ ...3-plugin-package-kubernetes-live-actor.cjs | 197 ++++++++-- ...lugin-package-kubernetes-live-contract.cjs | 29 +- test/back/ql3PackageBoundaryAudit.test.cjs | 4 +- ...uginPackageKubernetesLiveContract.test.cjs | 5 +- 13 files changed, 1437 insertions(+), 73 deletions(-) create mode 100644 packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageKubernetesSecretProjection.ts create mode 100644 scripts/ql3-plugin-package-kubernetes-k3s-live-contract.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 8ceb0fad..62510c6d 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:package-executor 在一个 SERIALIZABLE transaction 中复验 current staged head、上一 active lineage、durable 最大 generation 与可选上一 binding,并原子提交可选目标 binding 和 immutable receipt;数据库 trigger 和最小角色 readiness 防止绕过,recovery/直接 activation 缺 receipt 均失败关闭。真实 PostgreSQL 18.4 已通过 63 条 migration、原子提交、exact replay、错误状态/lineage 拒绝,并暴露和修复了 `jsonb` 键重排下不应依赖属性顺序的 plan 归一化问题。阶段门已串行完成:18 个 QL3 workspace package 统一 clean build/test 退出 0,backend 1192 pass/2 条条件 skip/0 fail,PostgreSQL 18.4 arm64 physical HA 以 timeline `1→2` 通过 125 gate(报告 SHA-256 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`),package/dependency/edge import 审计无新增越界。没有新增 workspace package、第三方依赖、连接、timer、watcher 或常驻资源。Cluster management/executor separation-of-duty 产品编排、真实 Kubernetes rotation/revoke、升级失败回滚和低配物理证据仍待完成。 +- 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`;临时容器/网络已清理。实现没有新增 workspace package,Secret projection/renderer 内聚在既有 `cluster-admin/plugin-package/secret-binding`,18-package boundary 仍为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`,cluster dependency 与 edge import 审计无 finding。上一阶段真实 PostgreSQL 18.4 的 63 条 migration、原子提交、exact replay、错误状态/lineage 拒绝,以及 physical HA 125 gate/timeline `1→2` 证据继续有效。当前增量的完整 18-package/PG/HA 回归、真实 Kubernetes revoke 工作负载、升级后业务 Pod rollout/失败回滚和低配物理证据仍待完成。 - 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/package.json b/package.json index 861fcec5..e5a5b2ce 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build:back": "tsc -p back/tsconfig.json", "build:packages:ql3": "node scripts/ql3-clean-package-artifacts.cjs && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' run build", "test:back": "node --test test/back/*.test.cjs", - "test:packages:ql3": "pnpm run build:packages:ql3 && pnpm -r --workspace-concurrency=2 --filter './packages/ql3-*' exec sh -c 'node --test test/*.test.cjs'", + "test:packages:ql3": "pnpm run build:packages:ql3 && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' exec sh -c 'node --test test/*.test.cjs'", "test:postgres-ha:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin check && pnpm --filter @qinglong/cluster-control check && node scripts/ql3-postgres-ha-contract.cjs", "audit:postgres-ha-evidence:ql3": "node scripts/ql3-postgres-ha-evidence-audit.cjs", "test:automation-postgres-integration:ql3": "pnpm --filter @qinglong/cluster-admin test:automation-integration", @@ -48,6 +48,7 @@ "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-kubernetes-k3s-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-plugin-package-kubernetes-k3s-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", diff --git a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageKubernetesActivation.ts b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageKubernetesActivation.ts index 32b8e1a3..a74d154f 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageKubernetesActivation.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageKubernetesActivation.ts @@ -18,9 +18,34 @@ import { normalizePluginPackageActivationReceipt, type PluginPackageActivationReceipt, } from '@qinglong/runtime-core/plugin-package-install'; +import type { PluginPackageSecretBindingRepository } from '@qinglong/runtime-core/plugin-package-secret-binding'; +import type { PluginPackageSecretBindingTransitionReceiptRepository } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'; -const ACTIVE_POINTER_SCHEMA = +import { + createPluginPackageKubernetesSecretProjection, + isPluginPackageKubernetesSecretName, + normalizePluginPackageKubernetesSecretProjection, + pluginPackageKubernetesProjectedSecretWorkloadVolume, + type PluginPackageKubernetesActiveDeployment, + type PluginPackageKubernetesProjectedSecretWorkloadVolume, + type PluginPackageKubernetesSecretProjection, + type PluginPackageKubernetesSecretProjectionAssignment, + type PluginPackageKubernetesSecretProjectionItem, +} from '../secret-binding/pluginPackageKubernetesSecretProjection'; + +export { + pluginPackageKubernetesProjectedSecretWorkloadVolume, + type PluginPackageKubernetesActiveDeployment, + type PluginPackageKubernetesProjectedSecretWorkloadVolume, + type PluginPackageKubernetesSecretProjection, + type PluginPackageKubernetesSecretProjectionAssignment, + type PluginPackageKubernetesSecretProjectionItem, +}; + +const ACTIVE_POINTER_SCHEMA_V2 = 'qinglong/plugin-package-kubernetes-active-pointer@v2'; +const ACTIVE_POINTER_SCHEMA_V3 = + 'qinglong/plugin-package-kubernetes-active-pointer@v3'; const ACTIVE_POINTER_KEY = 'active.json'; const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by'; const MANAGED_BY_VALUE = 'qinglong3'; @@ -58,6 +83,19 @@ export interface PluginPackageKubernetesActivationPublisherOptions { readonly namespace: string; /** Explicit authoritative clock called only for a new publication attempt. */ readonly now: () => number | Promise; + /** + * Optional content-blind source used by the production recovery Job. When + * configured, v3 pointers bind the exact projected Secret keys to the same + * resourceVersion-fenced activation as the Package generation. + */ + readonly secretProjection?: Readonly<{ + readonly sourceSecretName: string; + readonly bindings: Pick; + readonly transitions: Pick< + PluginPackageSecretBindingTransitionReceiptRepository, + 'find' + >; + }>; } export interface PluginPackageKubernetesConfigMap { @@ -110,17 +148,25 @@ export interface PluginPackageKubernetesConfigMapApi { ): Promise; } -interface ActivePointer { - readonly schema: typeof ACTIVE_POINTER_SCHEMA; +interface ActivePointerV2 { + readonly schema: typeof ACTIVE_POINTER_SCHEMA_V2; readonly clusterIdentityDigest: string; readonly intent: Readonly; readonly receipt: Readonly; } -interface StoredPointer extends ActivePointer { - readonly resourceVersion: string; +interface ActivePointerV3 { + readonly schema: typeof ACTIVE_POINTER_SCHEMA_V3; + readonly clusterIdentityDigest: string; + readonly intent: Readonly; + readonly receipt: Readonly; + readonly secretProjection: Readonly | null; } +type ActivePointer = ActivePointerV2 | ActivePointerV3; + +type StoredPointer = ActivePointer & Readonly<{ resourceVersion: string }>; + function apiStatus(error: unknown): number | null { if (!error || typeof error !== 'object') return null; if ('code' in error && typeof error.code === 'number') return error.code; @@ -237,11 +283,29 @@ export class PluginPackageKubernetesActivationPublisher !options || typeof options !== 'object' || Array.isArray(options) || - Object.keys(options).sort().join(',') !== - 'clusterIdentity,namespace,now' || + Object.keys(options).some( + (key) => + key !== 'clusterIdentity' && + key !== 'namespace' && + key !== 'now' && + key !== 'secretProjection', + ) || !SAFE_IDENTITY.test(options.clusterIdentity) || !DNS_LABEL.test(options.namespace) || - typeof options.now !== 'function' + typeof options.now !== 'function' || + (options.secretProjection !== undefined && + (!options.secretProjection || + typeof options.secretProjection !== 'object' || + Array.isArray(options.secretProjection) || + Object.keys(options.secretProjection).sort().join(',') !== + 'bindings,sourceSecretName,transitions' || + !isPluginPackageKubernetesSecretName( + options.secretProjection.sourceSecretName, + ) || + !options.secretProjection.bindings || + typeof options.secretProjection.bindings.find !== 'function' || + !options.secretProjection.transitions || + typeof options.secretProjection.transitions.find !== 'function')) ) { throw new TypeError( 'Plugin Package Kubernetes activation options are invalid', @@ -306,6 +370,38 @@ export class PluginPackageKubernetesActivationPublisher } } + async #secretProjection( + intent: Readonly, + ): Promise | null> { + const source = this.options.secretProjection; + if (!source) return null; + try { + const generationDigest = intent.resourceGeneration.generationDigest; + const [binding, transition] = await Promise.all([ + source.bindings.find(generationDigest), + source.transitions.find(generationDigest), + ]); + if ( + binding && + (binding.target.installationId !== intent.installationId || + binding.target.projectId !== intent.projectId || + binding.target.packageName !== intent.packageName || + binding.target.lockDigest !== intent.lockDigest || + binding.target.generation !== intent.targetGeneration) + ) { + throw new PluginPackageActivationConflictError(); + } + return createPluginPackageKubernetesSecretProjection( + source.sourceSecretName, + generationDigest, + binding, + transition, + ); + } catch (error) { + return preserveDomainError(error); + } + } + #parsePointer( configMap: PluginPackageKubernetesConfigMap, expectedName: string, @@ -336,37 +432,71 @@ export class PluginPackageKubernetesActivationPublisher const serialized = data[ACTIVE_POINTER_KEY]; if ( labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE || - labels[ACTIVE_LABEL] !== 'v2' || + (labels[ACTIVE_LABEL] !== 'v2' && labels[ACTIVE_LABEL] !== 'v3') || typeof serialized !== 'string' || Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES ) { throw new PluginPackageActivationConflictError(); } const pointer = dataRecord(JSON.parse(serialized)); - exactKeys(pointer, [ - 'schema', - 'clusterIdentityDigest', - 'intent', - 'receipt', - ]); + if (pointer.schema === ACTIVE_POINTER_SCHEMA_V2) { + exactKeys(pointer, [ + 'schema', + 'clusterIdentityDigest', + 'intent', + 'receipt', + ]); + } else if (pointer.schema === ACTIVE_POINTER_SCHEMA_V3) { + exactKeys(pointer, [ + 'schema', + 'clusterIdentityDigest', + 'intent', + 'receipt', + 'secretProjection', + ]); + } else { + throw new PluginPackageActivationConflictError(); + } const intent = normalizeIntent( pointer.intent as PluginPackageActivationIntent, ); const receipt = normalizePluginPackageActivationReceipt(pointer.receipt); - const normalized: ActivePointer = Object.freeze({ - schema: ACTIVE_POINTER_SCHEMA, - clusterIdentityDigest: this.#clusterIdentityDigest, - intent, - receipt, - }); + const secretProjection = + pointer.schema === ACTIVE_POINTER_SCHEMA_V3 + ? pointer.secretProjection === null + ? null + : normalizePluginPackageKubernetesSecretProjection( + pointer.secretProjection, + ) + : undefined; + const normalized: ActivePointer = + pointer.schema === ACTIVE_POINTER_SCHEMA_V3 + ? Object.freeze({ + schema: ACTIVE_POINTER_SCHEMA_V3, + clusterIdentityDigest: this.#clusterIdentityDigest, + intent, + receipt, + secretProjection: secretProjection!, + }) + : Object.freeze({ + schema: ACTIVE_POINTER_SCHEMA_V2, + clusterIdentityDigest: this.#clusterIdentityDigest, + intent, + receipt, + }); if ( - pointer.schema !== ACTIVE_POINTER_SCHEMA || pointer.clusterIdentityDigest !== this.#clusterIdentityDigest || this.#name(intent) !== expectedName || labels[TARGET_LABEL] !== Buffer.from(this.#targetDigest(intent), 'hex').toString( 'base64url', ) || + labels[ACTIVE_LABEL] !== + (pointer.schema === ACTIVE_POINTER_SCHEMA_V3 ? 'v3' : 'v2') || + (secretProjection !== undefined && + secretProjection !== null && + secretProjection.generationDigest !== + intent.resourceGeneration.generationDigest) || annotations[INTENT_ANNOTATION] !== intent.intentDigest || receipt.intentDigest !== intent.intentDigest || receipt.generation !== intent.targetGeneration || @@ -409,6 +539,7 @@ export class PluginPackageKubernetesActivationPublisher intent: Readonly, ): Promise> { await this.#verifyStage(intent); + const expectedProjection = await this.#secretProjection(intent); const pointer = await this.#optionalPointer(intent); if (!pointer) { if (intent.previousActiveLockDigest !== null) { @@ -416,7 +547,12 @@ export class PluginPackageKubernetesActivationPublisher } return Object.freeze({ status: 'not_published' }); } - if (same(pointer.intent, intent)) { + if ( + same(pointer.intent, intent) && + (pointer.schema === ACTIVE_POINTER_SCHEMA_V2 + ? expectedProjection === null + : same(pointer.secretProjection, expectedProjection)) + ) { return Object.freeze({ status: 'published', receipt: pointer.receipt }); } if ( @@ -433,14 +569,24 @@ export class PluginPackageKubernetesActivationPublisher intent: Readonly, receipt: Readonly, current: Readonly | null, + secretProjection: Readonly | null, ): ConfigMapWrite { const targetDigest = this.#targetDigest(intent); - const pointer: Readonly = Object.freeze({ - schema: ACTIVE_POINTER_SCHEMA, - clusterIdentityDigest: this.#clusterIdentityDigest, - intent, - receipt, - }); + const usesSecretProjection = secretProjection !== null; + const pointer: Readonly = usesSecretProjection + ? Object.freeze({ + schema: ACTIVE_POINTER_SCHEMA_V3, + clusterIdentityDigest: this.#clusterIdentityDigest, + intent, + receipt, + secretProjection, + }) + : Object.freeze({ + schema: ACTIVE_POINTER_SCHEMA_V2, + clusterIdentityDigest: this.#clusterIdentityDigest, + intent, + receipt, + }); const serialized = `${JSON.stringify(pointer)}\n`; if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) { throw new PluginPackageActivationUnavailableError(); @@ -455,7 +601,7 @@ export class PluginPackageKubernetesActivationPublisher ...(current ? { resourceVersion: current.resourceVersion } : {}), labels: Object.freeze({ [MANAGED_BY_LABEL]: MANAGED_BY_VALUE, - [ACTIVE_LABEL]: 'v2', + [ACTIVE_LABEL]: usesSecretProjection ? 'v3' : 'v2', [TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString( 'base64url', ), @@ -500,6 +646,42 @@ export class PluginPackageKubernetesActivationPublisher } } + /** + * Returns one parsed active deployment snapshot for a workload renderer. + * It performs no Secret API call and exposes only projection keys, never + * Secret material or reversible Secret references. + */ + async findActiveDeployment( + projectId: string, + packageName: string, + ): Promise | null> { + if ( + typeof projectId !== 'string' || + !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId) || + typeof packageName !== 'string' || + !DNS_LABEL.test(packageName) + ) { + throw new TypeError( + 'Plugin Package active deployment identity is invalid', + ); + } + try { + const pointer = await this.#optionalPointer( + Object.freeze({ projectId, packageName }), + ); + if (!pointer) return null; + return Object.freeze({ + resourceGeneration: pointer.intent.resourceGeneration, + secretProjection: + pointer.schema === ACTIVE_POINTER_SCHEMA_V3 + ? pointer.secretProjection + : null, + }); + } catch (error) { + return preserveDomainError(error); + } + } + async publish( value: Readonly, ): Promise> { @@ -508,7 +690,18 @@ export class PluginPackageKubernetesActivationPublisher const first = await this.#observe(intent); if (first.status === 'published') return first.receipt; const current = await this.#optionalPointer(intent); - if (current && same(current.intent, intent)) return current.receipt; + if (current && same(current.intent, intent)) { + const expectedProjection = await this.#secretProjection(intent); + if ( + (current.schema === ACTIVE_POINTER_SCHEMA_V2 && + expectedProjection === null) || + (current.schema === ACTIVE_POINTER_SCHEMA_V3 && + same(current.secretProjection, expectedProjection)) + ) { + return current.receipt; + } + throw new PluginPackageActivationConflictError(); + } if ( (!current && intent.previousActiveLockDigest !== null) || (current && @@ -529,7 +722,8 @@ export class PluginPackageKubernetesActivationPublisher contentDigest: intent.contentDigest, activatedAtMs, }); - const body = this.#body(intent, receipt, current); + const secretProjection = await this.#secretProjection(intent); + const body = this.#body(intent, receipt, current, secretProjection); try { if (current) { await this.api.replaceNamespacedConfigMap({ diff --git a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts index b26d9873..0b96a5a9 100644 --- a/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts +++ b/packages/ql3-cluster-admin/src/plugin-package/recovery/pluginPackageRecovery.ts @@ -41,6 +41,7 @@ import { PostgresPluginPackageMaterializedRevisionRepository, PostgresPluginPackageSecretBindingRepository, PostgresPluginPackageSecretBindingActivationPrerequisite, + PostgresPluginPackageSecretBindingTransitionRepository, PostgresPluginPackagePublisherProvenanceRepository, PostgresPluginPackageTaskReconciliationRepository, PostgresProjectToolDefinitionSnapshotRepository, @@ -307,6 +308,16 @@ export async function recoverClusterPluginPackages( clusterIdentity: options.clusterIdentity, namespace: options.namespace, now: options.now, + secretProjection: { + sourceSecretName: 'ql3-cluster-plugin-package-values', + bindings: new PostgresPluginPackageSecretBindingRepository( + database.pool, + ), + transitions: + new PostgresPluginPackageSecretBindingTransitionRepository( + database.pool, + ), + }, }, ); const resourceByteSource = diff --git a/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageKubernetesSecretProjection.ts b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageKubernetesSecretProjection.ts new file mode 100644 index 00000000..0fdc6bca --- /dev/null +++ b/packages/ql3-cluster-admin/src/plugin-package/secret-binding/pluginPackageKubernetesSecretProjection.ts @@ -0,0 +1,315 @@ +import { createHash } from 'node:crypto'; + +import { PluginPackageActivationConflictError } from '@qinglong/runtime-core/plugin-package-activation'; +import type { PluginPackageResourceGeneration } from '@qinglong/runtime-core/plugin-package-resource-generation'; +import type { PluginPackageSecretBinding } from '@qinglong/runtime-core/plugin-package-secret-binding'; +import type { PluginPackageSecretBindingTransitionReceipt } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'; +import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection'; + +export const PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA = + 'qinglong/plugin-package-kubernetes-secret-projection@v1' as const; +export const PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE = 0o440 as const; + +const DIGEST = /^[0-9a-f]{64}$/; +const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/; +const ASSIGNMENT_NAME = /^[A-Z_][A-Z0-9_]{0,127}$/; +const PROJECTION_DIGEST_DOMAIN = Buffer.from( + 'qinglong/plugin-package-kubernetes-secret-projection-digest@v1\0', + 'utf8', +); + +export interface PluginPackageKubernetesSecretProjectionItem { + readonly key: string; + readonly path: string; +} + +export interface PluginPackageKubernetesSecretProjectionAssignment { + readonly name: string; + readonly required: boolean; + readonly path: string | null; +} + +export interface PluginPackageKubernetesSecretProjection { + readonly schema: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA; + readonly sourceSecretName: string; + readonly defaultMode: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE; + readonly generationDigest: string; + readonly bindingDigest: string | null; + readonly transitionReceiptDigest: string | null; + readonly items: readonly Readonly[]; + readonly assignments: readonly Readonly[]; + readonly projectionDigest: string; +} + +export interface PluginPackageKubernetesActiveDeployment { + readonly resourceGeneration: Readonly; + readonly secretProjection: Readonly | null; +} + +export interface PluginPackageKubernetesProjectedSecretWorkloadVolume { + readonly volume: Readonly<{ + readonly name: 'plugin-package-values'; + readonly secret: Readonly<{ + readonly secretName: string; + readonly optional: false; + readonly defaultMode: typeof PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE; + readonly items: readonly Readonly[]; + }>; + }>; + readonly volumeMount: Readonly<{ + readonly name: 'plugin-package-values'; + readonly mountPath: '/var/run/secrets/qinglong3/plugin-package-values'; + readonly readOnly: true; + }>; +} + +function conflict(): never { + throw new PluginPackageActivationConflictError(); +} + +function dataRecord(value: unknown): Record { + if ( + !value || + typeof value !== 'object' || + Array.isArray(value) || + (Object.getPrototypeOf(value) !== Object.prototype && + Object.getPrototypeOf(value) !== null) + ) { + return conflict(); + } + const descriptors = Object.getOwnPropertyDescriptors(value); + if ( + Object.values(descriptors).some( + (descriptor) => + descriptor.get !== undefined || + descriptor.set !== undefined || + descriptor.enumerable !== true, + ) + ) { + return conflict(); + } + return value as Record; +} + +function exactKeys(value: object, expected: readonly string[]): void { + const actual = Object.keys(value).sort(); + const canonical = [...expected].sort(); + if ( + actual.length !== canonical.length || + actual.some((key, index) => key !== canonical[index]) + ) { + conflict(); + } +} + +export function isPluginPackageKubernetesSecretName( + value: unknown, +): value is string { + return ( + typeof value === 'string' && + value.length <= 253 && + value.split('.').every((label) => DNS_LABEL.test(label)) + ); +} + +function digest( + value: Omit, +): string { + return createHash('sha256') + .update(PROJECTION_DIGEST_DOMAIN) + .update(JSON.stringify(value), 'utf8') + .digest('hex'); +} + +function uniqueItems( + assignments: readonly Readonly[], +): readonly Readonly[] { + const seen = new Set(); + return Object.freeze( + assignments.flatMap((assignment) => { + if (assignment.path === null || seen.has(assignment.path)) return []; + seen.add(assignment.path); + return [Object.freeze({ key: assignment.path, path: assignment.path })]; + }), + ); +} + +export function createPluginPackageKubernetesSecretProjection( + sourceSecretName: string, + generationDigest: string, + binding: Readonly | null, + transition: Readonly | null, +): Readonly | null { + if ( + !isPluginPackageKubernetesSecretName(sourceSecretName) || + !DIGEST.test(generationDigest) + ) { + return conflict(); + } + if (transition) { + if ( + transition.transitionPlan.nextTarget.generationDigest !== + generationDigest || + transition.bindingDigest !== (binding?.bindingDigest ?? null) || + JSON.stringify( + transition.transitionPlan.nextBindingPlan?.entries ?? [], + ) !== JSON.stringify(binding?.entries ?? []) + ) { + return conflict(); + } + } else if ( + binding !== null && + binding.target.generationDigest !== generationDigest + ) { + return conflict(); + } + if (!binding && !transition) return null; + + const assignments = Object.freeze( + (binding?.entries ?? []).map((entry) => + Object.freeze({ + name: entry.name, + required: entry.required, + path: + entry.secretRef === null + ? null + : secretProjectionFileName(entry.secretRef), + }), + ), + ); + const unsigned = Object.freeze({ + schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA, + sourceSecretName, + defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE, + generationDigest, + bindingDigest: binding?.bindingDigest ?? null, + transitionReceiptDigest: transition?.receiptDigest ?? null, + items: uniqueItems(assignments), + assignments, + }); + return Object.freeze({ ...unsigned, projectionDigest: digest(unsigned) }); +} + +export function normalizePluginPackageKubernetesSecretProjection( + value: unknown, +): Readonly { + const candidate = dataRecord(value); + exactKeys(candidate, [ + 'schema', + 'sourceSecretName', + 'defaultMode', + 'generationDigest', + 'bindingDigest', + 'transitionReceiptDigest', + 'items', + 'assignments', + 'projectionDigest', + ]); + if ( + candidate.schema !== PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA || + !isPluginPackageKubernetesSecretName(candidate.sourceSecretName) || + candidate.defaultMode !== PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE || + typeof candidate.generationDigest !== 'string' || + !DIGEST.test(candidate.generationDigest) || + (candidate.bindingDigest !== null && + (typeof candidate.bindingDigest !== 'string' || + !DIGEST.test(candidate.bindingDigest))) || + (candidate.transitionReceiptDigest !== null && + (typeof candidate.transitionReceiptDigest !== 'string' || + !DIGEST.test(candidate.transitionReceiptDigest))) || + !Array.isArray(candidate.items) || + !Array.isArray(candidate.assignments) || + candidate.items.length > 64 || + candidate.assignments.length > 64 + ) { + return conflict(); + } + const assignments = Object.freeze( + candidate.assignments.map((value) => { + const assignment = dataRecord(value); + exactKeys(assignment, ['name', 'required', 'path']); + if ( + typeof assignment.name !== 'string' || + !ASSIGNMENT_NAME.test(assignment.name) || + typeof assignment.required !== 'boolean' || + (assignment.path !== null && + (typeof assignment.path !== 'string' || + !DIGEST.test(assignment.path))) || + (assignment.required && assignment.path === null) + ) { + return conflict(); + } + return Object.freeze({ + name: assignment.name, + required: assignment.required, + path: assignment.path as string | null, + }); + }), + ); + const items = Object.freeze( + candidate.items.map((value) => { + const item = dataRecord(value); + exactKeys(item, ['key', 'path']); + if ( + typeof item.key !== 'string' || + typeof item.path !== 'string' || + !DIGEST.test(item.key) || + item.path !== item.key + ) { + return conflict(); + } + return Object.freeze({ key: item.key, path: item.path }); + }), + ); + if (JSON.stringify(items) !== JSON.stringify(uniqueItems(assignments))) { + return conflict(); + } + const unsigned = Object.freeze({ + schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_PROJECTION_SCHEMA, + sourceSecretName: candidate.sourceSecretName, + defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE, + generationDigest: candidate.generationDigest, + bindingDigest: candidate.bindingDigest as string | null, + transitionReceiptDigest: candidate.transitionReceiptDigest as string | null, + items, + assignments, + }); + if ( + typeof candidate.projectionDigest !== 'string' || + candidate.projectionDigest !== digest(unsigned) + ) { + return conflict(); + } + return Object.freeze({ + ...unsigned, + projectionDigest: candidate.projectionDigest, + }); +} + +/** + * Pure Pod-spec fragment renderer. An empty/revoked projection deliberately + * returns null: an omitted/empty Secret items mapping can mean "all keys". + */ +export function pluginPackageKubernetesProjectedSecretWorkloadVolume( + value: Readonly | null, +): Readonly | null { + if (value === null) return null; + const projection = normalizePluginPackageKubernetesSecretProjection(value); + if (projection.items.length === 0) return null; + return Object.freeze({ + volume: Object.freeze({ + name: 'plugin-package-values' as const, + secret: Object.freeze({ + secretName: projection.sourceSecretName, + optional: false as const, + defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE, + items: projection.items, + }), + }), + volumeMount: Object.freeze({ + name: 'plugin-package-values' as const, + mountPath: '/var/run/secrets/qinglong3/plugin-package-values' as const, + readOnly: true as const, + }), + }); +} diff --git a/packages/ql3-cluster-admin/test/pluginPackageKubernetesActivation.test.cjs b/packages/ql3-cluster-admin/test/pluginPackageKubernetesActivation.test.cjs index bcf9fd76..f91f7c2c 100644 --- a/packages/ql3-cluster-admin/test/pluginPackageKubernetesActivation.test.cjs +++ b/packages/ql3-cluster-admin/test/pluginPackageKubernetesActivation.test.cjs @@ -9,6 +9,20 @@ const { const { createPluginPackageResourceGeneration, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBinding, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { + createPluginPackageSecretBindingFromTransitionPlan, + createPluginPackageSecretBindingTransitionReceipt, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); const { PLUGIN_PACKAGE_API_VERSION, PLUGIN_PACKAGE_KIND, @@ -28,6 +42,7 @@ const { } = require('@qinglong/runtime-core/plugin-package-recovery'); const { PluginPackageKubernetesActivationPublisher, + pluginPackageKubernetesProjectedSecretWorkloadVolume, } = require('../dist/plugin-package/recovery/pluginPackageKubernetesActivation'); function apiError(code) { @@ -298,11 +313,167 @@ function publisher(api = new FakeConfigMapApi(), overrides = {}) { nowCalls += 1; return overrides.now?.() ?? 500 + nowCalls; }, + ...(overrides.secretProjection === undefined + ? {} + : { secretProjection: overrides.secretProjection }), }, ); return { api, publisher: value, nowCalls: () => nowCalls }; } +function secretManifest(version, secrets) { + return { + apiVersion: PLUGIN_PACKAGE_API_VERSION, + kind: PLUGIN_PACKAGE_KIND, + metadata: { + name: 'example-monitor', + displayName: 'Example Monitor', + version, + description: 'Kubernetes Secret projection fixture', + license: 'Apache-2.0', + }, + spec: { + compatibility: { + qinglong: '>=3.0.0-0 <4.0.0', + architectures: ['arm64'], + deploymentProfiles: ['cluster-control'], + }, + runtimes: [], + resources: { + memory: { recommended: '16Mi' }, + disk: { install: '4Mi', working: '16Mi' }, + }, + permissions: { + network: { allowedHosts: [] }, + secrets, + tools: secrets.length === 0 ? [] : ['secret.use'], + }, + contents: { tasks: [], workflows: [], prompts: [], tools: [] }, + }, + }; +} + +function projectedTransition(kind) { + const previousManifest = secretManifest('1.0.0', [ + { name: 'TOKEN', required: true }, + ]); + const previousGeneration = createPluginPackageResourceGeneration({ + installationId: 'install-secret-v1', + projectId: 'default', + packageName: 'example-monitor', + lockDigest: '1'.repeat(64), + generation: 1, + previousActiveLockDigest: null, + contentDigest: '2'.repeat(64), + contents: previousManifest.spec.contents, + }); + const previousBinding = createPluginPackageSecretBinding({ + generation: previousGeneration, + manifest: previousManifest, + assignments: [ + { + name: 'TOKEN', + secretRef: createSecretRef({ + projectId: 'default', + name: 'token', + version: 1, + }), + }, + ], + authority: { + kind: 'approved-action-execution', + evidenceDigest: '3'.repeat(64), + }, + boundAtMs: 10, + }); + const previousActivation = intent({ + installationId: previousGeneration.installationId, + lockDigest: previousGeneration.lockDigest, + targetGeneration: previousGeneration.generation, + previousActiveLockDigest: null, + contentDigest: previousGeneration.contentDigest, + resourceGeneration: previousGeneration, + intentDigest: kind === 'revoke' ? 'b'.repeat(64) : 'c'.repeat(64), + }); + const nextManifest = + kind === 'revoke' + ? secretManifest('2.0.0', []) + : secretManifest('2.0.0', [{ name: 'TOKEN', required: true }]); + const nextGeneration = createPluginPackageResourceGeneration({ + installationId: `install-secret-${kind}`, + projectId: 'default', + packageName: 'example-monitor', + lockDigest: kind === 'revoke' ? '4'.repeat(64) : '5'.repeat(64), + generation: 2, + previousActiveLockDigest: previousBinding.target.lockDigest, + contentDigest: kind === 'revoke' ? '6'.repeat(64) : '7'.repeat(64), + contents: nextManifest.spec.contents, + }); + const secretRef = createSecretRef({ + projectId: 'default', + name: 'token', + version: 2, + }); + const plan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: previousBinding.target, + previousBinding, + previousAttemptGeneration: 1, + nextGeneration, + nextManifest, + assignments: kind === 'revoke' ? [] : [{ name: 'TOKEN', secretRef }], + plannedAtMs: 20, + }); + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'approved-action-execution', + '8'.repeat(64), + 30, + ); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: { + kind: 'approved-action-execution', + evidenceDigest: '8'.repeat(64), + }, + binding, + committedAtMs: 30, + }); + const activation = intent({ + installationId: nextGeneration.installationId, + lockDigest: nextGeneration.lockDigest, + targetGeneration: nextGeneration.generation, + previousActiveLockDigest: nextGeneration.previousActiveLockDigest, + contentDigest: nextGeneration.contentDigest, + resourceGeneration: nextGeneration, + intentDigest: kind === 'revoke' ? '9'.repeat(64) : 'a'.repeat(64), + }); + return { previousActivation, activation, binding, receipt, secretRef }; +} + +function projectionSource(value) { + return { + sourceSecretName: 'ql3-cluster-plugin-package-values', + bindings: { + async find(generationDigest) { + assert.equal( + generationDigest, + value.activation.resourceGeneration.generationDigest, + ); + return value.binding; + }, + }, + transitions: { + async find(generationDigest) { + assert.equal( + generationDigest, + value.activation.resourceGeneration.generationDigest, + ); + return value.receipt; + }, + }, + }; +} + test('publishes one resourceVersion-fenced ConfigMap and exact replays it', async () => { const fixture = publisher(); const value = intent(); @@ -391,6 +562,195 @@ test('replaces only the exact previous lock and rejects a stale writer', async ( ); }); +test('publishes a content-blind v3 projection for an approved Secret rotation', async () => { + const value = projectedTransition('rotate'); + const api = new FakeConfigMapApi(); + await publisher(api).publisher.publish(value.previousActivation); + const fixture = publisher(api, { + secretProjection: projectionSource(value), + }); + await fixture.publisher.publish(value.activation); + const deployment = await fixture.publisher.findActiveDeployment( + 'default', + 'example-monitor', + ); + assert.equal( + deployment.resourceGeneration.generationDigest, + value.activation.resourceGeneration.generationDigest, + ); + assert.deepEqual(deployment.secretProjection.assignments, [ + { + name: 'TOKEN', + required: true, + path: secretProjectionFileName(value.secretRef), + }, + ]); + assert.deepEqual(deployment.secretProjection.items, [ + { + key: secretProjectionFileName(value.secretRef), + path: secretProjectionFileName(value.secretRef), + }, + ]); + assert.equal(deployment.secretProjection.defaultMode, 0o440); + assert.equal( + deployment.secretProjection.bindingDigest, + value.binding.bindingDigest, + ); + assert.equal( + deployment.secretProjection.transitionReceiptDigest, + value.receipt.receiptDigest, + ); + const [stored] = fixture.api.items.values(); + const pointer = JSON.parse(stored.data['active.json']); + assert.equal(pointer.schema.endsWith('@v3'), true); + assert.equal( + stored.metadata.labels['qinglong.io/plugin-package-active'], + 'v3', + ); + assert.equal(JSON.stringify(pointer).includes(value.secretRef), false); + assert.deepEqual( + pluginPackageKubernetesProjectedSecretWorkloadVolume( + deployment.secretProjection, + ), + { + volume: { + name: 'plugin-package-values', + secret: { + secretName: 'ql3-cluster-plugin-package-values', + optional: false, + defaultMode: 0o440, + items: [ + { + key: secretProjectionFileName(value.secretRef), + path: secretProjectionFileName(value.secretRef), + }, + ], + }, + }, + volumeMount: { + name: 'plugin-package-values', + mountPath: '/var/run/secrets/qinglong3/plugin-package-values', + readOnly: true, + }, + }, + ); +}); + +test('publishes an explicit empty projection for revoke and rejects projection drift', async () => { + const value = projectedTransition('revoke'); + const api = new FakeConfigMapApi(); + await publisher(api).publisher.publish(value.previousActivation); + const fixture = publisher(api, { + secretProjection: projectionSource(value), + }); + await fixture.publisher.publish(value.activation); + const deployment = await fixture.publisher.findActiveDeployment( + 'default', + 'example-monitor', + ); + assert.deepEqual(deployment.secretProjection.items, []); + assert.deepEqual(deployment.secretProjection.assignments, []); + assert.equal(deployment.secretProjection.bindingDigest, null); + assert.equal( + deployment.secretProjection.transitionReceiptDigest, + value.receipt.receiptDigest, + ); + assert.equal( + pluginPackageKubernetesProjectedSecretWorkloadVolume( + deployment.secretProjection, + ), + null, + ); + + const [key, stored] = fixture.api.items.entries().next().value; + const pointer = JSON.parse(stored.data['active.json']); + pointer.secretProjection.projectionDigest = '0'.repeat(64); + fixture.api.items.set(key, { + ...stored, + data: { 'active.json': `${JSON.stringify(pointer)}\n` }, + }); + await assert.rejects( + fixture.publisher.findActiveDeployment('default', 'example-monitor'), + PluginPackageActivationConflictError, + ); +}); + +test('converges a lost v3 replacement response without republishing projection', async () => { + const value = projectedTransition('rotate'); + const api = new FakeConfigMapApi(); + await publisher(api).publisher.publish(value.previousActivation); + api.loseReplaceResponse = true; + const fixture = publisher(api, { + secretProjection: projectionSource(value), + }); + await assert.rejects( + fixture.publisher.publish(value.activation), + PluginPackageActivationUnavailableError, + ); + assert.equal(api.replaceCalls, 1); + assert.equal( + (await fixture.publisher.inspect(value.activation)).status, + 'published', + ); + await fixture.publisher.publish(value.activation); + assert.equal(api.replaceCalls, 1); + assert.equal(fixture.nowCalls(), 1); +}); + +test('does not switch the active pointer when projection evidence is unavailable', async () => { + const value = projectedTransition('rotate'); + const api = new FakeConfigMapApi(); + await publisher(api).publisher.publish(value.previousActivation); + const previousPointer = structuredClone([...api.items.values()][0]); + const fixture = publisher(api, { + secretProjection: { + ...projectionSource(value), + transitions: { + async find() { + throw new Error('database unavailable'); + }, + }, + }, + }); + await assert.rejects( + fixture.publisher.publish(value.activation), + PluginPackageActivationUnavailableError, + ); + assert.deepEqual([...api.items.values()][0], previousPointer); + assert.equal(fixture.api.replaceCalls, 0); + assert.equal(fixture.nowCalls(), 0); +}); + +test('keeps a staged upgrade without Secret facts on the compatible v2 pointer', async () => { + const value = projectedTransition('rotate'); + const api = new FakeConfigMapApi(); + await publisher(api).publisher.publish(value.previousActivation); + const fixture = publisher(api, { + secretProjection: { + ...projectionSource(value), + bindings: { + async find() { + return null; + }, + }, + transitions: { + async find() { + return null; + }, + }, + }, + }); + await fixture.publisher.publish(value.activation); + const deployment = await fixture.publisher.findActiveDeployment( + 'default', + 'example-monitor', + ); + assert.equal(deployment.secretProjection, null); + const pointer = JSON.parse([...api.items.values()][0].data['active.json']); + assert.equal(pointer.schema.endsWith('@v2'), true); + assert.equal(Object.hasOwn(pointer, 'secretProjection'), false); +}); + test('leaves response loss for recovery inspection without republishing', async () => { const api = new FakeConfigMapApi(); api.loseCreateResponse = true; diff --git a/packages/ql3-cluster-control/test/application.test.cjs b/packages/ql3-cluster-control/test/application.test.cjs index f99a4118..1aab27ca 100644 --- a/packages/ql3-cluster-control/test/application.test.cjs +++ b/packages/ql3-cluster-control/test/application.test.cjs @@ -200,6 +200,12 @@ function runtimePrivileges() { plugin_package_automation_disposition_events: [false, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_approval_plans: [ + false, + false, + false, + false, + ], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_transition_receipts: [ false, diff --git a/packages/ql3-cluster-control/test/bootstrap.test.cjs b/packages/ql3-cluster-control/test/bootstrap.test.cjs index 32c48b39..4c3a77a5 100644 --- a/packages/ql3-cluster-control/test/bootstrap.test.cjs +++ b/packages/ql3-cluster-control/test/bootstrap.test.cjs @@ -114,6 +114,12 @@ function runtimePrivileges() { plugin_package_automation_disposition_events: [false, false, false, false], plugin_package_automation_publication_heads: [true, false, false, false], plugin_package_secret_binding_approval_plans: [false, false, false, false], + plugin_package_secret_binding_transition_approval_plans: [ + false, + false, + false, + false, + ], plugin_package_secret_bindings: [false, false, false, false], plugin_package_secret_binding_transition_receipts: [ false, diff --git a/scripts/ql3-plugin-package-kubernetes-k3s-live-contract.cjs b/scripts/ql3-plugin-package-kubernetes-k3s-live-contract.cjs new file mode 100644 index 00000000..0829db3d --- /dev/null +++ b/scripts/ql3-plugin-package-kubernetes-k3s-live-contract.cjs @@ -0,0 +1,314 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const { + K3sDockerLiveFixture, + waitFor, +} = require('./lib/ql3-k3s-docker-live.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const ACTOR_FILE = path.join( + __dirname, + 'ql3-plugin-package-kubernetes-live-actor.cjs', +); +const RESULT_SCHEMA = 'qinglong/plugin-package-kubernetes-live-actor-result@v1'; +const NAMESPACE = 'ql3-plugin-package-live'; +const SERVICE_ACCOUNT = 'ql3-plugin-package-recovery-live'; +const IMAGE = 'qinglong3-cluster-admin:ql3-k3s-kubernetes-live'; + +function run(binary, args, options = {}) { + const result = spawnSync(binary, args, { + cwd: ROOT, + env: process.env, + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new Error( + `${path.basename(binary)} failed with ${String(result.status)}: ` + + `${result.stderr || result.stdout || ''}`, + ); + } + return result; +} + +function roleDocuments() { + return [ + { + apiVersion: 'v1', + kind: 'Namespace', + metadata: { name: NAMESPACE }, + }, + { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { name: SERVICE_ACCOUNT, namespace: NAMESPACE }, + automountServiceAccountToken: false, + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'Role', + metadata: { name: SERVICE_ACCOUNT, namespace: NAMESPACE }, + rules: [ + { + apiGroups: [''], + resources: ['configmaps'], + verbs: ['get', 'create', 'update'], + }, + ], + }, + { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'RoleBinding', + metadata: { name: SERVICE_ACCOUNT, namespace: NAMESPACE }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: SERVICE_ACCOUNT, + }, + subjects: [ + { + kind: 'ServiceAccount', + name: SERVICE_ACCOUNT, + namespace: NAMESPACE, + }, + ], + }, + ]; +} + +function actorPod(actor) { + return { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + name: `ql3-plugin-package-live-${actor}`, + namespace: NAMESPACE, + labels: { + 'app.kubernetes.io/name': 'ql3-plugin-package-live', + 'qinglong.io/live-actor': actor, + }, + }, + spec: { + serviceAccountName: SERVICE_ACCOUNT, + automountServiceAccountToken: true, + restartPolicy: 'Never', + securityContext: { + runAsNonRoot: true, + runAsUser: 10001, + runAsGroup: 10001, + fsGroup: 10001, + seccompProfile: { type: 'RuntimeDefault' }, + }, + containers: [ + { + name: 'recovery', + image: IMAGE, + imagePullPolicy: 'Never', + command: ['node', '/opt/ql3-live/actor.cjs'], + env: [ + { name: 'NODE_PATH', value: '/opt/qinglong/node_modules' }, + { name: 'QL3_LIVE_NAMESPACE', value: NAMESPACE }, + { name: 'QL3_LIVE_ACTOR', value: actor }, + ], + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: { drop: ['ALL'] }, + }, + resources: { + requests: { cpu: '25m', memory: '64Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }, + volumeMounts: [ + { name: 'actor', mountPath: '/opt/ql3-live', readOnly: true }, + ], + }, + ], + volumes: [ + { + name: 'actor', + configMap: { + name: 'ql3-plugin-package-live-actor', + defaultMode: 0o444, + items: [{ key: 'actor.cjs', path: 'actor.cjs' }], + }, + }, + ], + }, + }; +} + +async function actorResult(fixture, actor) { + const pod = `ql3-plugin-package-live-${actor}`; + const observed = await waitFor(`${pod} termination result`, 60_000, () => { + const value = fixture.kubectlJson(['-n', NAMESPACE, 'get', 'pod', pod]); + const terminated = value.status?.containerStatuses?.find( + (container) => container.name === 'recovery', + )?.state?.terminated; + if (!terminated) { + return { ready: false, fact: value.status?.phase ?? 'unknown' }; + } + if (terminated.exitCode !== 0) { + throw new Error( + `${pod} exited ${terminated.exitCode}: ${terminated.message ?? ''}`, + ); + } + return { ready: true, value: terminated.message }; + }); + const value = JSON.parse(observed.value); + assert.equal(value.schema, RESULT_SCHEMA); + assert.equal(value.actor, actor); + assert.equal(value.error, undefined); + return value; +} + +async function main() { + if (process.env.QL3_PLUGIN_PACKAGE_K3S_LIVE !== '1') { + throw new Error('refusing to run without QL3_PLUGIN_PACKAGE_K3S_LIVE=1'); + } + const fixture = new K3sDockerLiveFixture({ + prefix: 'ql3-plugin-v3-live', + kubectl: + process.env.QL3_KUBECTL_BIN ?? + '/Applications/Docker.app/Contents/Resources/bin/kubectl', + }); + const startedAt = Date.now(); + try { + run('docker', [ + 'build', + '--file', + 'deploy/containers/ql3-cluster-admin/Dockerfile', + '--tag', + IMAGE, + '--build-arg', + `SOURCE_REVISION=${process.env.GITHUB_SHA ?? 'local-k3s-live-contract'}`, + '.', + ]); + const nodes = await fixture.start(); + fixture.loadImage(IMAGE, 'plugin-package-v3-admin.tar'); + for (const document of roleDocuments()) fixture.apply(document); + fixture.apply({ + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: 'ql3-plugin-package-live-actor', + namespace: NAMESPACE, + }, + data: { 'actor.cjs': fs.readFileSync(ACTOR_FILE, 'utf8') }, + }); + fixture.apply(actorPod('a')); + fixture.apply(actorPod('b')); + await waitFor('two v3 projection CAS actors', 180_000, () => { + const pods = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'pods', + '-l', + 'app.kubernetes.io/name=ql3-plugin-package-live', + ]).items; + const failed = pods.find((pod) => pod.status.phase === 'Failed'); + if (failed) { + const logs = fixture.kubectl( + ['-n', NAMESPACE, 'logs', failed.metadata.name], + { capture: true, quiet: true, allowFailure: true }, + ); + throw new Error(logs.stderr || logs.stdout); + } + return pods.length === 2 && + pods.every((pod) => pod.status.phase === 'Succeeded') + ? { ready: true, value: pods } + : { + ready: false, + fact: pods.map((pod) => `${pod.metadata.name}:${pod.status.phase}`), + }; + }); + + const actors = await Promise.all([ + actorResult(fixture, 'a'), + actorResult(fixture, 'b'), + ]); + const winner = actors.find((actor) => actor.cas.status === 'fulfilled'); + const loser = actors.find((actor) => actor.cas.status === 'conflict'); + assert.ok(winner); + assert.ok(loser); + assert.equal(winner.final.pointerSchema.endsWith('@v3'), true); + assert.equal(winner.final.projectionItemCount, 1); + assert.equal(winner.final.projectedWorkloadVolume, true); + assert.equal(winner.final.projectionDigest, loser.final.projectionDigest); + assert.equal( + winner.final.transitionReceiptDigest, + loser.final.transitionReceiptDigest, + ); + assert.deepEqual(winner.rbac, { + listConfigMaps: 403, + deleteConfigMap: 403, + readSecret: 403, + crossNamespaceRead: 403, + }); + assert.deepEqual(loser.rbac, winner.rbac); + const pointers = fixture.kubectlJson([ + '-n', + NAMESPACE, + 'get', + 'configmaps', + '-l', + 'qinglong.io/plugin-package-active=v3', + ]).items; + assert.equal(pointers.length, 1); + process.stdout.write( + `${JSON.stringify( + { + schemaVersion: 1, + fixture: 'qinglong/plugin-package-kubernetes-k3s-live@v1', + platform: { + kubernetesVersion: fixture.kubectlJson(['version']).serverVersion + .gitVersion, + nodeCount: nodes.length, + }, + result: { + fulfilled: 1, + conflicts: 1, + pointerSchema: winner.final.pointerSchema, + projectionDigest: winner.final.projectionDigest, + transitionReceiptDigest: winner.final.transitionReceiptDigest, + exactProjectionItems: winner.final.projectionItemCount, + secretApiReadDenied: winner.rbac.readSecret === 403, + activePointers: pointers.length, + }, + gates: { + realThreeNodeKubernetes: true, + resourceVersionSingleWinner: true, + v3TransitionReceiptBound: true, + exactSecretProjectionRendered: true, + secretApiReadDenied: true, + passed: true, + }, + elapsedMs: Date.now() - startedAt, + }, + null, + 2, + )}\n`, + ); + } finally { + await fixture.cleanup(); + } +} + +main().catch((error) => { + process.stderr.write( + `QL3 Plugin Package K3s v3 live contract failed: ${ + error instanceof Error ? error.stack || error.message : String(error) + }\n`, + ); + process.exitCode = 1; +}); diff --git a/scripts/ql3-plugin-package-kubernetes-live-actor.cjs b/scripts/ql3-plugin-package-kubernetes-live-actor.cjs index f132ed6f..455b5037 100644 --- a/scripts/ql3-plugin-package-kubernetes-live-actor.cjs +++ b/scripts/ql3-plugin-package-kubernetes-live-actor.cjs @@ -14,8 +14,23 @@ const { const { createPluginPackageResourceGeneration, } = require('@qinglong/runtime-core/plugin-package-resource-generation'); +const { + createPluginPackageSecretBindingTarget, +} = require('@qinglong/runtime-core/plugin-package-secret-binding'); +const { + createPluginPackageSecretBindingTransitionPlan, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan'); +const { + createPluginPackageSecretBindingFromTransitionPlan, + createPluginPackageSecretBindingTransitionReceipt, +} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt'); +const { + secretProjectionFileName, +} = require('@qinglong/runtime-core/secret-projection'); +const { createSecretRef } = require('@qinglong/runtime-core/secret-reference'); const { PluginPackageKubernetesActivationPublisher, + pluginPackageKubernetesProjectedSecretWorkloadVolume, } = require('@qinglong/cluster-admin/plugin-package-kubernetes-activation'); const TOKEN_FILE = '/var/run/secrets/kubernetes.io/serviceaccount/token'; @@ -119,6 +134,83 @@ function activationIntent(overrides = {}) { }); } +function manifest(version, secrets) { + return Object.freeze({ + apiVersion: 'qinglong.io/v1alpha1', + kind: 'Package', + metadata: Object.freeze({ + name: 'live-cas-package', + displayName: 'Live CAS Package', + version, + description: 'Real Kubernetes Secret projection CAS gate', + license: 'Apache-2.0', + }), + spec: Object.freeze({ + compatibility: Object.freeze({ + qinglong: '>=3.0.0-0 <4.0.0', + architectures: Object.freeze(['arm64']), + deploymentProfiles: Object.freeze(['cluster-control']), + }), + runtimes: Object.freeze([]), + resources: Object.freeze({ + memory: Object.freeze({ recommended: '16Mi' }), + disk: Object.freeze({ install: '4Mi', working: '16Mi' }), + }), + permissions: Object.freeze({ + network: Object.freeze({ allowedHosts: Object.freeze([]) }), + secrets: Object.freeze(secrets), + tools: Object.freeze(secrets.length === 0 ? [] : ['secret.use']), + }), + contents: Object.freeze({ + tasks: Object.freeze(['tasks/live.yaml']), + workflows: Object.freeze([]), + prompts: Object.freeze([]), + tools: Object.freeze([]), + }), + }), + }); +} + +function transitionEvidence(initial, candidate) { + const secretRef = createSecretRef({ + projectId: 'default', + name: `live-token-${ACTOR}`, + version: 2, + }); + const previousManifest = manifest('1.0.0', []); + const nextManifest = manifest('2.0.0', [ + Object.freeze({ name: 'TOKEN', required: true }), + ]); + const plan = createPluginPackageSecretBindingTransitionPlan({ + previousTarget: createPluginPackageSecretBindingTarget( + initial.resourceGeneration, + previousManifest, + ), + previousBinding: null, + previousAttemptGeneration: 1, + nextGeneration: candidate.resourceGeneration, + nextManifest, + assignments: [Object.freeze({ name: 'TOKEN', secretRef })], + plannedAtMs: 100, + }); + const binding = createPluginPackageSecretBindingFromTransitionPlan( + plan, + 'approved-action-execution', + candidate.stageEvidenceDigest, + 200, + ); + const receipt = createPluginPackageSecretBindingTransitionReceipt({ + transitionPlan: plan, + authority: Object.freeze({ + kind: 'approved-action-execution', + evidenceDigest: candidate.stageEvidenceDigest, + }), + binding, + committedAtMs: 200, + }); + return Object.freeze({ secretRef, binding, receipt }); +} + function exactEvidence(intent) { return Object.freeze({ lockDigest: intent.lockDigest, @@ -222,29 +314,32 @@ async function main() { }; let nowCalls = 0; - const publisher = new PluginPackageKubernetesActivationPublisher( - activationApi, - { verify: async (intent) => exactEvidence(intent) }, - { - clusterIdentity: 'ql3-plugin-package-live-cluster', - namespace: NAMESPACE, - now() { - nowCalls += 1; - return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls; + const createPublisher = (secretProjection) => + new PluginPackageKubernetesActivationPublisher( + activationApi, + { verify: async (intent) => exactEvidence(intent) }, + { + clusterIdentity: 'ql3-plugin-package-live-cluster', + namespace: NAMESPACE, + now() { + nowCalls += 1; + return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls; + }, + ...(secretProjection === undefined ? {} : { secretProjection }), }, - }, - ); + ); const initial = activationIntent(); + const initialPublisher = createPublisher(); let responseLoss = null; if (ACTOR === 'a') { await assert.rejects( - publisher.publish(initial), + initialPublisher.publish(initial), PluginPackageActivationUnavailableError, ); - const observation = await publisher.inspect(initial); + const observation = await initialPublisher.inspect(initial); assert.equal(observation.status, 'published'); - const replay = await publisher.publish(initial); + const replay = await initialPublisher.publish(initial); assert.deepEqual(replay, observation.receipt); assert.equal(createCalls, 1); assert.equal(nowCalls, 1); @@ -258,7 +353,7 @@ async function main() { }); } else { await waitFor('initial active pointer', async () => { - const observation = await publisher.inspect(initial); + const observation = await initialPublisher.inspect(initial); return { ready: observation.status === 'published', fact: observation.status, @@ -272,6 +367,12 @@ async function main() { targetGeneration: 2, previousActiveLockDigest: INITIAL_LOCK_DIGEST, }); + const transition = transitionEvidence(initial, candidate); + const publisher = createPublisher({ + sourceSecretName: 'ql3-cluster-plugin-package-values', + bindings: { find: async () => transition.binding }, + transitions: { find: async () => transition.receipt }, + }); let outcome; try { const receipt = await publisher.publish(candidate); @@ -310,6 +411,25 @@ async function main() { name: targetName, }); const finalPointer = JSON.parse(finalConfigMap.data['active.json']); + assert.equal(finalPointer.schema.endsWith('@v3'), true); + assert.equal(finalPointer.secretProjection.defaultMode, 0o440); + assert.match( + finalPointer.secretProjection.transitionReceiptDigest, + /^[0-9a-f]{64}$/, + ); + const projectionPath = secretProjectionFileName(transition.secretRef); + const winnerUsesThisActor = + finalPointer.intent.lockDigest === candidate.lockDigest; + if (winnerUsesThisActor) { + assert.deepEqual(finalPointer.secretProjection.items, [ + { key: projectionPath, path: projectionPath }, + ]); + } + const workloadVolume = pluginPackageKubernetesProjectedSecretWorkloadVolume( + finalPointer.secretProjection, + ); + assert.equal(workloadVolume.volume.secret.optional, false); + assert.equal(workloadVolume.volume.secret.defaultMode, 0o440); const rbac = Object.freeze({ listConfigMaps: await expectForbidden(() => @@ -335,25 +455,34 @@ async function main() { ), }); - process.stdout.write( - `${JSON.stringify({ - schema: RESULT_SCHEMA, - actor: ACTOR, - serviceAccountTokenMounted: true, - responseLoss, - cas: { - ...outcome, - attemptedResourceVersion: replaceResourceVersion, - replaceCalls, - }, - final: { - resourceVersion: finalConfigMap.metadata.resourceVersion, - lockDigest: finalPointer.intent.lockDigest, - generation: finalPointer.receipt.generation, - }, - rbac, - })}\n`, - ); + const result = JSON.stringify({ + schema: RESULT_SCHEMA, + actor: ACTOR, + serviceAccountTokenMounted: true, + responseLoss, + cas: { + ...outcome, + attemptedResourceVersion: replaceResourceVersion, + replaceCalls, + }, + final: { + resourceVersion: finalConfigMap.metadata.resourceVersion, + lockDigest: finalPointer.intent.lockDigest, + generation: finalPointer.receipt.generation, + pointerSchema: finalPointer.schema, + projectionDigest: finalPointer.secretProjection.projectionDigest, + transitionReceiptDigest: + finalPointer.secretProjection.transitionReceiptDigest, + projectionItemCount: finalPointer.secretProjection.items.length, + projectedWorkloadVolume: workloadVolume !== null, + }, + rbac, + }); + fs.writeFileSync('/dev/termination-log', result, { + encoding: 'utf8', + flag: 'w', + }); + process.stdout.write(`${result}\n`); } main().catch((error) => { diff --git a/scripts/ql3-plugin-package-kubernetes-live-contract.cjs b/scripts/ql3-plugin-package-kubernetes-live-contract.cjs index 0eb031d0..802cc0be 100644 --- a/scripts/ql3-plugin-package-kubernetes-live-contract.cjs +++ b/scripts/ql3-plugin-package-kubernetes-live-contract.cjs @@ -505,6 +505,17 @@ async function main() { ); assert.equal(winner.final.generation, 2); assert.equal(winner.final.resourceVersion, loser.final.resourceVersion); + assert.equal(winner.final.pointerSchema, loser.final.pointerSchema); + assert.equal(winner.final.projectionDigest, loser.final.projectionDigest); + assert.equal( + winner.final.transitionReceiptDigest, + loser.final.transitionReceiptDigest, + ); + assert.match(winner.final.projectionDigest, /^[0-9a-f]{64}$/); + assert.match(winner.final.transitionReceiptDigest, /^[0-9a-f]{64}$/); + assert.equal(winner.final.pointerSchema.endsWith('@v3'), true); + assert.equal(winner.final.projectionItemCount, 1); + assert.equal(winner.final.projectedWorkloadVolume, true); const activePointers = kubectlJson([ '-n', @@ -512,7 +523,7 @@ async function main() { 'get', 'configmaps', '-l', - 'qinglong.io/plugin-package-active=v2', + 'qinglong.io/plugin-package-active=v3', ]).items; assert.equal(activePointers.length, 1); const barriers = kubectlJson([ @@ -582,6 +593,15 @@ async function main() { winner: winner.actor, activePointers: activePointers.length, }, + secretProjection: { + schema: winner.final.pointerSchema, + projectionDigest: winner.final.projectionDigest, + transitionReceiptDigest: winner.final.transitionReceiptDigest, + itemCount: winner.final.projectionItemCount, + defaultMode: 0o440, + workloadVolumeRendered: winner.final.projectedWorkloadVolume, + secretApiReadRequired: false, + }, rbac, sideEffects: { activePointers: activePointers.length, @@ -599,6 +619,9 @@ async function main() { concurrentReplacementSingleWinner: true, loserObservedConflict: true, finalPointerExactlyOne: true, + transitionReceiptBoundToV3Pointer: true, + exactSecretProjectionItemPublished: true, + projectedWorkloadVolumeUses0440: true, configMapGetCreateUpdateAllowed: true, configMapListDeleteDenied: true, secretReadCreateDenied: true, @@ -609,7 +632,9 @@ async function main() { limitations: [ 'response loss is injected after an API-confirmed create at the Kubernetes client boundary, not by dropping raw network packets', 'single-control-plane Kind proves API-server resourceVersion and RBAC semantics, not Kubernetes control-plane HA', - 'the gate isolates ConfigMap publication authority and does not exercise PostgreSQL or OCI registry recovery', + 'the gate uses in-memory content-blind binding/transition sources; durable PostgreSQL transition authority is proven independently', + 'the gate renders the exact Secret volume source but deliberately does not create or read Secret material', + 'the gate isolates ConfigMap publication authority and does not exercise OCI registry recovery', ], }, null, diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 20e80d5c..298e3014 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: 106, + sourceFiles: 107, rootSourceFiles: 1, rootSourceLines: 61, - nestedSourceFiles: 105, + nestedSourceFiles: 106, rootSourceFileRoles: { 'modelInvocationMigrationCli.ts': 'binary_entry', }, diff --git a/test/back/ql3PluginPackageKubernetesLiveContract.test.cjs b/test/back/ql3PluginPackageKubernetesLiveContract.test.cjs index f0e6d167..54fb5a49 100644 --- a/test/back/ql3PluginPackageKubernetesLiveContract.test.cjs +++ b/test/back/ql3PluginPackageKubernetesLiveContract.test.cjs @@ -63,7 +63,10 @@ test('live actors fence the same resourceVersion and recover one lost create res actorSource, /injected response loss after Kubernetes API-confirmed create/, ); - assert.match(actorSource, /publisher\.inspect\(initial\)/); + assert.match(actorSource, /initialPublisher\.inspect\(initial\)/); + assert.match(actorSource, /publisher\.publish\(candidate\)/); + assert.match(actorSource, /finalPointer\.schema\.endsWith\('@v3'\)/); + assert.match(actorSource, /finalPointer\.secretProjection\.items/); assert.match(actorSource, /assert\.equal\(createCalls, 1\)/); assert.match(actorSource, /ql3-live-cas-ready-\$\{ACTOR\}/); assert.match(hostSource, /sameResourceVersionAttemptedByBothPods: true/);