feat(ql3): admit plugin secret action jobs

This commit is contained in:
whyour
2026-08-14 10:22:14 +08:00
parent 75dbe7a4c4
commit 7f5339e047
27 changed files with 2019 additions and 10 deletions
@@ -27,6 +27,27 @@ function environment(overrides = {}) {
};
}
function actionControllerEnvironment(overrides = {}) {
return environment({
QL3_PLUGIN_PACKAGE_EXECUTOR_SECRET_ROOT: undefined,
QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_ENABLED: 'true',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_CONTROLLER_LIMIT: '6',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE:
'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' +
'c'.repeat(64),
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_SECRET:
'ql3-cluster-plugin-package-executor',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_CA_KEY: 'postgres-ca.crt',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_SERVERNAME:
'postgres.qinglong3-system.svc',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET:
'ql3-cluster-plugin-package-executor',
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_KEY:
'postgres-package-executor-url',
...overrides,
});
}
test('disabled executor opens no PostgreSQL authority', async () => {
let opened = 0;
const result = await runClusterPluginPackageExecutorProcess({
@@ -74,6 +95,27 @@ test('loads one bounded action-scoped dispatch without widening batch limits', (
assert.equal(config.maxBatches, 4);
});
test('loads a bounded digest-pinned Kubernetes Secret action controller', () => {
const config = loadClusterPluginPackageExecutorProcessConfig(
actionControllerEnvironment(),
);
assert.equal(config.enabled, true);
assert.equal(config.secretProjectionRoot, null);
assert.equal(config.kubernetesSecretActions.limit, 6);
assert.equal(
config.kubernetesSecretActions.job.serviceAccountName,
'ql3-plugin-package-secret-action',
);
assert.equal(
config.kubernetesSecretActions.job.postgres.connection.mode,
'url',
);
assert.equal(
config.kubernetesSecretActions.job.postgres.connection.secretName,
'ql3-cluster-plugin-package-executor',
);
});
test('action-scoped mode skips every Approval consumer and shared queue scan', async () => {
const calls = [];
const pool = {};
@@ -151,6 +193,129 @@ test('action-scoped mode skips every Approval consumer and shared queue scan', a
assert.equal(result.batches[0].dispatch.succeeded, 1);
});
test('batch mode consumes approvals before reconciling exact Secret action Jobs', async () => {
const calls = [];
const pool = {
async query() {
throw new Error('repositories are injected behind the controller factory');
},
async connect() {
throw new Error('repositories are injected behind the controller factory');
},
};
const approvalSummary = {
scanned: 0,
consumed: 0,
existing: 0,
expired: 0,
blocked: 0,
};
const readiness = {
ready: true,
writablePrimary: true,
serverVersionNum: 180004,
serverMajor: 18,
currentUser: 'ql3_package_executor',
contractName: 'control-core',
contractVersion: 62,
migrationIds: ['pg-0063-plugin-package-secret-binding-transition-receipts'],
};
const result = await runClusterPluginPackageExecutorProcess({
environment: actionControllerEnvironment(),
async openDatabase() {
calls.push('open');
return {
pool,
async close() {
calls.push('close');
},
};
},
async assertReady() {
calls.push('ready');
return readiness;
},
async consumeApprovals() {
calls.push('publisher-approvals');
return approvalSummary;
},
async consumeTrustTransitionApprovals() {
calls.push('trust-approvals');
return approvalSummary;
},
async consumeSecretBindingApprovals() {
calls.push('binding-approvals');
return { ...approvalSummary, scanned: 1, consumed: 1 };
},
async consumeSecretBindingTransitionApprovals() {
calls.push('transition-approvals');
return approvalSummary;
},
async createSecretActionController(options) {
assert.equal(options.job.image.endsWith('c'.repeat(64)), true);
calls.push('controller-open');
return {
controller: {
async reconcile({ limit }) {
calls.push(`reconcile:${limit}`);
return {
scanned: 1,
created: 1,
existing: 0,
active: 0,
recoveryRequired: 0,
unavailable: 0,
truncated: false,
};
},
},
dispose() {
calls.push('controller-close');
},
};
},
createDispatcher() {
return {
async dispatchById() {
throw new Error('batch mode must not exact dispatch');
},
async dispatchBatch() {
calls.push('dispatch-batch');
return {
scanned: 0,
claimed: 0,
started: 0,
succeeded: 0,
failed: 0,
blocked: 0,
retrying: 0,
deferred: 0,
recoveryRequired: 0,
alreadyTerminal: 0,
unavailable: 0,
truncated: false,
};
},
};
},
});
assert.equal(result.batches.length, 1);
assert.equal(result.batches[0].secretActionJobs.created, 1);
assert.deepEqual(calls, [
'open',
'ready',
'publisher-approvals',
'trust-approvals',
'binding-approvals',
'transition-approvals',
'controller-open',
'reconcile:6',
'dispatch-batch',
'controller-close',
'close',
]);
});
test('rejects implicit insecure PostgreSQL and unbounded work', () => {
for (const invalid of [
environment({ QL3_POSTGRES_ALLOW_INSECURE: undefined }),
@@ -161,6 +326,12 @@ test('rejects implicit insecure PostgreSQL and unbounded work', () => {
environment({
QL3_PLUGIN_PACKAGE_EXECUTOR_DISPATCH_ID: 'dispatch id with spaces',
}),
actionControllerEnvironment({
QL3_PLUGIN_PACKAGE_SECRET_ACTION_IMAGE: 'tag-only:latest',
}),
actionControllerEnvironment({
QL3_PLUGIN_PACKAGE_SECRET_ACTION_POSTGRES_URL_SECRET: undefined,
}),
]) {
assert.throws(
() => loadClusterPluginPackageExecutorProcessConfig(invalid),
@@ -0,0 +1,366 @@
'use strict';
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
consumeApprovalRequest,
createApprovalRequest,
decideApprovalRequest,
} = require('@qinglong/runtime-core/approved-action');
const {
createApprovedActionExecution,
} = require('@qinglong/runtime-core/approved-action-execution');
const {
createPluginPackageResourceGeneration,
} = require('@qinglong/runtime-core/plugin-package-resource-generation');
const {
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');
const {
PluginPackageKubernetesSecretActionController,
PluginPackageKubernetesSecretActionControllerConflictError,
} = require('@qinglong/cluster-admin/plugin-package-kubernetes-secret-action-controller');
const REQUESTER = Object.freeze({ type: 'user', id: 'cluster-owner' });
const REVIEWER = Object.freeze({ type: 'user', id: 'security-reviewer' });
const CONSUMER = Object.freeze({
type: 'system',
id: 'cluster_package_executor',
});
const FENCE = Object.freeze({ projectVersion: 3, bindingVersion: 4 });
function fixture() {
const manifest = {
apiVersion: 'qinglong.io/v1alpha1',
kind: 'Package',
metadata: {
name: 'controller-fixture',
displayName: 'Controller Fixture',
version: '1.0.0',
description: 'Secret action 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 generation = createPluginPackageResourceGeneration({
installationId: 'install-controller-1',
projectId: 'project-1',
packageName: 'controller-fixture',
lockDigest: 'a'.repeat(64),
generation: 1,
previousActiveLockDigest: null,
contentDigest: 'b'.repeat(64),
contents: manifest.spec.contents,
});
const bindingPlan = createPluginPackageSecretBindingPlan({
generation,
manifest,
assignments: [
{
name: 'TOKEN',
secretRef: createSecretRef({
projectId: 'project-1',
name: 'runtime-token',
version: 2,
}),
},
],
plannedAtMs: 100,
});
const plan = createPluginPackageSecretBindingApprovalPlan({
actionRef: 'secret-binding:controller-fixture',
bindingPlan,
requestedBy: REQUESTER,
expiresAtMs: 1_000,
});
const action = require('@qinglong/runtime-core/plugin-package-secret-binding-approval-plan')
.pluginPackageSecretBindingApprovedAction(plan);
const approved = decideApprovalRequest(
createApprovalRequest({
id: 'approval-controller-1',
projectId: 'project-1',
action,
risk: 'high',
decisionMode: 'separation_of_duty',
requestedBy: REQUESTER,
requestedAtMs: 110,
expiresAtMs: 900,
requestFence: FENCE,
}),
{
expectedVersion: 1,
decisionId: 'decision-controller-1',
decision: 'approved',
reasonCode: 'reviewed',
principal: {
subject: REVIEWER,
authenticationId: 'auth-reviewer',
authenticatedAtMs: 100,
expiresAtMs: 800,
assurance: 'multi_factor',
},
decidedAtMs: 120,
authorizationFence: FENCE,
},
);
const dispatch = consumeApprovalRequest(approved, {
expectedVersion: 2,
consumptionId: 'consume-controller-1',
dispatchId: 'dispatch-controller-1',
action,
requestedBy: REQUESTER,
consumedBy: CONSUMER,
consumedAtMs: 130,
authorizationFence: FENCE,
}).dispatch;
return {
plan,
snapshot: Object.freeze({
dispatch,
execution: createApprovedActionExecution(dispatch),
}),
};
}
function jobOptions() {
return {
namespace: 'qinglong3-system',
serviceAccountName: 'ql3-plugin-package-secret-action',
sourceSecretName: 'ql3-cluster-plugin-package-values',
image:
'registry.example.com/qinglong/qinglong3-cluster-admin@sha256:' +
'c'.repeat(64),
postgres: {
connection: {
mode: 'url',
secretName: 'ql3-cluster-plugin-package-executor',
urlKey: 'postgres-package-executor-url',
},
caSecretName: 'ql3-cluster-plugin-package-executor',
caKey: 'postgres-ca.crt',
servername: 'postgres.qinglong3-system.svc',
},
};
}
function apiError(code) {
return Object.assign(new Error(`Kubernetes ${code}`), { code });
}
function controller({ read, create, now = 200, snapshot: snapshotOverride }) {
const { plan, snapshot } = fixture();
return new PluginPackageKubernetesSecretActionController({
executions: {
async listReconciliableExecutions(query) {
assert.deepEqual(query.actionTypes, [
'plugin_package.secret_binding.bind',
'plugin_package.secret_binding.transition',
]);
return {
executions: [snapshotOverride ?? snapshot],
truncated: false,
};
},
},
bindingPlans: {
async findByActionRef(actionRef) {
assert.equal(actionRef, plan.actionRef);
return plan;
},
},
transitionPlans: {
async findByActionRef() {
throw new Error('transition reader must not run');
},
},
jobs: {
readNamespacedJob: read,
createNamespacedJob: create,
},
job: jobOptions(),
now: () => now,
});
}
test('creates one Strict deterministic Job without claiming the execution', async () => {
const calls = [];
const subject = controller({
async read(request) {
calls.push(['read', request]);
throw apiError(404);
},
async create(request) {
calls.push(['create', request]);
return request.body;
},
});
const result = await subject.reconcile({ limit: 4 });
assert.equal(result.created, 1);
assert.equal(result.recoveryRequired, 0);
assert.equal(calls[0][0], 'read');
assert.equal(calls[1][0], 'create');
assert.equal(calls[1][1].fieldValidation, 'Strict');
assert.equal(
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}$/);
});
test('converges a concurrent create through one exact get', async () => {
let desired;
let reads = 0;
const subject = controller({
async read() {
reads += 1;
if (reads === 1) throw apiError(404);
return {
...desired,
metadata: {
...desired.metadata,
uid: 'server-owned-uid',
},
spec: {
...desired.spec,
completionMode: 'NonIndexed',
},
};
},
async create(request) {
desired = request.body;
throw apiError(409);
},
});
const result = await subject.reconcile();
assert.equal(result.existing, 1);
assert.equal(reads, 2);
});
test('converges a lost successful CREATE response through one exact get', async () => {
let desired;
let reads = 0;
const subject = controller({
async read() {
reads += 1;
if (reads === 1) throw apiError(404);
return desired;
},
async create(request) {
desired = request.body;
throw apiError(503);
},
});
const result = await subject.reconcile();
assert.equal(result.existing, 1);
assert.equal(result.unavailable, 0);
assert.equal(reads, 2);
});
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' },
},
async read() {
throw apiError(404);
},
async create() {
creates += 1;
throw new Error('must not create');
},
});
const result = await subject.reconcile();
assert.equal(result.recoveryRequired, 1);
assert.equal(creates, 0);
});
test('does not create a missing Job after the approval plan expires', async () => {
let creates = 0;
const subject = controller({
now: 1_001,
async read() {
throw apiError(404);
},
async create() {
creates += 1;
throw new Error('must not create');
},
});
const result = await subject.reconcile();
assert.equal(result.recoveryRequired, 1);
assert.equal(creates, 0);
});
test('marks a terminal Job with a nonterminal execution as recovery required', async () => {
let desired;
const subject = controller({
async read() {
if (!desired) throw apiError(404);
return desired;
},
async create(request) {
desired = {
...request.body,
status: {
conditions: [{ type: 'Failed', status: 'True' }],
},
};
return desired;
},
});
const result = await subject.reconcile();
assert.equal(result.recoveryRequired, 1);
assert.equal(result.created, 0);
});
test('fails closed when a deterministic Job name contains another contract', async () => {
const subject = controller({
async read() {
const error = apiError(404);
throw error;
},
async create(request) {
return {
...request.body,
metadata: {
...request.body.metadata,
annotations: {
...request.body.metadata.annotations,
'qinglong.io/secret-action-job-digest': 'f'.repeat(64),
},
},
};
},
});
await assert.rejects(
() => subject.reconcile(),
PluginPackageKubernetesSecretActionControllerConflictError,
);
});