mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): project plugin secrets into kubernetes
This commit is contained in:
@@ -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-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-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-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(已接受)
|
- D-302/ADR-0390(已接受)
|
||||||
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
|
||||||
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
|
||||||
|
|||||||
+2
-1
@@ -20,7 +20,7 @@
|
|||||||
"build:back": "tsc -p back/tsconfig.json",
|
"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",
|
"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: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",
|
"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",
|
"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",
|
"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",
|
"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",
|
"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-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",
|
"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",
|
"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",
|
"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",
|
||||||
|
|||||||
+226
-32
@@ -18,9 +18,34 @@ import {
|
|||||||
normalizePluginPackageActivationReceipt,
|
normalizePluginPackageActivationReceipt,
|
||||||
type PluginPackageActivationReceipt,
|
type PluginPackageActivationReceipt,
|
||||||
} from '@qinglong/runtime-core/plugin-package-install';
|
} 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';
|
'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 ACTIVE_POINTER_KEY = 'active.json';
|
||||||
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
|
||||||
const MANAGED_BY_VALUE = 'qinglong3';
|
const MANAGED_BY_VALUE = 'qinglong3';
|
||||||
@@ -58,6 +83,19 @@ export interface PluginPackageKubernetesActivationPublisherOptions {
|
|||||||
readonly namespace: string;
|
readonly namespace: string;
|
||||||
/** Explicit authoritative clock called only for a new publication attempt. */
|
/** Explicit authoritative clock called only for a new publication attempt. */
|
||||||
readonly now: () => number | Promise<number>;
|
readonly now: () => number | Promise<number>;
|
||||||
|
/**
|
||||||
|
* 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<PluginPackageSecretBindingRepository, 'find'>;
|
||||||
|
readonly transitions: Pick<
|
||||||
|
PluginPackageSecretBindingTransitionReceiptRepository,
|
||||||
|
'find'
|
||||||
|
>;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPackageKubernetesConfigMap {
|
export interface PluginPackageKubernetesConfigMap {
|
||||||
@@ -110,17 +148,25 @@ export interface PluginPackageKubernetesConfigMapApi {
|
|||||||
): Promise<PluginPackageKubernetesConfigMap>;
|
): Promise<PluginPackageKubernetesConfigMap>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivePointer {
|
interface ActivePointerV2 {
|
||||||
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
|
readonly schema: typeof ACTIVE_POINTER_SCHEMA_V2;
|
||||||
readonly clusterIdentityDigest: string;
|
readonly clusterIdentityDigest: string;
|
||||||
readonly intent: Readonly<PluginPackageActivationIntent>;
|
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||||
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface StoredPointer extends ActivePointer {
|
interface ActivePointerV3 {
|
||||||
readonly resourceVersion: string;
|
readonly schema: typeof ACTIVE_POINTER_SCHEMA_V3;
|
||||||
|
readonly clusterIdentityDigest: string;
|
||||||
|
readonly intent: Readonly<PluginPackageActivationIntent>;
|
||||||
|
readonly receipt: Readonly<PluginPackageActivationReceipt>;
|
||||||
|
readonly secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActivePointer = ActivePointerV2 | ActivePointerV3;
|
||||||
|
|
||||||
|
type StoredPointer = ActivePointer & Readonly<{ resourceVersion: string }>;
|
||||||
|
|
||||||
function apiStatus(error: unknown): number | null {
|
function apiStatus(error: unknown): number | null {
|
||||||
if (!error || typeof error !== 'object') return null;
|
if (!error || typeof error !== 'object') return null;
|
||||||
if ('code' in error && typeof error.code === 'number') return error.code;
|
if ('code' in error && typeof error.code === 'number') return error.code;
|
||||||
@@ -237,11 +283,29 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
!options ||
|
!options ||
|
||||||
typeof options !== 'object' ||
|
typeof options !== 'object' ||
|
||||||
Array.isArray(options) ||
|
Array.isArray(options) ||
|
||||||
Object.keys(options).sort().join(',') !==
|
Object.keys(options).some(
|
||||||
'clusterIdentity,namespace,now' ||
|
(key) =>
|
||||||
|
key !== 'clusterIdentity' &&
|
||||||
|
key !== 'namespace' &&
|
||||||
|
key !== 'now' &&
|
||||||
|
key !== 'secretProjection',
|
||||||
|
) ||
|
||||||
!SAFE_IDENTITY.test(options.clusterIdentity) ||
|
!SAFE_IDENTITY.test(options.clusterIdentity) ||
|
||||||
!DNS_LABEL.test(options.namespace) ||
|
!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(
|
throw new TypeError(
|
||||||
'Plugin Package Kubernetes activation options are invalid',
|
'Plugin Package Kubernetes activation options are invalid',
|
||||||
@@ -306,6 +370,38 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #secretProjection(
|
||||||
|
intent: Readonly<PluginPackageActivationIntent>,
|
||||||
|
): Promise<Readonly<PluginPackageKubernetesSecretProjection> | 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(
|
#parsePointer(
|
||||||
configMap: PluginPackageKubernetesConfigMap,
|
configMap: PluginPackageKubernetesConfigMap,
|
||||||
expectedName: string,
|
expectedName: string,
|
||||||
@@ -336,37 +432,71 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
const serialized = data[ACTIVE_POINTER_KEY];
|
const serialized = data[ACTIVE_POINTER_KEY];
|
||||||
if (
|
if (
|
||||||
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
|
||||||
labels[ACTIVE_LABEL] !== 'v2' ||
|
(labels[ACTIVE_LABEL] !== 'v2' && labels[ACTIVE_LABEL] !== 'v3') ||
|
||||||
typeof serialized !== 'string' ||
|
typeof serialized !== 'string' ||
|
||||||
Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES
|
Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES
|
||||||
) {
|
) {
|
||||||
throw new PluginPackageActivationConflictError();
|
throw new PluginPackageActivationConflictError();
|
||||||
}
|
}
|
||||||
const pointer = dataRecord(JSON.parse(serialized));
|
const pointer = dataRecord(JSON.parse(serialized));
|
||||||
exactKeys(pointer, [
|
if (pointer.schema === ACTIVE_POINTER_SCHEMA_V2) {
|
||||||
'schema',
|
exactKeys(pointer, [
|
||||||
'clusterIdentityDigest',
|
'schema',
|
||||||
'intent',
|
'clusterIdentityDigest',
|
||||||
'receipt',
|
'intent',
|
||||||
]);
|
'receipt',
|
||||||
|
]);
|
||||||
|
} else if (pointer.schema === ACTIVE_POINTER_SCHEMA_V3) {
|
||||||
|
exactKeys(pointer, [
|
||||||
|
'schema',
|
||||||
|
'clusterIdentityDigest',
|
||||||
|
'intent',
|
||||||
|
'receipt',
|
||||||
|
'secretProjection',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
throw new PluginPackageActivationConflictError();
|
||||||
|
}
|
||||||
const intent = normalizeIntent(
|
const intent = normalizeIntent(
|
||||||
pointer.intent as PluginPackageActivationIntent,
|
pointer.intent as PluginPackageActivationIntent,
|
||||||
);
|
);
|
||||||
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
|
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
|
||||||
const normalized: ActivePointer = Object.freeze({
|
const secretProjection =
|
||||||
schema: ACTIVE_POINTER_SCHEMA,
|
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
|
||||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
? pointer.secretProjection === null
|
||||||
intent,
|
? null
|
||||||
receipt,
|
: 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 (
|
if (
|
||||||
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
|
|
||||||
pointer.clusterIdentityDigest !== this.#clusterIdentityDigest ||
|
pointer.clusterIdentityDigest !== this.#clusterIdentityDigest ||
|
||||||
this.#name(intent) !== expectedName ||
|
this.#name(intent) !== expectedName ||
|
||||||
labels[TARGET_LABEL] !==
|
labels[TARGET_LABEL] !==
|
||||||
Buffer.from(this.#targetDigest(intent), 'hex').toString(
|
Buffer.from(this.#targetDigest(intent), 'hex').toString(
|
||||||
'base64url',
|
'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 ||
|
annotations[INTENT_ANNOTATION] !== intent.intentDigest ||
|
||||||
receipt.intentDigest !== intent.intentDigest ||
|
receipt.intentDigest !== intent.intentDigest ||
|
||||||
receipt.generation !== intent.targetGeneration ||
|
receipt.generation !== intent.targetGeneration ||
|
||||||
@@ -409,6 +539,7 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
intent: Readonly<PluginPackageActivationIntent>,
|
intent: Readonly<PluginPackageActivationIntent>,
|
||||||
): Promise<Readonly<PluginPackageActivationObservation>> {
|
): Promise<Readonly<PluginPackageActivationObservation>> {
|
||||||
await this.#verifyStage(intent);
|
await this.#verifyStage(intent);
|
||||||
|
const expectedProjection = await this.#secretProjection(intent);
|
||||||
const pointer = await this.#optionalPointer(intent);
|
const pointer = await this.#optionalPointer(intent);
|
||||||
if (!pointer) {
|
if (!pointer) {
|
||||||
if (intent.previousActiveLockDigest !== null) {
|
if (intent.previousActiveLockDigest !== null) {
|
||||||
@@ -416,7 +547,12 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
}
|
}
|
||||||
return Object.freeze({ status: 'not_published' });
|
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 });
|
return Object.freeze({ status: 'published', receipt: pointer.receipt });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -433,14 +569,24 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
intent: Readonly<PluginPackageActivationIntent>,
|
intent: Readonly<PluginPackageActivationIntent>,
|
||||||
receipt: Readonly<PluginPackageActivationReceipt>,
|
receipt: Readonly<PluginPackageActivationReceipt>,
|
||||||
current: Readonly<StoredPointer> | null,
|
current: Readonly<StoredPointer> | null,
|
||||||
|
secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null,
|
||||||
): ConfigMapWrite {
|
): ConfigMapWrite {
|
||||||
const targetDigest = this.#targetDigest(intent);
|
const targetDigest = this.#targetDigest(intent);
|
||||||
const pointer: Readonly<ActivePointer> = Object.freeze({
|
const usesSecretProjection = secretProjection !== null;
|
||||||
schema: ACTIVE_POINTER_SCHEMA,
|
const pointer: Readonly<ActivePointer> = usesSecretProjection
|
||||||
clusterIdentityDigest: this.#clusterIdentityDigest,
|
? Object.freeze({
|
||||||
intent,
|
schema: ACTIVE_POINTER_SCHEMA_V3,
|
||||||
receipt,
|
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||||
});
|
intent,
|
||||||
|
receipt,
|
||||||
|
secretProjection,
|
||||||
|
})
|
||||||
|
: Object.freeze({
|
||||||
|
schema: ACTIVE_POINTER_SCHEMA_V2,
|
||||||
|
clusterIdentityDigest: this.#clusterIdentityDigest,
|
||||||
|
intent,
|
||||||
|
receipt,
|
||||||
|
});
|
||||||
const serialized = `${JSON.stringify(pointer)}\n`;
|
const serialized = `${JSON.stringify(pointer)}\n`;
|
||||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
|
||||||
throw new PluginPackageActivationUnavailableError();
|
throw new PluginPackageActivationUnavailableError();
|
||||||
@@ -455,7 +601,7 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
...(current ? { resourceVersion: current.resourceVersion } : {}),
|
...(current ? { resourceVersion: current.resourceVersion } : {}),
|
||||||
labels: Object.freeze({
|
labels: Object.freeze({
|
||||||
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
|
||||||
[ACTIVE_LABEL]: 'v2',
|
[ACTIVE_LABEL]: usesSecretProjection ? 'v3' : 'v2',
|
||||||
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
|
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
|
||||||
'base64url',
|
'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<Readonly<PluginPackageKubernetesActiveDeployment> | 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(
|
async publish(
|
||||||
value: Readonly<PluginPackageActivationIntent>,
|
value: Readonly<PluginPackageActivationIntent>,
|
||||||
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
): Promise<Readonly<PluginPackageActivationReceipt>> {
|
||||||
@@ -508,7 +690,18 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
const first = await this.#observe(intent);
|
const first = await this.#observe(intent);
|
||||||
if (first.status === 'published') return first.receipt;
|
if (first.status === 'published') return first.receipt;
|
||||||
const current = await this.#optionalPointer(intent);
|
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 (
|
if (
|
||||||
(!current && intent.previousActiveLockDigest !== null) ||
|
(!current && intent.previousActiveLockDigest !== null) ||
|
||||||
(current &&
|
(current &&
|
||||||
@@ -529,7 +722,8 @@ export class PluginPackageKubernetesActivationPublisher
|
|||||||
contentDigest: intent.contentDigest,
|
contentDigest: intent.contentDigest,
|
||||||
activatedAtMs,
|
activatedAtMs,
|
||||||
});
|
});
|
||||||
const body = this.#body(intent, receipt, current);
|
const secretProjection = await this.#secretProjection(intent);
|
||||||
|
const body = this.#body(intent, receipt, current, secretProjection);
|
||||||
try {
|
try {
|
||||||
if (current) {
|
if (current) {
|
||||||
await this.api.replaceNamespacedConfigMap({
|
await this.api.replaceNamespacedConfigMap({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
PostgresPluginPackageMaterializedRevisionRepository,
|
PostgresPluginPackageMaterializedRevisionRepository,
|
||||||
PostgresPluginPackageSecretBindingRepository,
|
PostgresPluginPackageSecretBindingRepository,
|
||||||
PostgresPluginPackageSecretBindingActivationPrerequisite,
|
PostgresPluginPackageSecretBindingActivationPrerequisite,
|
||||||
|
PostgresPluginPackageSecretBindingTransitionRepository,
|
||||||
PostgresPluginPackagePublisherProvenanceRepository,
|
PostgresPluginPackagePublisherProvenanceRepository,
|
||||||
PostgresPluginPackageTaskReconciliationRepository,
|
PostgresPluginPackageTaskReconciliationRepository,
|
||||||
PostgresProjectToolDefinitionSnapshotRepository,
|
PostgresProjectToolDefinitionSnapshotRepository,
|
||||||
@@ -307,6 +308,16 @@ export async function recoverClusterPluginPackages(
|
|||||||
clusterIdentity: options.clusterIdentity,
|
clusterIdentity: options.clusterIdentity,
|
||||||
namespace: options.namespace,
|
namespace: options.namespace,
|
||||||
now: options.now,
|
now: options.now,
|
||||||
|
secretProjection: {
|
||||||
|
sourceSecretName: 'ql3-cluster-plugin-package-values',
|
||||||
|
bindings: new PostgresPluginPackageSecretBindingRepository(
|
||||||
|
database.pool,
|
||||||
|
),
|
||||||
|
transitions:
|
||||||
|
new PostgresPluginPackageSecretBindingTransitionRepository(
|
||||||
|
database.pool,
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const resourceByteSource =
|
const resourceByteSource =
|
||||||
|
|||||||
+315
@@ -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<PluginPackageKubernetesSecretProjectionItem>[];
|
||||||
|
readonly assignments: readonly Readonly<PluginPackageKubernetesSecretProjectionAssignment>[];
|
||||||
|
readonly projectionDigest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPackageKubernetesActiveDeployment {
|
||||||
|
readonly resourceGeneration: Readonly<PluginPackageResourceGeneration>;
|
||||||
|
readonly secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | 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<PluginPackageKubernetesSecretProjectionItem>[];
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PluginPackageKubernetesSecretProjection, 'projectionDigest'>,
|
||||||
|
): string {
|
||||||
|
return createHash('sha256')
|
||||||
|
.update(PROJECTION_DIGEST_DOMAIN)
|
||||||
|
.update(JSON.stringify(value), 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueItems(
|
||||||
|
assignments: readonly Readonly<PluginPackageKubernetesSecretProjectionAssignment>[],
|
||||||
|
): readonly Readonly<PluginPackageKubernetesSecretProjectionItem>[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<PluginPackageSecretBinding> | null,
|
||||||
|
transition: Readonly<PluginPackageSecretBindingTransitionReceipt> | null,
|
||||||
|
): Readonly<PluginPackageKubernetesSecretProjection> | 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<PluginPackageKubernetesSecretProjection> {
|
||||||
|
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<PluginPackageKubernetesSecretProjection> | null,
|
||||||
|
): Readonly<PluginPackageKubernetesProjectedSecretWorkloadVolume> | 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -9,6 +9,20 @@ const {
|
|||||||
const {
|
const {
|
||||||
createPluginPackageResourceGeneration,
|
createPluginPackageResourceGeneration,
|
||||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
} = 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 {
|
const {
|
||||||
PLUGIN_PACKAGE_API_VERSION,
|
PLUGIN_PACKAGE_API_VERSION,
|
||||||
PLUGIN_PACKAGE_KIND,
|
PLUGIN_PACKAGE_KIND,
|
||||||
@@ -28,6 +42,7 @@ const {
|
|||||||
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
} = require('@qinglong/runtime-core/plugin-package-recovery');
|
||||||
const {
|
const {
|
||||||
PluginPackageKubernetesActivationPublisher,
|
PluginPackageKubernetesActivationPublisher,
|
||||||
|
pluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||||
} = require('../dist/plugin-package/recovery/pluginPackageKubernetesActivation');
|
} = require('../dist/plugin-package/recovery/pluginPackageKubernetesActivation');
|
||||||
|
|
||||||
function apiError(code) {
|
function apiError(code) {
|
||||||
@@ -298,11 +313,167 @@ function publisher(api = new FakeConfigMapApi(), overrides = {}) {
|
|||||||
nowCalls += 1;
|
nowCalls += 1;
|
||||||
return overrides.now?.() ?? 500 + nowCalls;
|
return overrides.now?.() ?? 500 + nowCalls;
|
||||||
},
|
},
|
||||||
|
...(overrides.secretProjection === undefined
|
||||||
|
? {}
|
||||||
|
: { secretProjection: overrides.secretProjection }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return { api, publisher: value, nowCalls: () => nowCalls };
|
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 () => {
|
test('publishes one resourceVersion-fenced ConfigMap and exact replays it', async () => {
|
||||||
const fixture = publisher();
|
const fixture = publisher();
|
||||||
const value = intent();
|
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 () => {
|
test('leaves response loss for recovery inspection without republishing', async () => {
|
||||||
const api = new FakeConfigMapApi();
|
const api = new FakeConfigMapApi();
|
||||||
api.loseCreateResponse = true;
|
api.loseCreateResponse = true;
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ function runtimePrivileges() {
|
|||||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||||
plugin_package_automation_publication_heads: [true, 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_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_bindings: [false, false, false, false],
|
||||||
plugin_package_secret_binding_transition_receipts: [
|
plugin_package_secret_binding_transition_receipts: [
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -114,6 +114,12 @@ function runtimePrivileges() {
|
|||||||
plugin_package_automation_disposition_events: [false, false, false, false],
|
plugin_package_automation_disposition_events: [false, false, false, false],
|
||||||
plugin_package_automation_publication_heads: [true, 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_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_bindings: [false, false, false, false],
|
||||||
plugin_package_secret_binding_transition_receipts: [
|
plugin_package_secret_binding_transition_receipts: [
|
||||||
false,
|
false,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -14,8 +14,23 @@ const {
|
|||||||
const {
|
const {
|
||||||
createPluginPackageResourceGeneration,
|
createPluginPackageResourceGeneration,
|
||||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
} = 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 {
|
const {
|
||||||
PluginPackageKubernetesActivationPublisher,
|
PluginPackageKubernetesActivationPublisher,
|
||||||
|
pluginPackageKubernetesProjectedSecretWorkloadVolume,
|
||||||
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-activation');
|
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-activation');
|
||||||
|
|
||||||
const TOKEN_FILE = '/var/run/secrets/kubernetes.io/serviceaccount/token';
|
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) {
|
function exactEvidence(intent) {
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
lockDigest: intent.lockDigest,
|
lockDigest: intent.lockDigest,
|
||||||
@@ -222,29 +314,32 @@ async function main() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let nowCalls = 0;
|
let nowCalls = 0;
|
||||||
const publisher = new PluginPackageKubernetesActivationPublisher(
|
const createPublisher = (secretProjection) =>
|
||||||
activationApi,
|
new PluginPackageKubernetesActivationPublisher(
|
||||||
{ verify: async (intent) => exactEvidence(intent) },
|
activationApi,
|
||||||
{
|
{ verify: async (intent) => exactEvidence(intent) },
|
||||||
clusterIdentity: 'ql3-plugin-package-live-cluster',
|
{
|
||||||
namespace: NAMESPACE,
|
clusterIdentity: 'ql3-plugin-package-live-cluster',
|
||||||
now() {
|
namespace: NAMESPACE,
|
||||||
nowCalls += 1;
|
now() {
|
||||||
return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls;
|
nowCalls += 1;
|
||||||
|
return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls;
|
||||||
|
},
|
||||||
|
...(secretProjection === undefined ? {} : { secretProjection }),
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
const initial = activationIntent();
|
const initial = activationIntent();
|
||||||
|
const initialPublisher = createPublisher();
|
||||||
let responseLoss = null;
|
let responseLoss = null;
|
||||||
if (ACTOR === 'a') {
|
if (ACTOR === 'a') {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
publisher.publish(initial),
|
initialPublisher.publish(initial),
|
||||||
PluginPackageActivationUnavailableError,
|
PluginPackageActivationUnavailableError,
|
||||||
);
|
);
|
||||||
const observation = await publisher.inspect(initial);
|
const observation = await initialPublisher.inspect(initial);
|
||||||
assert.equal(observation.status, 'published');
|
assert.equal(observation.status, 'published');
|
||||||
const replay = await publisher.publish(initial);
|
const replay = await initialPublisher.publish(initial);
|
||||||
assert.deepEqual(replay, observation.receipt);
|
assert.deepEqual(replay, observation.receipt);
|
||||||
assert.equal(createCalls, 1);
|
assert.equal(createCalls, 1);
|
||||||
assert.equal(nowCalls, 1);
|
assert.equal(nowCalls, 1);
|
||||||
@@ -258,7 +353,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await waitFor('initial active pointer', async () => {
|
await waitFor('initial active pointer', async () => {
|
||||||
const observation = await publisher.inspect(initial);
|
const observation = await initialPublisher.inspect(initial);
|
||||||
return {
|
return {
|
||||||
ready: observation.status === 'published',
|
ready: observation.status === 'published',
|
||||||
fact: observation.status,
|
fact: observation.status,
|
||||||
@@ -272,6 +367,12 @@ async function main() {
|
|||||||
targetGeneration: 2,
|
targetGeneration: 2,
|
||||||
previousActiveLockDigest: INITIAL_LOCK_DIGEST,
|
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;
|
let outcome;
|
||||||
try {
|
try {
|
||||||
const receipt = await publisher.publish(candidate);
|
const receipt = await publisher.publish(candidate);
|
||||||
@@ -310,6 +411,25 @@ async function main() {
|
|||||||
name: targetName,
|
name: targetName,
|
||||||
});
|
});
|
||||||
const finalPointer = JSON.parse(finalConfigMap.data['active.json']);
|
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({
|
const rbac = Object.freeze({
|
||||||
listConfigMaps: await expectForbidden(() =>
|
listConfigMaps: await expectForbidden(() =>
|
||||||
@@ -335,25 +455,34 @@ async function main() {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
process.stdout.write(
|
const result = JSON.stringify({
|
||||||
`${JSON.stringify({
|
schema: RESULT_SCHEMA,
|
||||||
schema: RESULT_SCHEMA,
|
actor: ACTOR,
|
||||||
actor: ACTOR,
|
serviceAccountTokenMounted: true,
|
||||||
serviceAccountTokenMounted: true,
|
responseLoss,
|
||||||
responseLoss,
|
cas: {
|
||||||
cas: {
|
...outcome,
|
||||||
...outcome,
|
attemptedResourceVersion: replaceResourceVersion,
|
||||||
attemptedResourceVersion: replaceResourceVersion,
|
replaceCalls,
|
||||||
replaceCalls,
|
},
|
||||||
},
|
final: {
|
||||||
final: {
|
resourceVersion: finalConfigMap.metadata.resourceVersion,
|
||||||
resourceVersion: finalConfigMap.metadata.resourceVersion,
|
lockDigest: finalPointer.intent.lockDigest,
|
||||||
lockDigest: finalPointer.intent.lockDigest,
|
generation: finalPointer.receipt.generation,
|
||||||
generation: finalPointer.receipt.generation,
|
pointerSchema: finalPointer.schema,
|
||||||
},
|
projectionDigest: finalPointer.secretProjection.projectionDigest,
|
||||||
rbac,
|
transitionReceiptDigest:
|
||||||
})}\n`,
|
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) => {
|
main().catch((error) => {
|
||||||
|
|||||||
@@ -505,6 +505,17 @@ async function main() {
|
|||||||
);
|
);
|
||||||
assert.equal(winner.final.generation, 2);
|
assert.equal(winner.final.generation, 2);
|
||||||
assert.equal(winner.final.resourceVersion, loser.final.resourceVersion);
|
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([
|
const activePointers = kubectlJson([
|
||||||
'-n',
|
'-n',
|
||||||
@@ -512,7 +523,7 @@ async function main() {
|
|||||||
'get',
|
'get',
|
||||||
'configmaps',
|
'configmaps',
|
||||||
'-l',
|
'-l',
|
||||||
'qinglong.io/plugin-package-active=v2',
|
'qinglong.io/plugin-package-active=v3',
|
||||||
]).items;
|
]).items;
|
||||||
assert.equal(activePointers.length, 1);
|
assert.equal(activePointers.length, 1);
|
||||||
const barriers = kubectlJson([
|
const barriers = kubectlJson([
|
||||||
@@ -582,6 +593,15 @@ async function main() {
|
|||||||
winner: winner.actor,
|
winner: winner.actor,
|
||||||
activePointers: activePointers.length,
|
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,
|
rbac,
|
||||||
sideEffects: {
|
sideEffects: {
|
||||||
activePointers: activePointers.length,
|
activePointers: activePointers.length,
|
||||||
@@ -599,6 +619,9 @@ async function main() {
|
|||||||
concurrentReplacementSingleWinner: true,
|
concurrentReplacementSingleWinner: true,
|
||||||
loserObservedConflict: true,
|
loserObservedConflict: true,
|
||||||
finalPointerExactlyOne: true,
|
finalPointerExactlyOne: true,
|
||||||
|
transitionReceiptBoundToV3Pointer: true,
|
||||||
|
exactSecretProjectionItemPublished: true,
|
||||||
|
projectedWorkloadVolumeUses0440: true,
|
||||||
configMapGetCreateUpdateAllowed: true,
|
configMapGetCreateUpdateAllowed: true,
|
||||||
configMapListDeleteDenied: true,
|
configMapListDeleteDenied: true,
|
||||||
secretReadCreateDenied: true,
|
secretReadCreateDenied: true,
|
||||||
@@ -609,7 +632,9 @@ async function main() {
|
|||||||
limitations: [
|
limitations: [
|
||||||
'response loss is injected after an API-confirmed create at the Kubernetes client boundary, not by dropping raw network packets',
|
'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',
|
'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,
|
null,
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 106,
|
sourceFiles: 107,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 105,
|
nestedSourceFiles: 106,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ test('live actors fence the same resourceVersion and recover one lost create res
|
|||||||
actorSource,
|
actorSource,
|
||||||
/injected response loss after Kubernetes API-confirmed create/,
|
/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, /assert\.equal\(createCalls, 1\)/);
|
||||||
assert.match(actorSource, /ql3-live-cas-ready-\$\{ACTOR\}/);
|
assert.match(actorSource, /ql3-live-cas-ready-\$\{ACTOR\}/);
|
||||||
assert.match(hostSource, /sameResourceVersionAttemptedByBothPods: true/);
|
assert.match(hostSource, /sameResourceVersionAttemptedByBothPods: true/);
|
||||||
|
|||||||
Reference in New Issue
Block a user