mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add cluster run management live gate
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
function imageIdDigest(image) {
|
||||
assert.match(image.Id, /^sha256:[a-f0-9]{64}$/);
|
||||
return image.Id;
|
||||
}
|
||||
|
||||
function localManifest(rendered, imageName, localImage) {
|
||||
const occurrences = rendered.split(imageName).length - 1;
|
||||
assert.ok(occurrences >= 1, 'reviewed image reference is missing');
|
||||
return rendered
|
||||
.replaceAll(imageName, localImage)
|
||||
.replaceAll('imagePullPolicy: IfNotPresent', 'imagePullPolicy: Never');
|
||||
}
|
||||
|
||||
function applySecret(fixture, name, type, stringData) {
|
||||
fixture.apply({
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
metadata: {
|
||||
name,
|
||||
namespace: 'qinglong3-system',
|
||||
labels: { 'cnpg.io/reload': 'true' },
|
||||
},
|
||||
immutable: false,
|
||||
type,
|
||||
stringData,
|
||||
});
|
||||
}
|
||||
|
||||
function psql(fixture, podName, sql) {
|
||||
return fixture.kubectl(
|
||||
[
|
||||
'-n',
|
||||
'qinglong3-system',
|
||||
'exec',
|
||||
podName,
|
||||
'-c',
|
||||
'postgres',
|
||||
'--',
|
||||
'psql',
|
||||
'--username=postgres',
|
||||
'--dbname=qinglong',
|
||||
'--no-psqlrc',
|
||||
'--tuples-only',
|
||||
'--no-align',
|
||||
'--set=ON_ERROR_STOP=1',
|
||||
'--command',
|
||||
sql,
|
||||
],
|
||||
{ capture: true, quiet: true },
|
||||
).stdout;
|
||||
}
|
||||
|
||||
function currentPrimaryPod(fixture) {
|
||||
const primaryName = fixture.kubectlJson([
|
||||
'-n',
|
||||
'qinglong3-system',
|
||||
'get',
|
||||
'cluster',
|
||||
'ql3-postgres',
|
||||
]).status.currentPrimary;
|
||||
assert.match(primaryName || '', /^ql3-postgres-[1-9][0-9]*$/);
|
||||
const pods = fixture.kubectlJson([
|
||||
'-n',
|
||||
'qinglong3-system',
|
||||
'get',
|
||||
'pods',
|
||||
'-l',
|
||||
'cnpg.io/cluster=ql3-postgres',
|
||||
]).items;
|
||||
const primary = pods.find((pod) => pod.metadata.name === primaryName);
|
||||
assert.ok(primary, 'CloudNativePG primary Pod not found');
|
||||
return primary;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applySecret,
|
||||
currentPrimaryPod,
|
||||
imageIdDigest,
|
||||
localManifest,
|
||||
psql,
|
||||
};
|
||||
@@ -100,9 +100,7 @@ async function waitForTwoPreserved(options) {
|
||||
'-l',
|
||||
'app.kubernetes.io/name=' + options.deployment,
|
||||
])
|
||||
.items.filter(
|
||||
(pod) => pod.metadata.deletionTimestamp === undefined,
|
||||
);
|
||||
.items.filter((pod) => pod.metadata.deletionTimestamp === undefined);
|
||||
const ready = pods.filter(podReady);
|
||||
minimumReady = Math.min(minimumReady, ready.length);
|
||||
const replacements = ready.filter(
|
||||
@@ -117,10 +115,7 @@ async function waitForTwoPreserved(options) {
|
||||
: {
|
||||
ready: false,
|
||||
fact:
|
||||
ready.length +
|
||||
' ready, ' +
|
||||
replacements.length +
|
||||
' replacements',
|
||||
ready.length + ' ready, ' + replacements.length + ' replacements',
|
||||
};
|
||||
});
|
||||
assert.ok(
|
||||
@@ -131,6 +126,17 @@ async function waitForTwoPreserved(options) {
|
||||
}
|
||||
|
||||
function createManagementClientExecutor(options) {
|
||||
const retryableClientCodes = options.retryableClientCodes ?? [
|
||||
'QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED',
|
||||
];
|
||||
assert.ok(
|
||||
Array.isArray(retryableClientCodes) &&
|
||||
retryableClientCodes.length >= 1 &&
|
||||
retryableClientCodes.every(
|
||||
(code) => typeof code === 'string' && /^[A-Z0-9_]{1,128}$/.test(code),
|
||||
),
|
||||
'management client retryable codes are invalid',
|
||||
);
|
||||
options.fixture.apply({
|
||||
apiVersion: 'v1',
|
||||
kind: 'ServiceAccount',
|
||||
@@ -243,9 +249,14 @@ function createManagementClientExecutor(options) {
|
||||
'--command=/tmp/command.json ' +
|
||||
'--assertion=/tmp/assertion.jwt 2>&1)"',
|
||||
' status=$?',
|
||||
' if [ "$status" -eq 0 ] || { ! printf \'%s\' "$output" | ' +
|
||||
'grep -q QL3_PLUGIN_PACKAGE_MANAGEMENT_CLIENT_REQUEST_FAILED && ' +
|
||||
'! printf \'%s\' "$output" | grep -q \'"statusCode":503\'; } || ' +
|
||||
' if [ "$status" -eq 0 ] || { ' +
|
||||
retryableClientCodes
|
||||
.map(
|
||||
(code) =>
|
||||
'! printf \'%s\' "$output" | grep -q ' + code,
|
||||
)
|
||||
.join(' && ') +
|
||||
' && ! printf \'%s\' "$output" | grep -q \'"statusCode":503\'; } || ' +
|
||||
'[ "$attempt" -ge 60 ]; then',
|
||||
' break',
|
||||
' fi',
|
||||
@@ -373,7 +384,10 @@ function createManagementClientExecutor(options) {
|
||||
assert.equal(output.event, 'command_completed');
|
||||
assert.equal(output.result.operation, definition.command.operation);
|
||||
if (expected.resultStatus) {
|
||||
assert.ok(expected.resultStatus.includes(output.result.status));
|
||||
const status = expected.resultField
|
||||
? output.result[expected.resultField]?.status
|
||||
: output.result.status;
|
||||
assert.ok(expected.resultStatus.includes(status));
|
||||
}
|
||||
} else {
|
||||
assert.equal(
|
||||
@@ -409,6 +423,36 @@ function createManagementClientExecutor(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function managementHealthStatus(options) {
|
||||
const script = [
|
||||
"const fs=require('node:fs');const https=require('node:https');",
|
||||
"const request=https.request({host:'127.0.0.1',port:Number(process.argv[1]),path:process.argv[2],",
|
||||
'servername:process.argv[3],ca:fs.readFileSync(process.argv[4]),',
|
||||
"minVersion:'TLSv1.3',maxVersion:'TLSv1.3',rejectUnauthorized:true,agent:false},",
|
||||
"(response)=>{response.resume();response.on('end',()=>process.stdout.write(String(response.statusCode)))});",
|
||||
"request.on('error',(error)=>{process.stderr.write(error.message);process.exitCode=1});request.end();",
|
||||
].join('\n');
|
||||
return Number(
|
||||
options.fixture.kubectl(
|
||||
[
|
||||
'-n',
|
||||
options.namespace,
|
||||
'exec',
|
||||
options.podName,
|
||||
'--',
|
||||
'node',
|
||||
'-e',
|
||||
script,
|
||||
String(options.port),
|
||||
options.route,
|
||||
options.servername,
|
||||
options.caFile,
|
||||
],
|
||||
{ capture: true, quiet: true },
|
||||
).stdout,
|
||||
);
|
||||
}
|
||||
|
||||
function podTcpProbe(options) {
|
||||
const script = [
|
||||
"const net=require('node:net');let finished=false;",
|
||||
@@ -437,9 +481,7 @@ function podTcpProbe(options) {
|
||||
async function clientTcpProbe(options) {
|
||||
const labels = {
|
||||
'app.kubernetes.io/name': options.appName,
|
||||
...(options.labelled
|
||||
? { [options.networkPolicyLabel]: 'true' }
|
||||
: {}),
|
||||
...(options.labelled ? { [options.networkPolicyLabel]: 'true' } : {}),
|
||||
};
|
||||
const script = [
|
||||
"const fs=require('node:fs');const net=require('node:net');let finished=false;let attempt=0;let socket;",
|
||||
@@ -497,30 +539,25 @@ async function clientTcpProbe(options) {
|
||||
},
|
||||
},
|
||||
});
|
||||
const observed = await waitFor(
|
||||
options.name + ' completion',
|
||||
180_000,
|
||||
() => {
|
||||
const job = options.fixture.kubectlJson([
|
||||
'-n',
|
||||
options.namespace,
|
||||
'get',
|
||||
'job',
|
||||
options.name,
|
||||
]);
|
||||
const complete = job.status.conditions?.some(
|
||||
(condition) =>
|
||||
condition.type === 'Complete' && condition.status === 'True',
|
||||
);
|
||||
const failed = job.status.conditions?.some(
|
||||
(condition) =>
|
||||
condition.type === 'Failed' && condition.status === 'True',
|
||||
);
|
||||
return complete || failed
|
||||
? { ready: true, value: { complete, failed } }
|
||||
: { ready: false, fact: JSON.stringify(job.status ?? {}) };
|
||||
},
|
||||
);
|
||||
const observed = await waitFor(options.name + ' completion', 180_000, () => {
|
||||
const job = options.fixture.kubectlJson([
|
||||
'-n',
|
||||
options.namespace,
|
||||
'get',
|
||||
'job',
|
||||
options.name,
|
||||
]);
|
||||
const complete = job.status.conditions?.some(
|
||||
(condition) =>
|
||||
condition.type === 'Complete' && condition.status === 'True',
|
||||
);
|
||||
const failed = job.status.conditions?.some(
|
||||
(condition) => condition.type === 'Failed' && condition.status === 'True',
|
||||
);
|
||||
return complete || failed
|
||||
? { ready: true, value: { complete, failed } }
|
||||
: { ready: false, fact: JSON.stringify(job.status ?? {}) };
|
||||
});
|
||||
const probePod = (
|
||||
await waitFor(options.name + ' terminal pod', 30_000, () => {
|
||||
const pods = options.fixture.kubectlJson([
|
||||
@@ -541,16 +578,8 @@ async function clientTcpProbe(options) {
|
||||
const terminated = probePod.status.containerStatuses[0].state.terminated;
|
||||
const observation =
|
||||
options.name + ': ' + (terminated.message ?? 'no-message');
|
||||
assert.equal(
|
||||
observed.value.complete,
|
||||
options.expectedConnected,
|
||||
observation,
|
||||
);
|
||||
assert.equal(
|
||||
observed.value.failed,
|
||||
!options.expectedConnected,
|
||||
observation,
|
||||
);
|
||||
assert.equal(observed.value.complete, options.expectedConnected, observation);
|
||||
assert.equal(observed.value.failed, !options.expectedConnected, observation);
|
||||
assert.equal(
|
||||
terminated.exitCode === 0,
|
||||
options.expectedConnected,
|
||||
@@ -562,14 +591,7 @@ async function clientTcpProbe(options) {
|
||||
assert.match(terminated.message ?? '', /^denied:/);
|
||||
}
|
||||
options.fixture.kubectl(
|
||||
[
|
||||
'-n',
|
||||
options.namespace,
|
||||
'delete',
|
||||
'job',
|
||||
options.name,
|
||||
'--wait=false',
|
||||
],
|
||||
['-n', options.namespace, 'delete', 'job', options.name, '--wait=false'],
|
||||
{ capture: true, quiet: true },
|
||||
);
|
||||
return options.expectedConnected
|
||||
@@ -580,6 +602,7 @@ async function clientTcpProbe(options) {
|
||||
module.exports = {
|
||||
clientTcpProbe,
|
||||
createManagementClientExecutor,
|
||||
managementHealthStatus,
|
||||
patchManagementGeneration,
|
||||
podReady,
|
||||
podTcpProbe,
|
||||
|
||||
@@ -57,7 +57,8 @@ function createManagementIdentityCeremony(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function assertion(key, suffix = crypto.randomUUID()) {
|
||||
function assertionForSubject(key, subject, suffix = crypto.randomUUID()) {
|
||||
assert.match(subject, /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
@@ -77,6 +78,43 @@ function createManagementIdentityCeremony(options) {
|
||||
iss: options.issuer,
|
||||
jti: options.jtiPrefix + '-' + suffix,
|
||||
ql3_purpose: options.purpose,
|
||||
sub: subject,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = header + '.' + payload;
|
||||
return (
|
||||
signed +
|
||||
'.' +
|
||||
crypto
|
||||
.sign(null, Buffer.from(signed, 'ascii'), key.privateKey)
|
||||
.toString('base64url')
|
||||
);
|
||||
}
|
||||
|
||||
function assertion(key, suffix = crypto.randomUUID()) {
|
||||
return assertionForSubject(key, options.subject, suffix);
|
||||
}
|
||||
|
||||
function weakAssertion(key, suffix = crypto.randomUUID()) {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: options.tokenType,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:password',
|
||||
amr: ['pwd'],
|
||||
aud: options.audience,
|
||||
auth_time: now - 1,
|
||||
exp: now + 290,
|
||||
iat: now,
|
||||
iss: options.issuer,
|
||||
jti: options.jtiPrefix + '-weak-' + suffix,
|
||||
ql3_purpose: options.purpose,
|
||||
sub: options.subject,
|
||||
}),
|
||||
).toString('base64url');
|
||||
@@ -90,7 +128,13 @@ function createManagementIdentityCeremony(options) {
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({ assertion, keyset, reviewedKey });
|
||||
return Object.freeze({
|
||||
assertion,
|
||||
assertionForSubject,
|
||||
keyset,
|
||||
reviewedKey,
|
||||
weakAssertion,
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { createManagementIdentityCeremony };
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
function eventId(ordinal) {
|
||||
assert.ok(Number.isSafeInteger(ordinal) && ordinal >= 1 && ordinal < 1e12);
|
||||
return '41000000-0000-4000-8000-' + String(ordinal).padStart(12, '0');
|
||||
}
|
||||
|
||||
function retryCommand(projectId, sourceRunId, requestId, mutationId, ordinal) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'run.retry',
|
||||
request: Object.freeze({
|
||||
projectId,
|
||||
sourceRunId,
|
||||
requestId,
|
||||
auditEventId: eventId(ordinal),
|
||||
failureAuditEventId: eventId(ordinal + 500_000),
|
||||
body: Object.freeze({
|
||||
schema: 'qinglong/run-manual-retry@v1',
|
||||
mutationId,
|
||||
expectedRunVersion: 3,
|
||||
expectedRunStatus: 'failed',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function stopCommand(projectId, runId, requestId, mutationId, ordinal) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'run.stop',
|
||||
request: Object.freeze({
|
||||
projectId,
|
||||
runId,
|
||||
requestId,
|
||||
auditEventId: eventId(ordinal),
|
||||
failureAuditEventId: eventId(ordinal + 500_000),
|
||||
body: Object.freeze({
|
||||
schema: 'qinglong/run-cancellation@v1',
|
||||
mutationId,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function sqlString(value) {
|
||||
assert.equal(typeof value, 'string');
|
||||
return "'" + value.replaceAll("'", "''") + "'";
|
||||
}
|
||||
|
||||
function seedRunManagement(fixture, podName, values, psql) {
|
||||
const nowMs = Date.now();
|
||||
const sourceDigest = 'a'.repeat(64);
|
||||
const taskRevision = `qltd:v1:1:${sourceDigest}`;
|
||||
const taskDigest = 'b'.repeat(64);
|
||||
const planDigest = 'c'.repeat(64);
|
||||
psql(
|
||||
fixture,
|
||||
podName,
|
||||
[
|
||||
'BEGIN;',
|
||||
'INSERT INTO "ql3"."projects" (id, name, slug, status, version, created_at_ms, updated_at_ms)',
|
||||
`VALUES (${sqlString(
|
||||
values.projectId,
|
||||
)}, 'Run Management Live', ${sqlString(
|
||||
values.projectId,
|
||||
)}, 'active', 1, ${nowMs}, ${nowMs});`,
|
||||
'INSERT INTO "ql3"."project_role_bindings" (project_id, subject_type, subject_id, version, state, role, mutation_id, changed_by_type, changed_by_id, created_at_ms)',
|
||||
`VALUES (${sqlString(values.projectId)}, 'user', ${sqlString(
|
||||
values.operatorId,
|
||||
)}, 1, 'active', 'operator', ${sqlString(
|
||||
'binding-' + values.suffix,
|
||||
)}, 'system', 'run-management-live', ${nowMs});`,
|
||||
'INSERT INTO "ql3"."task_definitions" (project_id, task_id, current_revision, created_at_ms, updated_at_ms)',
|
||||
`VALUES (${sqlString(values.projectId)}, ${sqlString(
|
||||
values.taskId,
|
||||
)}, 1, ${nowMs}, ${nowMs});`,
|
||||
'INSERT INTO "ql3"."task_definition_revisions" (project_id, task_id, revision, mutation_id, name, kind, spec_json, labels_json, enabled, content_digest, created_at_ms)',
|
||||
`VALUES (${sqlString(values.projectId)}, ${sqlString(
|
||||
values.taskId,
|
||||
)}, 1, ${sqlString(
|
||||
crypto.randomUUID(),
|
||||
)}::uuid, 'Run Management Live Task', 'command', '{"schema":"qinglong/command@v1","config":{"command":{"kind":"argv","file":"/bin/echo","args":["run-management-live"]}}}'::jsonb, '{}'::jsonb, true, ${sqlString(
|
||||
taskDigest,
|
||||
)}, ${nowMs});`,
|
||||
'INSERT INTO "ql3"."task_execution_revisions" (project_id, task_id, source_revision, task_revision, source_content_digest, executor_type, plan_schema, plan_json, content_digest, created_at_ms)',
|
||||
`VALUES (${sqlString(values.projectId)}, ${sqlString(
|
||||
values.taskId,
|
||||
)}, 1, ${sqlString(taskRevision)}, ${sqlString(
|
||||
sourceDigest,
|
||||
)}, 'remote_worker', 'qinglong/command-execution@v1', '{"file":"/bin/echo","args":["run-management-live"]}'::jsonb, ${sqlString(
|
||||
planDigest,
|
||||
)}, ${nowMs});`,
|
||||
'INSERT INTO "ql3"."runs" (id, project_id, task_id, task_revision, task_name, task_snapshot_ref, trigger_type, execution_origin, execution_owner, status, version, event_sequence, priority, created_at_ms, queued_at_ms, finished_at_ms, error_code, error_summary)',
|
||||
`VALUES (${sqlString(values.sourceRunId)}, ${sqlString(
|
||||
values.projectId,
|
||||
)}, ${sqlString(values.taskId)}, ${sqlString(
|
||||
taskRevision,
|
||||
)}, 'Run Management Live Task', ${sqlString(
|
||||
taskRevision,
|
||||
)}, 'manual', 'manual', 'runtime', 'failed', 3, 3, 0, ${nowMs}, ${nowMs}, ${nowMs}, 'LIVE_SOURCE_FAILURE', 'terminal source for Run management live');`,
|
||||
'INSERT INTO "ql3"."run_attempts" (id, run_id, attempt, status, executor_type, callback_sequence, created_at_ms, finished_at_ms, error_code, error_summary)',
|
||||
`VALUES (${sqlString(values.sourceAttemptId)}, ${sqlString(
|
||||
values.sourceRunId,
|
||||
)}, 1, 'failed', 'remote_worker', 0, ${nowMs}, ${nowMs}, 'LIVE_SOURCE_FAILURE', 'terminal source for Run management live');`,
|
||||
'INSERT INTO "ql3"."run_events" (id, run_id, sequence, type, dedupe_key, actor_type, actor_id, attempt_id, payload, created_at_ms)',
|
||||
`VALUES (${sqlString(crypto.randomUUID())}, ${sqlString(
|
||||
values.sourceRunId,
|
||||
)}, 1, 'run.created', ${sqlString(
|
||||
'run-management-live-created-' + values.suffix,
|
||||
)}, 'user', ${sqlString(values.operatorId)}, ${sqlString(
|
||||
values.sourceAttemptId,
|
||||
)}, '{"status":"created","version":1}'::jsonb, ${nowMs}),`,
|
||||
`(${sqlString(crypto.randomUUID())}, ${sqlString(
|
||||
values.sourceRunId,
|
||||
)}, 2, 'run.queued', ${sqlString(
|
||||
'run-management-live-queued-' + values.suffix,
|
||||
)}, 'user', ${sqlString(values.operatorId)}, ${sqlString(
|
||||
values.sourceAttemptId,
|
||||
)}, '{"from_status":"created","to_status":"queued","version":2}'::jsonb, ${nowMs}),`,
|
||||
`(${sqlString(crypto.randomUUID())}, ${sqlString(
|
||||
values.sourceRunId,
|
||||
)}, 3, 'run.failed', ${sqlString(
|
||||
'run-management-live-failed-' + values.suffix,
|
||||
)}, 'executor', 'run-management-live', ${sqlString(
|
||||
values.sourceAttemptId,
|
||||
)}, '{"from_status":"queued","to_status":"failed","version":3,"error_code":"LIVE_SOURCE_FAILURE"}'::jsonb, ${nowMs});`,
|
||||
'COMMIT;',
|
||||
].join('\n'),
|
||||
);
|
||||
return Object.freeze({ taskRevision, taskDigest, planDigest });
|
||||
}
|
||||
|
||||
function durableRunManagementFacts(fixture, podName, values, psql) {
|
||||
return JSON.parse(
|
||||
psql(
|
||||
fixture,
|
||||
podName,
|
||||
[
|
||||
'SELECT json_build_object(',
|
||||
' \'sourceRunStatus\', (SELECT status FROM "ql3"."runs" WHERE id = ' +
|
||||
sqlString(values.sourceRunId) +
|
||||
'),',
|
||||
' \'retryRunCount\', (SELECT count(*)::integer FROM "ql3"."runs" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND trigger_type = 'run_manual_retry'),",
|
||||
' \'retryAttemptCount\', (SELECT count(*)::integer FROM "ql3"."run_attempts" AS attempt JOIN "ql3"."runs" AS run ON run.id = attempt.run_id WHERE run.project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND run.trigger_type = 'run_manual_retry'),",
|
||||
' \'retryEventCount\', (SELECT count(*)::integer FROM "ql3"."run_events" AS event JOIN "ql3"."runs" AS run ON run.id = event.run_id WHERE run.project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND run.trigger_type = 'run_manual_retry' AND event.type IN ('run.created', 'run.queued')),",
|
||||
' \'stoppedRunCount\', (SELECT count(*)::integer FROM "ql3"."runs" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND trigger_type = 'run_manual_retry' AND cancel_requested_at_ms IS NOT NULL AND cancel_reason = 'user'),",
|
||||
' \'stopEventCount\', (SELECT count(*)::integer FROM "ql3"."run_events" AS event JOIN "ql3"."runs" AS run ON run.id = event.run_id WHERE run.project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND event.type = 'run.cancel_requested'),",
|
||||
' \'allowedAuditCount\', (SELECT count(*)::integer FROM "ql3"."security_audit_events" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND operation_id IN ('run.retry', 'run.stop') AND outcome = 'allowed'),",
|
||||
' \'deniedAuditCount\', (SELECT count(*)::integer FROM "ql3"."security_audit_events" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND operation_id IN ('run.retry', 'run.stop') AND outcome = 'denied'),",
|
||||
' \'weakAuthenticationAuditCount\', (SELECT count(*)::integer FROM "ql3"."security_audit_events" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND request_id = 'run-live-weak'),",
|
||||
' \'duplicateMutationCount\', greatest(0, (SELECT count(*)::integer FROM "ql3"."runs" WHERE project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
' AND trigger_type = \'run_manual_retry\') - 1) + greatest(0, (SELECT count(*)::integer FROM "ql3"."run_events" AS event JOIN "ql3"."runs" AS run ON run.id = event.run_id WHERE run.project_id = ' +
|
||||
sqlString(values.projectId) +
|
||||
" AND event.type = 'run.cancel_requested') - 1),",
|
||||
' \'identityGeneration\', (SELECT generation::integer FROM "ql3"."plugin_package_identity_keyset_ledger" WHERE authority = \'run-management\'),',
|
||||
' \'migrationCount\', (SELECT count(*)::integer FROM "ql3"."schema_migrations"),',
|
||||
' \'controlCoreCapability\', (SELECT contract_version::integer FROM "ql3"."schema_capabilities" WHERE contract_name = \'control-core\'),',
|
||||
" 'postgresVersionNumber', current_setting('server_version_num')::integer)",
|
||||
].join('\n'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
durableRunManagementFacts,
|
||||
eventId,
|
||||
retryCommand,
|
||||
seedRunManagement,
|
||||
sqlString,
|
||||
stopCommand,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ const { createMutualTlsPki } = require('./lib/ql3-live-pki.cjs');
|
||||
const {
|
||||
clientTcpProbe,
|
||||
createManagementClientExecutor,
|
||||
managementHealthStatus,
|
||||
patchManagementGeneration,
|
||||
podReady,
|
||||
podTcpProbe,
|
||||
@@ -90,10 +91,7 @@ const identity = createManagementIdentityCeremony({
|
||||
});
|
||||
|
||||
function sha256(value) {
|
||||
return (
|
||||
'sha256:' +
|
||||
crypto.createHash('sha256').update(value).digest('hex')
|
||||
);
|
||||
return 'sha256:' + crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function randomSecret() {
|
||||
@@ -102,9 +100,7 @@ function randomSecret() {
|
||||
|
||||
function eventId(ordinal) {
|
||||
assert.ok(Number.isSafeInteger(ordinal) && ordinal >= 1 && ordinal < 1e12);
|
||||
return (
|
||||
'40000000-0000-4000-8000-' + String(ordinal).padStart(12, '0')
|
||||
);
|
||||
return '40000000-0000-4000-8000-' + String(ordinal).padStart(12, '0');
|
||||
}
|
||||
|
||||
function reviewedKey(kid) {
|
||||
@@ -119,75 +115,12 @@ function assertion(key, suffix) {
|
||||
return identity.assertion(key, suffix);
|
||||
}
|
||||
|
||||
function assertionForSubject(
|
||||
key,
|
||||
subject,
|
||||
suffix = crypto.randomUUID(),
|
||||
) {
|
||||
assert.match(subject, /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/);
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-approval-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:mfa',
|
||||
amr: ['pwd', 'otp'],
|
||||
aud: AUDIENCE,
|
||||
auth_time: now - 1,
|
||||
exp: now + 290,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: 'ql3-approval-live-subject-' + suffix,
|
||||
ql3_purpose: 'approval-management',
|
||||
sub: subject,
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = header + '.' + payload;
|
||||
return (
|
||||
signed +
|
||||
'.' +
|
||||
crypto
|
||||
.sign(null, Buffer.from(signed, 'ascii'), key.privateKey)
|
||||
.toString('base64url')
|
||||
);
|
||||
function assertionForSubject(key, subject, suffix = crypto.randomUUID()) {
|
||||
return identity.assertionForSubject(key, subject, 'subject-' + suffix);
|
||||
}
|
||||
|
||||
function weakAssertion(key, suffix = crypto.randomUUID()) {
|
||||
const now = Math.floor(Date.now() / 1_000);
|
||||
const header = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: 'EdDSA',
|
||||
kid: key.kid,
|
||||
typ: 'ql3-approval-management+jwt',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
acr: 'urn:ql3:password',
|
||||
amr: ['pwd'],
|
||||
aud: AUDIENCE,
|
||||
auth_time: now - 1,
|
||||
exp: now + 290,
|
||||
iat: now,
|
||||
iss: ISSUER,
|
||||
jti: 'ql3-approval-live-weak-' + suffix,
|
||||
ql3_purpose: 'approval-management',
|
||||
sub: 'approval-operator',
|
||||
}),
|
||||
).toString('base64url');
|
||||
const signed = header + '.' + payload;
|
||||
return (
|
||||
signed +
|
||||
'.' +
|
||||
crypto
|
||||
.sign(null, Buffer.from(signed, 'ascii'), key.privateKey)
|
||||
.toString('base64url')
|
||||
);
|
||||
return identity.weakAssertion(key, suffix);
|
||||
}
|
||||
|
||||
function commandBase(projectId, approvalRequestId, requestId, ordinal) {
|
||||
@@ -204,12 +137,7 @@ function inspectCommand(projectId, approvalRequestId, requestId, ordinal) {
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.inspect',
|
||||
request: commandBase(
|
||||
projectId,
|
||||
approvalRequestId,
|
||||
requestId,
|
||||
ordinal,
|
||||
),
|
||||
request: commandBase(projectId, approvalRequestId, requestId, ordinal),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,12 +152,7 @@ function decisionCommand(
|
||||
schemaVersion: 1,
|
||||
operation: 'approval.decide',
|
||||
request: Object.freeze({
|
||||
...commandBase(
|
||||
projectId,
|
||||
approvalRequestId,
|
||||
requestId,
|
||||
ordinal,
|
||||
),
|
||||
...commandBase(projectId, approvalRequestId, requestId, ordinal),
|
||||
expectedVersion: 1,
|
||||
expectedAction: ACTION,
|
||||
decisionId,
|
||||
@@ -327,12 +250,7 @@ function loadApprovalContract() {
|
||||
return require(file);
|
||||
}
|
||||
|
||||
function seedApproval(
|
||||
fixture,
|
||||
primaryPod,
|
||||
projectId,
|
||||
approvalRequestId,
|
||||
) {
|
||||
function seedApproval(fixture, primaryPod, projectId, approvalRequestId) {
|
||||
const { approvalRequestDigest, createApprovalRequest } =
|
||||
loadApprovalContract();
|
||||
const requestedAtMs = Date.now() - 1_000;
|
||||
@@ -422,31 +340,15 @@ function patchGeneration(fixture, generation, annotations = {}) {
|
||||
}
|
||||
|
||||
function healthStatus(fixture, pod, route) {
|
||||
const script = [
|
||||
"const fs=require('node:fs');const https=require('node:https');",
|
||||
"const request=https.request({host:'127.0.0.1',port:8447,path:process.argv[1],",
|
||||
"servername:process.argv[2],ca:fs.readFileSync('/var/run/secrets/qinglong3/approval-management-tls/ca.crt'),",
|
||||
"minVersion:'TLSv1.3',maxVersion:'TLSv1.3',rejectUnauthorized:true,agent:false},",
|
||||
"(response)=>{response.resume();response.on('end',()=>process.stdout.write(String(response.statusCode)))});",
|
||||
"request.on('error',(error)=>{process.stderr.write(error.message);process.exitCode=1});request.end();",
|
||||
].join('\n');
|
||||
return Number(
|
||||
fixture.kubectl(
|
||||
[
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'exec',
|
||||
pod.metadata.name,
|
||||
'--',
|
||||
'node',
|
||||
'-e',
|
||||
script,
|
||||
route,
|
||||
SERVERNAME,
|
||||
],
|
||||
{ capture: true, quiet: true },
|
||||
).stdout,
|
||||
);
|
||||
return managementHealthStatus({
|
||||
fixture,
|
||||
namespace: NAMESPACE,
|
||||
podName: pod.metadata.name,
|
||||
port: 8447,
|
||||
route,
|
||||
servername: SERVERNAME,
|
||||
caFile: '/var/run/secrets/qinglong3/approval-management-tls/ca.crt',
|
||||
});
|
||||
}
|
||||
|
||||
function privateReportPath(argv) {
|
||||
@@ -507,10 +409,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
);
|
||||
const preloadTag = imageTag(reviewedImage);
|
||||
run(fixture.docker, ['tag', reviewedImage, preloadTag]);
|
||||
fixture.loadImage(
|
||||
preloadTag,
|
||||
path.basename(preloadTag) + '.tar',
|
||||
);
|
||||
fixture.loadImage(preloadTag, path.basename(preloadTag) + '.tar');
|
||||
}
|
||||
|
||||
const sourceRevision = run('git', ['rev-parse', 'HEAD'], {
|
||||
@@ -615,28 +514,24 @@ async function main(argv = process.argv.slice(2)) {
|
||||
'--timeout=20m',
|
||||
]);
|
||||
const databasePods = (
|
||||
await waitFor(
|
||||
'three ready CloudNativePG instances',
|
||||
600_000,
|
||||
() => {
|
||||
const pods = fixture
|
||||
.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'pods',
|
||||
'-l',
|
||||
'cnpg.io/cluster=' + POSTGRES_CLUSTER,
|
||||
])
|
||||
.items.filter(podReady);
|
||||
return pods.length === 3
|
||||
? { ready: true, value: pods }
|
||||
: {
|
||||
ready: false,
|
||||
fact: pods.length + '/3 ready database Pods',
|
||||
};
|
||||
},
|
||||
)
|
||||
await waitFor('three ready CloudNativePG instances', 600_000, () => {
|
||||
const pods = fixture
|
||||
.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'pods',
|
||||
'-l',
|
||||
'cnpg.io/cluster=' + POSTGRES_CLUSTER,
|
||||
])
|
||||
.items.filter(podReady);
|
||||
return pods.length === 3
|
||||
? { ready: true, value: pods }
|
||||
: {
|
||||
ready: false,
|
||||
fact: pods.length + '/3 ready database Pods',
|
||||
};
|
||||
})
|
||||
).value;
|
||||
|
||||
const migrationManifest = localManifest(
|
||||
@@ -685,12 +580,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
const approvalRequestId = 'approval-request-' + suffix;
|
||||
const decisionId = 'approval-decision-' + suffix;
|
||||
const primary = currentPrimaryPod(fixture);
|
||||
seedApproval(
|
||||
fixture,
|
||||
primary,
|
||||
projectId,
|
||||
approvalRequestId,
|
||||
);
|
||||
seedApproval(fixture, primary, projectId, approvalRequestId);
|
||||
|
||||
const pki = createMutualTlsPki({
|
||||
directory: fixture.temporary,
|
||||
@@ -764,13 +654,8 @@ async function main(argv = process.argv.slice(2)) {
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
fixture.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'pdb',
|
||||
DEPLOYMENT,
|
||||
]).spec.minAvailable,
|
||||
fixture.kubectlJson(['-n', NAMESPACE, 'get', 'pdb', DEPLOYMENT]).spec
|
||||
.minAvailable,
|
||||
1,
|
||||
);
|
||||
for (const pod of managerPods) {
|
||||
@@ -876,9 +761,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
{ statusCode: 403, responseCode: 'forbidden' },
|
||||
);
|
||||
|
||||
const generation1Uids = new Set(
|
||||
managerPods.map((pod) => pod.metadata.uid),
|
||||
);
|
||||
const generation1Uids = new Set(managerPods.map((pod) => pod.metadata.uid));
|
||||
applyIdentity(keysets[1]);
|
||||
patchGeneration(fixture, 2);
|
||||
const generation2 = await waitForTwoPreserved({
|
||||
@@ -925,9 +808,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
assert.equal(decided.output.result.approval.state, 'approved');
|
||||
assert.equal(decided.output.result.approval.version, 2);
|
||||
|
||||
const generation2Uids = new Set(
|
||||
managerPods.map((pod) => pod.metadata.uid),
|
||||
);
|
||||
const generation2Uids = new Set(managerPods.map((pod) => pod.metadata.uid));
|
||||
applyIdentity(keysets[2]);
|
||||
patchGeneration(fixture, 3);
|
||||
const generation3 = await waitForTwoPreserved({
|
||||
@@ -987,9 +868,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
'-l',
|
||||
'app.kubernetes.io/name=' + DEPLOYMENT,
|
||||
])
|
||||
.items.filter(
|
||||
(pod) => pod.metadata.deletionTimestamp === undefined,
|
||||
);
|
||||
.items.filter((pod) => pod.metadata.deletionTimestamp === undefined);
|
||||
const ready = pods.filter(podReady);
|
||||
const candidate = pods.find(
|
||||
(pod) =>
|
||||
@@ -1127,27 +1006,23 @@ async function main(argv = process.argv.slice(2)) {
|
||||
};
|
||||
},
|
||||
);
|
||||
await waitFor(
|
||||
'CloudNativePG recovery to three instances',
|
||||
900_000,
|
||||
() => {
|
||||
const status = fixture.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'cluster',
|
||||
POSTGRES_CLUSTER,
|
||||
]).status;
|
||||
return Number(status.readyInstances) === 3
|
||||
? { ready: true, value: status }
|
||||
: {
|
||||
ready: false,
|
||||
fact:
|
||||
String(status.readyInstances ?? 0) +
|
||||
'/3 ready database instances',
|
||||
};
|
||||
},
|
||||
);
|
||||
await waitFor('CloudNativePG recovery to three instances', 900_000, () => {
|
||||
const status = fixture.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'cluster',
|
||||
POSTGRES_CLUSTER,
|
||||
]).status;
|
||||
return Number(status.readyInstances) === 3
|
||||
? { ready: true, value: status }
|
||||
: {
|
||||
ready: false,
|
||||
fact:
|
||||
String(status.readyInstances ?? 0) +
|
||||
'/3 ready database instances',
|
||||
};
|
||||
});
|
||||
|
||||
const databaseService = fixture.kubectlJson([
|
||||
'-n',
|
||||
@@ -1175,8 +1050,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
managerPods.map((pod, index) =>
|
||||
executeClient(
|
||||
{
|
||||
name:
|
||||
'ql3-approval-database-unavailable-' + String(index + 1),
|
||||
name: 'ql3-approval-database-unavailable-' + String(index + 1),
|
||||
target: pod,
|
||||
command: decisionCommand(
|
||||
projectId,
|
||||
@@ -1209,9 +1083,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
? { ready: true, value: current }
|
||||
: {
|
||||
ready: false,
|
||||
fact:
|
||||
String(current.status.readyReplicas ?? 0) +
|
||||
' ready replicas',
|
||||
fact: String(current.status.readyReplicas ?? 0) + ' ready replicas',
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
@@ -1238,35 +1110,29 @@ async function main(argv = process.argv.slice(2)) {
|
||||
},
|
||||
]),
|
||||
]);
|
||||
await waitFor(
|
||||
'restored CloudNativePG service endpoint',
|
||||
120_000,
|
||||
() => {
|
||||
const endpoints = fixture.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'endpoints',
|
||||
POSTGRES_CLUSTER + '-rw',
|
||||
]);
|
||||
const count = endpoints.subsets?.flatMap(
|
||||
(subset) => subset.addresses ?? [],
|
||||
).length;
|
||||
return count >= 1
|
||||
? { ready: true, value: count }
|
||||
: {
|
||||
ready: false,
|
||||
fact: String(count ?? 0) + ' service endpoints',
|
||||
};
|
||||
},
|
||||
);
|
||||
await waitFor('restored CloudNativePG service endpoint', 120_000, () => {
|
||||
const endpoints = fixture.kubectlJson([
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'get',
|
||||
'endpoints',
|
||||
POSTGRES_CLUSTER + '-rw',
|
||||
]);
|
||||
const count = endpoints.subsets?.flatMap(
|
||||
(subset) => subset.addresses ?? [],
|
||||
).length;
|
||||
return count >= 1
|
||||
? { ready: true, value: count }
|
||||
: {
|
||||
ready: false,
|
||||
fact: String(count ?? 0) + ' service endpoints',
|
||||
};
|
||||
});
|
||||
assert.deepEqual(
|
||||
managerPods.map((pod) => healthStatus(fixture, pod, '/readyz')),
|
||||
[503, 503],
|
||||
);
|
||||
const staleUids = new Set(
|
||||
managerPods.map((pod) => pod.metadata.uid),
|
||||
);
|
||||
const staleUids = new Set(managerPods.map((pod) => pod.metadata.uid));
|
||||
patchGeneration(fixture, '3-database-recovered');
|
||||
managerPods = await readyManagementPods({
|
||||
...managerOptions(fixture),
|
||||
@@ -1277,8 +1143,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
managerPods.map((pod, index) =>
|
||||
executeClient(
|
||||
{
|
||||
name:
|
||||
'ql3-approval-database-recovered-' + String(index + 1),
|
||||
name: 'ql3-approval-database-recovered-' + String(index + 1),
|
||||
target: pod,
|
||||
command: decisionCommand(
|
||||
projectId,
|
||||
@@ -1402,10 +1267,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
resource,
|
||||
'-n',
|
||||
NAMESPACE,
|
||||
'--as=system:serviceaccount:' +
|
||||
NAMESPACE +
|
||||
':' +
|
||||
DEPLOYMENT,
|
||||
'--as=system:serviceaccount:' + NAMESPACE + ':' + DEPLOYMENT,
|
||||
],
|
||||
{ capture: true, quiet: true, allowFailure: true },
|
||||
);
|
||||
@@ -1576,12 +1438,8 @@ async function main(argv = process.argv.slice(2)) {
|
||||
port: 8447,
|
||||
replicas: deployment.spec.replicas,
|
||||
readyReplicas: managerPods.length,
|
||||
podIdentitySha256: managerPods.map((pod) =>
|
||||
sha256(pod.metadata.uid),
|
||||
),
|
||||
nodeIdentitySha256: managerPods.map((pod) =>
|
||||
sha256(pod.spec.nodeName),
|
||||
),
|
||||
podIdentitySha256: managerPods.map((pod) => sha256(pod.metadata.uid)),
|
||||
nodeIdentitySha256: managerPods.map((pod) => sha256(pod.spec.nodeName)),
|
||||
serviceAccount: DEPLOYMENT,
|
||||
automountServiceAccountToken: false,
|
||||
requiredPodAntiAffinity: true,
|
||||
@@ -1620,8 +1478,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
activeNewAssertionAccepted: replayed.statusCode === 200,
|
||||
rollbackSurgeFailedClosed: Boolean(rollback.value),
|
||||
twoReadyReplicasPreserved:
|
||||
generation2.minimumReady >= 2 &&
|
||||
generation3.minimumReady >= 2,
|
||||
generation2.minimumReady >= 2 && generation3.minimumReady >= 2,
|
||||
durableGenerationReachedThree: durable.identityGeneration === 3,
|
||||
},
|
||||
certificateRotation: {
|
||||
@@ -1630,16 +1487,13 @@ async function main(argv = process.argv.slice(2)) {
|
||||
previousBundleSha256,
|
||||
currentBundleSha256,
|
||||
oldClientAcceptedBefore: initialRequests[0].statusCode === 200,
|
||||
replacementClientAcceptedBefore:
|
||||
initialRequests[1].statusCode === 200,
|
||||
replacementClientAcceptedBefore: initialRequests[1].statusCode === 200,
|
||||
oldClientRejectedAfter: revokedCertificate.statusCode === 401,
|
||||
replacementClientAcceptedAfter:
|
||||
activeCertificate.statusCode === 200,
|
||||
replacementClientAcceptedAfter: activeCertificate.statusCode === 200,
|
||||
fullPodReplacement: managerPods.every(
|
||||
(pod) => !preCertificateUids.has(pod.metadata.uid),
|
||||
),
|
||||
allReplicasReadyThroughout:
|
||||
certificateRollout.minimumReady >= 2,
|
||||
allReplicasReadyThroughout: certificateRollout.minimumReady >= 2,
|
||||
},
|
||||
availability: {
|
||||
databaseFailureWithdrewReadiness: true,
|
||||
@@ -1660,8 +1514,7 @@ async function main(argv = process.argv.slice(2)) {
|
||||
publicInternetEgressDenied,
|
||||
cloudNativePgEgressAllowed,
|
||||
managerSecretReadDenied: canI('get', 'secrets') === 'no',
|
||||
managerMutationRbacDenied:
|
||||
canI('patch', 'deployments.apps') === 'no',
|
||||
managerMutationRbacDenied: canI('patch', 'deployments.apps') === 'no',
|
||||
},
|
||||
durability: {
|
||||
approvalVersion: durable.approvalVersion,
|
||||
@@ -1686,15 +1539,13 @@ async function main(argv = process.argv.slice(2)) {
|
||||
twoManagerPodsOnDistinctNodes:
|
||||
new Set(managerPods.map((pod) => pod.spec.nodeName)).size === 2,
|
||||
tls13ProductClientAcrossBothPods:
|
||||
new Set(baselineSuccesses.map((entry) => entry.targetPod)).size >=
|
||||
2,
|
||||
new Set(baselineSuccesses.map((entry) => entry.targetPod)).size >= 2,
|
||||
strongUserDecision:
|
||||
weakUserRejected.statusCode === 401 &&
|
||||
outsiderDenied.statusCode === 403 &&
|
||||
decided.statusCode === 200,
|
||||
identityProjectionRotation: durable.identityGeneration === 3,
|
||||
certificateRevocationRollout:
|
||||
revokedCertificate.statusCode === 401,
|
||||
certificateRevocationRollout: revokedCertificate.statusCode === 401,
|
||||
databaseReadinessFence: true,
|
||||
durableFactsSurvivedFailover: true,
|
||||
leastPrivilege: rolesLeastPrivilege,
|
||||
@@ -1739,7 +1590,9 @@ if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(
|
||||
'QL3 approval management Kubernetes live contract failed: ' +
|
||||
(error instanceof Error ? error.stack || error.message : String(error)) +
|
||||
(error instanceof Error
|
||||
? error.stack || error.message
|
||||
: String(error)) +
|
||||
'\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const FIXTURE = 'qinglong/run-management-kubernetes-live-contract@v1';
|
||||
const LIMITATIONS = Object.freeze([
|
||||
'three privileged K3s Docker nodes are not production infrastructure or control-plane HA evidence',
|
||||
'identity assertions use a deterministic local strong-User ceremony rather than an external IdP',
|
||||
'CloudNativePG failover inside one Docker host is not infrastructure STONITH evidence',
|
||||
]);
|
||||
const BANNED_KEYS = new Set([
|
||||
'assertion',
|
||||
'authorization',
|
||||
'bearer',
|
||||
'certificate',
|
||||
'clientkey',
|
||||
'connectionstring',
|
||||
'dsn',
|
||||
'kubeconfig',
|
||||
'password',
|
||||
'privatekey',
|
||||
'secret',
|
||||
'tlskey',
|
||||
'token',
|
||||
]);
|
||||
|
||||
function exactKeys(value, expected) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
JSON.stringify(Object.keys(value).sort()) ===
|
||||
JSON.stringify([...expected].sort())
|
||||
);
|
||||
}
|
||||
|
||||
function isDigest(value) {
|
||||
return typeof value === 'string' && /^sha256:[a-f0-9]{64}$/.test(value);
|
||||
}
|
||||
|
||||
function isIsoTime(value) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/.test(value) &&
|
||||
Number.isFinite(Date.parse(value))
|
||||
);
|
||||
}
|
||||
|
||||
function isToken(value, maximum = 128) {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
value.length >= 1 &&
|
||||
value.length <= maximum &&
|
||||
/^[A-Za-z0-9][A-Za-z0-9._:/@+-]*$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
function containsSensitiveMaterial(value, key = '') {
|
||||
if (BANNED_KEYS.has(key.toLowerCase())) return true;
|
||||
if (typeof value === 'string') {
|
||||
return (
|
||||
/-----BEGIN (?:CERTIFICATE|(?:RSA |EC |OPENSSH )?PRIVATE KEY)-----/.test(
|
||||
value,
|
||||
) ||
|
||||
/postgres(?:ql)?:\/\/[^/\s]+:[^@\s]+@/i.test(value) ||
|
||||
/\beyJ[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/.test(
|
||||
value,
|
||||
)
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((entry) => containsSensitiveMaterial(entry));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.entries(value).some(([childKey, child]) =>
|
||||
containsSensitiveMaterial(child, childKey),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validKubernetesVersion(value) {
|
||||
const match =
|
||||
typeof value === 'string'
|
||||
? /^v1\.([0-9]{2,3})\.([0-9]+)(?:[-+][0-9A-Za-z](?:[0-9A-Za-z.-]{0,62}[0-9A-Za-z])?)?$/.exec(
|
||||
value,
|
||||
)
|
||||
: null;
|
||||
return Boolean(match && Number(match[1]) >= 32);
|
||||
}
|
||||
|
||||
function uniqueDigests(value, count) {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.length === count &&
|
||||
value.every(isDigest) &&
|
||||
new Set(value).size === count
|
||||
);
|
||||
}
|
||||
|
||||
function allTrue(value, expected) {
|
||||
return (
|
||||
exactKeys(value, expected) && expected.every((key) => value[key] === true)
|
||||
);
|
||||
}
|
||||
|
||||
function validateRunManagementKubernetesLiveReport(report) {
|
||||
const findings = [];
|
||||
const reject = (code, detail) =>
|
||||
findings.push(Object.freeze({ code, detail }));
|
||||
if (
|
||||
!exactKeys(report, [
|
||||
'schemaVersion',
|
||||
'fixture',
|
||||
'observedAt',
|
||||
'platform',
|
||||
'database',
|
||||
'deployment',
|
||||
'client',
|
||||
'identityRotation',
|
||||
'certificateRotation',
|
||||
'availability',
|
||||
'isolation',
|
||||
'durability',
|
||||
'gates',
|
||||
'limitations',
|
||||
]) ||
|
||||
report?.schemaVersion !== 1 ||
|
||||
report?.fixture !== FIXTURE ||
|
||||
!isIsoTime(report?.observedAt)
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_REPORT_SHAPE',
|
||||
'the report must use the exact versioned Run management live envelope',
|
||||
);
|
||||
}
|
||||
if (containsSensitiveMaterial(report)) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_SECRET_EXPOSURE',
|
||||
'the report must not contain credentials, assertions, certificates, DSNs, kubeconfig or private keys',
|
||||
);
|
||||
}
|
||||
|
||||
const platform = report?.platform;
|
||||
if (
|
||||
!exactKeys(platform, [
|
||||
'distribution',
|
||||
'kubernetesVersion',
|
||||
'architecture',
|
||||
'kubernetesImageId',
|
||||
'managementImageId',
|
||||
'cniName',
|
||||
'cniDistributionBinding',
|
||||
'controlPlaneNodes',
|
||||
'workerNodes',
|
||||
'cniReadyNodes',
|
||||
]) ||
|
||||
platform?.distribution !== 'k3s' ||
|
||||
!validKubernetesVersion(platform?.kubernetesVersion) ||
|
||||
!['amd64', 'arm64'].includes(platform?.architecture) ||
|
||||
!isDigest(platform?.kubernetesImageId) ||
|
||||
!isDigest(platform?.managementImageId) ||
|
||||
platform?.cniName !== 'flannel' ||
|
||||
platform?.cniDistributionBinding !== 'rancher/k3s:v1.34.3-k3s1' ||
|
||||
platform?.controlPlaneNodes !== 1 ||
|
||||
platform?.workerNodes !== 2 ||
|
||||
platform?.cniReadyNodes !== 3
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_PLATFORM',
|
||||
'the fixture must bind three real K3s nodes, embedded Flannel and exact images',
|
||||
);
|
||||
}
|
||||
|
||||
const database = report?.database;
|
||||
if (
|
||||
!exactKeys(database, [
|
||||
'operator',
|
||||
'operatorVersion',
|
||||
'postgresVersionNumber',
|
||||
'postgresImageId',
|
||||
'instances',
|
||||
'readyInstances',
|
||||
'managerRole',
|
||||
'migrationCount',
|
||||
'controlCoreCapability',
|
||||
'tlsVerified',
|
||||
'primaryChangedDuringFailover',
|
||||
]) ||
|
||||
database?.operator !== 'cloudnative-pg' ||
|
||||
!isToken(database?.operatorVersion, 64) ||
|
||||
database?.postgresVersionNumber !== 180004 ||
|
||||
!isDigest(database?.postgresImageId) ||
|
||||
database?.instances !== 3 ||
|
||||
database?.readyInstances !== 3 ||
|
||||
database?.managerRole !== 'ql3_run_manager' ||
|
||||
database?.migrationCount !== 57 ||
|
||||
database?.controlCoreCapability !== 56 ||
|
||||
database?.tlsVerified !== true ||
|
||||
database?.primaryChangedDuringFailover !== true
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_DATABASE',
|
||||
'three TLS CloudNativePG instances must run migration 57, capability 56 and the isolated Run manager role',
|
||||
);
|
||||
}
|
||||
|
||||
const deployment = report?.deployment;
|
||||
if (
|
||||
!exactKeys(deployment, [
|
||||
'namespace',
|
||||
'service',
|
||||
'port',
|
||||
'replicas',
|
||||
'readyReplicas',
|
||||
'podIdentitySha256',
|
||||
'nodeIdentitySha256',
|
||||
'serviceAccount',
|
||||
'automountServiceAccountToken',
|
||||
'requiredPodAntiAffinity',
|
||||
'podDisruptionBudgetMinAvailable',
|
||||
'maxUnavailable',
|
||||
'maxConnectionsPerPod',
|
||||
]) ||
|
||||
deployment?.namespace !== 'qinglong3-system' ||
|
||||
deployment?.service !== 'ql3-run-management' ||
|
||||
deployment?.port !== 8448 ||
|
||||
deployment?.replicas !== 2 ||
|
||||
deployment?.readyReplicas !== 2 ||
|
||||
!uniqueDigests(deployment?.podIdentitySha256, 2) ||
|
||||
!uniqueDigests(deployment?.nodeIdentitySha256, 2) ||
|
||||
deployment?.serviceAccount !== 'ql3-run-management' ||
|
||||
deployment?.automountServiceAccountToken !== false ||
|
||||
deployment?.requiredPodAntiAffinity !== true ||
|
||||
deployment?.podDisruptionBudgetMinAvailable !== 1 ||
|
||||
deployment?.maxUnavailable !== 0 ||
|
||||
deployment?.maxConnectionsPerPod !== 2
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_DEPLOYMENT',
|
||||
'two tokenless Run manager replicas must be ready on distinct nodes with the exact budget',
|
||||
);
|
||||
}
|
||||
|
||||
const client = report?.client;
|
||||
if (
|
||||
!exactKeys(client, [
|
||||
'binary',
|
||||
'operations',
|
||||
'inputKind',
|
||||
'inputImmutable',
|
||||
'callerDrivenJob',
|
||||
'backoffLimit',
|
||||
'serviceAccountTokenMounted',
|
||||
'rbacGranted',
|
||||
'transportProtocol',
|
||||
'mutualTls',
|
||||
'servernameVerified',
|
||||
'exactPodRequests',
|
||||
'retryStatuses',
|
||||
'stopStatuses',
|
||||
'responseRedacted',
|
||||
]) ||
|
||||
client?.binary !== 'ql3-run-client' ||
|
||||
JSON.stringify(client?.operations) !==
|
||||
JSON.stringify(['run.retry', 'run.stop']) ||
|
||||
client?.inputKind !== 'Secret' ||
|
||||
client?.inputImmutable !== true ||
|
||||
client?.callerDrivenJob !== true ||
|
||||
client?.backoffLimit !== 0 ||
|
||||
client?.serviceAccountTokenMounted !== false ||
|
||||
client?.rbacGranted !== false ||
|
||||
client?.transportProtocol !== 'TLSv1.3' ||
|
||||
client?.mutualTls !== true ||
|
||||
client?.servernameVerified !== true ||
|
||||
client?.exactPodRequests !== 6 ||
|
||||
JSON.stringify(client?.retryStatuses) !==
|
||||
JSON.stringify(['accepted', 'existing', 'existing']) ||
|
||||
JSON.stringify(client?.stopStatuses) !==
|
||||
JSON.stringify(['accepted', 'already_requested', 'already_requested']) ||
|
||||
client?.responseRedacted !== true
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_CLIENT',
|
||||
'immutable caller-driven clients must retry and stop with exact replay across both Pods over TLS 1.3 mTLS',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!allTrue(report?.identityRotation, [
|
||||
'overlapOldAssertionAccepted',
|
||||
'overlapNewAssertionAccepted',
|
||||
'revokedOldAssertionRejected',
|
||||
'activeNewAssertionAccepted',
|
||||
'rollbackSurgeFailedClosed',
|
||||
'twoReadyReplicasPreserved',
|
||||
'durableGenerationReachedThree',
|
||||
])
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_IDENTITY_ROTATION',
|
||||
'identity overlap, revoke, rollback rejection and two-ready availability are mandatory',
|
||||
);
|
||||
}
|
||||
|
||||
const certificate = report?.certificateRotation;
|
||||
if (
|
||||
!exactKeys(certificate, [
|
||||
'previousSerialSha256',
|
||||
'currentSerialSha256',
|
||||
'previousBundleSha256',
|
||||
'currentBundleSha256',
|
||||
'oldClientAcceptedBefore',
|
||||
'replacementClientAcceptedBefore',
|
||||
'oldClientRejectedAfter',
|
||||
'replacementClientAcceptedAfter',
|
||||
'fullPodReplacement',
|
||||
'allReplicasReadyThroughout',
|
||||
]) ||
|
||||
!isDigest(certificate?.previousSerialSha256) ||
|
||||
!isDigest(certificate?.currentSerialSha256) ||
|
||||
certificate?.previousSerialSha256 === certificate?.currentSerialSha256 ||
|
||||
!isDigest(certificate?.previousBundleSha256) ||
|
||||
!isDigest(certificate?.currentBundleSha256) ||
|
||||
certificate?.previousBundleSha256 === certificate?.currentBundleSha256 ||
|
||||
![
|
||||
'oldClientAcceptedBefore',
|
||||
'replacementClientAcceptedBefore',
|
||||
'oldClientRejectedAfter',
|
||||
'replacementClientAcceptedAfter',
|
||||
'fullPodReplacement',
|
||||
'allReplicasReadyThroughout',
|
||||
].every((key) => certificate?.[key] === true)
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_CERTIFICATE_ROTATION',
|
||||
'CRL rotation must replace all Pods without dropping below two ready replicas',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!allTrue(report?.availability, [
|
||||
'databaseFailureWithdrewReadiness',
|
||||
'databaseFailurePreservedLiveness',
|
||||
'stalePodsDidNotRecoverInPlace',
|
||||
'freshPodsRecoveredAfterDatabase',
|
||||
'bothReplicasServedAfterRecovery',
|
||||
])
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_AVAILABILITY',
|
||||
'database loss must withdraw readiness, preserve liveness and require fresh manager Pods',
|
||||
);
|
||||
}
|
||||
if (
|
||||
!allTrue(report?.isolation, [
|
||||
'labelledClientAllowed',
|
||||
'unlabelledClientDenied',
|
||||
'wrongPortDenied',
|
||||
'kubernetesApiEgressDenied',
|
||||
'publicInternetEgressDenied',
|
||||
'cloudNativePgEgressAllowed',
|
||||
'managerSecretReadDenied',
|
||||
'managerMutationRbacDenied',
|
||||
])
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_ISOLATION',
|
||||
'CNI and Kubernetes RBAC least-privilege observations are incomplete',
|
||||
);
|
||||
}
|
||||
|
||||
const durability = report?.durability;
|
||||
if (
|
||||
!exactKeys(durability, [
|
||||
'sourceRunStatus',
|
||||
'retryRunCount',
|
||||
'retryAttemptCount',
|
||||
'retryEventCount',
|
||||
'stoppedRunCount',
|
||||
'stopEventCount',
|
||||
'allowedAuditCount',
|
||||
'deniedAuditCount',
|
||||
'duplicateMutationCount',
|
||||
'identityGeneration',
|
||||
'weakAuthenticationAuditCount',
|
||||
'survivedCloudNativePgFailover',
|
||||
]) ||
|
||||
durability?.sourceRunStatus !== 'failed' ||
|
||||
durability?.retryRunCount !== 1 ||
|
||||
durability?.retryAttemptCount !== 1 ||
|
||||
durability?.retryEventCount !== 2 ||
|
||||
durability?.stoppedRunCount !== 1 ||
|
||||
durability?.stopEventCount !== 1 ||
|
||||
durability?.allowedAuditCount !== 2 ||
|
||||
durability?.deniedAuditCount !== 1 ||
|
||||
durability?.duplicateMutationCount !== 0 ||
|
||||
durability?.identityGeneration !== 3 ||
|
||||
durability?.weakAuthenticationAuditCount !== 0 ||
|
||||
durability?.survivedCloudNativePgFailover !== true
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_DURABILITY',
|
||||
'exact Run, Attempt, Event, cancellation and audit facts must survive failover without duplicates',
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!allTrue(report?.gates, [
|
||||
'realThreeNodeKubernetes',
|
||||
'realCniPolicy',
|
||||
'threeInstanceCloudNativePg',
|
||||
'twoManagerPodsOnDistinctNodes',
|
||||
'tls13ProductClientAcrossBothPods',
|
||||
'strongUserRetryAndStop',
|
||||
'identityProjectionRotation',
|
||||
'certificateRevocationRollout',
|
||||
'databaseReadinessFence',
|
||||
'durableFactsSurvivedFailover',
|
||||
'leastPrivilege',
|
||||
'passed',
|
||||
])
|
||||
) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_GATES',
|
||||
'every independently observed release gate must pass',
|
||||
);
|
||||
}
|
||||
if (JSON.stringify(report?.limitations) !== JSON.stringify(LIMITATIONS)) {
|
||||
reject(
|
||||
'QL3_RUN_MANAGEMENT_KUBERNETES_LIVE_LIMITATIONS',
|
||||
'the exact non-production limitations must remain visible',
|
||||
);
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
fixture: FIXTURE,
|
||||
findings: Object.freeze(findings),
|
||||
compatible: findings.length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function main(argv = process.argv.slice(2)) {
|
||||
const argument = argv[0] === '--' ? argv.slice(1) : argv;
|
||||
if (
|
||||
argument.length !== 1 ||
|
||||
!argument[0].startsWith('--report=') ||
|
||||
!path.isAbsolute(argument[0].slice('--report='.length))
|
||||
) {
|
||||
throw new Error(
|
||||
'usage: ql3-run-management-kubernetes-live-audit --report=/absolute/private-report.json',
|
||||
);
|
||||
}
|
||||
const reportFile = argument[0].slice('--report='.length);
|
||||
const stat = fs.lstatSync(reportFile);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) {
|
||||
throw new Error(
|
||||
'Run management Kubernetes live report must be a private regular file',
|
||||
);
|
||||
}
|
||||
const audit = validateRunManagementKubernetesLiveReport(
|
||||
JSON.parse(fs.readFileSync(reportFile, 'utf8')),
|
||||
);
|
||||
process.stdout.write(JSON.stringify(audit, null, 2) + '\n');
|
||||
if (!audit.compatible) process.exitCode = 1;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
'QL3 Run management Kubernetes live audit failed: ' +
|
||||
(error instanceof Error ? error.message : String(error)) +
|
||||
'\n',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
FIXTURE,
|
||||
LIMITATIONS,
|
||||
validateRunManagementKubernetesLiveReport,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user