feat(ql3): project plugin secrets into kubernetes

This commit is contained in:
whyour
2026-08-14 00:34:49 +08:00
parent d41c821ce0
commit 9d7431c2cd
13 changed files with 1437 additions and 73 deletions
+1 -1
View File
@@ -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 v46PostgreSQL 追加 `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 退出 0backend 1,188 pass/2 skip,五项边界审计零 findingworkspace 仍无 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 引用逻辑 requirementmaterialization 只用当前 generation 的 D-305 binding 编译为已有、固定 version 的 Task `SecretRef`Package source 直接携带 SecretRef、缺失 binding、未批准 `secret.use`、跨 binding 引用和 optional/required 漂移全部失败关闭。binding 快照不含明文并进入 materialized revision digestLocal/Cluster 启动发布复用既有 repository/poolTask 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 退出 0backend 1,188 pass/2 条件 skip/0 fail;五项 package/dependency/edge/service-manager/local-image 审计零 findingworkspace 仍无 single-source/shallow-source package,两个有序 migration ledger 精确为 PostgreSQL 61、SQLite 95 个 sourcePostgreSQL 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 transactionCluster 使用既有 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 tokenmanagement 不挂载 Package valueexecutor 只验证投影元数据;最终恰好一条 immutable bindingApproval 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 v490097/0098)已完成 immutable transition receipt ledger、Local Owner staged `plan→execute`、单事务 binding/audit/receipt 和 activation prerequisite。PostgreSQL capability v62pg-0063现也具有 receipt ledgerpackage-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 退出 0backend 1192 pass/2 条条件 skip/0 failPostgreSQL 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 v490097/0098)已完成 immutable transition receipt ledger、Local Owner staged `plan→execute`、单事务 binding/audit/receipt 和 activation prerequisite。PostgreSQL capability v62pg-0063)具有 receipt ledgerCluster 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 keyrevoke 生成显式空 projectionPod volume renderer 对空项返回不挂载,避免 Kubernetes 空 `items` 被解释为投影全部 key。无 Secret generation 继续发布兼容 v2publisher 不获得 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 deniedprojection digest `22add8965accf3f736935167963b9dbdeab8fba05f739f24d39dec941aae9680`、transition receipt digest `355b89ecd54422af33fa573780c8b70ed41da4df226ec301bdd5b6c71de609e1`;临时容器/网络已清理。实现没有新增 workspace packageSecret projection/renderer 内聚在既有 `cluster-admin/plugin-package/secret-binding`18-package boundary 仍为 `singleSourcePackages=[]``shallowSourcePackages=[]`cluster dependency 与 edge import 审计无 finding。上一阶段真实 PostgreSQL 18.4 的 63 条 migration、原子提交、exact replay、错误状态/lineage 拒绝,以及 physical HA 125 gate/timeline `1→2` 证据继续有效。当前增量的完整 18-package/PG/HA 回归、真实 Kubernetes revoke 工作负载、升级后业务 Pod rollout/失败回滚和低配物理证据仍待完成。
- D-302/ADR-0390(已接受)
Cluster operator context 增加无网络、无 mutation 的内建 `ql3-cluster-admin context validate` 预检。它先复用 owner-private context
reader,再让每个 entry 经过与真实请求相同的 production HTTPS/Kubernetes configuration preparation,验证精确 route、hostname、CA、
+2 -1
View File
@@ -20,7 +20,7 @@
"build:back": "tsc -p back/tsconfig.json",
"build:packages:ql3": "node scripts/ql3-clean-package-artifacts.cjs && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' run build",
"test:back": "node --test test/back/*.test.cjs",
"test:packages:ql3": "pnpm run build:packages:ql3 && pnpm -r --workspace-concurrency=2 --filter './packages/ql3-*' exec sh -c 'node --test test/*.test.cjs'",
"test:packages:ql3": "pnpm run build:packages:ql3 && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' exec sh -c 'node --test test/*.test.cjs'",
"test:postgres-ha:ql3": "pnpm --filter @qinglong/ai build && pnpm --filter @qinglong/cluster-admin check && pnpm --filter @qinglong/cluster-control check && node scripts/ql3-postgres-ha-contract.cjs",
"audit:postgres-ha-evidence:ql3": "node scripts/ql3-postgres-ha-evidence-audit.cjs",
"test:automation-postgres-integration:ql3": "pnpm --filter @qinglong/cluster-admin test:automation-integration",
@@ -48,6 +48,7 @@
"test:prompt-output-projection-kubernetes-live:ql3": "pnpm --filter @qinglong/ai build && node scripts/ql3-prompt-output-projection-kubernetes-live-contract.cjs",
"audit:prompt-output-projection-kubernetes-live:ql3": "node scripts/ql3-prompt-output-projection-kubernetes-live-audit.cjs",
"test:plugin-package-kubernetes-live:ql3": "node scripts/ql3-plugin-package-kubernetes-live-contract.cjs",
"test:plugin-package-kubernetes-k3s-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-plugin-package-kubernetes-k3s-live-contract.cjs",
"test:plugin-package-secret-binding-kubernetes-live:ql3": "pnpm --filter @qinglong/cluster-admin build && node scripts/ql3-plugin-package-secret-binding-kubernetes-live-contract.cjs",
"audit:plugin-package-secret-binding-kubernetes-live:ql3": "node scripts/ql3-plugin-package-secret-binding-kubernetes-live-audit.cjs",
"test:plugin-package-recovery-e2e:ql3": "pnpm --filter @qinglong/cluster-admin check && pnpm --filter @qinglong/cluster-control check && node scripts/ql3-plugin-package-recovery-e2e-live-contract.cjs",
@@ -18,9 +18,34 @@ import {
normalizePluginPackageActivationReceipt,
type PluginPackageActivationReceipt,
} from '@qinglong/runtime-core/plugin-package-install';
import type { PluginPackageSecretBindingRepository } from '@qinglong/runtime-core/plugin-package-secret-binding';
import type { PluginPackageSecretBindingTransitionReceiptRepository } from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
const ACTIVE_POINTER_SCHEMA =
import {
createPluginPackageKubernetesSecretProjection,
isPluginPackageKubernetesSecretName,
normalizePluginPackageKubernetesSecretProjection,
pluginPackageKubernetesProjectedSecretWorkloadVolume,
type PluginPackageKubernetesActiveDeployment,
type PluginPackageKubernetesProjectedSecretWorkloadVolume,
type PluginPackageKubernetesSecretProjection,
type PluginPackageKubernetesSecretProjectionAssignment,
type PluginPackageKubernetesSecretProjectionItem,
} from '../secret-binding/pluginPackageKubernetesSecretProjection';
export {
pluginPackageKubernetesProjectedSecretWorkloadVolume,
type PluginPackageKubernetesActiveDeployment,
type PluginPackageKubernetesProjectedSecretWorkloadVolume,
type PluginPackageKubernetesSecretProjection,
type PluginPackageKubernetesSecretProjectionAssignment,
type PluginPackageKubernetesSecretProjectionItem,
};
const ACTIVE_POINTER_SCHEMA_V2 =
'qinglong/plugin-package-kubernetes-active-pointer@v2';
const ACTIVE_POINTER_SCHEMA_V3 =
'qinglong/plugin-package-kubernetes-active-pointer@v3';
const ACTIVE_POINTER_KEY = 'active.json';
const MANAGED_BY_LABEL = 'app.kubernetes.io/managed-by';
const MANAGED_BY_VALUE = 'qinglong3';
@@ -58,6 +83,19 @@ export interface PluginPackageKubernetesActivationPublisherOptions {
readonly namespace: string;
/** Explicit authoritative clock called only for a new publication attempt. */
readonly now: () => number | Promise<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 {
@@ -110,17 +148,25 @@ export interface PluginPackageKubernetesConfigMapApi {
): Promise<PluginPackageKubernetesConfigMap>;
}
interface ActivePointer {
readonly schema: typeof ACTIVE_POINTER_SCHEMA;
interface ActivePointerV2 {
readonly schema: typeof ACTIVE_POINTER_SCHEMA_V2;
readonly clusterIdentityDigest: string;
readonly intent: Readonly<PluginPackageActivationIntent>;
readonly receipt: Readonly<PluginPackageActivationReceipt>;
}
interface StoredPointer extends ActivePointer {
readonly resourceVersion: string;
interface ActivePointerV3 {
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 {
if (!error || typeof error !== 'object') return null;
if ('code' in error && typeof error.code === 'number') return error.code;
@@ -237,11 +283,29 @@ export class PluginPackageKubernetesActivationPublisher
!options ||
typeof options !== 'object' ||
Array.isArray(options) ||
Object.keys(options).sort().join(',') !==
'clusterIdentity,namespace,now' ||
Object.keys(options).some(
(key) =>
key !== 'clusterIdentity' &&
key !== 'namespace' &&
key !== 'now' &&
key !== 'secretProjection',
) ||
!SAFE_IDENTITY.test(options.clusterIdentity) ||
!DNS_LABEL.test(options.namespace) ||
typeof options.now !== 'function'
typeof options.now !== 'function' ||
(options.secretProjection !== undefined &&
(!options.secretProjection ||
typeof options.secretProjection !== 'object' ||
Array.isArray(options.secretProjection) ||
Object.keys(options.secretProjection).sort().join(',') !==
'bindings,sourceSecretName,transitions' ||
!isPluginPackageKubernetesSecretName(
options.secretProjection.sourceSecretName,
) ||
!options.secretProjection.bindings ||
typeof options.secretProjection.bindings.find !== 'function' ||
!options.secretProjection.transitions ||
typeof options.secretProjection.transitions.find !== 'function'))
) {
throw new TypeError(
'Plugin Package Kubernetes activation options are invalid',
@@ -306,6 +370,38 @@ export class PluginPackageKubernetesActivationPublisher
}
}
async #secretProjection(
intent: Readonly<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(
configMap: PluginPackageKubernetesConfigMap,
expectedName: string,
@@ -336,37 +432,71 @@ export class PluginPackageKubernetesActivationPublisher
const serialized = data[ACTIVE_POINTER_KEY];
if (
labels[MANAGED_BY_LABEL] !== MANAGED_BY_VALUE ||
labels[ACTIVE_LABEL] !== 'v2' ||
(labels[ACTIVE_LABEL] !== 'v2' && labels[ACTIVE_LABEL] !== 'v3') ||
typeof serialized !== 'string' ||
Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES
) {
throw new PluginPackageActivationConflictError();
}
const pointer = dataRecord(JSON.parse(serialized));
exactKeys(pointer, [
'schema',
'clusterIdentityDigest',
'intent',
'receipt',
]);
if (pointer.schema === ACTIVE_POINTER_SCHEMA_V2) {
exactKeys(pointer, [
'schema',
'clusterIdentityDigest',
'intent',
'receipt',
]);
} else if (pointer.schema === ACTIVE_POINTER_SCHEMA_V3) {
exactKeys(pointer, [
'schema',
'clusterIdentityDigest',
'intent',
'receipt',
'secretProjection',
]);
} else {
throw new PluginPackageActivationConflictError();
}
const intent = normalizeIntent(
pointer.intent as PluginPackageActivationIntent,
);
const receipt = normalizePluginPackageActivationReceipt(pointer.receipt);
const normalized: ActivePointer = Object.freeze({
schema: ACTIVE_POINTER_SCHEMA,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
});
const secretProjection =
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
? pointer.secretProjection === null
? null
: normalizePluginPackageKubernetesSecretProjection(
pointer.secretProjection,
)
: undefined;
const normalized: ActivePointer =
pointer.schema === ACTIVE_POINTER_SCHEMA_V3
? Object.freeze({
schema: ACTIVE_POINTER_SCHEMA_V3,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
secretProjection: secretProjection!,
})
: Object.freeze({
schema: ACTIVE_POINTER_SCHEMA_V2,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
});
if (
pointer.schema !== ACTIVE_POINTER_SCHEMA ||
pointer.clusterIdentityDigest !== this.#clusterIdentityDigest ||
this.#name(intent) !== expectedName ||
labels[TARGET_LABEL] !==
Buffer.from(this.#targetDigest(intent), 'hex').toString(
'base64url',
) ||
labels[ACTIVE_LABEL] !==
(pointer.schema === ACTIVE_POINTER_SCHEMA_V3 ? 'v3' : 'v2') ||
(secretProjection !== undefined &&
secretProjection !== null &&
secretProjection.generationDigest !==
intent.resourceGeneration.generationDigest) ||
annotations[INTENT_ANNOTATION] !== intent.intentDigest ||
receipt.intentDigest !== intent.intentDigest ||
receipt.generation !== intent.targetGeneration ||
@@ -409,6 +539,7 @@ export class PluginPackageKubernetesActivationPublisher
intent: Readonly<PluginPackageActivationIntent>,
): Promise<Readonly<PluginPackageActivationObservation>> {
await this.#verifyStage(intent);
const expectedProjection = await this.#secretProjection(intent);
const pointer = await this.#optionalPointer(intent);
if (!pointer) {
if (intent.previousActiveLockDigest !== null) {
@@ -416,7 +547,12 @@ export class PluginPackageKubernetesActivationPublisher
}
return Object.freeze({ status: 'not_published' });
}
if (same(pointer.intent, intent)) {
if (
same(pointer.intent, intent) &&
(pointer.schema === ACTIVE_POINTER_SCHEMA_V2
? expectedProjection === null
: same(pointer.secretProjection, expectedProjection))
) {
return Object.freeze({ status: 'published', receipt: pointer.receipt });
}
if (
@@ -433,14 +569,24 @@ export class PluginPackageKubernetesActivationPublisher
intent: Readonly<PluginPackageActivationIntent>,
receipt: Readonly<PluginPackageActivationReceipt>,
current: Readonly<StoredPointer> | null,
secretProjection: Readonly<PluginPackageKubernetesSecretProjection> | null,
): ConfigMapWrite {
const targetDigest = this.#targetDigest(intent);
const pointer: Readonly<ActivePointer> = Object.freeze({
schema: ACTIVE_POINTER_SCHEMA,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
});
const usesSecretProjection = secretProjection !== null;
const pointer: Readonly<ActivePointer> = usesSecretProjection
? Object.freeze({
schema: ACTIVE_POINTER_SCHEMA_V3,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
secretProjection,
})
: Object.freeze({
schema: ACTIVE_POINTER_SCHEMA_V2,
clusterIdentityDigest: this.#clusterIdentityDigest,
intent,
receipt,
});
const serialized = `${JSON.stringify(pointer)}\n`;
if (Buffer.byteLength(serialized, 'utf8') > MAX_ACTIVE_POINTER_BYTES) {
throw new PluginPackageActivationUnavailableError();
@@ -455,7 +601,7 @@ export class PluginPackageKubernetesActivationPublisher
...(current ? { resourceVersion: current.resourceVersion } : {}),
labels: Object.freeze({
[MANAGED_BY_LABEL]: MANAGED_BY_VALUE,
[ACTIVE_LABEL]: 'v2',
[ACTIVE_LABEL]: usesSecretProjection ? 'v3' : 'v2',
[TARGET_LABEL]: Buffer.from(targetDigest, 'hex').toString(
'base64url',
),
@@ -500,6 +646,42 @@ export class PluginPackageKubernetesActivationPublisher
}
}
/**
* Returns one parsed active deployment snapshot for a workload renderer.
* It performs no Secret API call and exposes only projection keys, never
* Secret material or reversible Secret references.
*/
async findActiveDeployment(
projectId: string,
packageName: string,
): Promise<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(
value: Readonly<PluginPackageActivationIntent>,
): Promise<Readonly<PluginPackageActivationReceipt>> {
@@ -508,7 +690,18 @@ export class PluginPackageKubernetesActivationPublisher
const first = await this.#observe(intent);
if (first.status === 'published') return first.receipt;
const current = await this.#optionalPointer(intent);
if (current && same(current.intent, intent)) return current.receipt;
if (current && same(current.intent, intent)) {
const expectedProjection = await this.#secretProjection(intent);
if (
(current.schema === ACTIVE_POINTER_SCHEMA_V2 &&
expectedProjection === null) ||
(current.schema === ACTIVE_POINTER_SCHEMA_V3 &&
same(current.secretProjection, expectedProjection))
) {
return current.receipt;
}
throw new PluginPackageActivationConflictError();
}
if (
(!current && intent.previousActiveLockDigest !== null) ||
(current &&
@@ -529,7 +722,8 @@ export class PluginPackageKubernetesActivationPublisher
contentDigest: intent.contentDigest,
activatedAtMs,
});
const body = this.#body(intent, receipt, current);
const secretProjection = await this.#secretProjection(intent);
const body = this.#body(intent, receipt, current, secretProjection);
try {
if (current) {
await this.api.replaceNamespacedConfigMap({
@@ -41,6 +41,7 @@ import {
PostgresPluginPackageMaterializedRevisionRepository,
PostgresPluginPackageSecretBindingRepository,
PostgresPluginPackageSecretBindingActivationPrerequisite,
PostgresPluginPackageSecretBindingTransitionRepository,
PostgresPluginPackagePublisherProvenanceRepository,
PostgresPluginPackageTaskReconciliationRepository,
PostgresProjectToolDefinitionSnapshotRepository,
@@ -307,6 +308,16 @@ export async function recoverClusterPluginPackages(
clusterIdentity: options.clusterIdentity,
namespace: options.namespace,
now: options.now,
secretProjection: {
sourceSecretName: 'ql3-cluster-plugin-package-values',
bindings: new PostgresPluginPackageSecretBindingRepository(
database.pool,
),
transitions:
new PostgresPluginPackageSecretBindingTransitionRepository(
database.pool,
),
},
},
);
const resourceByteSource =
@@ -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 {
createPluginPackageResourceGeneration,
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
const {
createPluginPackageSecretBinding,
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
const {
createPluginPackageSecretBindingTransitionPlan,
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
const {
createPluginPackageSecretBindingFromTransitionPlan,
createPluginPackageSecretBindingTransitionReceipt,
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
const {
secretProjectionFileName,
} = require('@qinglong/runtime-core/secret-projection');
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
const {
PLUGIN_PACKAGE_API_VERSION,
PLUGIN_PACKAGE_KIND,
@@ -28,6 +42,7 @@ const {
} = require('@qinglong/runtime-core/plugin-package-recovery');
const {
PluginPackageKubernetesActivationPublisher,
pluginPackageKubernetesProjectedSecretWorkloadVolume,
} = require('../dist/plugin-package/recovery/pluginPackageKubernetesActivation');
function apiError(code) {
@@ -298,11 +313,167 @@ function publisher(api = new FakeConfigMapApi(), overrides = {}) {
nowCalls += 1;
return overrides.now?.() ?? 500 + nowCalls;
},
...(overrides.secretProjection === undefined
? {}
: { secretProjection: overrides.secretProjection }),
},
);
return { api, publisher: value, nowCalls: () => nowCalls };
}
function secretManifest(version, secrets) {
return {
apiVersion: PLUGIN_PACKAGE_API_VERSION,
kind: PLUGIN_PACKAGE_KIND,
metadata: {
name: 'example-monitor',
displayName: 'Example Monitor',
version,
description: 'Kubernetes Secret projection fixture',
license: 'Apache-2.0',
},
spec: {
compatibility: {
qinglong: '>=3.0.0-0 <4.0.0',
architectures: ['arm64'],
deploymentProfiles: ['cluster-control'],
},
runtimes: [],
resources: {
memory: { recommended: '16Mi' },
disk: { install: '4Mi', working: '16Mi' },
},
permissions: {
network: { allowedHosts: [] },
secrets,
tools: secrets.length === 0 ? [] : ['secret.use'],
},
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
},
};
}
function projectedTransition(kind) {
const previousManifest = secretManifest('1.0.0', [
{ name: 'TOKEN', required: true },
]);
const previousGeneration = createPluginPackageResourceGeneration({
installationId: 'install-secret-v1',
projectId: 'default',
packageName: 'example-monitor',
lockDigest: '1'.repeat(64),
generation: 1,
previousActiveLockDigest: null,
contentDigest: '2'.repeat(64),
contents: previousManifest.spec.contents,
});
const previousBinding = createPluginPackageSecretBinding({
generation: previousGeneration,
manifest: previousManifest,
assignments: [
{
name: 'TOKEN',
secretRef: createSecretRef({
projectId: 'default',
name: 'token',
version: 1,
}),
},
],
authority: {
kind: 'approved-action-execution',
evidenceDigest: '3'.repeat(64),
},
boundAtMs: 10,
});
const previousActivation = intent({
installationId: previousGeneration.installationId,
lockDigest: previousGeneration.lockDigest,
targetGeneration: previousGeneration.generation,
previousActiveLockDigest: null,
contentDigest: previousGeneration.contentDigest,
resourceGeneration: previousGeneration,
intentDigest: kind === 'revoke' ? 'b'.repeat(64) : 'c'.repeat(64),
});
const nextManifest =
kind === 'revoke'
? secretManifest('2.0.0', [])
: secretManifest('2.0.0', [{ name: 'TOKEN', required: true }]);
const nextGeneration = createPluginPackageResourceGeneration({
installationId: `install-secret-${kind}`,
projectId: 'default',
packageName: 'example-monitor',
lockDigest: kind === 'revoke' ? '4'.repeat(64) : '5'.repeat(64),
generation: 2,
previousActiveLockDigest: previousBinding.target.lockDigest,
contentDigest: kind === 'revoke' ? '6'.repeat(64) : '7'.repeat(64),
contents: nextManifest.spec.contents,
});
const secretRef = createSecretRef({
projectId: 'default',
name: 'token',
version: 2,
});
const plan = createPluginPackageSecretBindingTransitionPlan({
previousTarget: previousBinding.target,
previousBinding,
previousAttemptGeneration: 1,
nextGeneration,
nextManifest,
assignments: kind === 'revoke' ? [] : [{ name: 'TOKEN', secretRef }],
plannedAtMs: 20,
});
const binding = createPluginPackageSecretBindingFromTransitionPlan(
plan,
'approved-action-execution',
'8'.repeat(64),
30,
);
const receipt = createPluginPackageSecretBindingTransitionReceipt({
transitionPlan: plan,
authority: {
kind: 'approved-action-execution',
evidenceDigest: '8'.repeat(64),
},
binding,
committedAtMs: 30,
});
const activation = intent({
installationId: nextGeneration.installationId,
lockDigest: nextGeneration.lockDigest,
targetGeneration: nextGeneration.generation,
previousActiveLockDigest: nextGeneration.previousActiveLockDigest,
contentDigest: nextGeneration.contentDigest,
resourceGeneration: nextGeneration,
intentDigest: kind === 'revoke' ? '9'.repeat(64) : 'a'.repeat(64),
});
return { previousActivation, activation, binding, receipt, secretRef };
}
function projectionSource(value) {
return {
sourceSecretName: 'ql3-cluster-plugin-package-values',
bindings: {
async find(generationDigest) {
assert.equal(
generationDigest,
value.activation.resourceGeneration.generationDigest,
);
return value.binding;
},
},
transitions: {
async find(generationDigest) {
assert.equal(
generationDigest,
value.activation.resourceGeneration.generationDigest,
);
return value.receipt;
},
},
};
}
test('publishes one resourceVersion-fenced ConfigMap and exact replays it', async () => {
const fixture = publisher();
const value = intent();
@@ -391,6 +562,195 @@ test('replaces only the exact previous lock and rejects a stale writer', async (
);
});
test('publishes a content-blind v3 projection for an approved Secret rotation', async () => {
const value = projectedTransition('rotate');
const api = new FakeConfigMapApi();
await publisher(api).publisher.publish(value.previousActivation);
const fixture = publisher(api, {
secretProjection: projectionSource(value),
});
await fixture.publisher.publish(value.activation);
const deployment = await fixture.publisher.findActiveDeployment(
'default',
'example-monitor',
);
assert.equal(
deployment.resourceGeneration.generationDigest,
value.activation.resourceGeneration.generationDigest,
);
assert.deepEqual(deployment.secretProjection.assignments, [
{
name: 'TOKEN',
required: true,
path: secretProjectionFileName(value.secretRef),
},
]);
assert.deepEqual(deployment.secretProjection.items, [
{
key: secretProjectionFileName(value.secretRef),
path: secretProjectionFileName(value.secretRef),
},
]);
assert.equal(deployment.secretProjection.defaultMode, 0o440);
assert.equal(
deployment.secretProjection.bindingDigest,
value.binding.bindingDigest,
);
assert.equal(
deployment.secretProjection.transitionReceiptDigest,
value.receipt.receiptDigest,
);
const [stored] = fixture.api.items.values();
const pointer = JSON.parse(stored.data['active.json']);
assert.equal(pointer.schema.endsWith('@v3'), true);
assert.equal(
stored.metadata.labels['qinglong.io/plugin-package-active'],
'v3',
);
assert.equal(JSON.stringify(pointer).includes(value.secretRef), false);
assert.deepEqual(
pluginPackageKubernetesProjectedSecretWorkloadVolume(
deployment.secretProjection,
),
{
volume: {
name: 'plugin-package-values',
secret: {
secretName: 'ql3-cluster-plugin-package-values',
optional: false,
defaultMode: 0o440,
items: [
{
key: secretProjectionFileName(value.secretRef),
path: secretProjectionFileName(value.secretRef),
},
],
},
},
volumeMount: {
name: 'plugin-package-values',
mountPath: '/var/run/secrets/qinglong3/plugin-package-values',
readOnly: true,
},
},
);
});
test('publishes an explicit empty projection for revoke and rejects projection drift', async () => {
const value = projectedTransition('revoke');
const api = new FakeConfigMapApi();
await publisher(api).publisher.publish(value.previousActivation);
const fixture = publisher(api, {
secretProjection: projectionSource(value),
});
await fixture.publisher.publish(value.activation);
const deployment = await fixture.publisher.findActiveDeployment(
'default',
'example-monitor',
);
assert.deepEqual(deployment.secretProjection.items, []);
assert.deepEqual(deployment.secretProjection.assignments, []);
assert.equal(deployment.secretProjection.bindingDigest, null);
assert.equal(
deployment.secretProjection.transitionReceiptDigest,
value.receipt.receiptDigest,
);
assert.equal(
pluginPackageKubernetesProjectedSecretWorkloadVolume(
deployment.secretProjection,
),
null,
);
const [key, stored] = fixture.api.items.entries().next().value;
const pointer = JSON.parse(stored.data['active.json']);
pointer.secretProjection.projectionDigest = '0'.repeat(64);
fixture.api.items.set(key, {
...stored,
data: { 'active.json': `${JSON.stringify(pointer)}\n` },
});
await assert.rejects(
fixture.publisher.findActiveDeployment('default', 'example-monitor'),
PluginPackageActivationConflictError,
);
});
test('converges a lost v3 replacement response without republishing projection', async () => {
const value = projectedTransition('rotate');
const api = new FakeConfigMapApi();
await publisher(api).publisher.publish(value.previousActivation);
api.loseReplaceResponse = true;
const fixture = publisher(api, {
secretProjection: projectionSource(value),
});
await assert.rejects(
fixture.publisher.publish(value.activation),
PluginPackageActivationUnavailableError,
);
assert.equal(api.replaceCalls, 1);
assert.equal(
(await fixture.publisher.inspect(value.activation)).status,
'published',
);
await fixture.publisher.publish(value.activation);
assert.equal(api.replaceCalls, 1);
assert.equal(fixture.nowCalls(), 1);
});
test('does not switch the active pointer when projection evidence is unavailable', async () => {
const value = projectedTransition('rotate');
const api = new FakeConfigMapApi();
await publisher(api).publisher.publish(value.previousActivation);
const previousPointer = structuredClone([...api.items.values()][0]);
const fixture = publisher(api, {
secretProjection: {
...projectionSource(value),
transitions: {
async find() {
throw new Error('database unavailable');
},
},
},
});
await assert.rejects(
fixture.publisher.publish(value.activation),
PluginPackageActivationUnavailableError,
);
assert.deepEqual([...api.items.values()][0], previousPointer);
assert.equal(fixture.api.replaceCalls, 0);
assert.equal(fixture.nowCalls(), 0);
});
test('keeps a staged upgrade without Secret facts on the compatible v2 pointer', async () => {
const value = projectedTransition('rotate');
const api = new FakeConfigMapApi();
await publisher(api).publisher.publish(value.previousActivation);
const fixture = publisher(api, {
secretProjection: {
...projectionSource(value),
bindings: {
async find() {
return null;
},
},
transitions: {
async find() {
return null;
},
},
},
});
await fixture.publisher.publish(value.activation);
const deployment = await fixture.publisher.findActiveDeployment(
'default',
'example-monitor',
);
assert.equal(deployment.secretProjection, null);
const pointer = JSON.parse([...api.items.values()][0].data['active.json']);
assert.equal(pointer.schema.endsWith('@v2'), true);
assert.equal(Object.hasOwn(pointer, 'secretProjection'), false);
});
test('leaves response loss for recovery inspection without republishing', async () => {
const api = new FakeConfigMapApi();
api.loseCreateResponse = true;
@@ -200,6 +200,12 @@ function runtimePrivileges() {
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [true, false, false, false],
plugin_package_secret_binding_approval_plans: [false, false, false, false],
plugin_package_secret_binding_transition_approval_plans: [
false,
false,
false,
false,
],
plugin_package_secret_bindings: [false, false, false, false],
plugin_package_secret_binding_transition_receipts: [
false,
@@ -114,6 +114,12 @@ function runtimePrivileges() {
plugin_package_automation_disposition_events: [false, false, false, false],
plugin_package_automation_publication_heads: [true, false, false, false],
plugin_package_secret_binding_approval_plans: [false, false, false, false],
plugin_package_secret_binding_transition_approval_plans: [
false,
false,
false,
false,
],
plugin_package_secret_bindings: [false, false, false, false],
plugin_package_secret_binding_transition_receipts: [
false,
@@ -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 {
createPluginPackageResourceGeneration,
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
const {
createPluginPackageSecretBindingTarget,
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
const {
createPluginPackageSecretBindingTransitionPlan,
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-plan');
const {
createPluginPackageSecretBindingFromTransitionPlan,
createPluginPackageSecretBindingTransitionReceipt,
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt');
const {
secretProjectionFileName,
} = require('@qinglong/runtime-core/secret-projection');
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
const {
PluginPackageKubernetesActivationPublisher,
pluginPackageKubernetesProjectedSecretWorkloadVolume,
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-activation');
const TOKEN_FILE = '/var/run/secrets/kubernetes.io/serviceaccount/token';
@@ -119,6 +134,83 @@ function activationIntent(overrides = {}) {
});
}
function manifest(version, secrets) {
return Object.freeze({
apiVersion: 'qinglong.io/v1alpha1',
kind: 'Package',
metadata: Object.freeze({
name: 'live-cas-package',
displayName: 'Live CAS Package',
version,
description: 'Real Kubernetes Secret projection CAS gate',
license: 'Apache-2.0',
}),
spec: Object.freeze({
compatibility: Object.freeze({
qinglong: '>=3.0.0-0 <4.0.0',
architectures: Object.freeze(['arm64']),
deploymentProfiles: Object.freeze(['cluster-control']),
}),
runtimes: Object.freeze([]),
resources: Object.freeze({
memory: Object.freeze({ recommended: '16Mi' }),
disk: Object.freeze({ install: '4Mi', working: '16Mi' }),
}),
permissions: Object.freeze({
network: Object.freeze({ allowedHosts: Object.freeze([]) }),
secrets: Object.freeze(secrets),
tools: Object.freeze(secrets.length === 0 ? [] : ['secret.use']),
}),
contents: Object.freeze({
tasks: Object.freeze(['tasks/live.yaml']),
workflows: Object.freeze([]),
prompts: Object.freeze([]),
tools: Object.freeze([]),
}),
}),
});
}
function transitionEvidence(initial, candidate) {
const secretRef = createSecretRef({
projectId: 'default',
name: `live-token-${ACTOR}`,
version: 2,
});
const previousManifest = manifest('1.0.0', []);
const nextManifest = manifest('2.0.0', [
Object.freeze({ name: 'TOKEN', required: true }),
]);
const plan = createPluginPackageSecretBindingTransitionPlan({
previousTarget: createPluginPackageSecretBindingTarget(
initial.resourceGeneration,
previousManifest,
),
previousBinding: null,
previousAttemptGeneration: 1,
nextGeneration: candidate.resourceGeneration,
nextManifest,
assignments: [Object.freeze({ name: 'TOKEN', secretRef })],
plannedAtMs: 100,
});
const binding = createPluginPackageSecretBindingFromTransitionPlan(
plan,
'approved-action-execution',
candidate.stageEvidenceDigest,
200,
);
const receipt = createPluginPackageSecretBindingTransitionReceipt({
transitionPlan: plan,
authority: Object.freeze({
kind: 'approved-action-execution',
evidenceDigest: candidate.stageEvidenceDigest,
}),
binding,
committedAtMs: 200,
});
return Object.freeze({ secretRef, binding, receipt });
}
function exactEvidence(intent) {
return Object.freeze({
lockDigest: intent.lockDigest,
@@ -222,29 +314,32 @@ async function main() {
};
let nowCalls = 0;
const publisher = new PluginPackageKubernetesActivationPublisher(
activationApi,
{ verify: async (intent) => exactEvidence(intent) },
{
clusterIdentity: 'ql3-plugin-package-live-cluster',
namespace: NAMESPACE,
now() {
nowCalls += 1;
return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls;
const createPublisher = (secretProjection) =>
new PluginPackageKubernetesActivationPublisher(
activationApi,
{ verify: async (intent) => exactEvidence(intent) },
{
clusterIdentity: 'ql3-plugin-package-live-cluster',
namespace: NAMESPACE,
now() {
nowCalls += 1;
return (ACTOR === 'a' ? 1_000 : 2_000) + nowCalls;
},
...(secretProjection === undefined ? {} : { secretProjection }),
},
},
);
);
const initial = activationIntent();
const initialPublisher = createPublisher();
let responseLoss = null;
if (ACTOR === 'a') {
await assert.rejects(
publisher.publish(initial),
initialPublisher.publish(initial),
PluginPackageActivationUnavailableError,
);
const observation = await publisher.inspect(initial);
const observation = await initialPublisher.inspect(initial);
assert.equal(observation.status, 'published');
const replay = await publisher.publish(initial);
const replay = await initialPublisher.publish(initial);
assert.deepEqual(replay, observation.receipt);
assert.equal(createCalls, 1);
assert.equal(nowCalls, 1);
@@ -258,7 +353,7 @@ async function main() {
});
} else {
await waitFor('initial active pointer', async () => {
const observation = await publisher.inspect(initial);
const observation = await initialPublisher.inspect(initial);
return {
ready: observation.status === 'published',
fact: observation.status,
@@ -272,6 +367,12 @@ async function main() {
targetGeneration: 2,
previousActiveLockDigest: INITIAL_LOCK_DIGEST,
});
const transition = transitionEvidence(initial, candidate);
const publisher = createPublisher({
sourceSecretName: 'ql3-cluster-plugin-package-values',
bindings: { find: async () => transition.binding },
transitions: { find: async () => transition.receipt },
});
let outcome;
try {
const receipt = await publisher.publish(candidate);
@@ -310,6 +411,25 @@ async function main() {
name: targetName,
});
const finalPointer = JSON.parse(finalConfigMap.data['active.json']);
assert.equal(finalPointer.schema.endsWith('@v3'), true);
assert.equal(finalPointer.secretProjection.defaultMode, 0o440);
assert.match(
finalPointer.secretProjection.transitionReceiptDigest,
/^[0-9a-f]{64}$/,
);
const projectionPath = secretProjectionFileName(transition.secretRef);
const winnerUsesThisActor =
finalPointer.intent.lockDigest === candidate.lockDigest;
if (winnerUsesThisActor) {
assert.deepEqual(finalPointer.secretProjection.items, [
{ key: projectionPath, path: projectionPath },
]);
}
const workloadVolume = pluginPackageKubernetesProjectedSecretWorkloadVolume(
finalPointer.secretProjection,
);
assert.equal(workloadVolume.volume.secret.optional, false);
assert.equal(workloadVolume.volume.secret.defaultMode, 0o440);
const rbac = Object.freeze({
listConfigMaps: await expectForbidden(() =>
@@ -335,25 +455,34 @@ async function main() {
),
});
process.stdout.write(
`${JSON.stringify({
schema: RESULT_SCHEMA,
actor: ACTOR,
serviceAccountTokenMounted: true,
responseLoss,
cas: {
...outcome,
attemptedResourceVersion: replaceResourceVersion,
replaceCalls,
},
final: {
resourceVersion: finalConfigMap.metadata.resourceVersion,
lockDigest: finalPointer.intent.lockDigest,
generation: finalPointer.receipt.generation,
},
rbac,
})}\n`,
);
const result = JSON.stringify({
schema: RESULT_SCHEMA,
actor: ACTOR,
serviceAccountTokenMounted: true,
responseLoss,
cas: {
...outcome,
attemptedResourceVersion: replaceResourceVersion,
replaceCalls,
},
final: {
resourceVersion: finalConfigMap.metadata.resourceVersion,
lockDigest: finalPointer.intent.lockDigest,
generation: finalPointer.receipt.generation,
pointerSchema: finalPointer.schema,
projectionDigest: finalPointer.secretProjection.projectionDigest,
transitionReceiptDigest:
finalPointer.secretProjection.transitionReceiptDigest,
projectionItemCount: finalPointer.secretProjection.items.length,
projectedWorkloadVolume: workloadVolume !== null,
},
rbac,
});
fs.writeFileSync('/dev/termination-log', result, {
encoding: 'utf8',
flag: 'w',
});
process.stdout.write(`${result}\n`);
}
main().catch((error) => {
@@ -505,6 +505,17 @@ async function main() {
);
assert.equal(winner.final.generation, 2);
assert.equal(winner.final.resourceVersion, loser.final.resourceVersion);
assert.equal(winner.final.pointerSchema, loser.final.pointerSchema);
assert.equal(winner.final.projectionDigest, loser.final.projectionDigest);
assert.equal(
winner.final.transitionReceiptDigest,
loser.final.transitionReceiptDigest,
);
assert.match(winner.final.projectionDigest, /^[0-9a-f]{64}$/);
assert.match(winner.final.transitionReceiptDigest, /^[0-9a-f]{64}$/);
assert.equal(winner.final.pointerSchema.endsWith('@v3'), true);
assert.equal(winner.final.projectionItemCount, 1);
assert.equal(winner.final.projectedWorkloadVolume, true);
const activePointers = kubectlJson([
'-n',
@@ -512,7 +523,7 @@ async function main() {
'get',
'configmaps',
'-l',
'qinglong.io/plugin-package-active=v2',
'qinglong.io/plugin-package-active=v3',
]).items;
assert.equal(activePointers.length, 1);
const barriers = kubectlJson([
@@ -582,6 +593,15 @@ async function main() {
winner: winner.actor,
activePointers: activePointers.length,
},
secretProjection: {
schema: winner.final.pointerSchema,
projectionDigest: winner.final.projectionDigest,
transitionReceiptDigest: winner.final.transitionReceiptDigest,
itemCount: winner.final.projectionItemCount,
defaultMode: 0o440,
workloadVolumeRendered: winner.final.projectedWorkloadVolume,
secretApiReadRequired: false,
},
rbac,
sideEffects: {
activePointers: activePointers.length,
@@ -599,6 +619,9 @@ async function main() {
concurrentReplacementSingleWinner: true,
loserObservedConflict: true,
finalPointerExactlyOne: true,
transitionReceiptBoundToV3Pointer: true,
exactSecretProjectionItemPublished: true,
projectedWorkloadVolumeUses0440: true,
configMapGetCreateUpdateAllowed: true,
configMapListDeleteDenied: true,
secretReadCreateDenied: true,
@@ -609,7 +632,9 @@ async function main() {
limitations: [
'response loss is injected after an API-confirmed create at the Kubernetes client boundary, not by dropping raw network packets',
'single-control-plane Kind proves API-server resourceVersion and RBAC semantics, not Kubernetes control-plane HA',
'the gate isolates ConfigMap publication authority and does not exercise PostgreSQL or OCI registry recovery',
'the gate uses in-memory content-blind binding/transition sources; durable PostgreSQL transition authority is proven independently',
'the gate renders the exact Secret volume source but deliberately does not create or read Secret material',
'the gate isolates ConfigMap publication authority and does not exercise OCI registry recovery',
],
},
null,
+2 -2
View File
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
},
{
sourceFiles: 106,
sourceFiles: 107,
rootSourceFiles: 1,
rootSourceLines: 61,
nestedSourceFiles: 105,
nestedSourceFiles: 106,
rootSourceFileRoles: {
'modelInvocationMigrationCli.ts': 'binary_entry',
},
@@ -63,7 +63,10 @@ test('live actors fence the same resourceVersion and recover one lost create res
actorSource,
/injected response loss after Kubernetes API-confirmed create/,
);
assert.match(actorSource, /publisher\.inspect\(initial\)/);
assert.match(actorSource, /initialPublisher\.inspect\(initial\)/);
assert.match(actorSource, /publisher\.publish\(candidate\)/);
assert.match(actorSource, /finalPointer\.schema\.endsWith\('@v3'\)/);
assert.match(actorSource, /finalPointer\.secretProjection\.items/);
assert.match(actorSource, /assert\.equal\(createCalls, 1\)/);
assert.match(actorSource, /ql3-live-cas-ready-\$\{ACTOR\}/);
assert.match(hostSource, /sameResourceVersionAttemptedByBothPods: true/);