mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-22 19:29:13 +08:00
feat(ql3): recover terminal secret action jobs
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -47,3 +47,7 @@ D-306B1 只允许给当前 active 且尚未绑定的 Package generation 做首
|
|||||||
- 真实 K3s `v1.34.3+k3s1` 现场门已证明策略可由 API Server 编译:合规 Job dry-run 被接受,镜像漂移和参数 ConfigMap 缺失被拒绝;controller SA 的 `list|watch|delete jobs`、Pod 创建和 Secret 读取均被拒绝,action SA 的 Job/Pod 创建和 Secret 读取也均被拒绝。实现没有新增 workspace package、Edge daemon/timer/watcher 或低配设备常驻负担,18-package boundary 继续为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`。
|
- 真实 K3s `v1.34.3+k3s1` 现场门已证明策略可由 API Server 编译:合规 Job dry-run 被接受,镜像漂移和参数 ConfigMap 缺失被拒绝;controller SA 的 `list|watch|delete jobs`、Pod 创建和 Secret 读取均被拒绝,action SA 的 Job/Pod 创建和 Secret 读取也均被拒绝。实现没有新增 workspace package、Edge daemon/timer/watcher 或低配设备常驻负担,18-package boundary 继续为 `singleSourcePackages=[]`、`shallowSourcePackages=[]`。
|
||||||
- 本切片完整性门:controller/renderer/process 定向 18/18;cluster-admin 339 pass/3 条件 skip、cluster-postgres 328 pass/2 条件 skip;完整 18-package clean build/test 退出 0;backend 1196 项为 1194 pass、2 条条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均零 finding。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `a3d34e61ea2064e1cde574e533137186e09fdce9048455da64f582906037fa0d`,临时 Docker 资源已清理。
|
- 本切片完整性门:controller/renderer/process 定向 18/18;cluster-admin 339 pass/3 条件 skip、cluster-postgres 328 pass/2 条件 skip;完整 18-package clean build/test 退出 0;backend 1196 项为 1194 pass、2 条条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均零 finding。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `a3d34e61ea2064e1cde574e533137186e09fdce9048455da64f582906037fa0d`,临时 Docker 资源已清理。
|
||||||
- ADR 继续保持 Proposed:升级失败自动回滚、终态 Job 的 durable 恢复决议和固定物理低配设备证据尚未完成。当前 controller 明确暴露恢复要求,不以不安全的自动重试冒充闭环。
|
- ADR 继续保持 Proposed:升级失败自动回滚、终态 Job 的 durable 恢复决议和固定物理低配设备证据尚未完成。当前 controller 明确暴露恢复要求,不以不安全的自动重试冒充闭环。
|
||||||
|
- 后续终态恢复切片取代上一条“终态 Job 的 durable 恢复决议尚未完成”的描述。Controller 只在 Job Complete/Failed 后执行恢复,此时 Kubernetes 已证明该 Job 不再运行;它用原 execution lease/start fence 重新推导 expected binding 或 transition receipt,并与 package-executor 只读取得的 immutable durable result 做 exact compare。匹配时补写 `succeeded`;Failed 且无 mutation 时写 `failed`;Complete 且无 receipt 时通过 `indeterminate` 写 `blocked`。Job 已被 TTL 清理但 receipt 存在也可收敛为 succeeded。
|
||||||
|
- start barrier 前的终态 Job、以及 approval 过期且 Job 尚未创建,复用共享 execution repository 的 claim→release-before-start 转换持久化为 `blocked`;controller 崩溃后 lease 可回收,不增加新状态、表、migration 或专用恢复 daemon。Durable result 漂移仍是全局 conflict;`executing + Job 缺失 + receipt 缺失` 不能排除孤儿 Pod 或未知副作用,继续保持 `recoveryRequired` 且绝不重建。
|
||||||
|
- 本切片定向 controller/process 21/21;cluster-admin 全包 348 项为 345 pass、3 条件 skip、0 fail;完整 18-package 串行 build/test 退出 0;backend 1196 项为 1194 pass、2 条件 skip、0 fail;package boundary、cluster dependency、edge import、cluster deployment 均无 finding,部署/包边界聚焦测试 61/61。PostgreSQL `18.4` arm64 physical HA 125 项、timeline `1→2` 通过,报告 SHA-256 `bec512767fbbd7774baa9366698f60c25c8b017ed66f459b154d143fe86293bc`,临时 Docker 资源已清理。共享 `approved_action_executions` contract、PostgreSQL 权限与 Worker Credential 调用链均未修改;实现继续位于既有 `cluster-admin/plugin-package/executor`,Edge/Standalone 不加载 controller,也没有新增 workspace package、连接、timer、watcher 或常驻内存。
|
||||||
|
- ADR 继续保持 Proposed:升级失败自动回滚、`executing + Job/receipt 均缺失` 的显式人工处置产品路径,以及固定物理低配设备证据仍待完成。
|
||||||
|
|||||||
+12
@@ -17,7 +17,9 @@ import {
|
|||||||
loadPostgresConnectionEnvironment,
|
loadPostgresConnectionEnvironment,
|
||||||
PostgresApprovedActionExecutionRepository,
|
PostgresApprovedActionExecutionRepository,
|
||||||
PostgresPluginPackageSecretBindingApprovalPlanReader,
|
PostgresPluginPackageSecretBindingApprovalPlanReader,
|
||||||
|
PostgresPluginPackageSecretBindingRepository,
|
||||||
PostgresPluginPackageSecretBindingTransitionApprovalPlanReader,
|
PostgresPluginPackageSecretBindingTransitionApprovalPlanReader,
|
||||||
|
PostgresPluginPackageSecretBindingTransitionRepository,
|
||||||
type PostgresConnectionOptions,
|
type PostgresConnectionOptions,
|
||||||
type PostgresPoolOptions,
|
type PostgresPoolOptions,
|
||||||
type PostgresSchemaReadinessReport,
|
type PostgresSchemaReadinessReport,
|
||||||
@@ -631,6 +633,9 @@ function emptySecretActionJobSummary(): Readonly<PluginPackageKubernetesSecretAc
|
|||||||
created: 0,
|
created: 0,
|
||||||
existing: 0,
|
existing: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
|
recoveredSucceeded: 0,
|
||||||
|
recoveredFailed: 0,
|
||||||
|
recoveredBlocked: 0,
|
||||||
recoveryRequired: 0,
|
recoveryRequired: 0,
|
||||||
unavailable: 0,
|
unavailable: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
@@ -796,6 +801,13 @@ export async function runClusterPluginPackageExecutorProcess(
|
|||||||
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
||||||
database.pool,
|
database.pool,
|
||||||
),
|
),
|
||||||
|
bindings: new PostgresPluginPackageSecretBindingRepository(
|
||||||
|
database.pool,
|
||||||
|
),
|
||||||
|
transitionReceipts:
|
||||||
|
new PostgresPluginPackageSecretBindingTransitionRepository(
|
||||||
|
database.pool,
|
||||||
|
),
|
||||||
job: config.kubernetesSecretActions.job,
|
job: config.kubernetesSecretActions.job,
|
||||||
...(options.now ? { now: options.now } : {}),
|
...(options.now ? { now: options.now } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
+276
-8
@@ -1,12 +1,27 @@
|
|||||||
import type { ApprovedActionExecutionSnapshot } from '@qinglong/runtime-core/approved-action-execution';
|
import type {
|
||||||
|
ApprovedActionExecutionSnapshot,
|
||||||
|
ClaimApprovedActionExecutionCommand,
|
||||||
|
ClaimApprovedActionExecutionResult,
|
||||||
|
CompleteApprovedActionExecutionCommand,
|
||||||
|
ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||||
|
} from '@qinglong/runtime-core/approved-action-execution';
|
||||||
import {
|
import {
|
||||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||||
|
createPluginPackageSecretBindingFromApprovalPlan,
|
||||||
|
normalizePluginPackageSecretBindingApprovalPlan,
|
||||||
type PluginPackageSecretBindingApprovalPlan,
|
type PluginPackageSecretBindingApprovalPlan,
|
||||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
||||||
|
import type { PluginPackageSecretBinding } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||||
import {
|
import {
|
||||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||||
|
normalizePluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
type PluginPackageSecretBindingTransitionApprovalPlan,
|
type PluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan';
|
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan';
|
||||||
|
import {
|
||||||
|
createPluginPackageSecretBindingFromTransitionPlan,
|
||||||
|
createPluginPackageSecretBindingTransitionReceipt,
|
||||||
|
type PluginPackageSecretBindingTransitionReceipt,
|
||||||
|
} from '@qinglong/runtime-core/plugin-package-secret-binding-transition-receipt';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
createPluginPackageKubernetesSecretActionJob,
|
createPluginPackageKubernetesSecretActionJob,
|
||||||
@@ -14,6 +29,8 @@ import {
|
|||||||
} from './pluginPackageKubernetesSecretActionJob';
|
} from './pluginPackageKubernetesSecretActionJob';
|
||||||
|
|
||||||
const FIELD_MANAGER = 'qinglong-plugin-package-secret-action-controller';
|
const FIELD_MANAGER = 'qinglong-plugin-package-secret-action-controller';
|
||||||
|
const RECOVERY_OWNER = 'qinglong-secret-action-controller';
|
||||||
|
const RECOVERY_LEASE_DURATION_MS = 60_000;
|
||||||
const MAX_PAGE_SIZE = 32;
|
const MAX_PAGE_SIZE = 32;
|
||||||
|
|
||||||
type SecretActionApprovalPlan =
|
type SecretActionApprovalPlan =
|
||||||
@@ -26,7 +43,7 @@ export interface PluginPackageSecretActionApprovalPlanReader<T> {
|
|||||||
findByActionRef(actionRef: string): Promise<Readonly<T> | null>;
|
findByActionRef(actionRef: string): Promise<Readonly<T> | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPackageKubernetesSecretActionExecutionReader {
|
export interface PluginPackageKubernetesSecretActionExecutionPort {
|
||||||
listReconciliableExecutions(query: Readonly<{
|
listReconciliableExecutions(query: Readonly<{
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
@@ -35,6 +52,19 @@ export interface PluginPackageKubernetesSecretActionExecutionReader {
|
|||||||
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}>>;
|
}>>;
|
||||||
|
completeExecution(
|
||||||
|
command: CompleteApprovedActionExecutionCommand,
|
||||||
|
): Promise<Readonly<ApprovedActionExecutionSnapshot>>;
|
||||||
|
claimExecution(
|
||||||
|
command: ClaimApprovedActionExecutionCommand,
|
||||||
|
): Promise<ClaimApprovedActionExecutionResult>;
|
||||||
|
releaseExecutionBeforeStart(
|
||||||
|
command: ReleaseApprovedActionExecutionBeforeStartCommand,
|
||||||
|
): Promise<Readonly<ApprovedActionExecutionSnapshot>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginPackageSecretActionDurableResultReader<T> {
|
||||||
|
find(generationDigest: string): Promise<Readonly<T> | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPackageKubernetesSecretActionJobResource {
|
export interface PluginPackageKubernetesSecretActionJobResource {
|
||||||
@@ -71,9 +101,11 @@ export interface PluginPackageKubernetesSecretActionJobApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginPackageKubernetesSecretActionControllerOptions {
|
export interface PluginPackageKubernetesSecretActionControllerOptions {
|
||||||
readonly executions: PluginPackageKubernetesSecretActionExecutionReader;
|
readonly executions: PluginPackageKubernetesSecretActionExecutionPort;
|
||||||
readonly bindingPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingApprovalPlan>;
|
readonly bindingPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingApprovalPlan>;
|
||||||
readonly transitionPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingTransitionApprovalPlan>;
|
readonly transitionPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingTransitionApprovalPlan>;
|
||||||
|
readonly bindings: PluginPackageSecretActionDurableResultReader<PluginPackageSecretBinding>;
|
||||||
|
readonly transitionReceipts: PluginPackageSecretActionDurableResultReader<PluginPackageSecretBindingTransitionReceipt>;
|
||||||
readonly jobs: PluginPackageKubernetesSecretActionJobApi;
|
readonly jobs: PluginPackageKubernetesSecretActionJobApi;
|
||||||
readonly job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
readonly job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||||
readonly now?: () => number;
|
readonly now?: () => number;
|
||||||
@@ -84,6 +116,9 @@ export interface PluginPackageKubernetesSecretActionControllerSummary {
|
|||||||
readonly created: number;
|
readonly created: number;
|
||||||
readonly existing: number;
|
readonly existing: number;
|
||||||
readonly active: number;
|
readonly active: number;
|
||||||
|
readonly recoveredSucceeded: number;
|
||||||
|
readonly recoveredFailed: number;
|
||||||
|
readonly recoveredBlocked: number;
|
||||||
readonly recoveryRequired: number;
|
readonly recoveryRequired: number;
|
||||||
readonly unavailable: number;
|
readonly unavailable: number;
|
||||||
readonly truncated: boolean;
|
readonly truncated: boolean;
|
||||||
@@ -214,10 +249,17 @@ export class PluginPackageKubernetesSecretActionController {
|
|||||||
typeof options !== 'object' ||
|
typeof options !== 'object' ||
|
||||||
!options.executions ||
|
!options.executions ||
|
||||||
typeof options.executions.listReconciliableExecutions !== 'function' ||
|
typeof options.executions.listReconciliableExecutions !== 'function' ||
|
||||||
|
typeof options.executions.completeExecution !== 'function' ||
|
||||||
|
typeof options.executions.claimExecution !== 'function' ||
|
||||||
|
typeof options.executions.releaseExecutionBeforeStart !== 'function' ||
|
||||||
!options.bindingPlans ||
|
!options.bindingPlans ||
|
||||||
typeof options.bindingPlans.findByActionRef !== 'function' ||
|
typeof options.bindingPlans.findByActionRef !== 'function' ||
|
||||||
!options.transitionPlans ||
|
!options.transitionPlans ||
|
||||||
typeof options.transitionPlans.findByActionRef !== 'function' ||
|
typeof options.transitionPlans.findByActionRef !== 'function' ||
|
||||||
|
!options.bindings ||
|
||||||
|
typeof options.bindings.find !== 'function' ||
|
||||||
|
!options.transitionReceipts ||
|
||||||
|
typeof options.transitionReceipts.find !== 'function' ||
|
||||||
!options.jobs ||
|
!options.jobs ||
|
||||||
typeof options.jobs.createNamespacedJob !== 'function' ||
|
typeof options.jobs.createNamespacedJob !== 'function' ||
|
||||||
typeof options.jobs.readNamespacedJob !== 'function' ||
|
typeof options.jobs.readNamespacedJob !== 'function' ||
|
||||||
@@ -269,6 +311,9 @@ export class PluginPackageKubernetesSecretActionController {
|
|||||||
created: 0,
|
created: 0,
|
||||||
existing: 0,
|
existing: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
|
recoveredSucceeded: 0,
|
||||||
|
recoveredFailed: 0,
|
||||||
|
recoveredBlocked: 0,
|
||||||
recoveryRequired: 0,
|
recoveryRequired: 0,
|
||||||
unavailable: 0,
|
unavailable: 0,
|
||||||
truncated: page.truncated,
|
truncated: page.truncated,
|
||||||
@@ -293,7 +338,15 @@ export class PluginPackageKubernetesSecretActionController {
|
|||||||
async #reconcileOne(
|
async #reconcileOne(
|
||||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
): Promise<'created' | 'existing' | 'active' | 'recoveryRequired'> {
|
): Promise<
|
||||||
|
| 'created'
|
||||||
|
| 'existing'
|
||||||
|
| 'active'
|
||||||
|
| 'recoveredSucceeded'
|
||||||
|
| 'recoveredFailed'
|
||||||
|
| 'recoveredBlocked'
|
||||||
|
| 'recoveryRequired'
|
||||||
|
> {
|
||||||
const plan = await this.#plan(snapshot);
|
const plan = await this.#plan(snapshot);
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError();
|
throw new PluginPackageKubernetesSecretActionControllerUnavailableError();
|
||||||
@@ -313,10 +366,22 @@ export class PluginPackageKubernetesSecretActionController {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (apiStatus(error) !== 404) throw error;
|
if (apiStatus(error) !== 404) throw error;
|
||||||
if (
|
if (
|
||||||
snapshot.execution.status === 'executing' ||
|
snapshot.execution.status === 'executing'
|
||||||
nowMs > plan.expiresAtMs
|
|
||||||
) {
|
) {
|
||||||
return 'recoveryRequired';
|
return this.#recoverMissingExecuting(
|
||||||
|
snapshot,
|
||||||
|
plan,
|
||||||
|
name,
|
||||||
|
nowMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nowMs > plan.expiresAtMs) {
|
||||||
|
return this.#blockBeforeStart(
|
||||||
|
snapshot,
|
||||||
|
name,
|
||||||
|
nowMs,
|
||||||
|
'package_secret_action_approval_expired',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
observed = await this.options.jobs.createNamespacedJob({
|
observed = await this.options.jobs.createNamespacedJob({
|
||||||
@@ -343,10 +408,213 @@ export class PluginPackageKubernetesSecretActionController {
|
|||||||
}
|
}
|
||||||
assertObservedJob(desired, observed);
|
assertObservedJob(desired, observed);
|
||||||
const terminal = terminalStatus(observed);
|
const terminal = terminalStatus(observed);
|
||||||
if (terminal !== 'active') return 'recoveryRequired';
|
if (terminal !== 'active') {
|
||||||
|
return this.#recoverTerminal(snapshot, plan, name, terminal, nowMs);
|
||||||
|
}
|
||||||
return disposition;
|
return disposition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #recoverTerminal(
|
||||||
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
|
plan: Readonly<SecretActionApprovalPlan>,
|
||||||
|
jobName: string,
|
||||||
|
terminal: 'complete' | 'failed',
|
||||||
|
nowMs: number,
|
||||||
|
): Promise<
|
||||||
|
'recoveredSucceeded' | 'recoveredFailed' | 'recoveredBlocked' | 'recoveryRequired'
|
||||||
|
> {
|
||||||
|
const execution = snapshot.execution;
|
||||||
|
if (execution.status !== 'executing') {
|
||||||
|
return this.#blockBeforeStart(
|
||||||
|
snapshot,
|
||||||
|
jobName,
|
||||||
|
nowMs,
|
||||||
|
terminal === 'failed'
|
||||||
|
? 'package_secret_action_job_failed_before_start'
|
||||||
|
: 'package_secret_action_job_completed_before_start',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
execution.startedAtMs === null ||
|
||||||
|
execution.leaseOwner === null ||
|
||||||
|
execution.leaseToken === null
|
||||||
|
) {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
const durableResultDigest = await this.#durableResultDigest(
|
||||||
|
plan,
|
||||||
|
execution.startedAtMs,
|
||||||
|
);
|
||||||
|
const outcome = durableResultDigest
|
||||||
|
? 'succeeded'
|
||||||
|
: terminal === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: 'indeterminate';
|
||||||
|
return this.#completeExecuting(
|
||||||
|
snapshot,
|
||||||
|
jobName,
|
||||||
|
nowMs,
|
||||||
|
outcome,
|
||||||
|
durableResultDigest
|
||||||
|
? snapshot.dispatch.action.actionType ===
|
||||||
|
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE
|
||||||
|
? 'package_secret_binding_job_recovered'
|
||||||
|
: 'package_secret_transition_job_recovered'
|
||||||
|
: terminal === 'failed'
|
||||||
|
? 'package_secret_action_job_failed'
|
||||||
|
: 'package_secret_action_receipt_missing',
|
||||||
|
durableResultDigest,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #recoverMissingExecuting(
|
||||||
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
|
plan: Readonly<SecretActionApprovalPlan>,
|
||||||
|
jobName: string,
|
||||||
|
nowMs: number,
|
||||||
|
): Promise<'recoveredSucceeded' | 'recoveryRequired'> {
|
||||||
|
const execution = snapshot.execution;
|
||||||
|
if (
|
||||||
|
execution.status !== 'executing' ||
|
||||||
|
execution.startedAtMs === null ||
|
||||||
|
execution.leaseOwner === null ||
|
||||||
|
execution.leaseToken === null
|
||||||
|
) {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
const durableResultDigest = await this.#durableResultDigest(
|
||||||
|
plan,
|
||||||
|
execution.startedAtMs,
|
||||||
|
);
|
||||||
|
if (!durableResultDigest) return 'recoveryRequired';
|
||||||
|
const recovered = await this.#completeExecuting(
|
||||||
|
snapshot,
|
||||||
|
jobName,
|
||||||
|
nowMs,
|
||||||
|
'succeeded',
|
||||||
|
snapshot.dispatch.action.actionType ===
|
||||||
|
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE
|
||||||
|
? 'package_secret_binding_job_recovered'
|
||||||
|
: 'package_secret_transition_job_recovered',
|
||||||
|
durableResultDigest,
|
||||||
|
);
|
||||||
|
if (recovered !== 'recoveredSucceeded') {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
return recovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #completeExecuting(
|
||||||
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
|
jobName: string,
|
||||||
|
nowMs: number,
|
||||||
|
outcome: 'succeeded' | 'failed' | 'indeterminate',
|
||||||
|
resultCode: string,
|
||||||
|
resultDigest: string | null,
|
||||||
|
): Promise<'recoveredSucceeded' | 'recoveredFailed' | 'recoveredBlocked'> {
|
||||||
|
const execution = snapshot.execution;
|
||||||
|
if (
|
||||||
|
execution.status !== 'executing' ||
|
||||||
|
execution.leaseOwner === null ||
|
||||||
|
execution.leaseToken === null ||
|
||||||
|
(outcome === 'succeeded') !== (resultDigest !== null)
|
||||||
|
) {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
const result = await this.options.executions.completeExecution({
|
||||||
|
dispatchId: execution.dispatchId,
|
||||||
|
owner: execution.leaseOwner,
|
||||||
|
leaseToken: execution.leaseToken,
|
||||||
|
expectedVersion: execution.version,
|
||||||
|
resultMutationId: `k8s-recovery-${jobName}`,
|
||||||
|
outcome,
|
||||||
|
resultCode,
|
||||||
|
...(resultDigest ? { resultDigest } : {}),
|
||||||
|
completedAtMs: Math.max(nowMs, execution.updatedAtMs),
|
||||||
|
});
|
||||||
|
if (result.execution.status === 'succeeded') return 'recoveredSucceeded';
|
||||||
|
if (result.execution.status === 'failed') return 'recoveredFailed';
|
||||||
|
if (result.execution.status === 'blocked') return 'recoveredBlocked';
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #blockBeforeStart(
|
||||||
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
|
jobName: string,
|
||||||
|
nowMs: number,
|
||||||
|
resultCode: string,
|
||||||
|
): Promise<'recoveredBlocked' | 'recoveryRequired'> {
|
||||||
|
const claimed = await this.options.executions.claimExecution({
|
||||||
|
dispatchId: snapshot.execution.dispatchId,
|
||||||
|
owner: RECOVERY_OWNER,
|
||||||
|
leaseToken: `recovery-${jobName}`,
|
||||||
|
nowMs,
|
||||||
|
leaseDurationMs: RECOVERY_LEASE_DURATION_MS,
|
||||||
|
});
|
||||||
|
if (claimed.status !== 'claimed') {
|
||||||
|
return claimed.status === 'blocked' ? 'recoveredBlocked' : 'recoveryRequired';
|
||||||
|
}
|
||||||
|
const released = await this.options.executions.releaseExecutionBeforeStart({
|
||||||
|
dispatchId: claimed.snapshot.execution.dispatchId,
|
||||||
|
owner: RECOVERY_OWNER,
|
||||||
|
leaseToken: claimed.snapshot.execution.leaseToken!,
|
||||||
|
expectedVersion: claimed.snapshot.execution.version,
|
||||||
|
resultMutationId: `k8s-recovery-${jobName}`,
|
||||||
|
resultCode,
|
||||||
|
atMs: Math.max(nowMs, claimed.snapshot.execution.updatedAtMs),
|
||||||
|
});
|
||||||
|
if (released.execution.status !== 'blocked') {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
return 'recoveredBlocked';
|
||||||
|
}
|
||||||
|
|
||||||
|
async #durableResultDigest(
|
||||||
|
plan: Readonly<SecretActionApprovalPlan>,
|
||||||
|
startedAtMs: number,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if ('bindingPlan' in plan) {
|
||||||
|
const normalized = normalizePluginPackageSecretBindingApprovalPlan(plan);
|
||||||
|
const expected = createPluginPackageSecretBindingFromApprovalPlan(
|
||||||
|
normalized,
|
||||||
|
startedAtMs,
|
||||||
|
);
|
||||||
|
const stored = await this.options.bindings.find(
|
||||||
|
normalized.bindingPlan.target.generationDigest,
|
||||||
|
);
|
||||||
|
if (!stored) return null;
|
||||||
|
if (JSON.stringify(stored) !== JSON.stringify(expected)) {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
return stored.bindingDigest;
|
||||||
|
}
|
||||||
|
const normalized =
|
||||||
|
normalizePluginPackageSecretBindingTransitionApprovalPlan(plan);
|
||||||
|
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||||
|
normalized.transitionPlan,
|
||||||
|
'approved-action-execution',
|
||||||
|
normalized.approvalPlanDigest,
|
||||||
|
startedAtMs,
|
||||||
|
);
|
||||||
|
const expected = createPluginPackageSecretBindingTransitionReceipt({
|
||||||
|
transitionPlan: normalized.transitionPlan,
|
||||||
|
authority: Object.freeze({
|
||||||
|
kind: 'approved-action-execution',
|
||||||
|
evidenceDigest: normalized.approvalPlanDigest,
|
||||||
|
}),
|
||||||
|
binding,
|
||||||
|
committedAtMs: startedAtMs,
|
||||||
|
});
|
||||||
|
const stored = await this.options.transitionReceipts.find(
|
||||||
|
normalized.transitionPlan.nextTarget.generationDigest,
|
||||||
|
);
|
||||||
|
if (!stored) return null;
|
||||||
|
if (JSON.stringify(stored) !== JSON.stringify(expected)) {
|
||||||
|
throw new PluginPackageKubernetesSecretActionControllerConflictError();
|
||||||
|
}
|
||||||
|
return stored.receiptDigest;
|
||||||
|
}
|
||||||
|
|
||||||
#plan(
|
#plan(
|
||||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||||
): Promise<Readonly<SecretActionApprovalPlan> | null> {
|
): Promise<Readonly<SecretActionApprovalPlan> | null> {
|
||||||
|
|||||||
@@ -253,6 +253,9 @@ test('batch mode consumes approvals before reconciling exact Secret action Jobs'
|
|||||||
},
|
},
|
||||||
async createSecretActionController(options) {
|
async createSecretActionController(options) {
|
||||||
assert.equal(options.job.image.endsWith('c'.repeat(64)), true);
|
assert.equal(options.job.image.endsWith('c'.repeat(64)), true);
|
||||||
|
assert.equal(typeof options.executions.completeExecution, 'function');
|
||||||
|
assert.equal(typeof options.bindings.find, 'function');
|
||||||
|
assert.equal(typeof options.transitionReceipts.find, 'function');
|
||||||
calls.push('controller-open');
|
calls.push('controller-open');
|
||||||
return {
|
return {
|
||||||
controller: {
|
controller: {
|
||||||
@@ -263,6 +266,9 @@ test('batch mode consumes approvals before reconciling exact Secret action Jobs'
|
|||||||
created: 1,
|
created: 1,
|
||||||
existing: 0,
|
existing: 0,
|
||||||
active: 0,
|
active: 0,
|
||||||
|
recoveredSucceeded: 0,
|
||||||
|
recoveredFailed: 0,
|
||||||
|
recoveredBlocked: 0,
|
||||||
recoveryRequired: 0,
|
recoveryRequired: 0,
|
||||||
unavailable: 0,
|
unavailable: 0,
|
||||||
truncated: false,
|
truncated: false,
|
||||||
|
|||||||
+534
-18
@@ -9,24 +9,44 @@ const {
|
|||||||
decideApprovalRequest,
|
decideApprovalRequest,
|
||||||
} = require('@qinglong/runtime-core/approved-action');
|
} = require('@qinglong/runtime-core/approved-action');
|
||||||
const {
|
const {
|
||||||
|
claimApprovedActionExecution,
|
||||||
|
completeApprovedActionExecution,
|
||||||
createApprovedActionExecution,
|
createApprovedActionExecution,
|
||||||
|
releaseApprovedActionExecutionBeforeStart,
|
||||||
|
startApprovedActionExecution,
|
||||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||||
const {
|
const {
|
||||||
createPluginPackageResourceGeneration,
|
createPluginPackageResourceGeneration,
|
||||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||||
const {
|
const {
|
||||||
|
createPluginPackageSecretBindingFromApprovalPlan,
|
||||||
createPluginPackageSecretBindingApprovalPlan,
|
createPluginPackageSecretBindingApprovalPlan,
|
||||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan');
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan');
|
||||||
const {
|
const {
|
||||||
createPluginPackageSecretBindingPlan,
|
createPluginPackageSecretBindingPlan,
|
||||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||||
const {
|
const {
|
||||||
createSecretRef,
|
createPluginPackageSecretBinding,
|
||||||
} = require('@qinglong/runtime-core/secret-reference');
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding');
|
||||||
|
const {
|
||||||
|
createPluginPackageSecretBindingTransitionApprovalPlan,
|
||||||
|
pluginPackageSecretBindingTransitionApprovedAction,
|
||||||
|
} = require('@qinglong/runtime-core/plugin-package-secret-binding-transition-approval-plan');
|
||||||
|
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 { createSecretRef } = require('@qinglong/runtime-core/secret-reference');
|
||||||
const {
|
const {
|
||||||
PluginPackageKubernetesSecretActionController,
|
PluginPackageKubernetesSecretActionController,
|
||||||
PluginPackageKubernetesSecretActionControllerConflictError,
|
PluginPackageKubernetesSecretActionControllerConflictError,
|
||||||
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-controller');
|
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-controller');
|
||||||
|
const {
|
||||||
|
createPluginPackageKubernetesSecretActionJob,
|
||||||
|
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-job');
|
||||||
|
|
||||||
const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' });
|
const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' });
|
||||||
const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
|
const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
|
||||||
@@ -97,8 +117,10 @@ function fixture() {
|
|||||||
requestedBy: REQUESTER,
|
requestedBy: REQUESTER,
|
||||||
expiresAtMs: 1_000,
|
expiresAtMs: 1_000,
|
||||||
});
|
});
|
||||||
const action = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan')
|
const action =
|
||||||
.pluginPackageSecretBindingApprovedAction(plan);
|
require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan').pluginPackageSecretBindingApprovedAction(
|
||||||
|
plan,
|
||||||
|
);
|
||||||
const approved = decideApprovalRequest(
|
const approved = decideApprovalRequest(
|
||||||
createApprovalRequest({
|
createApprovalRequest({
|
||||||
id: 'approval-controller-1',
|
id: 'approval-controller-1',
|
||||||
@@ -167,12 +189,197 @@ function jobOptions() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function transitionFixture() {
|
||||||
|
const manifest = (version) => ({
|
||||||
|
apiVersion: 'qinglong.io/v1alpha1',
|
||||||
|
kind: 'Package',
|
||||||
|
metadata: {
|
||||||
|
name: 'controller-transition',
|
||||||
|
displayName: 'Controller Transition',
|
||||||
|
version,
|
||||||
|
description: 'Secret action transition controller 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: true }],
|
||||||
|
tools: ['secret.use'],
|
||||||
|
},
|
||||||
|
contents: { tasks: [], workflows: [], prompts: [], tools: [] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const previousManifest = manifest('1.0.0');
|
||||||
|
const previousGeneration = createPluginPackageResourceGeneration({
|
||||||
|
installationId: 'install-controller-transition-v1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
packageName: 'controller-transition',
|
||||||
|
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: 'project-1',
|
||||||
|
name: 'runtime-token',
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
authority: {
|
||||||
|
kind: 'approved-action-execution',
|
||||||
|
evidenceDigest: '3'.repeat(64),
|
||||||
|
},
|
||||||
|
boundAtMs: 80,
|
||||||
|
});
|
||||||
|
const nextManifest = manifest('2.0.0');
|
||||||
|
const transitionPlan = createPluginPackageSecretBindingTransitionPlan({
|
||||||
|
previousTarget: previousBinding.target,
|
||||||
|
previousBinding,
|
||||||
|
previousAttemptGeneration: 1,
|
||||||
|
nextGeneration: createPluginPackageResourceGeneration({
|
||||||
|
installationId: 'install-controller-transition-v2',
|
||||||
|
projectId: 'project-1',
|
||||||
|
packageName: 'controller-transition',
|
||||||
|
lockDigest: '4'.repeat(64),
|
||||||
|
generation: 2,
|
||||||
|
previousActiveLockDigest: '1'.repeat(64),
|
||||||
|
contentDigest: '5'.repeat(64),
|
||||||
|
contents: nextManifest.spec.contents,
|
||||||
|
}),
|
||||||
|
nextManifest,
|
||||||
|
assignments: [
|
||||||
|
{
|
||||||
|
name: 'TOKEN',
|
||||||
|
secretRef: createSecretRef({
|
||||||
|
projectId: 'project-1',
|
||||||
|
name: 'runtime-token',
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
plannedAtMs: 100,
|
||||||
|
});
|
||||||
|
const plan = createPluginPackageSecretBindingTransitionApprovalPlan({
|
||||||
|
actionRef: 'secret-transition:controller-v2',
|
||||||
|
transitionPlan,
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
plannedAtMs: 100,
|
||||||
|
expiresAtMs: 1_000,
|
||||||
|
});
|
||||||
|
const action = pluginPackageSecretBindingTransitionApprovedAction(plan);
|
||||||
|
const approved = decideApprovalRequest(
|
||||||
|
createApprovalRequest({
|
||||||
|
id: 'approval-controller-transition',
|
||||||
|
projectId: 'project-1',
|
||||||
|
action,
|
||||||
|
risk: 'high',
|
||||||
|
decisionMode: 'separation_of_duty',
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
requestedAtMs: 110,
|
||||||
|
expiresAtMs: 900,
|
||||||
|
requestFence: FENCE,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
expectedVersion: 1,
|
||||||
|
decisionId: 'decision-controller-transition',
|
||||||
|
decision: 'approved',
|
||||||
|
reasonCode: 'reviewed',
|
||||||
|
principal: {
|
||||||
|
subject: REVIEWER,
|
||||||
|
authenticationId: 'auth-transition-reviewer',
|
||||||
|
authenticatedAtMs: 100,
|
||||||
|
expiresAtMs: 800,
|
||||||
|
assurance: 'multi_factor',
|
||||||
|
},
|
||||||
|
decidedAtMs: 120,
|
||||||
|
authorizationFence: FENCE,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const dispatch = consumeApprovalRequest(approved, {
|
||||||
|
expectedVersion: 2,
|
||||||
|
consumptionId: 'consume-controller-transition',
|
||||||
|
dispatchId: 'dispatch-controller-transition',
|
||||||
|
action,
|
||||||
|
requestedBy: REQUESTER,
|
||||||
|
consumedBy: CONSUMER,
|
||||||
|
consumedAtMs: 130,
|
||||||
|
authorizationFence: FENCE,
|
||||||
|
}).dispatch;
|
||||||
|
return {
|
||||||
|
plan,
|
||||||
|
snapshot: Object.freeze({
|
||||||
|
dispatch,
|
||||||
|
execution: createApprovedActionExecution(dispatch),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function apiError(code) {
|
function apiError(code) {
|
||||||
return Object.assign(new Error(`Kubernetes ${code}`), { code });
|
return Object.assign(new Error(`Kubernetes ${code}`), { code });
|
||||||
}
|
}
|
||||||
|
|
||||||
function controller({ read, create, now = 200, snapshot: snapshotOverride }) {
|
function executingSnapshot(snapshot, startedAtMs = 150) {
|
||||||
|
const leased = claimApprovedActionExecution(snapshot.execution, {
|
||||||
|
owner: 'secret-action-job',
|
||||||
|
leaseToken: 'secret-action-lease',
|
||||||
|
nowMs: 140,
|
||||||
|
leaseDurationMs: 100,
|
||||||
|
});
|
||||||
|
return Object.freeze({
|
||||||
|
dispatch: snapshot.dispatch,
|
||||||
|
execution: startApprovedActionExecution(
|
||||||
|
{ dispatch: snapshot.dispatch, execution: leased },
|
||||||
|
{
|
||||||
|
dispatchId: snapshot.dispatch.id,
|
||||||
|
approvalRequestId: snapshot.dispatch.approvalRequestId,
|
||||||
|
actionDigest: snapshot.dispatch.action.actionDigest,
|
||||||
|
owner: leased.leaseOwner,
|
||||||
|
leaseToken: leased.leaseToken,
|
||||||
|
expectedVersion: leased.version,
|
||||||
|
startedAtMs,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function desiredJob(plan, snapshot) {
|
||||||
|
return createPluginPackageKubernetesSecretActionJob({
|
||||||
|
dispatch: snapshot.dispatch,
|
||||||
|
approvalPlan: plan,
|
||||||
|
options: jobOptions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function controller({
|
||||||
|
read,
|
||||||
|
create,
|
||||||
|
complete,
|
||||||
|
findBinding,
|
||||||
|
findTransitionReceipt,
|
||||||
|
now = 200,
|
||||||
|
plan: planOverride,
|
||||||
|
snapshot: snapshotOverride,
|
||||||
|
}) {
|
||||||
const { plan, snapshot } = fixture();
|
const { plan, snapshot } = fixture();
|
||||||
|
const selectedPlan = planOverride ?? plan;
|
||||||
return new PluginPackageKubernetesSecretActionController({
|
return new PluginPackageKubernetesSecretActionController({
|
||||||
executions: {
|
executions: {
|
||||||
async listReconciliableExecutions(query) {
|
async listReconciliableExecutions(query) {
|
||||||
@@ -185,16 +392,72 @@ function controller({ read, create, now = 200, snapshot: snapshotOverride }) {
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
async completeExecution(command) {
|
||||||
|
if (!complete) throw new Error('completion must not run');
|
||||||
|
return complete(command, snapshotOverride ?? snapshot);
|
||||||
|
},
|
||||||
|
async claimExecution(command) {
|
||||||
|
const current = snapshotOverride ?? snapshot;
|
||||||
|
const execution = claimApprovedActionExecution(current.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
nowMs: command.nowMs,
|
||||||
|
leaseDurationMs: command.leaseDurationMs,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: 'claimed',
|
||||||
|
snapshot: { dispatch: current.dispatch, execution },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async releaseExecutionBeforeStart(command) {
|
||||||
|
const current = snapshotOverride ?? snapshot;
|
||||||
|
const claimed = claimApprovedActionExecution(current.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
nowMs: command.atMs,
|
||||||
|
leaseDurationMs: 60_000,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dispatch: current.dispatch,
|
||||||
|
execution: releaseApprovedActionExecutionBeforeStart(claimed, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
atMs: command.atMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bindingPlans: {
|
bindingPlans: {
|
||||||
async findByActionRef(actionRef) {
|
async findByActionRef(actionRef) {
|
||||||
assert.equal(actionRef, plan.actionRef);
|
if (!('bindingPlan' in selectedPlan)) {
|
||||||
return plan;
|
throw new Error('binding reader must not run');
|
||||||
|
}
|
||||||
|
assert.equal(actionRef, selectedPlan.actionRef);
|
||||||
|
return selectedPlan;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
transitionPlans: {
|
transitionPlans: {
|
||||||
async findByActionRef() {
|
async findByActionRef(actionRef) {
|
||||||
throw new Error('transition reader must not run');
|
if ('bindingPlan' in selectedPlan) {
|
||||||
|
throw new Error('transition reader must not run');
|
||||||
|
}
|
||||||
|
assert.equal(actionRef, selectedPlan.actionRef);
|
||||||
|
return selectedPlan;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bindings: {
|
||||||
|
async find(generationDigest) {
|
||||||
|
return findBinding ? findBinding(generationDigest) : null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transitionReceipts: {
|
||||||
|
async find(generationDigest) {
|
||||||
|
return findTransitionReceipt
|
||||||
|
? findTransitionReceipt(generationDigest)
|
||||||
|
: null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
jobs: {
|
jobs: {
|
||||||
@@ -228,7 +491,10 @@ test('creates one Strict deterministic Job without claiming the execution', asyn
|
|||||||
calls[1][1].fieldManager,
|
calls[1][1].fieldManager,
|
||||||
'qinglong-plugin-package-secret-action-controller',
|
'qinglong-plugin-package-secret-action-controller',
|
||||||
);
|
);
|
||||||
assert.match(calls[1][1].body.metadata.name, /^ql3-package-secret-[0-9a-f]{32}$/);
|
assert.match(
|
||||||
|
calls[1][1].body.metadata.name,
|
||||||
|
/^ql3-package-secret-[0-9a-f]{32}$/,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('converges a concurrent create through one exact get', async () => {
|
test('converges a concurrent create through one exact get', async () => {
|
||||||
@@ -284,10 +550,7 @@ test('does not recreate a missing Job for an already executing action', async ()
|
|||||||
const { snapshot } = fixture();
|
const { snapshot } = fixture();
|
||||||
let creates = 0;
|
let creates = 0;
|
||||||
const subject = controller({
|
const subject = controller({
|
||||||
snapshot: {
|
snapshot: executingSnapshot(snapshot),
|
||||||
...snapshot,
|
|
||||||
execution: { ...snapshot.execution, status: 'executing' },
|
|
||||||
},
|
|
||||||
async read() {
|
async read() {
|
||||||
throw apiError(404);
|
throw apiError(404);
|
||||||
},
|
},
|
||||||
@@ -301,7 +564,47 @@ test('does not recreate a missing Job for an already executing action', async ()
|
|||||||
assert.equal(creates, 0);
|
assert.equal(creates, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('does not create a missing Job after the approval plan expires', async () => {
|
test('recovers a missing executing Job when its exact durable binding exists', async () => {
|
||||||
|
const { plan, snapshot } = fixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const durable = createPluginPackageSecretBindingFromApprovalPlan(
|
||||||
|
plan,
|
||||||
|
executing.execution.startedAtMs,
|
||||||
|
);
|
||||||
|
const subject = controller({
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
throw apiError(404);
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async findBinding() {
|
||||||
|
return durable;
|
||||||
|
},
|
||||||
|
async complete(command) {
|
||||||
|
assert.equal(command.outcome, 'succeeded');
|
||||||
|
return {
|
||||||
|
dispatch: executing.dispatch,
|
||||||
|
execution: completeApprovedActionExecution(executing.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
outcome: command.outcome,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
resultDigest: command.resultDigest,
|
||||||
|
completedAtMs: command.completedAtMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await subject.reconcile();
|
||||||
|
assert.equal(result.recoveredSucceeded, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks a missing Job after the approval plan expires before start', async () => {
|
||||||
let creates = 0;
|
let creates = 0;
|
||||||
const subject = controller({
|
const subject = controller({
|
||||||
now: 1_001,
|
now: 1_001,
|
||||||
@@ -314,11 +617,12 @@ test('does not create a missing Job after the approval plan expires', async () =
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await subject.reconcile();
|
const result = await subject.reconcile();
|
||||||
assert.equal(result.recoveryRequired, 1);
|
assert.equal(result.recoveredBlocked, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
assert.equal(creates, 0);
|
assert.equal(creates, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('marks a terminal Job with a nonterminal execution as recovery required', async () => {
|
test('blocks a terminal Job before the execution start barrier', async () => {
|
||||||
let desired;
|
let desired;
|
||||||
const subject = controller({
|
const subject = controller({
|
||||||
async read() {
|
async read() {
|
||||||
@@ -336,10 +640,222 @@ test('marks a terminal Job with a nonterminal execution as recovery required', a
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await subject.reconcile();
|
const result = await subject.reconcile();
|
||||||
assert.equal(result.recoveryRequired, 1);
|
assert.equal(result.recoveredBlocked, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
assert.equal(result.created, 0);
|
assert.equal(result.created, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('recovers an executing terminal Job as succeeded from its exact durable binding', async () => {
|
||||||
|
const { plan, snapshot } = fixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const desired = desiredJob(plan, executing);
|
||||||
|
const durable = createPluginPackageSecretBindingFromApprovalPlan(
|
||||||
|
plan,
|
||||||
|
executing.execution.startedAtMs,
|
||||||
|
);
|
||||||
|
let completion;
|
||||||
|
const subject = controller({
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
return {
|
||||||
|
...desired,
|
||||||
|
status: { conditions: [{ type: 'Complete', status: 'True' }] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async findBinding(generationDigest) {
|
||||||
|
assert.equal(generationDigest, plan.bindingPlan.target.generationDigest);
|
||||||
|
return durable;
|
||||||
|
},
|
||||||
|
async complete(command) {
|
||||||
|
completion = command;
|
||||||
|
return {
|
||||||
|
dispatch: executing.dispatch,
|
||||||
|
execution: completeApprovedActionExecution(executing.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
outcome: command.outcome,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
resultDigest: command.resultDigest,
|
||||||
|
completedAtMs: command.completedAtMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await subject.reconcile();
|
||||||
|
assert.equal(result.recoveredSucceeded, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
|
assert.equal(completion.outcome, 'succeeded');
|
||||||
|
assert.equal(completion.resultDigest, durable.bindingDigest);
|
||||||
|
assert.equal(completion.resultCode, 'package_secret_binding_job_recovered');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers a failed executing Job without a durable mutation as failed', async () => {
|
||||||
|
const { plan, snapshot } = fixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const desired = desiredJob(plan, executing);
|
||||||
|
const subject = controller({
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
return {
|
||||||
|
...desired,
|
||||||
|
status: { conditions: [{ type: 'Failed', status: 'True' }] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async complete(command) {
|
||||||
|
assert.equal(command.outcome, 'failed');
|
||||||
|
return {
|
||||||
|
dispatch: executing.dispatch,
|
||||||
|
execution: completeApprovedActionExecution(executing.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
outcome: command.outcome,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
completedAtMs: command.completedAtMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await subject.reconcile();
|
||||||
|
assert.equal(result.recoveredFailed, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers a terminal transition Job from its exact durable receipt', async () => {
|
||||||
|
const { plan, snapshot } = transitionFixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const desired = desiredJob(plan, executing);
|
||||||
|
const binding = createPluginPackageSecretBindingFromTransitionPlan(
|
||||||
|
plan.transitionPlan,
|
||||||
|
'approved-action-execution',
|
||||||
|
plan.approvalPlanDigest,
|
||||||
|
executing.execution.startedAtMs,
|
||||||
|
);
|
||||||
|
const receipt = createPluginPackageSecretBindingTransitionReceipt({
|
||||||
|
transitionPlan: plan.transitionPlan,
|
||||||
|
authority: {
|
||||||
|
kind: 'approved-action-execution',
|
||||||
|
evidenceDigest: plan.approvalPlanDigest,
|
||||||
|
},
|
||||||
|
binding,
|
||||||
|
committedAtMs: executing.execution.startedAtMs,
|
||||||
|
});
|
||||||
|
const subject = controller({
|
||||||
|
plan,
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
return {
|
||||||
|
...desired,
|
||||||
|
status: { conditions: [{ type: 'Failed', status: 'True' }] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async findTransitionReceipt(generationDigest) {
|
||||||
|
assert.equal(
|
||||||
|
generationDigest,
|
||||||
|
plan.transitionPlan.nextTarget.generationDigest,
|
||||||
|
);
|
||||||
|
return receipt;
|
||||||
|
},
|
||||||
|
async complete(command) {
|
||||||
|
assert.equal(command.outcome, 'succeeded');
|
||||||
|
assert.equal(command.resultDigest, receipt.receiptDigest);
|
||||||
|
return {
|
||||||
|
dispatch: executing.dispatch,
|
||||||
|
execution: completeApprovedActionExecution(executing.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
outcome: command.outcome,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
resultDigest: command.resultDigest,
|
||||||
|
completedAtMs: command.completedAtMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await subject.reconcile();
|
||||||
|
assert.equal(result.recoveredSucceeded, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks a completed executing Job when its durable receipt is missing', async () => {
|
||||||
|
const { plan, snapshot } = fixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const desired = desiredJob(plan, executing);
|
||||||
|
const subject = controller({
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
return {
|
||||||
|
...desired,
|
||||||
|
status: { conditions: [{ type: 'Complete', status: 'True' }] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async complete(command) {
|
||||||
|
assert.equal(command.outcome, 'indeterminate');
|
||||||
|
return {
|
||||||
|
dispatch: executing.dispatch,
|
||||||
|
execution: completeApprovedActionExecution(executing.execution, {
|
||||||
|
owner: command.owner,
|
||||||
|
leaseToken: command.leaseToken,
|
||||||
|
expectedVersion: command.expectedVersion,
|
||||||
|
resultMutationId: command.resultMutationId,
|
||||||
|
outcome: command.outcome,
|
||||||
|
resultCode: command.resultCode,
|
||||||
|
completedAtMs: command.completedAtMs,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await subject.reconcile();
|
||||||
|
assert.equal(result.recoveredBlocked, 1);
|
||||||
|
assert.equal(result.recoveryRequired, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fails closed when the durable binding differs from the approved result', async () => {
|
||||||
|
const { plan, snapshot } = fixture();
|
||||||
|
const executing = executingSnapshot(snapshot);
|
||||||
|
const desired = desiredJob(plan, executing);
|
||||||
|
const durable = createPluginPackageSecretBindingFromApprovalPlan(
|
||||||
|
plan,
|
||||||
|
executing.execution.startedAtMs,
|
||||||
|
);
|
||||||
|
const subject = controller({
|
||||||
|
snapshot: executing,
|
||||||
|
async read() {
|
||||||
|
return {
|
||||||
|
...desired,
|
||||||
|
status: { conditions: [{ type: 'Complete', status: 'True' }] },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async create() {
|
||||||
|
throw new Error('must not create');
|
||||||
|
},
|
||||||
|
async findBinding() {
|
||||||
|
return { ...durable, bindingDigest: 'f'.repeat(64) };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => subject.reconcile(),
|
||||||
|
PluginPackageKubernetesSecretActionControllerConflictError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('fails closed when a deterministic Job name contains another contract', async () => {
|
test('fails closed when a deterministic Job name contains another contract', async () => {
|
||||||
const subject = controller({
|
const subject = controller({
|
||||||
async read() {
|
async read() {
|
||||||
|
|||||||
Reference in New Issue
Block a user