mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): recover terminal secret action jobs
This commit is contained in:
+12
@@ -17,7 +17,9 @@ import {
|
||||
loadPostgresConnectionEnvironment,
|
||||
PostgresApprovedActionExecutionRepository,
|
||||
PostgresPluginPackageSecretBindingApprovalPlanReader,
|
||||
PostgresPluginPackageSecretBindingRepository,
|
||||
PostgresPluginPackageSecretBindingTransitionApprovalPlanReader,
|
||||
PostgresPluginPackageSecretBindingTransitionRepository,
|
||||
type PostgresConnectionOptions,
|
||||
type PostgresPoolOptions,
|
||||
type PostgresSchemaReadinessReport,
|
||||
@@ -631,6 +633,9 @@ function emptySecretActionJobSummary(): Readonly<PluginPackageKubernetesSecretAc
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveredSucceeded: 0,
|
||||
recoveredFailed: 0,
|
||||
recoveredBlocked: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
@@ -796,6 +801,13 @@ export async function runClusterPluginPackageExecutorProcess(
|
||||
new PostgresPluginPackageSecretBindingTransitionApprovalPlanReader(
|
||||
database.pool,
|
||||
),
|
||||
bindings: new PostgresPluginPackageSecretBindingRepository(
|
||||
database.pool,
|
||||
),
|
||||
transitionReceipts:
|
||||
new PostgresPluginPackageSecretBindingTransitionRepository(
|
||||
database.pool,
|
||||
),
|
||||
job: config.kubernetesSecretActions.job,
|
||||
...(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 {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_ACTION_TYPE,
|
||||
createPluginPackageSecretBindingFromApprovalPlan,
|
||||
normalizePluginPackageSecretBindingApprovalPlan,
|
||||
type PluginPackageSecretBindingApprovalPlan,
|
||||
} from '@qinglong/runtime-core/plugin-package-secret-binding-approval-plan';
|
||||
import type { PluginPackageSecretBinding } from '@qinglong/runtime-core/plugin-package-secret-binding';
|
||||
import {
|
||||
PLUGIN_PACKAGE_SECRET_BINDING_TRANSITION_ACTION_TYPE,
|
||||
normalizePluginPackageSecretBindingTransitionApprovalPlan,
|
||||
type PluginPackageSecretBindingTransitionApprovalPlan,
|
||||
} 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 {
|
||||
createPluginPackageKubernetesSecretActionJob,
|
||||
@@ -14,6 +29,8 @@ import {
|
||||
} from './pluginPackageKubernetesSecretActionJob';
|
||||
|
||||
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;
|
||||
|
||||
type SecretActionApprovalPlan =
|
||||
@@ -26,7 +43,7 @@ export interface PluginPackageSecretActionApprovalPlanReader<T> {
|
||||
findByActionRef(actionRef: string): Promise<Readonly<T> | null>;
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionExecutionReader {
|
||||
export interface PluginPackageKubernetesSecretActionExecutionPort {
|
||||
listReconciliableExecutions(query: Readonly<{
|
||||
nowMs: number;
|
||||
limit: number;
|
||||
@@ -35,6 +52,19 @@ export interface PluginPackageKubernetesSecretActionExecutionReader {
|
||||
executions: readonly Readonly<ApprovedActionExecutionSnapshot>[];
|
||||
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 {
|
||||
@@ -71,9 +101,11 @@ export interface PluginPackageKubernetesSecretActionJobApi {
|
||||
}
|
||||
|
||||
export interface PluginPackageKubernetesSecretActionControllerOptions {
|
||||
readonly executions: PluginPackageKubernetesSecretActionExecutionReader;
|
||||
readonly executions: PluginPackageKubernetesSecretActionExecutionPort;
|
||||
readonly bindingPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingApprovalPlan>;
|
||||
readonly transitionPlans: PluginPackageSecretActionApprovalPlanReader<PluginPackageSecretBindingTransitionApprovalPlan>;
|
||||
readonly bindings: PluginPackageSecretActionDurableResultReader<PluginPackageSecretBinding>;
|
||||
readonly transitionReceipts: PluginPackageSecretActionDurableResultReader<PluginPackageSecretBindingTransitionReceipt>;
|
||||
readonly jobs: PluginPackageKubernetesSecretActionJobApi;
|
||||
readonly job: Readonly<PluginPackageKubernetesSecretActionJobOptions>;
|
||||
readonly now?: () => number;
|
||||
@@ -84,6 +116,9 @@ export interface PluginPackageKubernetesSecretActionControllerSummary {
|
||||
readonly created: number;
|
||||
readonly existing: number;
|
||||
readonly active: number;
|
||||
readonly recoveredSucceeded: number;
|
||||
readonly recoveredFailed: number;
|
||||
readonly recoveredBlocked: number;
|
||||
readonly recoveryRequired: number;
|
||||
readonly unavailable: number;
|
||||
readonly truncated: boolean;
|
||||
@@ -214,10 +249,17 @@ export class PluginPackageKubernetesSecretActionController {
|
||||
typeof options !== 'object' ||
|
||||
!options.executions ||
|
||||
typeof options.executions.listReconciliableExecutions !== 'function' ||
|
||||
typeof options.executions.completeExecution !== 'function' ||
|
||||
typeof options.executions.claimExecution !== 'function' ||
|
||||
typeof options.executions.releaseExecutionBeforeStart !== 'function' ||
|
||||
!options.bindingPlans ||
|
||||
typeof options.bindingPlans.findByActionRef !== 'function' ||
|
||||
!options.transitionPlans ||
|
||||
typeof options.transitionPlans.findByActionRef !== 'function' ||
|
||||
!options.bindings ||
|
||||
typeof options.bindings.find !== 'function' ||
|
||||
!options.transitionReceipts ||
|
||||
typeof options.transitionReceipts.find !== 'function' ||
|
||||
!options.jobs ||
|
||||
typeof options.jobs.createNamespacedJob !== 'function' ||
|
||||
typeof options.jobs.readNamespacedJob !== 'function' ||
|
||||
@@ -269,6 +311,9 @@ export class PluginPackageKubernetesSecretActionController {
|
||||
created: 0,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveredSucceeded: 0,
|
||||
recoveredFailed: 0,
|
||||
recoveredBlocked: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: page.truncated,
|
||||
@@ -293,7 +338,15 @@ export class PluginPackageKubernetesSecretActionController {
|
||||
async #reconcileOne(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
nowMs: number,
|
||||
): Promise<'created' | 'existing' | 'active' | 'recoveryRequired'> {
|
||||
): Promise<
|
||||
| 'created'
|
||||
| 'existing'
|
||||
| 'active'
|
||||
| 'recoveredSucceeded'
|
||||
| 'recoveredFailed'
|
||||
| 'recoveredBlocked'
|
||||
| 'recoveryRequired'
|
||||
> {
|
||||
const plan = await this.#plan(snapshot);
|
||||
if (!plan) {
|
||||
throw new PluginPackageKubernetesSecretActionControllerUnavailableError();
|
||||
@@ -313,10 +366,22 @@ export class PluginPackageKubernetesSecretActionController {
|
||||
} catch (error) {
|
||||
if (apiStatus(error) !== 404) throw error;
|
||||
if (
|
||||
snapshot.execution.status === 'executing' ||
|
||||
nowMs > plan.expiresAtMs
|
||||
snapshot.execution.status === 'executing'
|
||||
) {
|
||||
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 {
|
||||
observed = await this.options.jobs.createNamespacedJob({
|
||||
@@ -343,10 +408,213 @@ export class PluginPackageKubernetesSecretActionController {
|
||||
}
|
||||
assertObservedJob(desired, observed);
|
||||
const terminal = terminalStatus(observed);
|
||||
if (terminal !== 'active') return 'recoveryRequired';
|
||||
if (terminal !== 'active') {
|
||||
return this.#recoverTerminal(snapshot, plan, name, terminal, nowMs);
|
||||
}
|
||||
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(
|
||||
snapshot: Readonly<ApprovedActionExecutionSnapshot>,
|
||||
): Promise<Readonly<SecretActionApprovalPlan> | null> {
|
||||
|
||||
@@ -253,6 +253,9 @@ test('batch mode consumes approvals before reconciling exact Secret action Jobs'
|
||||
},
|
||||
async createSecretActionController(options) {
|
||||
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');
|
||||
return {
|
||||
controller: {
|
||||
@@ -263,6 +266,9 @@ test('batch mode consumes approvals before reconciling exact Secret action Jobs'
|
||||
created: 1,
|
||||
existing: 0,
|
||||
active: 0,
|
||||
recoveredSucceeded: 0,
|
||||
recoveredFailed: 0,
|
||||
recoveredBlocked: 0,
|
||||
recoveryRequired: 0,
|
||||
unavailable: 0,
|
||||
truncated: false,
|
||||
|
||||
+534
-18
@@ -9,24 +9,44 @@ const {
|
||||
decideApprovalRequest,
|
||||
} = require('@qinglong/runtime-core/approved-action');
|
||||
const {
|
||||
claimApprovedActionExecution,
|
||||
completeApprovedActionExecution,
|
||||
createApprovedActionExecution,
|
||||
releaseApprovedActionExecutionBeforeStart,
|
||||
startApprovedActionExecution,
|
||||
} = require('@qinglong/runtime-core/approved-action-execution');
|
||||
const {
|
||||
createPluginPackageResourceGeneration,
|
||||
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
|
||||
const {
|
||||
createPluginPackageSecretBindingFromApprovalPlan,
|
||||
createPluginPackageSecretBindingApprovalPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan');
|
||||
const {
|
||||
createPluginPackageSecretBindingPlan,
|
||||
} = require('@qinglong/runtime-core/plugin-package-secret-binding-plan');
|
||||
const {
|
||||
createSecretRef,
|
||||
} = require('@qinglong/runtime-core/secret-reference');
|
||||
createPluginPackageSecretBinding,
|
||||
} = 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 {
|
||||
PluginPackageKubernetesSecretActionController,
|
||||
PluginPackageKubernetesSecretActionControllerConflictError,
|
||||
} = 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 REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
|
||||
@@ -97,8 +117,10 @@ function fixture() {
|
||||
requestedBy: REQUESTER,
|
||||
expiresAtMs: 1_000,
|
||||
});
|
||||
const action = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan')
|
||||
.pluginPackageSecretBindingApprovedAction(plan);
|
||||
const action =
|
||||
require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan').pluginPackageSecretBindingApprovedAction(
|
||||
plan,
|
||||
);
|
||||
const approved = decideApprovalRequest(
|
||||
createApprovalRequest({
|
||||
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) {
|
||||
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 selectedPlan = planOverride ?? plan;
|
||||
return new PluginPackageKubernetesSecretActionController({
|
||||
executions: {
|
||||
async listReconciliableExecutions(query) {
|
||||
@@ -185,16 +392,72 @@ function controller({ read, create, now = 200, snapshot: snapshotOverride }) {
|
||||
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: {
|
||||
async findByActionRef(actionRef) {
|
||||
assert.equal(actionRef, plan.actionRef);
|
||||
return plan;
|
||||
if (!('bindingPlan' in selectedPlan)) {
|
||||
throw new Error('binding reader must not run');
|
||||
}
|
||||
assert.equal(actionRef, selectedPlan.actionRef);
|
||||
return selectedPlan;
|
||||
},
|
||||
},
|
||||
transitionPlans: {
|
||||
async findByActionRef() {
|
||||
throw new Error('transition reader must not run');
|
||||
async findByActionRef(actionRef) {
|
||||
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: {
|
||||
@@ -228,7 +491,10 @@ test('creates one Strict deterministic Job without claiming the execution', asyn
|
||||
calls[1][1].fieldManager,
|
||||
'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 () => {
|
||||
@@ -284,10 +550,7 @@ test('does not recreate a missing Job for an already executing action', async ()
|
||||
const { snapshot } = fixture();
|
||||
let creates = 0;
|
||||
const subject = controller({
|
||||
snapshot: {
|
||||
...snapshot,
|
||||
execution: { ...snapshot.execution, status: 'executing' },
|
||||
},
|
||||
snapshot: executingSnapshot(snapshot),
|
||||
async read() {
|
||||
throw apiError(404);
|
||||
},
|
||||
@@ -301,7 +564,47 @@ test('does not recreate a missing Job for an already executing action', async ()
|
||||
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;
|
||||
const subject = controller({
|
||||
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();
|
||||
assert.equal(result.recoveryRequired, 1);
|
||||
assert.equal(result.recoveredBlocked, 1);
|
||||
assert.equal(result.recoveryRequired, 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;
|
||||
const subject = controller({
|
||||
async read() {
|
||||
@@ -336,10 +640,222 @@ test('marks a terminal Job with a nonterminal execution as recovery required', a
|
||||
},
|
||||
});
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const subject = controller({
|
||||
async read() {
|
||||
|
||||
Reference in New Issue
Block a user