mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 20:15:19 +08:00
feat(ql3): isolate plugin secret action execution
This commit is contained in:
@@ -65,8 +65,6 @@ spec:
|
|||||||
value: '16'
|
value: '16'
|
||||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
||||||
value: '16'
|
value: '16'
|
||||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT
|
|
||||||
value: /var/run/secrets/qinglong3/plugin-package-values
|
|
||||||
- name: QL3_POSTGRES_TLS_MODE
|
- name: QL3_POSTGRES_TLS_MODE
|
||||||
value: verify-full
|
value: verify-full
|
||||||
- name: QL3_POSTGRES_TLS_CA_FILE
|
- name: QL3_POSTGRES_TLS_CA_FILE
|
||||||
@@ -98,9 +96,6 @@ spec:
|
|||||||
- name: postgres-package-executor-ca
|
- name: postgres-package-executor-ca
|
||||||
mountPath: /var/run/secrets/qinglong3/postgres-package-executor
|
mountPath: /var/run/secrets/qinglong3/postgres-package-executor
|
||||||
readOnly: true
|
readOnly: true
|
||||||
- name: plugin-package-values
|
|
||||||
mountPath: /var/run/secrets/qinglong3/plugin-package-values
|
|
||||||
readOnly: true
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir:
|
emptyDir:
|
||||||
@@ -113,8 +108,3 @@ spec:
|
|||||||
items:
|
items:
|
||||||
- key: postgres-ca.crt
|
- key: postgres-ca.crt
|
||||||
path: ca.crt
|
path: ca.crt
|
||||||
- name: plugin-package-values
|
|
||||||
secret:
|
|
||||||
secretName: ql3-cluster-plugin-package-values
|
|
||||||
optional: true
|
|
||||||
defaultMode: 288
|
|
||||||
|
|||||||
-1
@@ -10,7 +10,6 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
podSelector:
|
podSelector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app.kubernetes.io/name: ql3-plugin-package-executor
|
|
||||||
app.kubernetes.io/component: plugin-package-executor
|
app.kubernetes.io/component: plugin-package-executor
|
||||||
policyTypes:
|
policyTypes:
|
||||||
- Ingress
|
- Ingress
|
||||||
|
|||||||
-2
@@ -17,8 +17,6 @@
|
|||||||
value: '16'
|
value: '16'
|
||||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES
|
||||||
value: '16'
|
value: '16'
|
||||||
- name: QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT
|
|
||||||
value: /var/run/secrets/qinglong3/plugin-package-values
|
|
||||||
- name: QL3_POSTGRES_TLS_MODE
|
- name: QL3_POSTGRES_TLS_MODE
|
||||||
value: verify-full
|
value: verify-full
|
||||||
- name: QL3_POSTGRES_TLS_CA_FILE
|
- name: QL3_POSTGRES_TLS_CA_FILE
|
||||||
|
|||||||
+3
-2
@@ -11,8 +11,9 @@ stringData:
|
|||||||
postgres-tls-servername: REPLACE_WITH_POSTGRES_DNS_NAME
|
postgres-tls-servername: REPLACE_WITH_POSTGRES_DNS_NAME
|
||||||
postgres-ca.crt: REPLACE_WITH_POSTGRES_CA_CERTIFICATE
|
postgres-ca.crt: REPLACE_WITH_POSTGRES_CA_CERTIFICATE
|
||||||
---
|
---
|
||||||
# Optional SecretRef projection. Each key is the lowercase SHA-256 of the
|
# Source for action-scoped Secret projection Jobs. The batch executor never
|
||||||
# canonical qlsecret:v1 reference; the executor checks metadata only.
|
# mounts this Secret. Each exact Job selects only the reviewed lowercase
|
||||||
|
# SHA-256 key(s) for one durable dispatch and checks metadata only.
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -40,3 +40,5 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首
|
|||||||
- 阶段完整性门已串行复验:18 个 `ql3-*` workspace package 的统一 clean build/test 退出 0;backend 共 1194 项,1192 pass、2 条条件 skip、0 fail;PostgreSQL 18.4 arm64 physical HA 完成 timeline `1→2` 提升并通过 125 项门禁,报告 SHA-256 为 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`。package boundary、cluster dependency 与 edge import 审计均无新增越界;这些证据证明当前 authority 与既有运行路径兼容,不替代尚未完成的真实 Kubernetes rotation/revoke 和固定低配物理设备验证。
|
- 阶段完整性门已串行复验:18 个 `ql3-*` workspace package 的统一 clean build/test 退出 0;backend 共 1194 项,1192 pass、2 条条件 skip、0 fail;PostgreSQL 18.4 arm64 physical HA 完成 timeline `1→2` 提升并通过 125 项门禁,报告 SHA-256 为 `a72477cfd40e9945fd97ed18dd014f4600e0049a29285990c6359054309db812`。package boundary、cluster dependency 与 edge import 审计均无新增越界;这些证据证明当前 authority 与既有运行路径兼容,不替代尚未完成的真实 Kubernetes rotation/revoke 和固定低配物理设备验证。
|
||||||
- Runtime Core 534/534、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1072 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。
|
- Runtime Core 534/534、Local SQLite 235/235、Local Admin 91/91、Local Owner CLI 165 pass/5 条 root-only skip、Local Application 47 pass/4 条平台 skip,合计 1072 pass/9 条条件 skip/0 fail;定向纵切面覆盖 staged 阻断、Owner plan/execute、binding+receipt 原子提交、跨时钟 replay 和 receipt 后恢复激活。
|
||||||
- Cluster management/executor 的 separation-of-duty 产品编排仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片把既有 package-manager Approval 与 package-executor 接入这条 durable authority,再进入 Kubernetes/低配现场门。
|
- Cluster management/executor 的 separation-of-duty 产品编排仍未消费 transition plan;真实 Kubernetes rotation/revoke、升级失败回滚及物理低配设备证据也仍待完成。因此 ADR 继续保持 Proposed,下一切片把既有 package-manager Approval 与 package-executor 接入这条 durable authority,再进入 Kubernetes/低配现场门。
|
||||||
|
- 后续切片已经完成 Cluster transition Approval/执行、Kubernetes active pointer v3 与三节点 rotation/revoke rollout 原语现场门;该历史未完成描述由本条取代。当前 executor 重构进一步增加 exact `dispatchById` start-barrier 路径和由 durable dispatch + approval plan 联合绑定的 action-scoped Job renderer。常规 batch CronJob 不再挂载 `ql3-cluster-plugin-package-values`,也不注册需要 Secret projection 的 handler;action Job 只投影单动作所需的去重 SHA-256 key,固定 `0440`、`optional:false`、digest 镜像、tokenless ServiceAccount、单连接 PostgreSQL 与 48 MiB request。零 Secret transition 使用空目录而不是空 Secret `items`。因此“整个 optional Secret 暴露给批处理 executor”的生产缺口已经失败关闭,但自动 controller 仍未启用:Kubernetes `jobs.create` 是可间接选择镜像/ServiceAccount/Secret 的放大权限,必须由 ValidatingAdmissionPolicy(或同等级外部 admission)把 renderer 契约固定后,才允许 create/get-only RBAC、响应丢失收敛与失败 Job 恢复。升级失败自动回滚、controller/admission/RBAC 真实门及固定物理低配证据继续阻断 ADR Accepted。
|
||||||
|
- 本阶段完整性门已闭合:18-package clean build/test 退出 0;backend 1195 项为 1193 pass、2 条条件 skip、0 fail;package boundary 仍为 18 个 package 且无 single-source/shallow-source package,cluster dependency、edge import、cluster deployment 均零 finding。Edge arm64 本机观测模块加载 RSS `+8,945,664` bytes、1 万行输出峰值 `+5,226,496` bytes,不冒充固定物理低配门。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `45fab400eb449774d50429103dd766a2755166530ac54ddc1056f777bc16c15f`,临时 Docker 资源已清理。
|
||||||
|
|||||||
@@ -220,6 +220,11 @@
|
|||||||
"require": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js",
|
"require": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js",
|
||||||
"default": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js"
|
"default": "./dist/plugin-package/executor/pluginPackageExecutorProcess.js"
|
||||||
},
|
},
|
||||||
|
"./plugin-package-kubernetes-secret-action-job": {
|
||||||
|
"types": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.d.ts",
|
||||||
|
"require": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js",
|
||||||
|
"default": "./dist/plugin-package/executor/pluginPackageKubernetesSecretActionJob.js"
|
||||||
|
},
|
||||||
"./prompt-output-gc-process": {
|
"./prompt-output-gc-process": {
|
||||||
"types": "./dist/prompt-output/retention/promptOutputGcProcess.d.ts",
|
"types": "./dist/prompt-output/retention/promptOutputGcProcess.d.ts",
|
||||||
"require": "./dist/prompt-output/retention/promptOutputGcProcess.js",
|
"require": "./dist/prompt-output/retention/promptOutputGcProcess.js",
|
||||||
|
|||||||
+17
-11
@@ -40,7 +40,7 @@ export interface ClusterPluginPackageApprovedActionDispatcherOptions
|
|||||||
readonly defaultBatchSize?: number;
|
readonly defaultBatchSize?: number;
|
||||||
readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort;
|
readonly publisherRevocations?: ClusterPluginPackagePublisherRevocationExecutionPort;
|
||||||
readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort;
|
readonly publisherTrustTransitions?: ClusterPluginPackagePublisherTrustTransitionExecutionPort;
|
||||||
readonly secretExistenceInspector: PluginPackageSecretExistenceInspector;
|
readonly secretExistenceInspector?: PluginPackageSecretExistenceInspector;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createClusterPluginPackageApprovedActionDispatcher(
|
export function createClusterPluginPackageApprovedActionDispatcher(
|
||||||
@@ -64,16 +64,22 @@ export function createClusterPluginPackageApprovedActionDispatcher(
|
|||||||
);
|
);
|
||||||
const handlers = [
|
const handlers = [
|
||||||
installHandler,
|
installHandler,
|
||||||
new ClusterPluginPackageSecretBindingApprovedActionHandler(
|
...(secretExistenceInspector
|
||||||
new PostgresPluginPackageSecretBindingApprovalPlanReader(pool),
|
? [
|
||||||
new PostgresPluginPackageSecretBindingRepository(pool),
|
new ClusterPluginPackageSecretBindingApprovedActionHandler(
|
||||||
secretExistenceInspector,
|
new PostgresPluginPackageSecretBindingApprovalPlanReader(pool),
|
||||||
),
|
new PostgresPluginPackageSecretBindingRepository(pool),
|
||||||
new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler(
|
secretExistenceInspector,
|
||||||
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(pool),
|
),
|
||||||
new PostgresPluginPackageSecretBindingTransitionRepository(pool),
|
new ClusterPluginPackageSecretBindingTransitionApprovedActionHandler(
|
||||||
secretExistenceInspector,
|
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
||||||
),
|
pool,
|
||||||
|
),
|
||||||
|
new PostgresPluginPackageSecretBindingTransitionRepository(pool),
|
||||||
|
secretExistenceInspector,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(['overlap_add', 'safe_retire'] as const).map(
|
...(['overlap_add', 'safe_retire'] as const).map(
|
||||||
(mode) =>
|
(mode) =>
|
||||||
new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler(
|
new ClusterPluginPackagePublisherTrustTransitionApprovedActionHandler(
|
||||||
|
|||||||
+64
-15
@@ -64,6 +64,7 @@ export type ClusterPluginPackageExecutorProcessConfig =
|
|||||||
leaseDurationMs: number;
|
leaseDurationMs: number;
|
||||||
revocationPageSize: number;
|
revocationPageSize: number;
|
||||||
revocationMaxPages: number;
|
revocationMaxPages: number;
|
||||||
|
dispatchId: string | null;
|
||||||
secretProjectionRoot: string | null;
|
secretProjectionRoot: string | null;
|
||||||
database: Readonly<{
|
database: Readonly<{
|
||||||
connection: PostgresConnectionOptions;
|
connection: PostgresConnectionOptions;
|
||||||
@@ -111,6 +112,9 @@ export interface RunClusterPluginPackageExecutorProcessOptions {
|
|||||||
readonly createDispatcher?: (
|
readonly createDispatcher?: (
|
||||||
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
options: ClusterPluginPackageApprovedActionDispatcherOptions,
|
||||||
) => ApprovedActionDispatcher;
|
) => ApprovedActionDispatcher;
|
||||||
|
readonly assertReady?: (
|
||||||
|
pool: PostgresPool,
|
||||||
|
) => Promise<PostgresSchemaReadinessReport>;
|
||||||
readonly now?: () => number;
|
readonly now?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +130,7 @@ export class ClusterPluginPackageExecutorProcessConfigError extends TypeError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
const SAFE_OWNER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
const SAFE_DISPATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
|
|
||||||
function enabledValue(
|
function enabledValue(
|
||||||
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
environment: ClusterPluginPackageExecutorProcessEnvironment,
|
||||||
@@ -319,6 +324,17 @@ export function loadClusterPluginPackageExecutorProcessConfig(
|
|||||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER is invalid',
|
'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER is invalid',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const dispatchId =
|
||||||
|
boundedValue(
|
||||||
|
environment,
|
||||||
|
'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID',
|
||||||
|
128,
|
||||||
|
) ?? null;
|
||||||
|
if (dispatchId !== null && !SAFE_DISPATCH_ID.test(dispatchId)) {
|
||||||
|
throw new ClusterPluginPackageExecutorProcessConfigError(
|
||||||
|
'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID is invalid',
|
||||||
|
);
|
||||||
|
}
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
owner,
|
owner,
|
||||||
@@ -364,6 +380,7 @@ export function loadClusterPluginPackageExecutorProcessConfig(
|
|||||||
1,
|
1,
|
||||||
64,
|
64,
|
||||||
),
|
),
|
||||||
|
dispatchId,
|
||||||
secretProjectionRoot: secretProjectionRoot(environment),
|
secretProjectionRoot: secretProjectionRoot(environment),
|
||||||
database: databaseConfig(environment),
|
database: databaseConfig(environment),
|
||||||
});
|
});
|
||||||
@@ -391,6 +408,22 @@ function isIdleBatch(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptyApprovalSummary(): Readonly<{
|
||||||
|
scanned: 0;
|
||||||
|
consumed: 0;
|
||||||
|
existing: 0;
|
||||||
|
expired: 0;
|
||||||
|
blocked: 0;
|
||||||
|
}> {
|
||||||
|
return Object.freeze({
|
||||||
|
scanned: 0,
|
||||||
|
consumed: 0,
|
||||||
|
existing: 0,
|
||||||
|
expired: 0,
|
||||||
|
blocked: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function runClusterPluginPackageExecutorProcess(
|
export async function runClusterPluginPackageExecutorProcess(
|
||||||
options: RunClusterPluginPackageExecutorProcessOptions,
|
options: RunClusterPluginPackageExecutorProcessOptions,
|
||||||
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
): Promise<ClusterPluginPackageExecutorProcessResult> {
|
||||||
@@ -411,6 +444,8 @@ export async function runClusterPluginPackageExecutorProcess(
|
|||||||
typeof options.consumeSecretBindingTransitionApprovals !== 'function') ||
|
typeof options.consumeSecretBindingTransitionApprovals !== 'function') ||
|
||||||
(options.createDispatcher !== undefined &&
|
(options.createDispatcher !== undefined &&
|
||||||
typeof options.createDispatcher !== 'function') ||
|
typeof options.createDispatcher !== 'function') ||
|
||||||
|
(options.assertReady !== undefined &&
|
||||||
|
typeof options.assertReady !== 'function') ||
|
||||||
(options.now !== undefined && typeof options.now !== 'function')
|
(options.now !== undefined && typeof options.now !== 'function')
|
||||||
) {
|
) {
|
||||||
throw new TypeError('Plugin Package executor process options are invalid');
|
throw new TypeError('Plugin Package executor process options are invalid');
|
||||||
@@ -430,9 +465,9 @@ export async function runClusterPluginPackageExecutorProcess(
|
|||||||
const database = await openDatabase();
|
const database = await openDatabase();
|
||||||
let failure: unknown;
|
let failure: unknown;
|
||||||
try {
|
try {
|
||||||
const evidence = await assertPostgresPackageExecutorSchemaReady(
|
const evidence = await (
|
||||||
database.pool,
|
options.assertReady ?? assertPostgresPackageExecutorSchemaReady
|
||||||
);
|
)(database.pool);
|
||||||
const dispatcherFactory =
|
const dispatcherFactory =
|
||||||
options.createDispatcher ??
|
options.createDispatcher ??
|
||||||
createClusterPluginPackageApprovedActionDispatcher;
|
createClusterPluginPackageApprovedActionDispatcher;
|
||||||
@@ -453,18 +488,14 @@ export async function runClusterPluginPackageExecutorProcess(
|
|||||||
owner: config.owner,
|
owner: config.owner,
|
||||||
leaseDurationMs: config.leaseDurationMs,
|
leaseDurationMs: config.leaseDurationMs,
|
||||||
defaultBatchSize: config.dispatchBatchSize,
|
defaultBatchSize: config.dispatchBatchSize,
|
||||||
secretExistenceInspector:
|
...(config.secretProjectionRoot === null
|
||||||
config.secretProjectionRoot === null
|
? {}
|
||||||
? Object.freeze({
|
: {
|
||||||
async assertExists(): Promise<never> {
|
secretExistenceInspector:
|
||||||
throw new Error(
|
new ProjectedPluginPackageSecretExistenceInspector({
|
||||||
'Plugin Package Secret projection is not configured',
|
rootDirectory: config.secretProjectionRoot,
|
||||||
);
|
}),
|
||||||
},
|
}),
|
||||||
})
|
|
||||||
: new ProjectedPluginPackageSecretExistenceInspector({
|
|
||||||
rootDirectory: config.secretProjectionRoot,
|
|
||||||
}),
|
|
||||||
...(options.now ? { clock: options.now } : {}),
|
...(options.now ? { clock: options.now } : {}),
|
||||||
publisherRevocations: {
|
publisherRevocations: {
|
||||||
async run(receipt) {
|
async run(receipt) {
|
||||||
@@ -486,6 +517,24 @@ export async function runClusterPluginPackageExecutorProcess(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const batches: Readonly<ClusterPluginPackageExecutorBatchResult>[] = [];
|
const batches: Readonly<ClusterPluginPackageExecutorBatchResult>[] = [];
|
||||||
|
if (config.dispatchId !== null) {
|
||||||
|
const dispatch = await dispatcher.dispatchById({
|
||||||
|
dispatchId: config.dispatchId,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
status: 'completed',
|
||||||
|
database: evidence,
|
||||||
|
batches: Object.freeze([
|
||||||
|
Object.freeze({
|
||||||
|
approvals: emptyApprovalSummary(),
|
||||||
|
trustTransitionApprovals: emptyApprovalSummary(),
|
||||||
|
secretBindingApprovals: emptyApprovalSummary(),
|
||||||
|
secretBindingTransitionApprovals: emptyApprovalSummary(),
|
||||||
|
dispatch,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
}
|
||||||
for (let index = 0; index < config.maxBatches; index += 1) {
|
for (let index = 0; index < config.maxBatches; index += 1) {
|
||||||
const approvals = await consumeApprovals({
|
const approvals = await consumeApprovals({
|
||||||
pool: database.pool,
|
pool: database.pool,
|
||||||
|
|||||||
+504
@@ -0,0 +1,504 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeApprovedActionDispatchRecord,
|
||||||
|
type ApprovedActionDispatchRecord,
|
||||||
|
} from '@qinglong/runtime-core/approved-action';
|
||||||
|
import {
|
||||||
|
PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA,
|
||||||
|
normalizePluginPackageSecretBindingApprovalPlan,
|
||||||
|
pluginPackageSecretBindingApprovedAction,
|
||||||
|
type PluginPackageSecretBindingApprovalPlan,
|
||||||
|
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
||||||
|
import {
|
||||||
|
normalizePluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
|
pluginPackageSecretBindingTransitionApprovedAction,
|
||||||
|
type PluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
|
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan';
|
||||||
|
import { secretProjectionFileName } from '@qinglong/runtime-core/secret-projection';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isPluginPackageKubernetesSecretName,
|
||||||
|
PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE,
|
||||||
|
} from '../secret-binding/pluginPackageKubernetesSecretProjection';
|
||||||
|
|
||||||
|
export const PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA =
|
||||||
|
'qinglong/plugin-package-kubernetes-secret-action-job@v1' as const;
|
||||||
|
export const PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT =
|
||||||
|
'/var/run/secrets/qinglong3/plugin-package-values' as const;
|
||||||
|
|
||||||
|
const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]{0,61}[a-z0-9])?$/;
|
||||||
|
const SECRET_KEY = /^[A-Za-z0-9._-]{1,253}$/;
|
||||||
|
const DATABASE_NAME = /^[A-Za-z_][A-Za-z0-9_$-]{0,62}$/;
|
||||||
|
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/;
|
||||||
|
const IMAGE_DIGEST =
|
||||||
|
/^[a-z0-9](?:[a-z0-9._:/-]{0,510}[a-z0-9])?@sha256:[0-9a-f]{64}$/;
|
||||||
|
const JOB_DIGEST_DOMAIN = Buffer.from(
|
||||||
|
'qinglong/plugin-package-kubernetes-secret-action-job-digest@v1\0',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
type SecretActionApprovalPlan =
|
||||||
|
| PluginPackageSecretBindingApprovalPlan
|
||||||
|
| PluginPackageSecretBindingTransitionApprovalPlan;
|
||||||
|
|
||||||
|
export type PluginPackageKubernetesPostgresConnection =
|
||||||
|
| Readonly<{
|
||||||
|
mode: 'url';
|
||||||
|
secretName: string;
|
||||||
|
urlKey: string;
|
||||||
|
}>
|
||||||
|
| Readonly<{
|
||||||
|
mode: 'fields';
|
||||||
|
authSecretName: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
database: string;
|
||||||
|
usernameKey: string;
|
||||||
|
passwordKey: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export interface PluginPackageKubernetesSecretActionJobOptions {
|
||||||
|
readonly namespace: string;
|
||||||
|
readonly serviceAccountName: string;
|
||||||
|
readonly sourceSecretName: string;
|
||||||
|
readonly image: string;
|
||||||
|
readonly postgres: Readonly<{
|
||||||
|
connection: PluginPackageKubernetesPostgresConnection;
|
||||||
|
caSecretName: string;
|
||||||
|
caKey: string;
|
||||||
|
servername: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPackageKubernetesSecretActionJobInput {
|
||||||
|
readonly dispatch: Readonly<ApprovedActionDispatchRecord>;
|
||||||
|
readonly approvalPlan: Readonly<SecretActionApprovalPlan>;
|
||||||
|
readonly options: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidPluginPackageKubernetesSecretActionJobError extends TypeError {
|
||||||
|
readonly code = 'PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_INVALID';
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`Plugin Package Kubernetes Secret action Job is invalid: ${message}`);
|
||||||
|
this.name = 'InvalidPluginPackageKubernetesSecretActionJobError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalid(message: string): never {
|
||||||
|
throw new InvalidPluginPackageKubernetesSecretActionJobError(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(value: unknown, label: string): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return invalid(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
keys: readonly string[],
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
const actual = Object.keys(value).sort();
|
||||||
|
const expected = [...keys].sort();
|
||||||
|
if (
|
||||||
|
actual.length !== expected.length ||
|
||||||
|
actual.some((key, index) => key !== expected[index])
|
||||||
|
) {
|
||||||
|
invalid(`${label} shape is invalid`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dnsLabel(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !DNS_LABEL.test(value)) {
|
||||||
|
return invalid(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function secretKey(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== 'string' || !SECRET_KEY.test(value)) {
|
||||||
|
return invalid(`${label} is invalid`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptions(
|
||||||
|
value: PluginPackageKubernetesSecretActionJobOptions,
|
||||||
|
): PluginPackageKubernetesSecretActionJobOptions {
|
||||||
|
const options = record(value, 'options');
|
||||||
|
exactKeys(
|
||||||
|
options,
|
||||||
|
[
|
||||||
|
'image',
|
||||||
|
'namespace',
|
||||||
|
'postgres',
|
||||||
|
'serviceAccountName',
|
||||||
|
'sourceSecretName',
|
||||||
|
],
|
||||||
|
'options',
|
||||||
|
);
|
||||||
|
const postgres = record(value.postgres, 'postgres');
|
||||||
|
exactKeys(
|
||||||
|
postgres,
|
||||||
|
['caKey', 'caSecretName', 'connection', 'servername'],
|
||||||
|
'postgres',
|
||||||
|
);
|
||||||
|
const connection = record(value.postgres.connection, 'connection');
|
||||||
|
if (connection.mode === 'url') {
|
||||||
|
exactKeys(connection, ['mode', 'secretName', 'urlKey'], 'connection');
|
||||||
|
} else if (connection.mode === 'fields') {
|
||||||
|
exactKeys(
|
||||||
|
connection,
|
||||||
|
[
|
||||||
|
'authSecretName',
|
||||||
|
'database',
|
||||||
|
'host',
|
||||||
|
'mode',
|
||||||
|
'passwordKey',
|
||||||
|
'port',
|
||||||
|
'usernameKey',
|
||||||
|
],
|
||||||
|
'connection',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return invalid('connection mode is invalid');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isPluginPackageKubernetesSecretName(value.sourceSecretName) ||
|
||||||
|
typeof value.image !== 'string' ||
|
||||||
|
!IMAGE_DIGEST.test(value.image) ||
|
||||||
|
!isPluginPackageKubernetesSecretName(value.postgres.caSecretName) ||
|
||||||
|
!DNS_NAME.test(value.postgres.servername)
|
||||||
|
) {
|
||||||
|
return invalid('options contain an invalid Kubernetes identity');
|
||||||
|
}
|
||||||
|
const normalizedConnection =
|
||||||
|
value.postgres.connection.mode === 'url'
|
||||||
|
? Object.freeze({
|
||||||
|
mode: 'url' as const,
|
||||||
|
secretName: dnsLabel(
|
||||||
|
value.postgres.connection.secretName,
|
||||||
|
'connection Secret name',
|
||||||
|
),
|
||||||
|
urlKey: secretKey(value.postgres.connection.urlKey, 'URL key'),
|
||||||
|
})
|
||||||
|
: Object.freeze({
|
||||||
|
mode: 'fields' as const,
|
||||||
|
authSecretName: dnsLabel(
|
||||||
|
value.postgres.connection.authSecretName,
|
||||||
|
'authentication Secret name',
|
||||||
|
),
|
||||||
|
host: DNS_NAME.test(value.postgres.connection.host)
|
||||||
|
? value.postgres.connection.host
|
||||||
|
: invalid('PostgreSQL host is invalid'),
|
||||||
|
port:
|
||||||
|
Number.isSafeInteger(value.postgres.connection.port) &&
|
||||||
|
value.postgres.connection.port >= 1 &&
|
||||||
|
value.postgres.connection.port <= 65_535
|
||||||
|
? value.postgres.connection.port
|
||||||
|
: invalid('PostgreSQL port is invalid'),
|
||||||
|
database: DATABASE_NAME.test(value.postgres.connection.database)
|
||||||
|
? value.postgres.connection.database
|
||||||
|
: invalid('PostgreSQL database is invalid'),
|
||||||
|
usernameKey: secretKey(
|
||||||
|
value.postgres.connection.usernameKey,
|
||||||
|
'username key',
|
||||||
|
),
|
||||||
|
passwordKey: secretKey(
|
||||||
|
value.postgres.connection.passwordKey,
|
||||||
|
'password key',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
namespace: dnsLabel(value.namespace, 'namespace'),
|
||||||
|
serviceAccountName: dnsLabel(
|
||||||
|
value.serviceAccountName,
|
||||||
|
'ServiceAccount name',
|
||||||
|
),
|
||||||
|
sourceSecretName: value.sourceSecretName,
|
||||||
|
image: value.image,
|
||||||
|
postgres: Object.freeze({
|
||||||
|
connection: normalizedConnection,
|
||||||
|
caSecretName: value.postgres.caSecretName,
|
||||||
|
caKey: secretKey(value.postgres.caKey, 'CA key'),
|
||||||
|
servername: value.postgres.servername,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePlan(value: SecretActionApprovalPlan): Readonly<{
|
||||||
|
plan: Readonly<SecretActionApprovalPlan>;
|
||||||
|
action: ReturnType<typeof pluginPackageSecretBindingApprovedAction>;
|
||||||
|
secretRefs: readonly string[];
|
||||||
|
}> {
|
||||||
|
if (
|
||||||
|
record(value, 'approval plan').schema ===
|
||||||
|
PLUGIN_PACKAGE_SECRET_BINDING_APPROVAL_PLAN_SCHEMA
|
||||||
|
) {
|
||||||
|
const plan = normalizePluginPackageSecretBindingApprovalPlan(
|
||||||
|
value as PluginPackageSecretBindingApprovalPlan,
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
plan,
|
||||||
|
action: pluginPackageSecretBindingApprovedAction(plan),
|
||||||
|
secretRefs: Object.freeze(
|
||||||
|
plan.bindingPlan.entries.flatMap((entry) =>
|
||||||
|
entry.secretRef === null ? [] : [entry.secretRef],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const plan = normalizePluginPackageSecretBindingTransitionApprovalPlan(
|
||||||
|
value as PluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
|
);
|
||||||
|
return Object.freeze({
|
||||||
|
plan,
|
||||||
|
action: pluginPackageSecretBindingTransitionApprovedAction(plan),
|
||||||
|
secretRefs: Object.freeze(
|
||||||
|
plan.transitionPlan.nextBindingPlan?.entries.flatMap((entry) =>
|
||||||
|
entry.secretRef === null ? [] : [entry.secretRef],
|
||||||
|
) ?? [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueItems(secretRefs: readonly string[]): readonly Readonly<{
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
}>[] {
|
||||||
|
const keys = [...new Set(secretRefs.map(secretProjectionFileName))].sort();
|
||||||
|
return Object.freeze(
|
||||||
|
keys.map((key) => Object.freeze({ key, path: key })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function valueFromSecret(name: string, key: string): object {
|
||||||
|
return {
|
||||||
|
valueFrom: { secretKeyRef: { name, key, optional: false } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionEnvironment(
|
||||||
|
connection: PluginPackageKubernetesPostgresConnection,
|
||||||
|
): readonly object[] {
|
||||||
|
if (connection.mode === 'url') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_URL',
|
||||||
|
...valueFromSecret(connection.secretName, connection.urlKey),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_HOST', value: connection.host },
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PORT',
|
||||||
|
value: String(connection.port),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_DATABASE',
|
||||||
|
value: connection.database,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_USER',
|
||||||
|
...valueFromSecret(connection.authSecretName, connection.usernameKey),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_PACKAGE_EXECUTOR_PASSWORD',
|
||||||
|
...valueFromSecret(connection.authSecretName, connection.passwordKey),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepFreeze<T>(value: T): Readonly<T> {
|
||||||
|
if (!value || typeof value !== 'object' || Object.isFrozen(value)) {
|
||||||
|
return value as Readonly<T>;
|
||||||
|
}
|
||||||
|
for (const child of Object.values(value as Record<string, unknown>)) {
|
||||||
|
deepFreeze(child);
|
||||||
|
}
|
||||||
|
return Object.freeze(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPluginPackageKubernetesSecretActionJob(
|
||||||
|
input: PluginPackageKubernetesSecretActionJobInput,
|
||||||
|
): Readonly<Record<string, unknown>> {
|
||||||
|
const candidate = record(input, 'input');
|
||||||
|
exactKeys(candidate, ['approvalPlan', 'dispatch', 'options'], 'input');
|
||||||
|
const dispatch = normalizeApprovedActionDispatchRecord(input.dispatch);
|
||||||
|
const approved = normalizePlan(input.approvalPlan);
|
||||||
|
if (
|
||||||
|
JSON.stringify(dispatch.action) !== JSON.stringify(approved.action) ||
|
||||||
|
dispatch.projectId !==
|
||||||
|
('bindingPlan' in approved.plan
|
||||||
|
? approved.plan.bindingPlan.target.projectId
|
||||||
|
: approved.plan.transitionPlan.nextTarget.projectId) ||
|
||||||
|
dispatch.requestedBy.type !== approved.plan.requestedBy.type ||
|
||||||
|
dispatch.requestedBy.id !== approved.plan.requestedBy.id ||
|
||||||
|
dispatch.createdAtMs > approved.plan.expiresAtMs
|
||||||
|
) {
|
||||||
|
return invalid('dispatch does not match the approved plan');
|
||||||
|
}
|
||||||
|
const options = normalizeOptions(input.options);
|
||||||
|
const items = uniqueItems(approved.secretRefs);
|
||||||
|
const unsigned = {
|
||||||
|
schema: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA,
|
||||||
|
dispatch,
|
||||||
|
approvalPlanDigest: approved.plan.approvalPlanDigest,
|
||||||
|
namespace: options.namespace,
|
||||||
|
serviceAccountName: options.serviceAccountName,
|
||||||
|
sourceSecretName: options.sourceSecretName,
|
||||||
|
image: options.image,
|
||||||
|
postgres: options.postgres,
|
||||||
|
items,
|
||||||
|
};
|
||||||
|
const jobDigest = createHash('sha256')
|
||||||
|
.update(JOB_DIGEST_DOMAIN)
|
||||||
|
.update(JSON.stringify(unsigned), 'utf8')
|
||||||
|
.digest('hex');
|
||||||
|
const name = `ql3-package-secret-${jobDigest.slice(0, 32)}`;
|
||||||
|
const valueVolume =
|
||||||
|
items.length === 0
|
||||||
|
? { name: 'plugin-package-values', emptyDir: { sizeLimit: '1Ki' } }
|
||||||
|
: {
|
||||||
|
name: 'plugin-package-values',
|
||||||
|
secret: {
|
||||||
|
secretName: options.sourceSecretName,
|
||||||
|
optional: false,
|
||||||
|
defaultMode: PLUGIN_PACKAGE_KUBERNETES_SECRET_FILE_MODE,
|
||||||
|
items,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return deepFreeze({
|
||||||
|
apiVersion: 'batch/v1',
|
||||||
|
kind: 'Job',
|
||||||
|
metadata: {
|
||||||
|
name,
|
||||||
|
namespace: options.namespace,
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'ql3-plugin-package-secret-action',
|
||||||
|
'app.kubernetes.io/component': 'plugin-package-executor',
|
||||||
|
'app.kubernetes.io/part-of': 'qinglong3',
|
||||||
|
},
|
||||||
|
annotations: {
|
||||||
|
'qinglong.io/secret-action-job-schema':
|
||||||
|
PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_JOB_SCHEMA,
|
||||||
|
'qinglong.io/secret-action-job-digest': jobDigest,
|
||||||
|
'qinglong.io/approved-action-type': dispatch.action.actionType,
|
||||||
|
'qinglong.io/approved-action-digest': dispatch.action.actionDigest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
backoffLimit: 2,
|
||||||
|
activeDeadlineSeconds: 600,
|
||||||
|
ttlSecondsAfterFinished: 3600,
|
||||||
|
template: {
|
||||||
|
metadata: {
|
||||||
|
labels: {
|
||||||
|
'app.kubernetes.io/name': 'ql3-plugin-package-secret-action',
|
||||||
|
'app.kubernetes.io/component': 'plugin-package-executor',
|
||||||
|
'app.kubernetes.io/part-of': 'qinglong3',
|
||||||
|
'qinglong.io/secret-action-job': name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
serviceAccountName: options.serviceAccountName,
|
||||||
|
automountServiceAccountToken: false,
|
||||||
|
enableServiceLinks: false,
|
||||||
|
restartPolicy: 'Never',
|
||||||
|
securityContext: {
|
||||||
|
runAsNonRoot: true,
|
||||||
|
runAsUser: 10001,
|
||||||
|
runAsGroup: 10001,
|
||||||
|
fsGroup: 10001,
|
||||||
|
seccompProfile: { type: 'RuntimeDefault' },
|
||||||
|
},
|
||||||
|
containers: [
|
||||||
|
{
|
||||||
|
name: 'executor',
|
||||||
|
image: options.image,
|
||||||
|
imagePullPolicy: 'IfNotPresent',
|
||||||
|
command: [
|
||||||
|
'node',
|
||||||
|
'/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/plugin-package/executor/pluginPackageExecutorCli.js',
|
||||||
|
],
|
||||||
|
securityContext: {
|
||||||
|
allowPrivilegeEscalation: false,
|
||||||
|
readOnlyRootFilesystem: true,
|
||||||
|
capabilities: { drop: ['ALL'] },
|
||||||
|
},
|
||||||
|
env: [
|
||||||
|
{ name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_ENABLED', value: 'true' },
|
||||||
|
{
|
||||||
|
name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER',
|
||||||
|
value: `package_secret_${jobDigest.slice(0, 24)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID',
|
||||||
|
value: dispatch.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT',
|
||||||
|
value: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS',
|
||||||
|
value: '600000',
|
||||||
|
},
|
||||||
|
{ name: 'QL3_POSTGRES_TLS_MODE', value: 'verify-full' },
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_TLS_CA_FILE',
|
||||||
|
value: '/var/run/secrets/qinglong3/postgres/ca.crt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_TLS_SERVERNAME',
|
||||||
|
value: options.postgres.servername,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'QL3_POSTGRES_APPLICATION_NAME',
|
||||||
|
value: 'qinglong3-package-secret-action',
|
||||||
|
},
|
||||||
|
{ name: 'QL3_POSTGRES_MAX_CONNECTIONS', value: '1' },
|
||||||
|
...connectionEnvironment(options.postgres.connection),
|
||||||
|
],
|
||||||
|
resources: {
|
||||||
|
requests: { cpu: '25m', memory: '48Mi' },
|
||||||
|
limits: { cpu: '250m', memory: '192Mi' },
|
||||||
|
},
|
||||||
|
volumeMounts: [
|
||||||
|
{ name: 'tmp', mountPath: '/tmp' },
|
||||||
|
{
|
||||||
|
name: 'postgres-ca',
|
||||||
|
mountPath: '/var/run/secrets/qinglong3/postgres',
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'plugin-package-values',
|
||||||
|
mountPath: PLUGIN_PACKAGE_KUBERNETES_SECRET_ACTION_ROOT,
|
||||||
|
readOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
volumes: [
|
||||||
|
{ name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '8Mi' } },
|
||||||
|
{
|
||||||
|
name: 'postgres-ca',
|
||||||
|
secret: {
|
||||||
|
secretName: options.postgres.caSecretName,
|
||||||
|
optional: false,
|
||||||
|
defaultMode: 0o444,
|
||||||
|
items: [{ key: options.postgres.caKey, path: 'ca.crt' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
valueVolume,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ test('loads bounded low-footprint Package-executor configuration', () => {
|
|||||||
assert.equal(config.maxBatches, 2);
|
assert.equal(config.maxBatches, 2);
|
||||||
assert.equal(config.revocationPageSize, 8);
|
assert.equal(config.revocationPageSize, 8);
|
||||||
assert.equal(config.revocationMaxPages, 4);
|
assert.equal(config.revocationMaxPages, 4);
|
||||||
|
assert.equal(config.dispatchId, null);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
config.secretProjectionRoot,
|
config.secretProjectionRoot,
|
||||||
'/var/run/secrets/qinglong3/plugin-package-values',
|
'/var/run/secrets/qinglong3/plugin-package-values',
|
||||||
@@ -57,6 +58,99 @@ test('loads bounded low-footprint Package-executor configuration', () => {
|
|||||||
assert.equal(config.database.connection.tls.mode, 'disable');
|
assert.equal(config.database.connection.tls.mode, 'disable');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('loads one bounded action-scoped dispatch without widening batch limits', () => {
|
||||||
|
const config = loadClusterPluginPackageExecutorProcessConfig(
|
||||||
|
environment({
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch.secret-binding.42',
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_APPROVAL_BATCH_SIZE: undefined,
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_BATCH_SIZE: undefined,
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_MAX_BATCHES: undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.equal(config.enabled, true);
|
||||||
|
assert.equal(config.dispatchId, 'dispatch.secret-binding.42');
|
||||||
|
assert.equal(config.approvalBatchSize, 8);
|
||||||
|
assert.equal(config.dispatchBatchSize, 8);
|
||||||
|
assert.equal(config.maxBatches, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('action-scoped mode skips every Approval consumer and shared queue scan', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {};
|
||||||
|
const readiness = {
|
||||||
|
ready: true,
|
||||||
|
writablePrimary: true,
|
||||||
|
serverVersionNum: 180004,
|
||||||
|
serverMajor: 18,
|
||||||
|
currentUser: 'ql3_package_executor',
|
||||||
|
contractName: 'control-core',
|
||||||
|
contractVersion: 62,
|
||||||
|
migrationIds: ['pg-0063-plugin-package-secret-binding-transition-receipts'],
|
||||||
|
};
|
||||||
|
const rejectConsumer = async () => {
|
||||||
|
throw new Error('action-scoped executor must not consume approvals');
|
||||||
|
};
|
||||||
|
const result = await runClusterPluginPackageExecutorProcess({
|
||||||
|
environment: environment({
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch.secret-binding.42',
|
||||||
|
}),
|
||||||
|
async openDatabase() {
|
||||||
|
calls.push('open');
|
||||||
|
return {
|
||||||
|
pool,
|
||||||
|
async close() {
|
||||||
|
calls.push('close');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async assertReady(candidate) {
|
||||||
|
assert.equal(candidate, pool);
|
||||||
|
calls.push('ready');
|
||||||
|
return readiness;
|
||||||
|
},
|
||||||
|
consumeApprovals: rejectConsumer,
|
||||||
|
consumeTrustTransitionApprovals: rejectConsumer,
|
||||||
|
consumeSecretBindingApprovals: rejectConsumer,
|
||||||
|
consumeSecretBindingTransitionApprovals: rejectConsumer,
|
||||||
|
createDispatcher(options) {
|
||||||
|
assert.equal(options.pool, pool);
|
||||||
|
assert.equal(typeof options.secretExistenceInspector.assertExists, 'function');
|
||||||
|
return {
|
||||||
|
async dispatchBatch() {
|
||||||
|
throw new Error('action-scoped executor must not scan the queue');
|
||||||
|
},
|
||||||
|
async dispatchById({ dispatchId }) {
|
||||||
|
calls.push(`dispatch:${dispatchId}`);
|
||||||
|
return {
|
||||||
|
scanned: 1,
|
||||||
|
claimed: 1,
|
||||||
|
started: 1,
|
||||||
|
succeeded: 1,
|
||||||
|
failed: 0,
|
||||||
|
blocked: 0,
|
||||||
|
retrying: 0,
|
||||||
|
deferred: 0,
|
||||||
|
recoveryRequired: 0,
|
||||||
|
alreadyTerminal: 0,
|
||||||
|
unavailable: 0,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
'open',
|
||||||
|
'ready',
|
||||||
|
'dispatch:dispatch.secret-binding.42',
|
||||||
|
'close',
|
||||||
|
]);
|
||||||
|
assert.equal(result.status, 'completed');
|
||||||
|
assert.equal(result.batches.length, 1);
|
||||||
|
assert.equal(result.batches[0].approvals.scanned, 0);
|
||||||
|
assert.equal(result.batches[0].dispatch.succeeded, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test('rejects implicit insecure PostgreSQL and unbounded work', () => {
|
test('rejects implicit insecure PostgreSQL and unbounded work', () => {
|
||||||
for (const invalid of [
|
for (const invalid of [
|
||||||
environment({ QL3_POSTGRES_ALLOW_INSECURE: undefined }),
|
environment({ QL3_POSTGRES_ALLOW_INSECURE: undefined }),
|
||||||
@@ -64,6 +158,9 @@ test('rejects implicit insecure PostgreSQL and unbounded work', () => {
|
|||||||
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE: '129' }),
|
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE: '129' }),
|
||||||
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER: 'not safe' }),
|
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_OWNER: 'not safe' }),
|
||||||
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: 'relative/path' }),
|
environment({ QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: 'relative/path' }),
|
||||||
|
environment({
|
||||||
|
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch id with spaces',
|
||||||
|
}),
|
||||||
]) {
|
]) {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => loadClusterPluginPackageExecutorProcessConfig(invalid),
|
() => loadClusterPluginPackageExecutorProcessConfig(invalid),
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { test } = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
consumeApprovalRequest,
|
||||||
|
createApprovalRequest,
|
||||||
|
decideApprovalRequest,
|
||||||
|
} = require('@qinglong/runtime-core/approved-action');
|
||||||
|
const {
|
||||||
|
createPluginPackageResourceGeneration,
|
||||||
|
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||||
|
const {
|
||||||
|
createPluginPackageSecretBindingApprovalPlan,
|
||||||
|
pluginPackageSecretBindingApprovedAction,
|
||||||
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan');
|
||||||
|
const {
|
||||||
|
createPluginPackageSecretBindingPlan,
|
||||||
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||||
|
const {
|
||||||
|
secretProjectionFileName,
|
||||||
|
} = require('@qinglong/runtime-core/secret-projection');
|
||||||
|
const { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||||
|
const {
|
||||||
|
InvalidPluginPackageKubernetesSecretActionJobError,
|
||||||
|
createPluginPackageKubernetesSecretActionJob,
|
||||||
|
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-job');
|
||||||
|
|
||||||
|
const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' });
|
||||||
|
const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
|
||||||
|
const CONSUMER = Object.freeze({
|
||||||
|
type: 'system',
|
||||||
|
id: 'cluster_package_executor',
|
||||||
|
});
|
||||||
|
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 });
|
||||||
|
|
||||||
|
function fixture({ withoutValues = false } = {}) {
|
||||||
|
const manifest = {
|
||||||
|
apiVersion: 'qinglong.io/v1alpha1',
|
||||||
|
kind: 'Package',
|
||||||
|
metadata: {
|
||||||
|
name: 'example-monitor',
|
||||||
|
displayName: 'Example Monitor',
|
||||||
|
version: '1.0.0',
|
||||||
|
description: 'Action-scoped Job fixture',
|
||||||
|
license: 'Apache-2.0',
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
compatibility: {
|
||||||
|
qinglong: '>=3.0.0-0 <4.0.0',
|
||||||
|
architectures: ['arm64'],
|
||||||
|
deploymentProfiles: ['cluster-control'],
|
||||||
|
},
|
||||||
|
runtimes: [],
|
||||||
|
resources: {
|
||||||
|
memory: { recommended: '32Mi' },
|
||||||
|
disk: { install: '4Mi', working: '8Mi' },
|
||||||
|
},
|
||||||
|
permissions: {
|
||||||
|
network: { allowedHosts: [] },
|
||||||
|
secrets: [
|
||||||
|
{ name: 'TOKEN', required: !withoutValues },
|
||||||
|
{ name: 'TOKEN_ALIAS', required: !withoutValues },
|
||||||
|
],
|
||||||
|
tools: ['secret.use'],
|
||||||
|
},
|
||||||
|
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const generation = createPluginPackageResourceGeneration({
|
||||||
|
installationId: 'install-secret-action-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
packageName: 'example-monitor',
|
||||||
|
lockDigest: 'a'.repeat(64),
|
||||||
|
generation: 1,
|
||||||
|
previousActiveLockDigest: null,
|
||||||
|
contentDigest: 'b'.repeat(64),
|
||||||
|
contents: manifest.spec.contents,
|
||||||
|
});
|
||||||
|
const secretRef = createSecretRef({
|
||||||
|
projectId: 'project-1',
|
||||||
|
name: 'runtime-token',
|
||||||
|
version: 2,
|
||||||
|
});
|
||||||
|
const bindingPlan = createPluginPackageSecretBindingPlan({
|
||||||
|
generation,
|
||||||
|
manifest,
|
||||||
|
assignments: [
|
||||||
|
{ name: 'TOKEN', secretRef: withoutValues ? null : secretRef },
|
||||||
|
{ name: 'TOKEN_ALIAS', secretRef: withoutValues ? null : secretRef },
|
||||||
|
],
|
||||||
|
plannedAtMs: 100,
|
||||||
|
});
|
||||||
|
const approvalPlan = createPluginPackageSecretBindingApprovalPlan({
|
||||||
|
actionRef: 'secret-binding:example-monitor-v1',
|
||||||
|
bindingPlan,
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
expiresAtMs: 1_000,
|
||||||
|
});
|
||||||
|
const action = pluginPackageSecretBindingApprovedAction(approvalPlan);
|
||||||
|
const pending = createApprovalRequest({
|
||||||
|
id: 'approval-secret-action-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
action,
|
||||||
|
risk: 'high',
|
||||||
|
decisionMode: 'separation_of_duty',
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
requestedAtMs: 110,
|
||||||
|
expiresAtMs: 900,
|
||||||
|
requestFence: FENCE,
|
||||||
|
});
|
||||||
|
const approved = decideApprovalRequest(pending, {
|
||||||
|
expectedVersion: 1,
|
||||||
|
decisionId: 'decision-secret-action-1',
|
||||||
|
decision: 'approved',
|
||||||
|
reasonCode: 'reviewed',
|
||||||
|
principal: {
|
||||||
|
subject: REVIEWER,
|
||||||
|
authenticationId: 'auth-reviewer',
|
||||||
|
authenticatedAtMs: 100,
|
||||||
|
expiresAtMs: 800,
|
||||||
|
assurance: 'multi_factor',
|
||||||
|
},
|
||||||
|
decidedAtMs: 120,
|
||||||
|
authorizationFence: FENCE,
|
||||||
|
});
|
||||||
|
const dispatch = consumeApprovalRequest(approved, {
|
||||||
|
expectedVersion: 2,
|
||||||
|
consumptionId: 'consume-secret-action-1',
|
||||||
|
dispatchId: 'dispatch-secret-action-1',
|
||||||
|
action,
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
consumedBy: CONSUMER,
|
||||||
|
consumedAtMs: 130,
|
||||||
|
authorizationFence: FENCE,
|
||||||
|
}).dispatch;
|
||||||
|
return { approvalPlan, dispatch, secretRef };
|
||||||
|
}
|
||||||
|
|
||||||
|
function options(overrides = {}) {
|
||||||
|
return {
|
||||||
|
namespace: 'qinglong3-system',
|
||||||
|
serviceAccountName: 'ql3-plugin-package-secret-action',
|
||||||
|
sourceSecretName: 'ql3-cluster-plugin-package-values',
|
||||||
|
image:
|
||||||
|
'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' +
|
||||||
|
'c'.repeat(64),
|
||||||
|
postgres: {
|
||||||
|
connection: {
|
||||||
|
mode: 'fields',
|
||||||
|
authSecretName: 'ql3-postgres-package-executor-auth',
|
||||||
|
host: 'ql3-postgres-rw.qinglong3-system.svc',
|
||||||
|
port: 5432,
|
||||||
|
database: 'qinglong',
|
||||||
|
usernameKey: 'username',
|
||||||
|
passwordKey: 'password',
|
||||||
|
},
|
||||||
|
caSecretName: 'ql3-postgres-ca',
|
||||||
|
caKey: 'ca.crt',
|
||||||
|
servername: 'ql3-postgres-rw.qinglong3-system.svc',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders one deterministic exact-key Secret action Job', () => {
|
||||||
|
const { approvalPlan, dispatch, secretRef } = fixture();
|
||||||
|
const job = createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch,
|
||||||
|
approvalPlan,
|
||||||
|
options: options(),
|
||||||
|
});
|
||||||
|
const replay = createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch,
|
||||||
|
approvalPlan,
|
||||||
|
options: options(),
|
||||||
|
});
|
||||||
|
assert.deepEqual(replay, job);
|
||||||
|
assert.match(job.metadata.name, /^ql3-package-secret-[0-9a-f]{32}$/);
|
||||||
|
assert.equal(Object.isFrozen(job), true);
|
||||||
|
assert.equal(Object.isFrozen(job.spec.template.spec.volumes), true);
|
||||||
|
assert.equal(job.spec.template.spec.automountServiceAccountToken, false);
|
||||||
|
|
||||||
|
const values = job.spec.template.spec.volumes.find(
|
||||||
|
(volume) => volume.name === 'plugin-package-values',
|
||||||
|
);
|
||||||
|
assert.deepEqual(values.secret.items, [
|
||||||
|
{
|
||||||
|
key: secretProjectionFileName(secretRef),
|
||||||
|
path: secretProjectionFileName(secretRef),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(values.secret.optional, false);
|
||||||
|
assert.equal(values.secret.defaultMode, 0o440);
|
||||||
|
|
||||||
|
const container = job.spec.template.spec.containers[0];
|
||||||
|
assert.equal(
|
||||||
|
container.env.find(
|
||||||
|
(entry) => entry.name === 'QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID',
|
||||||
|
).value,
|
||||||
|
dispatch.id,
|
||||||
|
);
|
||||||
|
assert.equal(container.resources.requests.memory, '48Mi');
|
||||||
|
assert.equal(JSON.stringify(job).includes('qlsecret:'), false);
|
||||||
|
assert.equal(JSON.stringify(job).includes('runtime-token'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a tag-only image and a dispatch bound to another plan', () => {
|
||||||
|
const { approvalPlan, dispatch } = fixture();
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch,
|
||||||
|
approvalPlan,
|
||||||
|
options: options({ image: 'qinglong3-cluster-admin:latest' }),
|
||||||
|
}),
|
||||||
|
InvalidPluginPackageKubernetesSecretActionJobError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch: { ...dispatch, id: 'dispatch-secret-action-drift' },
|
||||||
|
approvalPlan: { ...approvalPlan, actionRef: 'other-action' },
|
||||||
|
options: options(),
|
||||||
|
}),
|
||||||
|
TypeError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses an empty directory for a reviewed action with no Secret values', () => {
|
||||||
|
const { approvalPlan, dispatch } = fixture({ withoutValues: true });
|
||||||
|
const job = createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch,
|
||||||
|
approvalPlan,
|
||||||
|
options: options(),
|
||||||
|
});
|
||||||
|
const values = job.spec.template.spec.volumes.find(
|
||||||
|
(volume) => volume.name === 'plugin-package-values',
|
||||||
|
);
|
||||||
|
assert.deepEqual(values, {
|
||||||
|
name: 'plugin-package-values',
|
||||||
|
emptyDir: { sizeLimit: '1Ki' },
|
||||||
|
});
|
||||||
|
assert.equal(values.secret, undefined);
|
||||||
|
});
|
||||||
@@ -74,6 +74,10 @@ export interface ApprovedActionDispatchBatchSummary {
|
|||||||
readonly nextCursor?: Readonly<ApprovedActionExecutionCursor>;
|
readonly nextCursor?: Readonly<ApprovedActionExecutionCursor>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApprovedActionDispatchByIdOptions {
|
||||||
|
readonly dispatchId: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface MutableSummary {
|
interface MutableSummary {
|
||||||
scanned: number;
|
scanned: number;
|
||||||
claimed: number;
|
claimed: number;
|
||||||
@@ -262,6 +266,54 @@ export class ApprovedActionDispatcher {
|
|||||||
return Object.freeze({ ...summary });
|
return Object.freeze({ ...summary });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute one durable dispatch without scanning the shared due queue.
|
||||||
|
*
|
||||||
|
* This is the entry point for an action-scoped executor (for example a
|
||||||
|
* Kubernetes Job with an exact Secret projection). The handler check happens
|
||||||
|
* before the claim so a narrowly configured executor cannot lease and block
|
||||||
|
* an action outside its authority.
|
||||||
|
*/
|
||||||
|
async dispatchById(
|
||||||
|
options: Readonly<ApprovedActionDispatchByIdOptions>,
|
||||||
|
): Promise<Readonly<ApprovedActionDispatchBatchSummary>> {
|
||||||
|
if (
|
||||||
|
!options ||
|
||||||
|
typeof options !== 'object' ||
|
||||||
|
Array.isArray(options) ||
|
||||||
|
!exactKeys(options, ['dispatchId'])
|
||||||
|
) {
|
||||||
|
throw new TypeError('Approved Action exact dispatch is invalid');
|
||||||
|
}
|
||||||
|
const dispatchId = identifier(options.dispatchId, 'dispatch id');
|
||||||
|
const summary: MutableSummary = {
|
||||||
|
scanned: 0,
|
||||||
|
claimed: 0,
|
||||||
|
started: 0,
|
||||||
|
succeeded: 0,
|
||||||
|
failed: 0,
|
||||||
|
blocked: 0,
|
||||||
|
retrying: 0,
|
||||||
|
deferred: 0,
|
||||||
|
recoveryRequired: 0,
|
||||||
|
alreadyTerminal: 0,
|
||||||
|
unavailable: 0,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const snapshot = await this.#find(dispatchId);
|
||||||
|
if (!snapshot) {
|
||||||
|
summary.unavailable = 1;
|
||||||
|
return Object.freeze({ ...summary });
|
||||||
|
}
|
||||||
|
summary.scanned = 1;
|
||||||
|
if (!this.#handlers.has(snapshot.dispatch.action.actionType)) {
|
||||||
|
summary.unavailable = 1;
|
||||||
|
return Object.freeze({ ...summary });
|
||||||
|
}
|
||||||
|
await this.#dispatchOne(dispatchId, summary);
|
||||||
|
return Object.freeze({ ...summary });
|
||||||
|
}
|
||||||
|
|
||||||
async #dispatchOne(
|
async #dispatchOne(
|
||||||
dispatchId: string,
|
dispatchId: string,
|
||||||
summary: MutableSummary,
|
summary: MutableSummary,
|
||||||
|
|||||||
@@ -302,3 +302,43 @@ test('does not claim an action without a matching handler', async () => {
|
|||||||
assert.equal(repository.execution.status, 'pending');
|
assert.equal(repository.execution.status, 'pending');
|
||||||
assert.equal(repository.startCalls, 0);
|
assert.equal(repository.startCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dispatches only the requested durable action without a queue scan', async () => {
|
||||||
|
const repository = new InMemoryExecutionRepository(dispatch());
|
||||||
|
repository.listDueExecutions = async () => {
|
||||||
|
throw new Error('exact dispatch must not scan');
|
||||||
|
};
|
||||||
|
const summary = await createDispatcher(repository, {
|
||||||
|
actionType: 'plugin_package.install',
|
||||||
|
async inspect(value) {
|
||||||
|
return { status: 'ready', actionDigest: value.action.actionDigest };
|
||||||
|
},
|
||||||
|
async execute() {
|
||||||
|
return {
|
||||||
|
outcome: 'succeeded',
|
||||||
|
resultCode: 'package_admitted',
|
||||||
|
resultDigest: RESULT_DIGEST,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}).dispatchById({ dispatchId: 'dispatch-dispatcher-v1' });
|
||||||
|
assert.equal(summary.scanned, 1);
|
||||||
|
assert.equal(summary.claimed, 1);
|
||||||
|
assert.equal(summary.succeeded, 1);
|
||||||
|
assert.equal(summary.truncated, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('exact dispatch does not claim an action outside configured authority', async () => {
|
||||||
|
const repository = new InMemoryExecutionRepository(dispatch());
|
||||||
|
const dispatcher = new ApprovedActionDispatcher(repository, [], {
|
||||||
|
owner: 'dispatcher_instance_1',
|
||||||
|
clock: () => 100,
|
||||||
|
createId: () => 'dispatcher-exact-id',
|
||||||
|
});
|
||||||
|
const summary = await dispatcher.dispatchById({
|
||||||
|
dispatchId: 'dispatch-dispatcher-v1',
|
||||||
|
});
|
||||||
|
assert.equal(summary.scanned, 1);
|
||||||
|
assert.equal(summary.claimed, 0);
|
||||||
|
assert.equal(summary.unavailable, 1);
|
||||||
|
assert.equal(repository.execution.status, 'pending');
|
||||||
|
});
|
||||||
|
|||||||
@@ -3254,10 +3254,6 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
|||||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', '600000'],
|
['QL3_PLUGIN_PACKAGE_EXECUTOR_LEASE_DURATION_MS', '600000'],
|
||||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE', '16'],
|
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_PAGE_SIZE', '16'],
|
||||||
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES', '16'],
|
['QL3_PLUGIN_PACKAGE_EXECUTOR_REVOCATION_MAX_PAGES', '16'],
|
||||||
[
|
|
||||||
'QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT',
|
|
||||||
'/var/run/secrets/qinglong3/plugin-package-values',
|
|
||||||
],
|
|
||||||
['QL3_POSTGRES_TLS_MODE', 'verify-full'],
|
['QL3_POSTGRES_TLS_MODE', 'verify-full'],
|
||||||
[
|
[
|
||||||
'QL3_POSTGRES_TLS_CA_FILE',
|
'QL3_POSTGRES_TLS_CA_FILE',
|
||||||
@@ -3311,13 +3307,14 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
|||||||
caVolume?.secret?.defaultMode !== 0o444 ||
|
caVolume?.secret?.defaultMode !== 0o444 ||
|
||||||
JSON.stringify(caVolume?.secret?.items) !==
|
JSON.stringify(caVolume?.secret?.items) !==
|
||||||
JSON.stringify([{ key: 'postgres-ca.crt', path: 'ca.crt' }]) ||
|
JSON.stringify([{ key: 'postgres-ca.crt', path: 'ca.crt' }]) ||
|
||||||
secretMount?.readOnly !== true ||
|
secretMount !== undefined ||
|
||||||
secretMount?.mountPath !==
|
secretVolume !== undefined ||
|
||||||
'/var/run/secrets/qinglong3/plugin-package-values' ||
|
env.has('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT') ||
|
||||||
secretVolume?.secret?.secretName !==
|
env.has('QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID') ||
|
||||||
'ql3-cluster-plugin-package-values' ||
|
JSON.stringify(networkPolicy?.spec?.podSelector?.matchLabels) !==
|
||||||
secretVolume?.secret?.optional !== true ||
|
JSON.stringify({
|
||||||
secretVolume?.secret?.defaultMode !== 0o440 ||
|
'app.kubernetes.io/component': 'plugin-package-executor',
|
||||||
|
}) ||
|
||||||
JSON.stringify(networkPolicy?.spec?.policyTypes) !==
|
JSON.stringify(networkPolicy?.spec?.policyTypes) !==
|
||||||
JSON.stringify(['Ingress', 'Egress']) ||
|
JSON.stringify(['Ingress', 'Egress']) ||
|
||||||
JSON.stringify(networkPolicy?.spec?.ingress) !== JSON.stringify([]) ||
|
JSON.stringify(networkPolicy?.spec?.ingress) !== JSON.stringify([]) ||
|
||||||
@@ -3326,7 +3323,7 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
|||||||
findings.push(
|
findings.push(
|
||||||
finding(
|
finding(
|
||||||
'QL3_CLUSTER_PLUGIN_EXECUTOR_BOUNDARY',
|
'QL3_CLUSTER_PLUGIN_EXECUTOR_BOUNDARY',
|
||||||
'Plugin Package executor must have private read-only CA and optional SecretRef projections, no ingress and DNS-only base egress',
|
'Plugin Package batch executor must have private read-only CA, no Package value projection, no ingress and DNS-only base egress',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3382,8 +3379,8 @@ function assertPluginPackageExecutorDeployment(readFile, root, findings) {
|
|||||||
},
|
},
|
||||||
]) ||
|
]) ||
|
||||||
cloudNativeEnv.has('QL3_POSTGRES_PACKAGE_EXECUTOR_URL') ||
|
cloudNativeEnv.has('QL3_POSTGRES_PACKAGE_EXECUTOR_URL') ||
|
||||||
cloudNativeEnv.get('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT')?.value !==
|
cloudNativeEnv.has('QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT') ||
|
||||||
'/var/run/secrets/qinglong3/plugin-package-values' ||
|
cloudNativeEnv.has('QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID') ||
|
||||||
cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_HOST')?.value !==
|
cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_HOST')?.value !==
|
||||||
'ql3-postgres-rw.qinglong3-system.svc' ||
|
'ql3-postgres-rw.qinglong3-system.svc' ||
|
||||||
cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_USER')?.valueFrom
|
cloudNativeEnv.get('QL3_POSTGRES_PACKAGE_EXECUTOR_USER')?.valueFrom
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 107,
|
sourceFiles: 108,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 106,
|
nestedSourceFiles: 107,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user