Files
qinglong/packages/ql3-cluster-admin/test/pluginPackageKubernetesSecretActionController.test.cjs
T

367 lines
9.9 KiB
JavaScript

'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,
);
});