Files
qinglong/scripts/ql3-automation-management-kubernetes-live-contract.cjs
T

1915 lines
60 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const fs = require('node:fs');
const path = require('node:path');
const {
K3sDockerLiveFixture,
run,
waitFor,
} = require('./lib/ql3-k3s-docker-live.cjs');
const { createMutualTlsPki } = require('./lib/ql3-live-pki.cjs');
const {
imageDigest,
imageTag,
reviewedOperatorManifest,
} = require('./ql3-cloudnativepg-live-contract.cjs');
const {
FIXTURE,
LIMITATIONS,
validateAutomationManagementKubernetesLiveReport,
} = require('./ql3-automation-management-kubernetes-live-audit.cjs');
const ROOT = path.resolve(__dirname, '..');
const NAMESPACE = 'qinglong3-system';
const DEPLOYMENT = 'ql3-automation-management';
const SERVICE = DEPLOYMENT;
const SERVERNAME = `${SERVICE}.${NAMESPACE}.svc`;
const MANAGEMENT_PATH = '/api/v3/automations/management';
const POSTGRES_CLUSTER = 'ql3-postgres';
const ISSUER = 'https://identity.qinglong.test/';
const AUDIENCE = 'qinglong3-automation-management';
const LOCK = JSON.parse(
fs.readFileSync(
path.join(
ROOT,
'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg/operator-lock.json',
),
'utf8',
),
);
const OPERATOR_IMAGE = LOCK.operator.image;
const POSTGRES_IMAGE = LOCK.operand.image;
const OPERATOR_VERSION = LOCK.operator.version;
const ADMIN_IMAGE_BASE = 'ql3-automation-manager-live';
const CONTROL_IMAGE_BASE = 'ql3-automation-migration-live';
const ZERO_DIGEST = `sha256:${'0'.repeat(64)}`;
const ROLE_NAMES = Object.freeze([
'ql3_migration',
'ql3_runtime',
'ql3_admin',
'ql3_package_manager',
'ql3_package_executor',
'ql3_automation_manager',
'ql3_approval_manager',
'ql3_worker_credential_manager',
'ql3_worker_credential_executor',
'ql3_worker_ingress',
]);
function sha256(value) {
return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`;
}
function randomSecret() {
return crypto.randomBytes(32).toString('base64url');
}
function reviewedKey(kid) {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
return Object.freeze({
kid,
privateKey,
publicJwk: Object.freeze({
...publicKey.export({ format: 'jwk' }),
alg: 'EdDSA',
kid,
use: 'sig',
}),
});
}
function keyset(generation, keys, revokedKids = []) {
return Object.freeze({
schemaVersion: 1,
generation,
issuer: ISSUER,
audience: AUDIENCE,
keys: keys.map((key) => key.publicJwk),
revokedKids,
assuranceMappings: [
{
acr: 'urn:ql3:mfa',
assurance: 'multi_factor',
requiredAmr: ['pwd', 'otp'],
},
],
constraints: {
maxAssertionBytes: 8 * 1024,
maxLifetimeMs: 5 * 60 * 1000,
maxAuthenticationAgeMs: 5 * 60 * 1000,
clockSkewMs: 5 * 1000,
},
});
}
function assertion(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-automation-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-automation-live-${suffix}`,
ql3_purpose: 'automation-management',
sub: 'automation-operator',
}),
).toString('base64url');
const signed = `${header}.${payload}`;
return `${signed}.${crypto
.sign(null, Buffer.from(signed, 'ascii'), key.privateKey)
.toString('base64url')}`;
}
function envelope(operation, requestId, command) {
return Object.freeze({
schemaVersion: 1,
operation,
request: Object.freeze({ requestId, command }),
});
}
function taskCommand(projectId, revision, suffix, value) {
return Object.freeze({
projectId,
taskId: 'managed-task',
expectedRevision: revision,
mutationId: `123e4567-e89b-42d3-a456-426614176${suffix}`,
name: 'Managed Task',
kind: 'command',
spec: Object.freeze({
schema: 'qinglong/command@v1',
config: Object.freeze({
command: Object.freeze({
kind: 'argv',
file: '/bin/echo',
args: Object.freeze([value]),
}),
}),
}),
labels: Object.freeze({}),
enabled: true,
occurredAtMs: 10_000 + Number(suffix),
});
}
function triggerCommand(projectId, revision, task, suffix) {
return Object.freeze({
projectId,
triggerId: 'managed-trigger',
expectedRevision: revision,
mutationId: `123e4567-e89b-42d3-a456-426614177${suffix}`,
taskId: task.taskId,
taskRevision: task.revision,
taskContentDigest: task.contentDigest,
spec: Object.freeze({
schema: 'qinglong/cron@v1',
config: Object.freeze({
expression: '*/5 * * * *',
timezone: 'UTC',
misfirePolicy: 'skip',
}),
}),
enabled: true,
occurredAtMs: 20_000 + Number(suffix),
});
}
function podReady(pod) {
return Boolean(
pod.metadata.deletionTimestamp === undefined &&
pod.status.conditions?.some(
(condition) =>
condition.type === 'Ready' && condition.status === 'True',
),
);
}
function imageIdDigest(image) {
assert.match(image.Id, /^sha256:[a-f0-9]{64}$/);
return image.Id;
}
function localManifest(rendered, imageName, localImage) {
const placeholder = `${imageName}@${ZERO_DIGEST}`;
const matches = rendered.split(placeholder).length - 1;
assert.equal(
matches,
1,
`rendered manifest must contain one ${imageName} placeholder`,
);
return rendered.replace(placeholder, localImage);
}
function applySecret(fixture, name, type, stringData, immutable = false) {
fixture.apply({
apiVersion: 'v1',
kind: 'Secret',
metadata: { name, namespace: NAMESPACE },
type,
...(immutable ? { immutable: true } : {}),
stringData,
});
}
function psql(fixture, podName, sql) {
return fixture.kubectl(
[
'-n',
NAMESPACE,
'exec',
podName,
'-c',
'postgres',
'--',
'psql',
'-U',
'postgres',
'-d',
'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',
NAMESPACE,
'get',
'cluster',
POSTGRES_CLUSTER,
]).status.currentPrimary;
assert.match(primaryName || '', /^ql3-postgres-[1-9][0-9]*$/);
const pods = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`cnpg.io/cluster=${POSTGRES_CLUSTER}`,
]).items;
const primary = pods.find((pod) => pod.metadata.name === primaryName);
assert.ok(primary, 'CloudNativePG primary Pod not found');
return primary;
}
async function readyManagerPods(
fixture,
excludedUids = new Set(),
expectedGeneration,
) {
const result = await waitFor(
'two Ready automation manager Pods on distinct nodes',
300_000,
() => {
const pods = fixture
.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`app.kubernetes.io/name=${DEPLOYMENT}`,
])
.items.filter(
(pod) =>
podReady(pod) &&
!excludedUids.has(pod.metadata.uid) &&
(expectedGeneration === undefined ||
pod.metadata.annotations?.['qinglong.io/identity-generation'] ===
String(expectedGeneration)),
);
const nodes = new Set(pods.map((pod) => pod.spec.nodeName));
return pods.length === 2 && nodes.size === 2
? {
ready: true,
value: pods.sort((a, b) =>
a.metadata.name.localeCompare(b.metadata.name),
),
}
: {
ready: false,
fact: `${pods.length} Ready Pods on ${nodes.size} nodes`,
};
},
);
return result.value;
}
function patchGeneration(fixture, generation, annotations = {}) {
fixture.kubectl(
[
'-n',
NAMESPACE,
'patch',
'deployment',
DEPLOYMENT,
'--type=merge',
'-p',
JSON.stringify({
spec: {
template: {
metadata: {
annotations: {
'qinglong.io/identity-generation': String(generation),
...annotations,
},
},
},
},
}),
],
{ capture: true, quiet: true },
);
}
function waitRollout(fixture) {
fixture.kubectl([
'-n',
NAMESPACE,
'rollout',
'status',
`deployment/${DEPLOYMENT}`,
'--timeout=5m',
]);
}
function createClientExecutor({ fixture, adminImage, ca }) {
fixture.apply({
apiVersion: 'v1',
kind: 'ServiceAccount',
metadata: {
name: 'ql3-automation-management-client',
namespace: NAMESPACE,
},
automountServiceAccountToken: false,
});
return async function executeClient(definition, expected) {
const input = `${definition.name}-input`;
const clientConfig = {
schemaVersion: 1,
endpoint: `https://${SERVERNAME}:8445${MANAGEMENT_PATH}`,
servername: SERVERNAME,
caFile: '/tmp/ca.crt',
clientCertificateFile: '/tmp/client.crt',
clientPrivateKeyFile: '/tmp/client.key',
requestTimeoutMs: 5_000,
};
fixture.create({
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: input, namespace: NAMESPACE },
immutable: true,
type: 'Opaque',
stringData: {
'client.json': `${JSON.stringify(clientConfig)}\n`,
'command.json': `${JSON.stringify(definition.command)}\n`,
'assertion.jwt': definition.bearer,
'ca.crt': ca,
'client.crt': definition.clientCertificate,
'client.key': definition.clientKey,
},
});
fixture.create({
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: definition.name,
namespace: NAMESPACE,
labels: {
'app.kubernetes.io/name': 'ql3-automation-management-client',
'app.kubernetes.io/component': 'automation-management-client',
'qinglong.io/execution-model': 'caller-driven',
},
},
spec: {
backoffLimit: 0,
activeDeadlineSeconds: 240,
ttlSecondsAfterFinished: 600,
template: {
metadata: {
labels: {
'app.kubernetes.io/name': 'ql3-automation-management-client',
'app.kubernetes.io/component': 'automation-management-client',
'qinglong.io/automation-management-client': 'true',
'qinglong.io/execution-model': 'caller-driven',
},
},
spec: {
serviceAccountName: 'ql3-automation-management-client',
automountServiceAccountToken: false,
enableServiceLinks: false,
restartPolicy: 'Never',
hostAliases: [
{ ip: definition.target.status.podIP, hostnames: [SERVERNAME] },
],
securityContext: {
runAsNonRoot: true,
runAsUser: 10001,
runAsGroup: 10001,
fsGroup: 10001,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'client',
image: adminImage,
imagePullPolicy: 'Never',
command: ['/bin/sh', '-c'],
args: [
[
'set -eu',
'umask 077',
'cp /var/run/qinglong3/client/client.json /tmp/client.json',
'cp /var/run/qinglong3/client/command.json /tmp/command.json',
'cp /var/run/qinglong3/client/assertion.jwt /tmp/assertion.jwt',
'cp /var/run/qinglong3/client/ca.crt /tmp/ca.crt',
'cp /var/run/qinglong3/client/client.crt /tmp/client.crt',
'cp /var/run/qinglong3/client/client.key /tmp/client.key',
'chmod 600 /tmp/client.json /tmp/command.json ' +
'/tmp/assertion.jwt /tmp/ca.crt /tmp/client.crt ' +
'/tmp/client.key',
'set +e',
'attempt=0',
'while true; do',
' attempt=$((attempt + 1))',
' output="$(node /opt/qinglong/node_modules/@qinglong/' +
'cluster-admin/dist/automation-management/automationManagementClientCli.js ' +
'--config=/tmp/client.json ' +
'--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\'; } || ' +
'[ "$attempt" -ge 60 ]; then',
' break',
' fi',
' sleep 1',
'done',
'printf \'%s\\n\' "$output" > /dev/termination-log',
'printf \'%s\\n\' "$output"',
'exit "$status"',
].join('\n'),
],
terminationMessagePolicy: 'File',
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ['ALL'] },
},
resources: {
requests: { cpu: '10m', memory: '32Mi' },
limits: { cpu: '250m', memory: '128Mi' },
},
volumeMounts: [
{ name: 'tmp', mountPath: '/tmp' },
{
name: 'input',
mountPath: '/var/run/qinglong3/client',
readOnly: true,
},
],
},
],
volumes: [
{
name: 'tmp',
emptyDir: { medium: 'Memory', sizeLimit: '4Mi' },
},
{
name: 'input',
secret: { secretName: input, defaultMode: 288 },
},
],
},
},
},
});
const completion = await waitFor(
`${definition.name} completion`,
300_000,
() => {
const job = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'job',
definition.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 clientPod = (
await waitFor(`${definition.name} terminal pod`, 60_000, () => {
const pods = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`batch.kubernetes.io/job-name=${definition.name}`,
]).items;
const job = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'job',
definition.name,
]);
return pods.length === 1 && pods[0].status.containerStatuses?.[0]
? { ready: true, value: pods[0] }
: {
ready: false,
fact:
`observed ${pods.length} client pods; job=` +
JSON.stringify(job.status ?? {}),
};
})
).value;
assert.equal(clientPod.spec.automountServiceAccountToken, false);
assert.equal(
clientPod.spec.volumes.some((volume) =>
volume.projected?.sources?.some(
(source) => source.serviceAccountToken !== undefined,
),
),
false,
);
assert.equal(
fixture.kubectlJson(['-n', NAMESPACE, 'get', 'secret', input]).immutable,
true,
);
const terminated =
clientPod.status.containerStatuses?.[0]?.state?.terminated;
assert.ok(terminated, 'automation client termination state is missing');
const message = terminated.message ?? '';
assert.equal(message.includes(definition.bearer), false);
assert.equal(message.includes(definition.clientKey), false);
const output = JSON.parse(message.split('\n').filter(Boolean).at(-1));
if (expected.statusCode === 200) {
assert.equal(
completion.value.complete,
true,
`automation client unexpectedly failed: ${JSON.stringify(output)}`,
);
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));
}
} else {
assert.equal(
completion.value.failed,
true,
`automation client unexpectedly succeeded: ${JSON.stringify(output)}`,
);
assert.equal(output.event, 'command_failed');
assert.equal(
output.statusCode,
expected.statusCode,
`unexpected remote response: ${JSON.stringify(output)}`,
);
assert.equal(
output.responseCode,
expected.responseCode,
`unexpected remote response: ${JSON.stringify(output)}`,
);
}
const result = Object.freeze({
targetPod: definition.target.metadata.name,
targetPodUid: definition.target.metadata.uid,
targetNode: definition.target.spec.nodeName,
statusCode: expected.statusCode,
output,
});
fixture.kubectl(
[
'-n',
NAMESPACE,
'delete',
`job/${definition.name}`,
`secret/${input}`,
'--wait=false',
],
{ capture: true, quiet: true },
);
return result;
};
}
async function waitForTwoPreserved(fixture, excludedUids, expectedGeneration) {
let minimumReady = Number.POSITIVE_INFINITY;
const observed = await waitFor(
'zero-unavailable automation manager rollout',
300_000,
() => {
const pods = fixture
.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`app.kubernetes.io/name=${DEPLOYMENT}`,
])
.items.filter((pod) => pod.metadata.deletionTimestamp === undefined);
const ready = pods.filter(podReady);
minimumReady = Math.min(minimumReady, ready.length);
const replacements = ready.filter(
(pod) =>
!excludedUids.has(pod.metadata.uid) &&
pod.metadata.annotations?.['qinglong.io/identity-generation'] ===
String(expectedGeneration),
);
const nodes = new Set(replacements.map((pod) => pod.spec.nodeName));
return replacements.length === 2 && nodes.size === 2
? { ready: true, value: replacements }
: {
ready: false,
fact: `${ready.length} ready, ${replacements.length} replacements`,
};
},
);
assert.ok(
minimumReady >= 2,
`rollout availability dropped to ${minimumReady}`,
);
return Object.freeze({ pods: observed.value, minimumReady });
}
function podTcpProbe(fixture, podName, host, port) {
const script = String.raw`
const net=require('node:net');let finished=false;
const socket=net.createConnection({host:process.argv[1],port:Number(process.argv[2])});
const finish=(status)=>{if(finished)return;finished=true;socket.destroy();process.exitCode=status};
socket.setTimeout(3000);socket.once('connect',()=>finish(0));
socket.once('timeout',()=>finish(1));socket.once('error',()=>finish(1));`;
return fixture.kubectl(
[
'-n',
NAMESPACE,
'exec',
podName,
'--',
'node',
'-e',
script,
host,
String(port),
],
{ capture: true, quiet: true, allowFailure: true },
);
}
async function clientTcpProbe({
fixture,
adminImage,
name,
targetHost,
port,
labelled,
expectedConnected,
}) {
const labels = {
'app.kubernetes.io/name': 'ql3-automation-network-probe',
...(labelled ? { 'qinglong.io/automation-management-client': 'true' } : {}),
};
const script = String.raw`
const fs=require('node:fs');const net=require('node:net');let finished=false;let attempt=0;let socket;
const maximum=Number(process.argv[3]);
const finish=(message,status)=>{if(finished)return;finished=true;fs.writeFileSync('/dev/termination-log',message);socket?.destroy();process.exitCode=status};
const retry=(reason)=>{socket.destroy();attempt+=1;if(attempt>=maximum){finish('denied:'+reason,1);return;}setTimeout(connect,500);};
const connect=()=>{let settled=false;const failed=(reason)=>{if(settled)return;settled=true;retry(reason)};socket=net.createConnection({host:process.argv[1],port:Number(process.argv[2])});socket.setTimeout(3000);socket.once('connect',()=>{if(settled)return;settled=true;finish('connected',0)});socket.once('timeout',()=>failed('timeout'));socket.once('error',(error)=>failed(error.code||'error'));};
connect();`;
fixture.create({
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name, namespace: NAMESPACE },
spec: {
backoffLimit: 0,
activeDeadlineSeconds: 120,
ttlSecondsAfterFinished: 600,
template: {
metadata: { labels },
spec: {
automountServiceAccountToken: false,
restartPolicy: 'Never',
securityContext: {
runAsNonRoot: true,
runAsUser: 10001,
runAsGroup: 10001,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'probe',
image: adminImage,
imagePullPolicy: 'Never',
command: [
'node',
'-e',
script,
targetHost,
String(port),
String(expectedConnected ? 12 : 1),
],
terminationMessagePolicy: 'File',
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ['ALL'] },
},
resources: {
requests: { cpu: '5m', memory: '16Mi' },
limits: { cpu: '100m', memory: '64Mi' },
},
},
],
},
},
},
});
const observed = await waitFor(`${name} completion`, 180_000, () => {
const job = fixture.kubectlJson(['-n', NAMESPACE, 'get', 'job', 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(`${name} terminal pod`, 30_000, () => {
const pods = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`batch.kubernetes.io/job-name=${name}`,
]).items;
const terminated =
pods[0]?.status.containerStatuses?.[0]?.state?.terminated;
return pods.length === 1 && terminated
? { ready: true, value: pods[0] }
: { ready: false, fact: `observed ${pods.length} probe pods` };
})
).value;
const terminated = probePod.status.containerStatuses[0].state.terminated;
const observation = `${name}: ${terminated.message ?? 'no-message'}`;
assert.equal(observed.value.complete, expectedConnected, observation);
assert.equal(observed.value.failed, !expectedConnected, observation);
assert.equal(terminated.exitCode === 0, expectedConnected, observation);
if (expectedConnected) {
assert.equal(terminated.message, 'connected');
} else {
assert.match(terminated.message ?? '', /^denied:/);
}
fixture.kubectl(['-n', NAMESPACE, 'delete', 'job', name, '--wait=false'], {
capture: true,
quiet: true,
});
return expectedConnected ? observed.value.complete : observed.value.failed;
}
async function main() {
if (process.env.QL3_AUTOMATION_MANAGEMENT_KUBERNETES_LIVE !== '1') {
throw new Error(
'Refusing to mutate Docker/Kubernetes without ' +
'QL3_AUTOMATION_MANAGEMENT_KUBERNETES_LIVE=1',
);
}
const operatorManifestFile = process.env.QL3_CNPG_OPERATOR_MANIFEST_FILE;
if (!operatorManifestFile) {
throw new Error('QL3_CNPG_OPERATOR_MANIFEST_FILE is required');
}
const reviewedManifest = reviewedOperatorManifest(operatorManifestFile);
const fixture = new K3sDockerLiveFixture({ prefix: 'ql3-automation-live' });
const suffix = `${process.pid.toString(36)}-${crypto
.randomBytes(3)
.toString('hex')}`;
const adminImage = `${ADMIN_IMAGE_BASE}:${suffix}`;
const controlImage = `${CONTROL_IMAGE_BASE}:${suffix}`;
let adminImageBuilt = false;
let controlImageBuilt = false;
let stoppedNode = '';
try {
const nodes = await fixture.start();
const architecture = fixture.inspectImage(fixture.k3sImage).Architecture;
assert.ok(['amd64', 'arm64'].includes(architecture));
for (const reviewedImage of [OPERATOR_IMAGE, POSTGRES_IMAGE]) {
run(fixture.docker, ['pull', reviewedImage]);
const inspected = fixture.inspectImage(reviewedImage);
assert.ok(
inspected.RepoDigests?.some((entry) =>
entry.endsWith(`@${imageDigest(reviewedImage)}`),
),
`Docker did not retain reviewed digest for ${reviewedImage}`,
);
const preloadTag = imageTag(reviewedImage);
run(fixture.docker, ['tag', reviewedImage, preloadTag]);
fixture.loadImage(preloadTag, `${path.basename(preloadTag)}.tar`);
}
const sourceRevision = run('git', ['rev-parse', 'HEAD'], {
capture: true,
quiet: true,
}).stdout;
run(fixture.docker, [
'build',
'--file',
'deploy/containers/ql3-cluster-admin/Dockerfile',
'--tag',
adminImage,
'--build-arg',
`SOURCE_REVISION=${sourceRevision}`,
'.',
]);
adminImageBuilt = true;
fixture.loadImage(adminImage, 'automation-admin.tar');
run(fixture.docker, [
'build',
'--file',
'deploy/containers/ql3-cluster-control/Dockerfile',
'--tag',
controlImage,
'--build-arg',
`SOURCE_REVISION=${sourceRevision}`,
'.',
]);
controlImageBuilt = true;
fixture.loadImage(controlImage, 'automation-migration.tar');
const adminImageInfo = fixture.inspectImage(adminImage);
const postgresImageInfo = fixture.inspectImage(POSTGRES_IMAGE);
const k3sImageInfo = fixture.inspectImage(fixture.k3sImage);
fixture.kubectl(['apply', '--server-side', '-f', reviewedManifest]);
fixture.kubectl([
'-n',
'cnpg-system',
'set',
'image',
'deployment/cnpg-controller-manager',
`manager=${imageTag(OPERATOR_IMAGE)}`,
]);
fixture.kubectl([
'wait',
'--for=condition=Established',
'crd/clusters.postgresql.cnpg.io',
'crd/databaseroles.postgresql.cnpg.io',
'crd/databases.postgresql.cnpg.io',
'--timeout=5m',
]);
fixture.kubectl([
'-n',
'cnpg-system',
'rollout',
'status',
'deployment/cnpg-controller-manager',
'--timeout=5m',
]);
fixture.kubectl([
'apply',
'-f',
'deploy/kubernetes/ql3-cluster/base/namespace.yaml',
]);
fixture.kubectl([
'-n',
NAMESPACE,
'apply',
'-f',
'deploy/kubernetes/ql3-cluster/base/service-account.yaml',
]);
const passwords = Object.fromEntries(
ROLE_NAMES.map((role) => [role, randomSecret()]),
);
for (const role of ROLE_NAMES) {
applySecret(
fixture,
`ql3-postgres-${role.replace(/^ql3_/, '').replaceAll('_', '-')}-auth`,
'kubernetes.io/basic-auth',
{ username: role, password: passwords[role] },
);
}
const databaseManifest = fixture
.kubectl(
['kustomize', 'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg'],
{ capture: true, quiet: true },
)
.stdout.replace(POSTGRES_IMAGE, imageTag(POSTGRES_IMAGE));
assert.notEqual(databaseManifest.includes(POSTGRES_IMAGE), true);
fixture.kubectl(['apply', '-f', '-'], { input: `${databaseManifest}\n` });
fixture.kubectl([
'-n',
NAMESPACE,
'wait',
'--for=condition=Ready',
`cluster/${POSTGRES_CLUSTER}`,
'--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` };
})
).value;
const migrationManifest = localManifest(
fixture.kubectl(
[
'kustomize',
'deploy/kubernetes/ql3-cluster/operations/cloudnative-pg',
],
{ capture: true, quiet: true },
).stdout,
'registry.example.com/qinglong/qinglong3-cluster-control',
controlImage,
);
fixture.kubectl(['create', '-f', '-'], {
input: `${migrationManifest}\n`,
});
fixture.kubectl([
'-n',
NAMESPACE,
'wait',
'--for=condition=Complete',
'job/ql3-cluster-migration',
'--timeout=10m',
]);
const migrationPrimary = currentPrimaryPod(fixture);
assert.equal(
psql(
fixture,
migrationPrimary.metadata.name,
`SELECT (SELECT count(*) FROM "ql3"."schema_migrations") || ':' ||
(SELECT contract_version FROM "ql3"."schema_capabilities"
WHERE contract_name = 'control-core')`,
),
'54:53',
);
const projectId = `automation-live-${suffix}`;
const primary = currentPrimaryPod(fixture);
psql(
fixture,
primary.metadata.name,
`INSERT INTO "ql3"."projects" (
id, name, slug, status, version, created_at_ms, updated_at_ms
) VALUES ('${projectId}', 'Automation Live', '${projectId}',
'active', 1, 1000, 1000);
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 ('${projectId}', 'user', 'automation-operator', 1,
'active', 'owner', '123e4567-e89b-42d3-a456-426614176000',
'user', 'automation-operator', 1000);`,
);
const pki = createMutualTlsPki({
directory: fixture.temporary,
servername: SERVERNAME,
label: 'QL3 Automation Management Live',
run,
crypto,
});
let pkiMaterial = pki.read();
const oldKey = reviewedKey('automation-live-key-1');
const newKey = reviewedKey('automation-live-key-2');
const keysets = [
keyset(1, [oldKey]),
keyset(2, [oldKey, newKey]),
keyset(3, [oldKey, newKey], [oldKey.kid]),
];
const applyIdentity = (document) =>
applySecret(fixture, DEPLOYMENT + '-identity', 'Opaque', {
'keyset.json': `${JSON.stringify(document)}\n`,
});
const applyTls = () =>
applySecret(fixture, DEPLOYMENT + '-tls', 'kubernetes.io/tls', {
'tls.crt': pkiMaterial.serverCertificate,
'tls.key': pkiMaterial.serverKey,
'ca.crt': pkiMaterial.ca,
'client.crl': pkiMaterial.clientCrl,
});
applyIdentity(keysets[0]);
applyTls();
const previousBundleSha256 = pki.bundleSha256();
const caDigest = sha256(pkiMaterial.ca);
const crlDigest = sha256(pkiMaterial.clientCrl);
let managerManifest = localManifest(
fixture.kubectl(
[
'kustomize',
'deploy/kubernetes/ql3-cluster/operations/automation-management/cloudnative-pg',
],
{ capture: true, quiet: true },
).stdout,
'registry.example.com/qinglong/qinglong3-cluster-admin',
adminImage,
);
assert.equal(managerManifest.split(ZERO_DIGEST).length - 1, 2);
managerManifest = managerManifest
.replace(ZERO_DIGEST, caDigest)
.replace(ZERO_DIGEST, crlDigest);
fixture.kubectl(['apply', '-f', '-'], {
input: `${managerManifest}\n`,
});
waitRollout(fixture);
let managerPods = await readyManagerPods(fixture);
const deployment = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'deployment',
DEPLOYMENT,
]);
assert.equal(deployment.spec.replicas, 2);
assert.equal(deployment.spec.strategy.rollingUpdate.maxUnavailable, 0);
assert.equal(
deployment.spec.template.spec.automountServiceAccountToken,
false,
);
assert.equal(
deployment.spec.template.spec.affinity.podAntiAffinity
.requiredDuringSchedulingIgnoredDuringExecution.length,
1,
);
assert.equal(
fixture.kubectlJson(['-n', NAMESPACE, 'get', 'pdb', DEPLOYMENT]).spec
.minAvailable,
1,
);
for (const pod of managerPods) {
assert.equal(pod.spec.serviceAccountName, DEPLOYMENT);
assert.equal(pod.spec.automountServiceAccountToken, false);
assert.equal(
pod.spec.volumes.some((volume) =>
volume.projected?.sources?.some(
(source) => source.serviceAccountToken !== undefined,
),
),
false,
);
}
const executeClient = createClientExecutor({
fixture,
adminImage,
ca: pkiMaterial.ca,
});
const oldAssertion = () => assertion(oldKey);
const newAssertion = () => assertion(newKey);
const taskV1Command = envelope(
'task.publish',
'task-create-v1',
taskCommand(projectId, null, '001', 'v1'),
);
const initialAssertion = oldAssertion();
const initialRequests = await Promise.all([
executeClient(
{
name: 'ql3-automation-task-v1-a',
target: managerPods[0],
command: taskV1Command,
bearer: initialAssertion,
clientCertificate: pkiMaterial.oldClientCertificate,
clientKey: pkiMaterial.oldClientKey,
},
{ statusCode: 200, resultStatus: ['created', 'existing'] },
),
executeClient(
{
name: 'ql3-automation-task-v1-b',
target: managerPods[1],
command: taskV1Command,
bearer: initialAssertion,
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['created', 'existing'] },
),
]);
const initialStatuses = initialRequests
.map((entry) => entry.output.result.status)
.sort();
assert.deepEqual(initialStatuses, ['created', 'existing']);
const generation1Uids = new Set(managerPods.map((pod) => pod.metadata.uid));
applyIdentity(keysets[1]);
patchGeneration(fixture, 2);
const generation2 = await waitForTwoPreserved(fixture, generation1Uids, 2);
managerPods = generation2.pods;
const taskV2 = await executeClient(
{
name: 'ql3-automation-task-v2-old-key',
target: managerPods[0],
command: envelope(
'task.publish',
'task-update-v2',
taskCommand(projectId, 1, '002', 'v2'),
),
bearer: oldAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['updated'] },
);
const triggerV1 = await executeClient(
{
name: 'ql3-automation-trigger-v1-new-key',
target: managerPods[1],
command: envelope(
'trigger.publish',
'trigger-create-v1',
triggerCommand(projectId, null, taskV2.output.result.task, '001'),
),
bearer: newAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['created'] },
);
assert.equal(triggerV1.output.result.trigger.taskRevision, 2);
const generation2Uids = new Set(managerPods.map((pod) => pod.metadata.uid));
applyIdentity(keysets[2]);
patchGeneration(fixture, 3);
const generation3 = await waitForTwoPreserved(fixture, generation2Uids, 3);
managerPods = generation3.pods;
const taskV3Command = envelope(
'task.publish',
'task-update-v3',
taskCommand(projectId, 2, '003', 'v3'),
);
const rejectedOldKey = await executeClient(
{
name: 'ql3-automation-task-v3-revoked-key',
target: managerPods[0],
command: taskV3Command,
bearer: oldAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 401, responseCode: 'authentication_required' },
);
const taskV3 = await executeClient(
{
name: 'ql3-automation-task-v3-active-key',
target: managerPods[1],
command: taskV3Command,
bearer: newAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['updated'] },
);
applyIdentity(keysets[1]);
patchGeneration(fixture, 'rollback-2');
const rollback = await waitFor(
'identity ledger rollback surge failure with two ready replicas',
180_000,
() => {
const pods = fixture
.kubectlJson([
'-n',
NAMESPACE,
'get',
'pods',
'-l',
`app.kubernetes.io/name=${DEPLOYMENT}`,
])
.items.filter((pod) => pod.metadata.deletionTimestamp === undefined);
const ready = pods.filter(podReady);
const candidate = pods.find(
(pod) =>
!managerPods.some(
(current) => current.metadata.uid === pod.metadata.uid,
) &&
pod.status.containerStatuses?.[0] &&
!pod.status.containerStatuses[0].ready &&
(pod.status.containerStatuses[0].restartCount > 0 ||
pod.status.containerStatuses[0].state?.waiting?.reason ===
'CrashLoopBackOff'),
);
return ready.length === 2 && candidate
? { ready: true, value: candidate }
: {
ready: false,
fact: `${ready.length} ready Pods; rollback candidate=${Boolean(
candidate,
)}`,
};
},
);
applyIdentity(keysets[2]);
patchGeneration(fixture, '3-rollback-recovered');
fixture.kubectl([
'-n',
NAMESPACE,
'delete',
'pod',
rollback.value.metadata.name,
'--grace-period=0',
'--force',
'--wait=true',
]);
waitRollout(fixture);
managerPods = await readyManagerPods(fixture);
const previousSerialSha256 = pki.oldSerialSha256();
pki.revokeOldClient();
pkiMaterial = pki.read();
const currentBundleSha256 = pki.bundleSha256();
assert.notEqual(currentBundleSha256, previousBundleSha256);
applyTls();
const preCertificateUids = new Set(
managerPods.map((pod) => pod.metadata.uid),
);
patchGeneration(fixture, '3-client-crl-2', {
'qinglong.io/automation-management-client-ca-sha256': sha256(
pkiMaterial.ca,
),
'qinglong.io/automation-management-client-crl-sha256': sha256(
pkiMaterial.clientCrl,
),
});
const certificateRollout = await waitForTwoPreserved(
fixture,
preCertificateUids,
'3-client-crl-2',
);
managerPods = certificateRollout.pods;
const triggerV2Command = envelope(
'trigger.publish',
'trigger-update-v2',
triggerCommand(projectId, 1, taskV3.output.result.task, '002'),
);
const rejectedOldCertificate = await executeClient(
{
name: 'ql3-automation-trigger-v2-revoked-cert',
target: managerPods[0],
command: triggerV2Command,
bearer: newAssertion(),
clientCertificate: pkiMaterial.oldClientCertificate,
clientKey: pkiMaterial.oldClientKey,
},
{ statusCode: 401, responseCode: 'client_certificate_required' },
);
const triggerV2 = await executeClient(
{
name: 'ql3-automation-trigger-v2-active-cert',
target: managerPods[1],
command: triggerV2Command,
bearer: newAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['updated'] },
);
assert.equal(triggerV2.output.result.trigger.taskRevision, 3);
const primaryBeforeFailover = currentPrimaryPod(fixture);
fixture.kubectl([
'-n',
NAMESPACE,
'delete',
'pod',
primaryBeforeFailover.metadata.name,
'--grace-period=0',
'--force',
'--wait=false',
]);
const promoted = await waitFor(
'CloudNativePG primary promotion',
600_000,
() => {
const status = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'cluster',
POSTGRES_CLUSTER,
]).status;
return status.currentPrimary &&
status.currentPrimary !== primaryBeforeFailover.metadata.name &&
Number(status.readyInstances) >= 2
? { ready: true, value: status.currentPrimary }
: {
ready: false,
fact: `primary=${status.currentPrimary || 'none'} ready=${
status.readyInstances ?? 0
}`,
};
},
);
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: `${status.readyInstances ?? 0}/3 ready database instances`,
};
});
const databaseService = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'service',
`${POSTGRES_CLUSTER}-rw`,
]);
const databaseSelector = databaseService.spec.selector;
fixture.kubectl([
'-n',
NAMESPACE,
'patch',
'service',
`${POSTGRES_CLUSTER}-rw`,
'--type=merge',
'-p',
JSON.stringify({
spec: { selector: { ...databaseSelector, 'ql3.invalid': 'true' } },
}),
]);
const unavailable = await Promise.all(
managerPods.map((pod, index) =>
executeClient(
{
name: `ql3-automation-database-unavailable-${index + 1}`,
target: pod,
command: taskV3Command,
bearer: newAssertion(),
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 503, responseCode: 'unavailable' },
),
),
);
assert.deepEqual(
unavailable.map((entry) => entry.statusCode),
[503, 503],
);
await waitFor('manager readiness withdrawal', 60_000, () => {
const current = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'deployment',
DEPLOYMENT,
]);
return Number(current.status.readyReplicas ?? 0) === 0
? { ready: true, value: current }
: {
ready: false,
fact: `${current.status.readyReplicas ?? 0} ready replicas`,
};
});
const health = (pod, route) => {
const script = String.raw`
const fs=require('node:fs');const https=require('node:https');
const request=https.request({host:'127.0.0.1',port:8445,path:process.argv[1],
servername:process.argv[2],ca:fs.readFileSync('/var/run/secrets/qinglong3/automation-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();`;
return Number(
fixture.kubectl(
[
'-n',
NAMESPACE,
'exec',
pod.metadata.name,
'--',
'node',
'-e',
script,
route,
SERVERNAME,
],
{ capture: true, quiet: true },
).stdout,
);
};
assert.deepEqual(
managerPods.map((pod) => health(pod, '/readyz')),
[503, 503],
);
assert.deepEqual(
managerPods.map((pod) => health(pod, '/livez')),
[200, 200],
);
fixture.kubectl([
'-n',
NAMESPACE,
'patch',
'service',
`${POSTGRES_CLUSTER}-rw`,
'--type=json',
'-p',
JSON.stringify([
{
op: 'replace',
path: '/spec/selector',
value: databaseSelector,
},
]),
]);
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: `${count ?? 0} service endpoints` };
});
assert.deepEqual(
managerPods.map((pod) => health(pod, '/readyz')),
[503, 503],
);
const staleUids = new Set(managerPods.map((pod) => pod.metadata.uid));
patchGeneration(fixture, '3-database-recovered');
managerPods = await readyManagerPods(
fixture,
staleUids,
'3-database-recovered',
);
const recoveredTaskCommand = envelope(
'task.publish',
'task-update-v4-recovery',
taskCommand(projectId, 3, '004', 'v4-recovery'),
);
const recoveryAssertion = newAssertion();
const recoveredRequests = await Promise.all(
managerPods.map((pod, index) =>
executeClient(
{
name: `ql3-automation-database-recovered-${index + 1}`,
target: pod,
command: recoveredTaskCommand,
bearer: recoveryAssertion,
clientCertificate: pkiMaterial.newClientCertificate,
clientKey: pkiMaterial.newClientKey,
},
{ statusCode: 200, resultStatus: ['updated', 'existing'] },
),
),
);
assert.deepEqual(
recoveredRequests.map((entry) => entry.output.result.status).sort(),
['existing', 'updated'],
);
const finalPrimary = currentPrimaryPod(fixture);
const durable = JSON.parse(
psql(
fixture,
finalPrimary.metadata.name,
`SELECT json_build_object(
'taskRevisionCount', (SELECT count(*)::integer
FROM "ql3"."task_definition_revisions"
WHERE project_id = '${projectId}' AND task_id = 'managed-task'),
'taskCurrentRevision', (SELECT current_revision
FROM "ql3"."task_definitions"
WHERE project_id = '${projectId}' AND task_id = 'managed-task'),
'triggerRevisionCount', (SELECT count(*)::integer
FROM "ql3"."trigger_revisions"
WHERE project_id = '${projectId}' AND trigger_id = 'managed-trigger'),
'triggerCurrentRevision', (SELECT current_revision
FROM "ql3"."triggers"
WHERE project_id = '${projectId}' AND trigger_id = 'managed-trigger'),
'allowedAuditCount', (SELECT count(*)::integer
FROM "ql3"."security_audit_events"
WHERE project_id = '${projectId}' AND outcome = 'allowed'),
'identityGeneration', (SELECT generation::integer
FROM "ql3"."plugin_package_identity_keyset_ledger"
WHERE authority = 'automation-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,
'currentUser', current_user)`,
),
);
assert.deepEqual(durable, {
taskRevisionCount: 4,
taskCurrentRevision: 4,
triggerRevisionCount: 2,
triggerCurrentRevision: 2,
allowedAuditCount: 6,
identityGeneration: 3,
migrationCount: 54,
controlCoreCapability: 53,
postgresVersionNumber: 180004,
currentUser: 'postgres',
});
const roleList = ROLE_NAMES.map((role) => `'${role}'`).join(',');
const roleRows = JSON.parse(
psql(
fixture,
finalPrimary.metadata.name,
`SELECT json_agg(json_build_object(
'name', rolname,
'login', rolcanlogin,
'superuser', rolsuper,
'createDatabase', rolcreatedb,
'createRole', rolcreaterole,
'replication', rolreplication,
'bypassRls', rolbypassrls) ORDER BY rolname)
FROM pg_roles WHERE rolname IN (${roleList})`,
),
);
assert.deepEqual(
roleRows.map((role) => role.name),
[...ROLE_NAMES].sort(),
);
const rolesLeastPrivilege = roleRows.every(
(role) =>
role.login === true &&
role.superuser === false &&
role.createDatabase === false &&
role.createRole === false &&
role.replication === false &&
role.bypassRls === false,
);
assert.equal(rolesLeastPrivilege, true);
const canI = (verb, resource) => {
const result = fixture.kubectl(
[
'auth',
'can-i',
verb,
resource,
'-n',
NAMESPACE,
`--as=system:serviceaccount:${NAMESPACE}:${DEPLOYMENT}`,
],
{ capture: true, quiet: true, allowFailure: true },
);
assert.equal(
result.status,
result.stdout === 'yes' ? 0 : 1,
`unexpected kubectl auth can-i result: ${result.stdout}`,
);
return result.stdout;
};
assert.equal(canI('get', 'secrets'), 'no');
assert.equal(canI('patch', 'deployments.apps'), 'no');
const managerServiceIp = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'service',
SERVICE,
]).spec.clusterIP;
const labelledClientAllowed = await clientTcpProbe({
fixture,
adminImage,
name: 'ql3-automation-network-labelled',
targetHost: managerServiceIp,
port: 8445,
labelled: true,
expectedConnected: true,
});
const unlabelledClientDenied = await clientTcpProbe({
fixture,
adminImage,
name: 'ql3-automation-network-unlabelled',
targetHost: managerServiceIp,
port: 8445,
labelled: false,
expectedConnected: false,
});
const wrongPortDenied = await clientTcpProbe({
fixture,
adminImage,
name: 'ql3-automation-network-wrong-port',
targetHost: managerServiceIp,
port: 8444,
labelled: true,
expectedConnected: false,
});
const kubernetesServiceIp = fixture.kubectlJson([
'get',
'service',
'kubernetes',
'-n',
'default',
]).spec.clusterIP;
const postgresServiceIp = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'service',
`${POSTGRES_CLUSTER}-rw`,
]).spec.clusterIP;
const cloudNativePgEgressAllowed =
podTcpProbe(
fixture,
managerPods[0].metadata.name,
postgresServiceIp,
5432,
).status === 0;
const kubernetesApiEgressDenied =
podTcpProbe(
fixture,
managerPods[0].metadata.name,
kubernetesServiceIp,
443,
).status !== 0;
const publicInternetEgressDenied =
podTcpProbe(fixture, managerPods[0].metadata.name, '1.1.1.1', 443)
.status !== 0;
assert.equal(cloudNativePgEgressAllowed, true);
assert.equal(kubernetesApiEgressDenied, true);
assert.equal(publicInternetEgressDenied, true);
const finalNodes = fixture.kubectlJson(['get', 'nodes']).items;
const cniReadyNodes = finalNodes.filter(
(node) =>
podReady(node) &&
Array.isArray(node.spec.podCIDRs) &&
node.spec.podCIDRs.length === 1,
);
assert.equal(cniReadyNodes.length, 3);
assert.equal(
new Set(cniReadyNodes.map((node) => node.spec.podCIDRs[0])).size,
3,
);
const serverNode = finalNodes.find(
(node) => node.metadata.name === fixture.server,
);
assert.equal(
serverNode?.metadata.annotations?.[
'flannel.alpha.coreos.com/backend-type'
],
'vxlan',
);
assert.equal(
serverNode?.metadata.annotations?.[
'flannel.alpha.coreos.com/kube-subnet-manager'
],
'true',
);
const finalCluster = fixture.kubectlJson([
'-n',
NAMESPACE,
'get',
'cluster',
POSTGRES_CLUSTER,
]);
assert.equal(Number(finalCluster.status.readyInstances), 3);
const report = {
schemaVersion: 1,
fixture: FIXTURE,
observedAt: new Date().toISOString(),
platform: {
distribution: 'k3s',
kubernetesVersion: nodes[0].status.nodeInfo.kubeletVersion,
architecture,
kubernetesImageId: imageIdDigest(k3sImageInfo),
managementImageId: imageIdDigest(adminImageInfo),
cniName: 'flannel',
cniDistributionBinding: fixture.k3sImage,
controlPlaneNodes: 1,
workerNodes: 2,
cniReadyNodes: cniReadyNodes.length,
},
database: {
operator: 'cloudnative-pg',
operatorVersion: OPERATOR_VERSION,
postgresVersionNumber: durable.postgresVersionNumber,
postgresImageId: imageIdDigest(postgresImageInfo),
instances: Number(finalCluster.spec.instances),
readyInstances: Number(finalCluster.status.readyInstances),
managerRole: 'ql3_automation_manager',
controlCoreCapability: durable.controlCoreCapability,
tlsVerified: true,
primaryChangedDuringFailover:
promoted.value !== primaryBeforeFailover.metadata.name,
},
deployment: {
namespace: NAMESPACE,
service: SERVICE,
port: 8445,
replicas: deployment.spec.replicas,
readyReplicas: managerPods.length,
podIdentitySha256: managerPods.map((pod) => sha256(pod.metadata.uid)),
nodeIdentitySha256: managerPods.map((pod) => sha256(pod.spec.nodeName)),
serviceAccount: DEPLOYMENT,
automountServiceAccountToken: false,
requiredPodAntiAffinity: true,
podDisruptionBudgetMinAvailable: 1,
maxUnavailable: 0,
maxConnectionsPerPod: 2,
},
client: {
binary: 'ql3-automation-client',
operation: 'task.publish',
inputKind: 'Secret',
inputImmutable: true,
callerDrivenJob: true,
backoffLimit: 0,
serviceAccountTokenMounted: false,
rbacGranted: false,
transportProtocol: 'TLSv1.3',
mutualTls: true,
servernameVerified: true,
exactPodRequests: initialRequests.length,
resultStatuses: initialStatuses,
responseRedacted: true,
},
identityRotation: {
overlapOldAssertionAccepted: taskV2.statusCode === 200,
overlapNewAssertionAccepted: triggerV1.statusCode === 200,
revokedOldAssertionRejected: rejectedOldKey.statusCode === 401,
activeNewAssertionAccepted: taskV3.statusCode === 200,
rollbackSurgeFailedClosed: Boolean(rollback.value),
twoReadyReplicasPreserved:
generation2.minimumReady >= 2 && generation3.minimumReady >= 2,
durableGenerationReachedThree: durable.identityGeneration === 3,
},
certificateRotation: {
previousSerialSha256,
currentSerialSha256: pki.newSerialSha256(),
previousBundleSha256,
currentBundleSha256,
oldClientAcceptedBefore: initialRequests[0].statusCode === 200,
replacementClientAcceptedBefore: initialRequests[1].statusCode === 200,
oldClientRejectedAfter: rejectedOldCertificate.statusCode === 401,
replacementClientAcceptedAfter: triggerV2.statusCode === 200,
fullPodReplacement: managerPods.every(
(pod) => !preCertificateUids.has(pod.metadata.uid),
),
allReplicasReadyThroughout: certificateRollout.minimumReady >= 2,
},
availability: {
databaseFailureWithdrewReadiness: true,
databaseFailurePreservedLiveness: true,
stalePodsDidNotRecoverInPlace: true,
freshPodsRecoveredAfterDatabase: managerPods.every(
(pod) => !staleUids.has(pod.metadata.uid),
),
bothReplicasServedAfterRecovery: recoveredRequests.every(
(entry) => entry.statusCode === 200,
),
},
isolation: {
labelledClientAllowed,
unlabelledClientDenied,
wrongPortDenied,
kubernetesApiEgressDenied,
publicInternetEgressDenied,
cloudNativePgEgressAllowed,
managerSecretReadDenied: canI('get', 'secrets') === 'no',
managerMutationRbacDenied: canI('patch', 'deployments.apps') === 'no',
},
durability: {
taskRevisionCount: durable.taskRevisionCount,
triggerRevisionCount: durable.triggerRevisionCount,
allowedAuditCount: durable.allowedAuditCount,
replayDuplicateCount:
initialStatuses.join(',') === 'created,existing' ? 0 : 1,
taskCurrentRevision: durable.taskCurrentRevision,
triggerCurrentRevision: durable.triggerCurrentRevision,
survivedCloudNativePgFailover: true,
},
gates: {
realThreeNodeKubernetes: nodes.length === 3,
realCniPolicy:
labelledClientAllowed &&
unlabelledClientDenied &&
wrongPortDenied &&
kubernetesApiEgressDenied &&
publicInternetEgressDenied &&
cloudNativePgEgressAllowed,
threeInstanceCloudNativePg: databasePods.length === 3,
twoManagerPodsOnDistinctNodes:
new Set(managerPods.map((pod) => pod.spec.nodeName)).size === 2,
tls13ProductClientAcrossBothPods: initialRequests.length === 2,
identityProjectionRotation: durable.identityGeneration === 3,
certificateRevocationRollout: rejectedOldCertificate.statusCode === 401,
databaseReadinessFence: true,
durableFactsSurvivedFailover: true,
leastPrivilege: rolesLeastPrivilege,
passed: true,
},
limitations: [...LIMITATIONS],
};
const audit = validateAutomationManagementKubernetesLiveReport(report);
assert.deepEqual(audit.findings, []);
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
} finally {
if (stoppedNode) fixture.startNode(stoppedNode);
await fixture.cleanup();
if (adminImageBuilt) {
run(fixture.docker, ['image', 'rm', '-f', adminImage], {
capture: true,
quiet: true,
allowFailure: true,
});
}
if (controlImageBuilt) {
run(fixture.docker, ['image', 'rm', '-f', controlImage], {
capture: true,
quiet: true,
allowFailure: true,
});
}
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(
`QL3 automation management Kubernetes live contract failed: ${
error instanceof Error ? error.stack || error.message : String(error)
}\n`,
);
process.exitCode = 1;
});
}
module.exports = {
assertion,
envelope,
keyset,
reviewedKey,
taskCommand,
triggerCommand,
};