Files
qinglong/scripts/ql3-cloudnativepg-barman-live-contract.cjs
T

3459 lines
106 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 os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const {
validateCloudNativePgDrEvidence,
} = require('./ql3-cloudnativepg-dr-evidence-audit.cjs');
const ROOT = path.resolve(__dirname, '..');
const K3S_IMAGE =
'rancher/k3s:v1.32.8-k3s1@sha256:f9f125ef9c662a231a98c507afdd3ba9a94d5f02631f946d1d34000ef67f7263';
const REGISTRY_IMAGE =
'registry:2@sha256:a3d8aaa63ed8681a604f1dea0aa03f100d5895b6a58ace528858a7b332415373';
const SKOPEO_IMAGE =
'quay.io/skopeo/stable:v1.20.0@sha256:47853bb9fb24202af9110531ebd6e43c5f97701254ca290596640290d17942f4';
const K3S_VERSION = '1.32.8';
const K3S_EVICTION_HARD =
'memory.available<100Mi,nodefs.available<64Mi,imagefs.available<64Mi,nodefs.inodesFree<1%';
const DOCKER_SCOPE_LABEL =
'io.qinglong.ql3.live=cloudnativepg-barman-disaster-recovery';
const DOCKER_RUN_LABEL_KEY = 'io.qinglong.ql3.run';
const MINIMUM_DR_FREE_BYTES = 35n * 1024n * 1024n * 1024n;
const CERT_MANAGER_VERSION = '1.20.3';
const BARMAN_VERSION = '0.13.0';
const CNPG_VERSION = '1.30.0';
const CERT_MANAGER_MANIFEST_URL =
'https://github.com/cert-manager/cert-manager/releases/download/v1.20.3/cert-manager.yaml';
const CERT_MANAGER_MANIFEST_SHA256 =
'7ee74ba06845213e96d8ceaff3d20dd51e682765c1418eddda4e8780ba082261';
const BARMAN_MANIFEST_URL =
'https://github.com/cloudnative-pg/plugin-barman-cloud/releases/download/v0.13.0/manifest.yaml';
const BARMAN_MANIFEST_SHA256 =
'd2e71e7b06822448f1a421f05781846cfdb9cc621e7ef32eef5e20c5133213b0';
const CNPG_MANIFEST_URL =
'https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml';
const CNPG_MANIFEST_SHA256 =
'f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88';
const POSTGRES_DATABASE_ROLE_MANIFEST = path.join(
ROOT,
'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg/database-roles.yaml',
);
const POSTGRES_ROLE_NAMES = Object.freeze([
'ql3_admin',
'ql3_ai_credential_manager',
'ql3_ai_credential_tester',
'ql3_ai_maintenance',
'ql3_approval_manager',
'ql3_automation_manager',
'ql3_migration',
'ql3_package_executor',
'ql3_package_manager',
'ql3_run_manager',
'ql3_runtime',
'ql3_worker_credential_executor',
'ql3_worker_credential_manager',
'ql3_worker_ingress',
]);
const REGISTRY_DATA_TARGETS = Object.freeze(['/var/lib/registry']);
const K3S_DATA_TARGETS = Object.freeze([
'/var/lib/cni',
'/var/lib/kubelet',
'/var/lib/rancher/k3s',
'/var/log',
]);
const REVIEWED_DOCKER_DATA_TARGETS = new Set([
...REGISTRY_DATA_TARGETS,
...K3S_DATA_TARGETS,
]);
function assertCloudNativePgDrRunnerCapacity(
statfs = fs.statfsSync,
filesystemPath = os.tmpdir(),
) {
assert.equal(typeof statfs, 'function');
assert.equal(path.isAbsolute(filesystemPath), true);
const stats = statfs(filesystemPath, { bigint: true });
assert.ok(stats && typeof stats === 'object');
assert.equal(typeof stats.bavail, 'bigint');
assert.equal(typeof stats.bsize, 'bigint');
assert.ok(stats.bavail >= 0n && stats.bsize > 0n);
const availableBytes = stats.bavail * stats.bsize;
assert.ok(
availableBytes >= MINIMUM_DR_FREE_BYTES,
`CloudNativePG DR live gate requires at least 35 GiB free; found ${availableBytes} bytes`,
);
return Object.freeze({
minimumBytes: MINIMUM_DR_FREE_BYTES,
availableBytes,
});
}
const IMAGES = Object.freeze({
cnpg: 'ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0@sha256:a2701eb97cdd2a34b1fdb2cb51987f544b706e40bec72ae7146cd8580efefebb',
postgres:
'ghcr.io/cloudnative-pg/postgresql:18.4-minimal-trixie@sha256:24d229d801663f95b584416f8ebdfad4849b1a3fa4cfcf95a7f026df7aa6e22d',
barman:
'ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.13.0@sha256:71589dbac582333442812b07b31f7ea4d00324a8358aac7ca507dabf9f4b6c96',
barmanSidecar:
'ghcr.io/cloudnative-pg/plugin-barman-cloud-sidecar:v0.13.0@sha256:990361af3319f9e23aafa0f6d7981f99bf1f69b4e6a85cf1bc7d71d6f09bb288',
certManager:
'quay.io/jetstack/cert-manager-controller:v1.20.3@sha256:6c13d61e0348a5bc3477f8ea9a928624300b30d19b1c72a7d2b90372fc713db4',
certManagerCainjector:
'quay.io/jetstack/cert-manager-cainjector:v1.20.3@sha256:06ad347fe0dc2eb84cc355c26f6752e05e87dceb6447f5cd29b963dd66dfd8bd',
certManagerWebhook:
'quay.io/jetstack/cert-manager-webhook:v1.20.3@sha256:a61e817632cebed3bb59a189327e786fa3fdd7597167d994a1848d98fd55848f',
minio:
'docker.io/minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e',
minioClient:
'docker.io/minio/mc@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727',
});
const PLATFORM_DIGESTS = Object.freeze({
amd64: Object.freeze({
cnpg: 'sha256:091d306935cfdf646debfe78010d59ebfb572150eb6eb922b0203873c0c68841',
barman:
'sha256:417449fe4f6f0a56acdeb30e4131930815f2b46b9afeb808059b57aa8b4c2ef5',
barmanSidecar:
'sha256:15cb1a01e7c5235eedac2061cab8208e5f7c39dbda292f9c2d4ddaa0c1f211e6',
certManager:
'sha256:1e4af57beb469cc3bb0fb48b9201caea2723819b9ffd3c3ea98568f55b4dd38b',
certManagerCainjector:
'sha256:a2b12d27950d1603d2c8168c3ccd95d07b93ce6ec4b530316196a31db592a9c0',
certManagerWebhook:
'sha256:953a97df613f7da7eda8ce4b1c8d8e6b50963db0800fab595d040db6eb5cb060',
minio:
'sha256:a1a8bd4ac40ad7881a245bab97323e18f971e4d4cba2c2007ec1bedd21cbaba2',
minioClient:
'sha256:eb4ea9884b77704230e2423e9004d2fa738dc272876b9cc41a297d29443b8780',
postgres:
'sha256:67e56bcbe50a58e60509815dd89e58effc5f331ab844f66331d945cd42131e8d',
}),
arm64: Object.freeze({
cnpg: 'sha256:6c7926147fd23a053dea6605d61c013d43bfd411be3532e7500ef2d2b68bb98c',
barman:
'sha256:de612e3ad8633a198b91ffbea53848407424155daf2183d656490d843a83b100',
barmanSidecar:
'sha256:f53e168e341661cd76334215ead9dfd69f06117685d3232206192cf25218da71',
certManager:
'sha256:af62a025ae4f8fd03209b5e0760868296bad5a9370aab0c91ad3b5476bcb282d',
certManagerCainjector:
'sha256:3c052c134ad1b93122b957f4d214aaa9d85a37b5ff15acc5b4d86f50e3ed822e',
certManagerWebhook:
'sha256:7c510875e038f79f7fba707b5f86d8736777a4dfefcd42179b08844ee75e685b',
minio:
'sha256:9966a92a734f9411e32f4f41d7d9d826fcdc0f68c4e20b70295bd4e7c11f8a2f',
minioClient:
'sha256:37d109dddbbb2c95873f5fc81ac93f37023264770fc580a7564148892087b1b7',
postgres:
'sha256:2adbb634a1c0af8cb036e81200aaa7c8b62517bf5e501699926b337bd9f863f1',
}),
});
function run(binary, args, options = {}) {
if (!options.quiet) {
process.stderr.write(`+ ${path.basename(binary)} ${args.join(' ')}\n`);
}
const result = spawnSync(binary, args, {
cwd: options.cwd || ROOT,
env: options.env || process.env,
input: options.input,
encoding: 'utf8',
maxBuffer: 64 * 1024 * 1024,
stdio: options.capture
? ['pipe', 'pipe', 'pipe']
: [
options.input === undefined ? 'inherit' : 'pipe',
'inherit',
'inherit',
],
});
if (result.error) throw result.error;
if (result.status !== 0 && !options.allowFailure) {
throw new Error(
`${path.basename(binary)} failed with ${String(result.status)}: ${
result.stderr || result.stdout || ''
}`,
);
}
return Object.freeze({
status: result.status,
stdout: options.capture ? result.stdout.trim() : '',
stderr: options.capture ? result.stderr.trim() : '',
});
}
function sleep(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function waitFor(description, timeoutMs, inspect) {
const startedAt = Date.now();
let last = 'not observed';
while (Date.now() - startedAt < timeoutMs) {
try {
const result = inspect();
if (result?.ready) {
return Object.freeze({
elapsedMs: Date.now() - startedAt,
value: result.value,
});
}
if (result?.fact) last = result.fact;
} catch (error) {
last = error instanceof Error ? error.message : String(error);
}
await sleep(1_000);
}
throw new Error(`${description} timed out: ${last}`);
}
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex');
}
function normalizeArchitecture(value) {
if (value === 'x86_64' || value === 'x64') return 'amd64';
if (value === 'aarch64') return 'arm64';
return value;
}
function privateDockerDataBindArgs(root, identity, targets) {
assert.ok(path.isAbsolute(root), 'Docker data root must be absolute');
assert.match(identity, /^[a-z0-9][a-z0-9-]{0,127}$/);
assert.ok(Array.isArray(targets) && targets.length > 0);
assert.equal(new Set(targets).size, targets.length);
assert.ok(!root.includes(','), 'Docker data root cannot contain a comma');
return targets.flatMap((target, index) => {
assert.ok(
REVIEWED_DOCKER_DATA_TARGETS.has(target),
`unreviewed Docker data target ${target}`,
);
const directory = path.join(
root,
identity,
`${String(index).padStart(2, '0')}-${path.basename(target)}`,
);
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
fs.chmodSync(directory, 0o700);
return ['--mount', `type=bind,src=${directory},dst=${target}`];
});
}
function redactRuntimeText(value, sensitiveValues) {
return sensitiveValues.reduce((redacted, sensitive) => {
if (typeof sensitive !== 'string' || sensitive.length === 0)
return redacted;
return redacted.split(sensitive).join('[REDACTED]');
}, String(value));
}
function downloadManifest(curl, target, url) {
run(curl, [
'--http1.1',
'--fail',
'--location',
'--silent',
'--show-error',
'--retry',
'4',
'--retry-all-errors',
'--connect-timeout',
'15',
'--max-time',
'300',
'--output',
target,
url,
]);
fs.chmodSync(target, 0o600);
}
function manifestSource(environmentKey, fallback, curl, target, url) {
const selected = process.env[environmentKey] || fallback;
if (selected && fs.existsSync(selected)) return path.resolve(selected);
downloadManifest(curl, target, url);
return target;
}
function replaceExactlyOnce(source, from, to) {
return replaceExactly(source, from, to, 1);
}
function replaceExactly(source, from, to, expectedCount) {
assert.ok(Number.isSafeInteger(expectedCount) && expectedCount >= 1);
const occurrences = source.split(from).length - 1;
assert.equal(
occurrences,
expectedCount,
`expected exactly ${String(expectedCount)} manifest references ${from}`,
);
return source.split(from).join(to);
}
function reviewedManifest(source, target, expectedSha256, replacements) {
const stat = fs.lstatSync(source);
assert.ok(
stat.isFile() && !stat.isSymbolicLink() && stat.size >= 1024,
'release manifest must be a non-symlink regular file',
);
const bytes = fs.readFileSync(source);
assert.equal(sha256(bytes), expectedSha256, 'release manifest digest drift');
let text = bytes.toString('utf8');
for (const [from, to, expectedCount = 1] of replacements) {
text = replaceExactly(text, from, to, expectedCount);
}
fs.writeFileSync(target, text, { mode: 0o600, flag: 'wx' });
return Object.freeze({
sourceSha256: expectedSha256,
pinnedSha256: sha256(Buffer.from(text)),
target,
});
}
function imageIdDigest(value) {
const match = String(value || '').match(/sha256:[a-f0-9]{64}$/);
assert.ok(match, `invalid Kubernetes imageID ${String(value)}`);
return match[0];
}
function platformDigestFromImageIndex(index, architecture) {
const manifests = Array.isArray(index?.manifests) ? index.manifests : [];
const matching = manifests.filter(
(manifest) =>
manifest?.platform?.os === 'linux' &&
normalizeArchitecture(manifest.platform.architecture) === architecture,
);
assert.equal(
matching.length,
1,
`expected one linux/${architecture} manifest in the reviewed image index`,
);
return imageIdDigest(matching[0].digest);
}
function digestOnlyReference(image) {
const at = image.lastIndexOf('@');
assert.ok(at > 0, `reviewed image must contain a digest: ${image}`);
const digest = image.slice(at + 1);
imageIdDigest(digest);
const named = image.slice(0, at);
const slash = named.lastIndexOf('/');
const colon = named.lastIndexOf(':');
const repository = colon > slash ? named.slice(0, colon) : named;
return `${repository}@${digest}`;
}
function runtimeImageEvidence(
pod,
containerName,
reviewedImage,
expectedPlatformDigest,
) {
const status = (pod?.status?.containerStatuses || []).find(
(candidate) => candidate.name === containerName,
);
assert.ok(status, `missing runtime container status ${containerName}`);
const imageId = imageIdDigest(status.imageID);
const indexDigest = imageIdDigest(reviewedImage);
assert.equal(imageId, expectedPlatformDigest);
return Object.freeze({
imageId,
indexDigest,
platformDigest: expectedPlatformDigest,
});
}
function mirrorPlatformImage(
docker,
registry,
sourceImage,
repository,
architecture,
expectedPlatformDigest,
) {
assert.match(repository, /^[a-z][a-z0-9-]*$/);
const localDestinationTag = `localhost:5000/ql3/${repository}:reviewed-${architecture}`;
run(
docker,
[
'run',
'--rm',
'--network',
`container:${registry}`,
SKOPEO_IMAGE,
'copy',
'--retry-times',
'4',
'--retry-delay',
'2s',
'--preserve-digests',
'--override-os',
'linux',
'--override-arch',
architecture,
'--dest-tls-verify=false',
`docker://${digestOnlyReference(sourceImage)}`,
`docker://${localDestinationTag}`,
],
{ capture: true, quiet: true },
);
const inspection = JSON.parse(
run(
docker,
[
'run',
'--rm',
'--network',
`container:${registry}`,
SKOPEO_IMAGE,
'inspect',
'--tls-verify=false',
`docker://${localDestinationTag}`,
],
{ capture: true, quiet: true },
).stdout,
);
assert.equal(
inspection.Digest,
expectedPlatformDigest,
`local registry digest drift for ${repository}`,
);
return `${registry}:5000/ql3/${repository}@${expectedPlatformDigest}`;
}
function buildClusterControlImage({
docker,
registry,
registryPort,
architecture,
suffix,
}) {
assert.match(registry, /^ql3-barman-dr-[0-9]+-[a-f0-9]{6}-registry$/);
assert.match(registryPort, /^127\.0\.0\.1:[0-9]+$/);
assert.match(architecture, /^(?:amd64|arm64)$/);
assert.match(suffix, /^[0-9]+-[a-f0-9]{6}$/);
const repository = `${registryPort}/ql3/cluster-control`;
const localTag = `${repository}:dr-${suffix}`;
run(docker, [
'build',
'--file',
'deploy/containers/ql3-cluster-control/Dockerfile',
'--platform',
`linux/${architecture}`,
'--provenance=false',
'--target',
'runtime',
'--tag',
localTag,
'--build-arg',
'SOURCE_REVISION=cloudnativepg-barman-live',
'.',
]);
run(docker, ['push', localTag]);
const inspection = JSON.parse(
run(docker, ['image', 'inspect', localTag], {
capture: true,
quiet: true,
}).stdout,
);
assert.equal(inspection.length, 1);
const prefix = `${repository}@`;
const matching = (inspection[0].RepoDigests || []).filter((reference) =>
reference.startsWith(prefix),
);
assert.equal(
matching.length,
1,
'missing exact local image repository digest',
);
const platformDigest = imageIdDigest(matching[0]);
return Object.freeze({
localTag,
platformDigest,
runtimeImage: `${registry}:5000/ql3/cluster-control@${platformDigest}`,
});
}
function kubernetesSecret(namespace, name, values) {
return {
apiVersion: 'v1',
kind: 'Secret',
metadata: { namespace, name },
type: 'Opaque',
data: Object.fromEntries(
Object.entries(values).map(([key, value]) => [
key,
Buffer.from(value, 'utf8').toString('base64'),
]),
),
};
}
function postgresRoleSecretResources(credentials) {
assert.ok(credentials && typeof credentials === 'object');
assert.deepEqual(Object.keys(credentials).sort(), POSTGRES_ROLE_NAMES);
const items = POSTGRES_ROLE_NAMES.map((role) => {
const password = credentials[role];
assert.match(password, /^[A-Za-z0-9_-]{32,128}$/);
return Object.freeze({
apiVersion: 'v1',
kind: 'Secret',
metadata: {
namespace: 'ql3-dr',
name: `ql3-postgres-${role
.replace(/^ql3_/, '')
.replaceAll('_', '-')}-auth`,
},
type: 'kubernetes.io/basic-auth',
data: {
username: Buffer.from(role, 'utf8').toString('base64'),
password: Buffer.from(password, 'utf8').toString('base64'),
},
});
});
return Object.freeze({
apiVersion: 'v1',
kind: 'List',
items: Object.freeze(items),
});
}
function postgresMigrationJobResource({ controlImage }) {
imageIdDigest(controlImage);
assert.match(
controlImage,
/^ql3-barman-dr-[0-9]+-[a-f0-9]{6}-registry:5000\/ql3\/cluster-control@sha256:[a-f0-9]{64}$/,
);
const roleSecret = 'ql3-postgres-migration-auth';
const secretValue = (name, key) => ({
name,
valueFrom: { secretKeyRef: { name: roleSecret, key } },
});
return Object.freeze({
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
namespace: 'ql3-dr',
name: 'ql3-cluster-migration',
labels: {
'app.kubernetes.io/name': 'ql3-cluster-migration',
'app.kubernetes.io/component': 'database-migration',
'app.kubernetes.io/part-of': 'qinglong3',
},
},
spec: {
backoffLimit: 0,
activeDeadlineSeconds: 600,
template: {
metadata: {
labels: {
'app.kubernetes.io/name': 'ql3-cluster-migration',
'app.kubernetes.io/component': 'database-migration',
'app.kubernetes.io/part-of': 'qinglong3',
},
},
spec: {
automountServiceAccountToken: false,
restartPolicy: 'Never',
securityContext: {
runAsNonRoot: true,
runAsUser: 10001,
runAsGroup: 10001,
fsGroup: 10001,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'migration',
image: controlImage,
imagePullPolicy: 'IfNotPresent',
command: [
'node',
'/opt/qinglong/node_modules/@qinglong/cluster-postgres/dist/migration/migrationCli.js',
],
env: [
{ name: 'QL3_POSTGRES_TLS_MODE', value: 'verify-full' },
{
name: 'QL3_POSTGRES_TLS_CA_FILE',
value: '/var/run/secrets/qinglong3/postgres/ca.crt',
},
{
name: 'QL3_POSTGRES_TLS_SERVERNAME',
value: 'ql3-postgres-rw.ql3-dr.svc',
},
{
name: 'QL3_POSTGRES_MIGRATION_HOST',
value: 'ql3-postgres-rw.ql3-dr.svc',
},
{ name: 'QL3_POSTGRES_MIGRATION_PORT', value: '5432' },
{
name: 'QL3_POSTGRES_MIGRATION_DATABASE',
value: 'qinglong',
},
secretValue('QL3_POSTGRES_MIGRATION_USER', 'username'),
secretValue('QL3_POSTGRES_MIGRATION_PASSWORD', 'password'),
{
name: 'QL3_POSTGRES_APPLICATION_NAME',
value: 'qinglong3-cluster-migration',
},
],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '1', memory: '256Mi' },
},
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ['ALL'] },
},
volumeMounts: [
{ name: 'tmp', mountPath: '/tmp' },
{
name: 'postgres-ca',
mountPath: '/var/run/secrets/qinglong3/postgres',
readOnly: true,
},
],
},
],
volumes: [
{ name: 'tmp', emptyDir: { medium: 'Memory', sizeLimit: '16Mi' } },
{
name: 'postgres-ca',
secret: {
secretName: 'ql3-postgres-ca',
defaultMode: 292,
items: [{ key: 'ca.crt', path: 'ca.crt' }],
},
},
],
},
},
},
});
}
function postgresRestoreApplicationProbeResources({
clusterName,
controlImage,
apiCredentialPepper,
}) {
assert.match(clusterName, /^ql3-postgres-restore-(?:latest|pitr)$/);
imageIdDigest(controlImage);
assert.match(
controlImage,
/^ql3-barman-dr-[0-9]+-[a-f0-9]{6}-registry:5000\/ql3\/cluster-control@sha256:[a-f0-9]{64}$/,
);
assert.match(apiCredentialPepper, /^[A-Za-z0-9_-]{43}$/);
assert.equal(
Buffer.from(apiCredentialPepper, 'base64url').toString('base64url'),
apiCredentialPepper,
);
const suffix = clusterName.endsWith('-latest') ? 'latest' : 'pitr';
const name = `ql3-dr-application-${suffix}`;
const secretName = `${name}-security`;
const databaseSecret = 'ql3-postgres-runtime-auth';
const secretValue = (name, secret, key) => ({
name,
valueFrom: { secretKeyRef: { name: secret, key } },
});
const labels = Object.freeze({
'app.kubernetes.io/name': name,
'app.kubernetes.io/component': 'restore-application-probe',
'app.kubernetes.io/part-of': 'qinglong3',
'ql3.cloud/restore-cluster': clusterName,
});
return Object.freeze({
apiVersion: 'v1',
kind: 'List',
items: Object.freeze([
kubernetesSecret('ql3-dr', secretName, {
'api-credential-pepper': apiCredentialPepper,
}),
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { namespace: 'ql3-dr', name, labels },
spec: {
replicas: 1,
minReadySeconds: 2,
progressDeadlineSeconds: 300,
strategy: { type: 'Recreate' },
selector: { matchLabels: labels },
template: {
metadata: { labels },
spec: {
automountServiceAccountToken: false,
terminationGracePeriodSeconds: 15,
securityContext: {
runAsNonRoot: true,
runAsUser: 10001,
runAsGroup: 10001,
fsGroup: 10001,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'cluster-control',
image: controlImage,
imagePullPolicy: 'IfNotPresent',
env: [
{ name: 'QL_DEPLOYMENT_PROFILE', value: 'cluster-control' },
{ name: 'QL3_CLUSTER_CONTROL_ENABLED', value: 'true' },
{ name: 'QL3_WORKER_INGRESS_ENABLED', value: 'false' },
{ name: 'QL3_CLUSTER_HTTP_HOST', value: '0.0.0.0' },
{ name: 'QL3_CLUSTER_HTTP_PORT', value: '5800' },
{
name: 'QL3_CLUSTER_REPLICA_ID',
valueFrom: {
fieldRef: {
apiVersion: 'v1',
fieldPath: 'metadata.name',
},
},
},
{ name: 'QL3_POSTGRES_TLS_MODE', value: 'verify-full' },
{
name: 'QL3_POSTGRES_TLS_CA_FILE',
value: '/var/run/secrets/qinglong3/postgres/ca.crt',
},
{
name: 'QL3_POSTGRES_TLS_SERVERNAME',
value: `${clusterName}-rw.ql3-dr.svc`,
},
{
name: 'QL3_POSTGRES_RUNTIME_HOST',
value: `${clusterName}-rw.ql3-dr.svc`,
},
{ name: 'QL3_POSTGRES_RUNTIME_PORT', value: '5432' },
{
name: 'QL3_POSTGRES_RUNTIME_DATABASE',
value: 'qinglong',
},
secretValue(
'QL3_POSTGRES_RUNTIME_USER',
databaseSecret,
'username',
),
secretValue(
'QL3_POSTGRES_RUNTIME_PASSWORD',
databaseSecret,
'password',
),
{
name: 'QL3_POSTGRES_APPLICATION_NAME',
value: `qinglong3-dr-${suffix}`,
},
{ name: 'QL3_POSTGRES_MAX_CONNECTIONS', value: '2' },
secretValue(
'QL3_API_CREDENTIAL_PEPPER',
secretName,
'api-credential-pepper',
),
],
ports: [{ name: 'http', containerPort: 5800 }],
startupProbe: {
httpGet: { path: '/livez', port: 'http' },
periodSeconds: 2,
timeoutSeconds: 1,
failureThreshold: 30,
},
readinessProbe: {
httpGet: { path: '/readyz', port: 'http' },
periodSeconds: 2,
timeoutSeconds: 1,
failureThreshold: 5,
},
livenessProbe: {
httpGet: { path: '/livez', port: 'http' },
periodSeconds: 10,
timeoutSeconds: 2,
failureThreshold: 3,
},
resources: {
requests: { cpu: '50m', memory: '128Mi' },
limits: { cpu: '1', memory: '512Mi' },
},
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ['ALL'] },
},
volumeMounts: [
{ name: 'tmp', mountPath: '/tmp' },
{
name: 'postgres-ca',
mountPath: '/var/run/secrets/qinglong3/postgres',
readOnly: true,
},
],
},
],
volumes: [
{
name: 'tmp',
emptyDir: { medium: 'Memory', sizeLimit: '16Mi' },
},
{
name: 'postgres-ca',
secret: {
secretName: `${clusterName}-ca`,
defaultMode: 292,
items: [{ key: 'ca.crt', path: 'ca.crt' }],
},
},
],
},
},
},
},
]),
});
}
function minioFixtureResources({ minioImage, clientImage, credentials }) {
const namespace = 'ql3-dr';
assert.notEqual(credentials.writer.accessKey, credentials.recovery.accessKey);
assert.notEqual(credentials.writer.secretKey, credentials.recovery.secretKey);
const writerPolicy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:GetBucketLocation',
's3:GetBucketVersioning',
's3:GetObjectLockConfiguration',
's3:ListBucket',
's3:ListBucketMultipartUploads',
's3:ListBucketVersions',
],
Resource: ['arn:aws:s3:::ql3-dr'],
},
{
Effect: 'Allow',
Action: [
's3:AbortMultipartUpload',
's3:DeleteObject',
's3:DeleteObjectVersion',
's3:GetObject',
's3:GetObjectVersion',
's3:ListMultipartUploadParts',
's3:PutObject',
],
Resource: ['arn:aws:s3:::ql3-dr/*'],
},
],
});
const recoveryPolicy = JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Action: [
's3:GetBucketLocation',
's3:GetBucketVersioning',
's3:GetObjectLockConfiguration',
's3:ListBucket',
's3:ListBucketVersions',
],
Resource: ['arn:aws:s3:::ql3-dr'],
},
{
Effect: 'Allow',
Action: ['s3:GetObject', 's3:GetObjectVersion'],
Resource: ['arn:aws:s3:::ql3-dr/*'],
},
],
});
const credentialEnv = (name, secretName, key) => ({
name,
valueFrom: { secretKeyRef: { name: secretName, key } },
});
const clientEnvironment = [
{ name: 'MC_CONFIG_DIR', value: '/tmp/mc' },
{ name: 'SSL_CERT_FILE', value: '/certs/ca.crt' },
credentialEnv('MINIO_ROOT_USER', 'ql3-minio-root', 'ACCESS_KEY_ID'),
credentialEnv('MINIO_ROOT_PASSWORD', 'ql3-minio-root', 'ACCESS_SECRET_KEY'),
credentialEnv(
'WRITER_ACCESS_KEY',
'ql3-object-store-writer',
'ACCESS_KEY_ID',
),
credentialEnv(
'WRITER_SECRET_KEY',
'ql3-object-store-writer',
'ACCESS_SECRET_KEY',
),
credentialEnv(
'RECOVERY_ACCESS_KEY',
'ql3-object-store-recovery',
'ACCESS_KEY_ID',
),
credentialEnv(
'RECOVERY_SECRET_KEY',
'ql3-object-store-recovery',
'ACCESS_SECRET_KEY',
),
];
const clientVolumes = [
{
name: 'ca',
secret: {
secretName: 'ql3-minio-ca',
items: [{ key: 'tls.crt', path: 'ca.crt' }],
},
},
{ name: 'tmp', emptyDir: {} },
];
const clientMounts = [
{ name: 'ca', mountPath: '/certs', readOnly: true },
{ name: 'tmp', mountPath: '/tmp' },
];
const clientSecurityContext = {
allowPrivilegeEscalation: false,
capabilities: { drop: ['ALL'] },
readOnlyRootFilesystem: true,
runAsNonRoot: true,
runAsUser: 1000,
seccompProfile: { type: 'RuntimeDefault' },
};
const bootstrapScript = `set -eu
until mc alias set ql3 https://ql3-minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" --api S3v4 --path on >/dev/null 2>&1; do sleep 2; done
mc mb --with-lock --ignore-existing ql3/ql3-dr >/dev/null
mc retention set --default governance 30d ql3/ql3-dr/ >/dev/null
mc ilm rule add --expire-days 45 --noncurrent-expire-days 45 ql3/ql3-dr/ >/dev/null
printf '%s' '${writerPolicy}' > /tmp/writer-policy.json
printf '%s' '${recoveryPolicy}' > /tmp/recovery-policy.json
mc admin policy create ql3 ql3-writer /tmp/writer-policy.json >/dev/null
mc admin user add ql3 "$WRITER_ACCESS_KEY" "$WRITER_SECRET_KEY" >/dev/null
mc admin policy attach ql3 ql3-writer --user "$WRITER_ACCESS_KEY" >/dev/null
mc admin policy create ql3 ql3-recovery /tmp/recovery-policy.json >/dev/null
mc admin user add ql3 "$RECOVERY_ACCESS_KEY" "$RECOVERY_SECRET_KEY" >/dev/null
mc admin policy attach ql3 ql3-recovery --user "$RECOVERY_ACCESS_KEY" >/dev/null
mc alias set writer https://ql3-minio:9000 "$WRITER_ACCESS_KEY" "$WRITER_SECRET_KEY" --api S3v4 --path on >/dev/null
printf 'ql3-object-store-authority\n' > /tmp/probe
mc cp /tmp/probe writer/ql3-dr/authority/probe >/dev/null
mc version info ql3/ql3-dr >/dev/null
mc retention info ql3/ql3-dr/ >/dev/null
mc ilm rule ls ql3/ql3-dr/ >/dev/null
echo QL3_MINIO_AUTHORITY_READY`;
const verifierScript = `set -eu
mc alias set recovery https://ql3-minio:9000 "$RECOVERY_ACCESS_KEY" "$RECOVERY_SECRET_KEY" --api S3v4 --path on >/dev/null
mc cat recovery/ql3-dr/authority/probe > /tmp/readback
grep -qx ql3-object-store-authority /tmp/readback
printf 'unauthorized\n' > /tmp/unauthorized
if mc cp /tmp/unauthorized recovery/ql3-dr/authority/unauthorized >/dev/null 2>&1; then exit 51; fi
if mc rm recovery/ql3-dr/authority/probe >/dev/null 2>&1; then exit 52; fi
mc ls recovery/ql3-dr/ >/dev/null
echo QL3_RECOVERY_READ_ONLY_READY`;
const job = (name, script, backoffLimit) => ({
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { namespace, name },
spec: {
backoffLimit,
template: {
metadata: { labels: { app: name } },
spec: {
restartPolicy: 'Never',
securityContext: { fsGroup: 1000 },
containers: [
{
name: 'mc',
image: clientImage,
imagePullPolicy: 'IfNotPresent',
command: ['/bin/sh', '-ec', script],
env: clientEnvironment,
resources: {
requests: { cpu: '10m', memory: '32Mi' },
limits: { cpu: '250m', memory: '128Mi' },
},
securityContext: clientSecurityContext,
volumeMounts: clientMounts,
},
],
volumes: clientVolumes,
},
},
},
});
const items = [
{
apiVersion: 'v1',
kind: 'Namespace',
metadata: { name: namespace },
},
kubernetesSecret(namespace, 'ql3-minio-root', {
ACCESS_KEY_ID: credentials.root.accessKey,
ACCESS_SECRET_KEY: credentials.root.secretKey,
}),
kubernetesSecret(namespace, 'ql3-object-store-writer', {
ACCESS_KEY_ID: credentials.writer.accessKey,
ACCESS_SECRET_KEY: credentials.writer.secretKey,
}),
kubernetesSecret(namespace, 'ql3-object-store-recovery', {
ACCESS_KEY_ID: credentials.recovery.accessKey,
ACCESS_SECRET_KEY: credentials.recovery.secretKey,
}),
{
apiVersion: 'cert-manager.io/v1',
kind: 'Issuer',
metadata: { namespace, name: 'ql3-minio-selfsigned' },
spec: { selfSigned: {} },
},
{
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
metadata: { namespace, name: 'ql3-minio-ca' },
spec: {
isCA: true,
commonName: 'ql3-minio-ca',
secretName: 'ql3-minio-ca',
duration: '24h',
renewBefore: '1h',
issuerRef: { name: 'ql3-minio-selfsigned', kind: 'Issuer' },
},
},
{
apiVersion: 'cert-manager.io/v1',
kind: 'Issuer',
metadata: { namespace, name: 'ql3-minio-ca' },
spec: { ca: { secretName: 'ql3-minio-ca' } },
},
{
apiVersion: 'cert-manager.io/v1',
kind: 'Certificate',
metadata: { namespace, name: 'ql3-minio-server' },
spec: {
secretName: 'ql3-minio-tls',
duration: '12h',
renewBefore: '1h',
issuerRef: { name: 'ql3-minio-ca', kind: 'Issuer' },
dnsNames: [
'ql3-minio',
'ql3-minio.ql3-dr',
'ql3-minio.ql3-dr.svc',
'ql3-minio.ql3-dr.svc.cluster.local',
],
},
},
{
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { namespace, name: 'ql3-minio-data' },
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName: 'local-path',
resources: { requests: { storage: '2Gi' } },
},
},
{
apiVersion: 'v1',
kind: 'Service',
metadata: { namespace, name: 'ql3-minio' },
spec: {
selector: { app: 'ql3-minio' },
ports: [
{ name: 'api', port: 9000, targetPort: 9000 },
{ name: 'console', port: 9001, targetPort: 9001 },
],
},
},
{
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { namespace, name: 'ql3-minio' },
spec: {
replicas: 1,
selector: { matchLabels: { app: 'ql3-minio' } },
template: {
metadata: { labels: { app: 'ql3-minio' } },
spec: {
securityContext: {
fsGroup: 1000,
fsGroupChangePolicy: 'OnRootMismatch',
},
containers: [
{
name: 'minio',
image: minioImage,
imagePullPolicy: 'IfNotPresent',
args: [
'server',
'--certs-dir',
'/certs',
'--console-address',
':9001',
'/data',
],
env: [
{ name: 'HOME', value: '/tmp' },
credentialEnv(
'MINIO_ROOT_USER',
'ql3-minio-root',
'ACCESS_KEY_ID',
),
credentialEnv(
'MINIO_ROOT_PASSWORD',
'ql3-minio-root',
'ACCESS_SECRET_KEY',
),
],
ports: [
{ name: 'api', containerPort: 9000 },
{ name: 'console', containerPort: 9001 },
],
readinessProbe: {
httpGet: {
path: '/minio/health/ready',
port: 'api',
scheme: 'HTTPS',
},
periodSeconds: 2,
timeoutSeconds: 2,
failureThreshold: 30,
},
resources: {
requests: { cpu: '25m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
securityContext: clientSecurityContext,
volumeMounts: [
{ name: 'data', mountPath: '/data' },
{ name: 'certs', mountPath: '/certs', readOnly: true },
{ name: 'tmp', mountPath: '/tmp' },
],
},
],
volumes: [
{
name: 'data',
persistentVolumeClaim: { claimName: 'ql3-minio-data' },
},
{
name: 'certs',
secret: {
secretName: 'ql3-minio-tls',
items: [
{ key: 'tls.crt', path: 'public.crt' },
{ key: 'tls.key', path: 'private.key' },
],
},
},
{ name: 'tmp', emptyDir: {} },
],
},
},
},
},
{
apiVersion: 'barmancloud.cnpg.io/v1',
kind: 'ObjectStore',
metadata: { namespace, name: 'ql3-postgres-backup' },
spec: {
retentionPolicy: '30d',
configuration: {
destinationPath: 's3://ql3-dr/qinglong3/ql3-postgres',
endpointURL: 'https://ql3-minio.ql3-dr.svc:9000',
endpointCA: { name: 'ql3-minio-ca', key: 'tls.crt' },
s3Credentials: {
accessKeyId: {
name: 'ql3-object-store-writer',
key: 'ACCESS_KEY_ID',
},
secretAccessKey: {
name: 'ql3-object-store-writer',
key: 'ACCESS_SECRET_KEY',
},
},
wal: { compression: 'lz4', encryption: 'AES256', maxParallel: 2 },
data: { compression: 'lz4', encryption: 'AES256', jobs: 2 },
},
},
},
{
apiVersion: 'barmancloud.cnpg.io/v1',
kind: 'ObjectStore',
metadata: { namespace, name: 'ql3-postgres-recovery-source' },
spec: {
configuration: {
destinationPath: 's3://ql3-dr/qinglong3/ql3-postgres',
endpointURL: 'https://ql3-minio.ql3-dr.svc:9000',
endpointCA: { name: 'ql3-minio-ca', key: 'tls.crt' },
s3Credentials: {
accessKeyId: {
name: 'ql3-object-store-recovery',
key: 'ACCESS_KEY_ID',
},
secretAccessKey: {
name: 'ql3-object-store-recovery',
key: 'ACCESS_SECRET_KEY',
},
},
},
},
},
job('ql3-minio-bootstrap', bootstrapScript, 3),
];
return Object.freeze({
core: Object.freeze({ apiVersion: 'v1', kind: 'List', items }),
verifier: Object.freeze({
apiVersion: 'v1',
kind: 'List',
items: [job('ql3-minio-recovery-authority', verifierScript, 0)],
}),
});
}
function postgresSourceFixtureResources({ postgresImage }) {
assert.match(
postgresImage,
/^[a-z0-9.-]+(?::[0-9]+)?\/[a-z0-9/_-]+@sha256:[a-f0-9]{64}$/,
);
const namespace = 'ql3-dr';
const clusterName = 'ql3-postgres';
const pluginName = 'barman-cloud.cloudnative-pg.io';
const cluster = {
apiVersion: 'postgresql.cnpg.io/v1',
kind: 'Cluster',
metadata: {
namespace,
name: clusterName,
labels: {
'app.kubernetes.io/name': clusterName,
'app.kubernetes.io/component': 'database',
'app.kubernetes.io/part-of': 'qinglong3',
},
},
spec: {
instances: 3,
imageName: postgresImage,
imagePullPolicy: 'IfNotPresent',
enableSuperuserAccess: false,
primaryUpdateMethod: 'switchover',
failoverDelay: 0,
switchoverDelay: 60,
smartShutdownTimeout: 60,
stopDelay: 300,
bootstrap: {
initdb: { database: 'qinglong', owner: 'qinglong' },
},
plugins: [
{
name: pluginName,
isWALArchiver: true,
parameters: { barmanObjectName: 'ql3-postgres-backup' },
},
],
postgresql: {
parameters: {
max_connections: '100',
password_encryption: 'scram-sha-256',
shared_buffers: '64MB',
synchronous_commit: 'remote_apply',
},
synchronous: {
method: 'any',
number: 1,
dataDurability: 'required',
failoverQuorum: true,
},
},
affinity: {
enablePodAntiAffinity: true,
podAntiAffinityType: 'required',
topologyKey: 'kubernetes.io/hostname',
},
managed: { services: { disabledDefaultServices: ['r', 'ro'] } },
resources: {
requests: { cpu: '100m', memory: '256Mi' },
limits: { cpu: '1', memory: '512Mi' },
},
storage: { storageClass: 'local-path', size: '1Gi' },
walStorage: { storageClass: 'local-path', size: '512Mi' },
},
};
const backup = {
apiVersion: 'postgresql.cnpg.io/v1',
kind: 'Backup',
metadata: { namespace, name: 'ql3-postgres-base-backup' },
spec: {
cluster: { name: clusterName },
method: 'plugin',
pluginConfiguration: { name: pluginName },
target: 'prefer-standby',
},
};
return Object.freeze({
cluster: Object.freeze(cluster),
backup: Object.freeze(backup),
});
}
function postgresRestoreFixtureResources({
postgresImage,
clusterName,
targetTime,
}) {
assert.match(clusterName, /^ql3-postgres-restore-(?:latest|pitr)$/);
if (targetTime !== undefined) {
assert.match(
targetTime,
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/,
);
assert.ok(Number.isFinite(Date.parse(targetTime)));
}
assert.equal(clusterName.endsWith('-pitr'), targetTime !== undefined);
const source = postgresSourceFixtureResources({ postgresImage }).cluster;
const { bootstrap: _bootstrap, plugins: _plugins, ...haSpec } = source.spec;
const recovery = { source: 'ql3-postgres-origin' };
if (targetTime !== undefined) {
recovery.recoveryTarget = { targetTime };
}
return Object.freeze({
apiVersion: 'postgresql.cnpg.io/v1',
kind: 'Cluster',
metadata: {
namespace: 'ql3-dr',
name: clusterName,
labels: {
'app.kubernetes.io/name': clusterName,
'app.kubernetes.io/component': 'database-restore-drill',
'app.kubernetes.io/part-of': 'qinglong3',
},
},
spec: {
...haSpec,
bootstrap: { recovery },
externalClusters: [
{
name: 'ql3-postgres-origin',
plugin: {
name: 'barman-cloud.cloudnative-pg.io',
parameters: {
barmanObjectName: 'ql3-postgres-recovery-source',
serverName: 'ql3-postgres',
},
},
},
],
},
});
}
function podReady(pod) {
return pod?.status?.conditions?.some(
(condition) => condition.type === 'Ready' && condition.status === 'True',
);
}
function postgresClusterRuntimeEvidence(cluster, pods) {
const clusterName = cluster?.metadata?.name;
assert.match(
clusterName || '',
/^ql3-postgres(?:-restore-(?:latest|pitr))?$/,
);
assert.ok(Array.isArray(pods));
const clusterPods = pods.filter(
(pod) => pod?.metadata?.labels?.['cnpg.io/cluster'] === clusterName,
);
const readyPods = clusterPods.filter(podReady);
const nodes = [...new Set(readyPods.map((pod) => pod.spec?.nodeName))].filter(
Boolean,
);
const primary = cluster?.status?.currentPrimary;
const ready =
cluster?.spec?.instances === 3 &&
Number(cluster?.status?.readyInstances) === 3 &&
typeof primary === 'string' &&
readyPods.some((pod) => pod.metadata?.name === primary) &&
readyPods.length === 3 &&
nodes.length === 3;
return Object.freeze({
ready,
value: ready
? Object.freeze({
primary,
pods: Object.freeze(readyPods),
nodes: Object.freeze(nodes.sort()),
})
: undefined,
fact: `${readyPods.length}/3 ready Pods across ${
nodes.length
}/3 nodes; primary=${primary || 'none'}; statusReady=${String(
cluster?.status?.readyInstances ?? 0,
)}`,
});
}
function postgresRestoreApplicationRuntimeEvidence(
deployment,
pods,
clusterName,
) {
assert.match(clusterName, /^ql3-postgres-restore-(?:latest|pitr)$/);
assert.ok(Array.isArray(pods));
const expectedName = `ql3-dr-application-${
clusterName.endsWith('-latest') ? 'latest' : 'pitr'
}`;
const matchingPods = pods.filter(
(pod) =>
pod?.metadata?.labels?.['app.kubernetes.io/name'] === expectedName &&
pod?.metadata?.labels?.['ql3.cloud/restore-cluster'] === clusterName,
);
const readyPods = matchingPods.filter(podReady);
const ready =
deployment?.metadata?.name === expectedName &&
deployment?.spec?.replicas === 1 &&
deployment?.status?.availableReplicas === 1 &&
deployment?.status?.readyReplicas === 1 &&
readyPods.length === 1;
return Object.freeze({
ready,
value: ready ? Object.freeze({ pod: readyPods[0] }) : undefined,
fact: `${String(
readyPods.length,
)}/1 ready application Pods; available=${String(
deployment?.status?.availableReplicas ?? 0,
)}; readyReplicas=${String(deployment?.status?.readyReplicas ?? 0)}`,
});
}
function backupRuntimeEvidence(backup) {
const status = backup?.status || {};
const phase = String(status.phase || '').toLowerCase();
const startedAt = status.startedAt;
const stoppedAt = status.stoppedAt;
const evidence = Object.freeze({
method: status.method,
phase,
targetPod: status.instanceID?.podName,
backupId: status.backupId,
beginWal: status.beginWal,
endWal: status.endWal,
startedAt,
stoppedAt,
});
const complete =
phase === 'completed' &&
status.method === 'plugin' &&
/^ql3-postgres-[1-9][0-9]*$/.test(status.instanceID?.podName || '') &&
typeof status.backupId === 'string' &&
status.backupId.length > 0 &&
/^[0-9A-F]{24}$/.test(status.beginWal || '') &&
/^[0-9A-F]{24}$/.test(status.endWal || '') &&
Number.isFinite(Date.parse(startedAt)) &&
Number.isFinite(Date.parse(stoppedAt)) &&
Date.parse(stoppedAt) >= Date.parse(startedAt);
return Object.freeze({
ready: complete,
value: complete ? evidence : undefined,
fact: `phase=${phase || 'missing'} method=${
status.method || 'missing'
} backupId=${
typeof status.backupId === 'string' && status.backupId.length > 0
? 'present'
: 'missing'
} beginWal=${status.beginWal || 'missing'} endWal=${
status.endWal || 'missing'
}`,
});
}
function postgresArchiverEvidence(row) {
const evidence = Object.freeze({
archivedCount: Number(row?.archivedCount),
failedCount: Number(row?.failedCount),
lastArchivedWal: row?.lastArchivedWal,
lastArchivedTime: row?.lastArchivedTime,
});
assert.ok(Number.isSafeInteger(evidence.archivedCount));
assert.ok(evidence.archivedCount >= 1);
assert.equal(evidence.failedCount, 0);
assert.match(evidence.lastArchivedWal || '', /^[0-9A-F]{24}$/);
assert.ok(Number.isFinite(Date.parse(evidence.lastArchivedTime)));
return evidence;
}
function postgresSql(kubectl, namespace, podName, sql) {
assert.match(namespace, /^[a-z0-9][a-z0-9-]*$/);
assert.match(
podName,
/^ql3-postgres(?:-restore-(?:latest|pitr))?-[1-9][0-9]*$/,
);
assert.equal(typeof sql, 'string');
assert.ok(sql.length > 0 && !sql.includes('\0'));
return kubectl(
[
'-n',
namespace,
'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 postgresQueryJson(kubectl, namespace, podName, sql) {
const output = postgresSql(kubectl, namespace, podName, sql);
assert.ok(output.length > 0, 'PostgreSQL JSON query returned no rows');
return JSON.parse(output);
}
function restoreMarkerEvidence(row, expectedAfterMarker) {
assert.equal(typeof row?.beforeMarkerPresent, 'boolean');
assert.equal(typeof row?.afterMarkerPresent, 'boolean');
assert.equal(row.beforeMarkerPresent, true);
assert.equal(row.afterMarkerPresent, expectedAfterMarker);
return Object.freeze({
beforeMarkerPresent: row.beforeMarkerPresent,
afterMarkerPresent: row.afterMarkerPresent,
});
}
function postgresMarkerEvidence(row) {
assert.match(
row?.id || '',
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
assert.match(
row?.createdAt || '',
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/,
);
assert.ok(Number.isFinite(Date.parse(row.createdAt)));
assert.match(row?.wal || '', /^[0-9A-F]{24}$/);
return Object.freeze({ id: row.id, createdAt: row.createdAt, wal: row.wal });
}
function auditedCloudNativePgDrEvidence(report) {
const validation = validateCloudNativePgDrEvidence(report);
assert.equal(
validation.compatible,
true,
`generated DR evidence is incompatible: ${JSON.stringify(
validation.findings,
)}`,
);
return Object.freeze(report);
}
function parseEvidenceReportPath(argv) {
assert.ok(Array.isArray(argv));
if (argv.length === 0) return undefined;
assert.equal(
argv.length,
1,
'usage: ql3-cloudnativepg-barman-live-contract [--report=/absolute/path]',
);
assert.match(
argv[0],
/^--report=\/.{1,4085}$/,
'report must use --report=/absolute/path',
);
const target = argv[0].slice('--report='.length);
assert.equal(path.isAbsolute(target), true);
assert.ok(Buffer.byteLength(target, 'utf8') <= 4096);
assert.notEqual(path.basename(target), '.');
assert.notEqual(path.basename(target), '..');
return path.normalize(target);
}
function preflightPrivateEvidenceReportPath(target) {
assert.equal(path.isAbsolute(target), true);
const directory = fs.realpathSync(path.dirname(target));
const directoryStat = fs.lstatSync(directory);
assert.ok(directoryStat.isDirectory() && !directoryStat.isSymbolicLink());
const publishedTarget = path.join(directory, path.basename(target));
try {
fs.lstatSync(publishedTarget);
} catch (error) {
if (error?.code === 'ENOENT') return publishedTarget;
throw error;
}
throw new Error('refusing to overwrite an existing DR evidence report');
}
function writePrivateEvidenceReport(target, report) {
assert.equal(path.isAbsolute(target), true);
const directory = fs.realpathSync(path.dirname(target));
const directoryStat = fs.lstatSync(directory);
assert.ok(directoryStat.isDirectory() && !directoryStat.isSymbolicLink());
const publishedTarget = path.join(directory, path.basename(target));
const bytes = Buffer.from(`${JSON.stringify(report)}\n`, 'utf8');
assert.ok(bytes.length >= 2 && bytes.length <= 1024 * 1024);
const temporary = path.join(
directory,
`.${path.basename(target)}.${process.pid}-${crypto
.randomBytes(6)
.toString('hex')}.tmp`,
);
let descriptor;
try {
descriptor = fs.openSync(temporary, 'wx', 0o600);
fs.writeFileSync(descriptor, bytes);
fs.fsyncSync(descriptor);
fs.closeSync(descriptor);
descriptor = undefined;
fs.linkSync(temporary, publishedTarget);
fs.unlinkSync(temporary);
const published = fs.lstatSync(publishedTarget);
assert.ok(published.isFile() && !published.isSymbolicLink());
assert.equal(published.size, bytes.length);
assert.equal(published.mode & 0o077, 0);
return Object.freeze({
path: publishedTarget,
bytes: published.size,
sha256: `sha256:${sha256(bytes)}`,
});
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
try {
fs.unlinkSync(temporary);
} catch (error) {
if (error?.code !== 'ENOENT') throw error;
}
}
}
function postgresRoleEvidence(rows) {
assert.ok(Array.isArray(rows));
assert.deepEqual(
rows.map((role) => role?.name),
POSTGRES_ROLE_NAMES,
);
return Object.freeze(
rows.map((role) => {
assert.equal(role.login, true);
for (const attribute of [
'superuser',
'createdb',
'createrole',
'replication',
'bypassrls',
]) {
assert.equal(role[attribute], false, `${role.name}.${attribute}`);
}
return Object.freeze({
name: role.name,
superuser: false,
createdb: false,
createrole: false,
replication: false,
bypassrls: false,
});
}),
);
}
function postgresDatabaseContractEvidence(row) {
const migrationCount = Number(row?.migrationCount);
const controlCoreCapability = Number(row?.controlCoreCapability);
const postgresVersionNumber = Number(row?.postgresVersionNumber);
assert.equal(migrationCount, 52);
assert.equal(controlCoreCapability, 51);
assert.equal(row?.databaseOwner, 'ql3_migration');
assert.equal(postgresVersionNumber, 180004);
return Object.freeze({
migrationCount,
controlCoreCapability,
databaseOwner: row.databaseOwner,
postgresVersionNumber,
roles: postgresRoleEvidence(row.roles),
});
}
function certificateEvidence(secret) {
const encoded = secret?.data?.['tls.crt'];
assert.equal(typeof encoded, 'string');
const certificate = new crypto.X509Certificate(
Buffer.from(encoded, 'base64'),
);
return Object.freeze({
serialSha256: `sha256:${sha256(
Buffer.from(certificate.serialNumber, 'ascii'),
)}`,
subjectSha256: `sha256:${sha256(Buffer.from(certificate.subject, 'utf8'))}`,
subjectAltNameSha256: `sha256:${sha256(
Buffer.from(certificate.subjectAltName || '', 'utf8'),
)}`,
resourceVersion: secret.metadata.resourceVersion,
validFrom: new Date(certificate.validFrom).toISOString(),
validTo: new Date(certificate.validTo).toISOString(),
});
}
function certificateRotationEvidence(
previous,
current,
previousRevision,
currentRevision,
) {
assert.match(previous?.serialSha256 || '', /^sha256:[a-f0-9]{64}$/);
assert.match(current?.serialSha256 || '', /^sha256:[a-f0-9]{64}$/);
assert.notEqual(previous.serialSha256, current.serialSha256);
assert.equal(typeof previous.resourceVersion, 'string');
assert.equal(typeof current.resourceVersion, 'string');
assert.notEqual(previous.resourceVersion, current.resourceVersion);
assert.ok(Number.isSafeInteger(previousRevision) && previousRevision >= 1);
assert.ok(
Number.isSafeInteger(currentRevision) && currentRevision > previousRevision,
);
return Object.freeze({
previousSerialSha256: previous.serialSha256,
currentSerialSha256: current.serialSha256,
previousSecretResourceVersion: previous.resourceVersion,
currentSecretResourceVersion: current.resourceVersion,
});
}
function webhookConfigurationHasCaBundle(configuration) {
const webhooks = configuration?.webhooks;
return (
Array.isArray(webhooks) &&
webhooks.length > 0 &&
webhooks.every((webhook) => {
const caBundle = webhook?.clientConfig?.caBundle;
return (
typeof caBundle === 'string' &&
caBundle.length > 0 &&
/^[A-Za-z0-9+/]+={0,2}$/.test(caBundle) &&
Buffer.from(caBundle, 'base64').length > 0
);
})
);
}
async function main(argv = process.argv.slice(2)) {
const requestedEvidenceReportPath = parseEvidenceReportPath(argv);
const evidenceReportPath =
requestedEvidenceReportPath === undefined
? undefined
: preflightPrivateEvidenceReportPath(requestedEvidenceReportPath);
if (process.env.QL3_CLOUDNATIVEPG_BARMAN_LIVE !== '1') {
throw new Error(
'Refusing Docker/Kubernetes mutation without QL3_CLOUDNATIVEPG_BARMAN_LIVE=1',
);
}
const sourceRevision = process.env.QL3_SOURCE_REVISION;
assert.match(
sourceRevision || '',
/^[a-f0-9]{40,64}$/,
'QL3_SOURCE_REVISION must bind the exact source commit',
);
assertCloudNativePgDrRunnerCapacity();
const docker = process.env.QL3_DOCKER_BIN || 'docker';
const kubectlBinary = process.env.QL3_KUBECTL_BIN || 'kubectl';
const curl = process.env.QL3_CURL_BIN || 'curl';
const suffix = `${process.pid}-${crypto.randomBytes(3).toString('hex')}`;
const prefix = `ql3-barman-dr-${suffix}`;
assert.match(prefix, /^ql3-barman-dr-[0-9]+-[a-f0-9]{6}$/);
const dockerLabels = [
'--label',
DOCKER_SCOPE_LABEL,
'--label',
`${DOCKER_RUN_LABEL_KEY}=${prefix}`,
];
const network = `${prefix}-network`;
const registry = `${prefix}-registry`;
const server = `${prefix}-server`;
const agents = [
`${prefix}-agent-a`,
`${prefix}-agent-b`,
`${prefix}-agent-c`,
];
const nodes = [server, ...agents];
const containers = [registry, ...nodes];
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-barman-dr-'));
fs.chmodSync(temporary, 0o700);
const dockerDataRoot = path.join(temporary, 'docker-data');
const registryDataBindArgs = privateDockerDataBindArgs(
dockerDataRoot,
registry,
REGISTRY_DATA_TARGETS,
);
const nodeDataBindArgs = new Map(
nodes.map((node) => [
node,
privateDockerDataBindArgs(dockerDataRoot, node, K3S_DATA_TARGETS),
]),
);
const kubeconfig = path.join(temporary, 'kubeconfig');
const registriesConfig = path.join(temporary, 'registries.yaml');
const noProxy = [
registry,
'localhost',
'127.0.0.1',
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'.svc',
'.cluster.local',
].join(',');
fs.writeFileSync(
registriesConfig,
`mirrors:\n "${registry}:5000":\n endpoint:\n - "http://${registry}:5000"\n`,
{ mode: 0o600, flag: 'wx' },
);
let certManagerManifest;
let barmanManifest;
let cnpgManifest;
let clusterControlImage;
const clusterToken = crypto.randomBytes(32).toString('base64url');
const createdContainers = new Set();
const builtImages = new Set();
let networkCreated = false;
const keep = process.env.QL3_KEEP_BARMAN_DR_CLUSTER === '1';
try {
const certManagerSource = manifestSource(
'QL3_CERT_MANAGER_MANIFEST_FILE',
'/private/tmp/ql3-cert-manager-v1.20.3-release-manifest.yaml',
curl,
path.join(temporary, 'cert-manager-source.yaml'),
CERT_MANAGER_MANIFEST_URL,
);
const barmanSource = manifestSource(
'QL3_BARMAN_MANIFEST_FILE',
'/private/tmp/ql3-barman-cloud-v0.13.0-release-manifest.yaml',
curl,
path.join(temporary, 'barman-source.yaml'),
BARMAN_MANIFEST_URL,
);
const cnpgSource = manifestSource(
'QL3_CNPG_OPERATOR_MANIFEST_FILE',
'',
curl,
path.join(temporary, 'cnpg-source.yaml'),
CNPG_MANIFEST_URL,
);
run(docker, ['version'], { capture: true, quiet: true });
const architecture = normalizeArchitecture(
run(docker, ['info', '--format', '{{.Architecture}}'], {
capture: true,
quiet: true,
}).stdout,
);
const expected = PLATFORM_DIGESTS[architecture];
assert.ok(expected, `unsupported Docker architecture ${architecture}`);
const runtimeImages = Object.freeze({
cnpg: `${registry}:5000/ql3/cloudnative-pg@${expected.cnpg}`,
barman: `${registry}:5000/ql3/barman-cloud@${expected.barman}`,
barmanSidecar: `${registry}:5000/ql3/barman-cloud-sidecar@${expected.barmanSidecar}`,
certManager: `${registry}:5000/ql3/cert-manager-controller@${expected.certManager}`,
certManagerCainjector: `${registry}:5000/ql3/cert-manager-cainjector@${expected.certManagerCainjector}`,
certManagerWebhook: `${registry}:5000/ql3/cert-manager-webhook@${expected.certManagerWebhook}`,
minio: `${registry}:5000/ql3/minio@${expected.minio}`,
minioClient: `${registry}:5000/ql3/minio-client@${expected.minioClient}`,
postgres: `${registry}:5000/ql3/postgresql@${expected.postgres}`,
});
certManagerManifest = reviewedManifest(
certManagerSource,
path.join(temporary, 'cert-manager-pinned.yaml'),
CERT_MANAGER_MANIFEST_SHA256,
[
[
'quay.io/jetstack/cert-manager-controller:v1.20.3',
runtimeImages.certManager,
],
[
'quay.io/jetstack/cert-manager-cainjector:v1.20.3',
runtimeImages.certManagerCainjector,
],
[
'quay.io/jetstack/cert-manager-webhook:v1.20.3',
runtimeImages.certManagerWebhook,
],
],
);
barmanManifest = reviewedManifest(
barmanSource,
path.join(temporary, 'barman-pinned.yaml'),
BARMAN_MANIFEST_SHA256,
[
[
'ghcr.io/cloudnative-pg/plugin-barman-cloud:v0.13.0',
runtimeImages.barman,
],
[
' Z2hjci5pby9jbG91ZG5hdGl2ZS1wZy9wbHVnaW4tYmFybWFuLWNsb3VkLXNpZGVjYXI6dj\n AuMTMuMA==',
` ${Buffer.from(runtimeImages.barmanSidecar).toString('base64')}`,
],
],
);
cnpgManifest = reviewedManifest(
cnpgSource,
path.join(temporary, 'cnpg-pinned.yaml'),
CNPG_MANIFEST_SHA256,
[
['ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0', runtimeImages.cnpg, 2],
[
' imagePullPolicy: Always',
' imagePullPolicy: IfNotPresent',
],
],
);
for (const container of containers) {
assert.equal(
run(docker, ['inspect', container], {
capture: true,
quiet: true,
allowFailure: true,
}).status,
1,
`refusing to reuse Docker container ${container}`,
);
}
assert.equal(
run(docker, ['network', 'inspect', network], {
capture: true,
quiet: true,
allowFailure: true,
}).status,
1,
`refusing to reuse Docker network ${network}`,
);
run(docker, ['network', 'create', ...dockerLabels, network], {
capture: true,
quiet: true,
});
networkCreated = true;
run(
docker,
[
'run',
'-d',
'--network',
network,
...dockerLabels,
'--name',
registry,
'-p',
'127.0.0.1::5000',
...registryDataBindArgs,
REGISTRY_IMAGE,
],
{ capture: true, quiet: true },
);
createdContainers.add(registry);
const registryPort = run(docker, ['port', registry, '5000/tcp'], {
capture: true,
quiet: true,
}).stdout;
assert.match(registryPort, /^127\.0\.0\.1:\d+$/);
await waitFor('local OCI registry readiness', 60_000, () => {
const result = run(
curl,
['--fail', '--silent', `http://${registryPort}/v2/`],
{
capture: true,
quiet: true,
allowFailure: true,
},
);
return result.status === 0 && result.stdout === '{}'
? { ready: true, value: true }
: { ready: false, fact: result.stderr || result.stdout };
});
const clusterControlLocalTag = `${registryPort}/ql3/cluster-control:dr-${suffix}`;
builtImages.add(clusterControlLocalTag);
clusterControlImage = buildClusterControlImage({
docker,
registry,
registryPort,
architecture,
suffix,
});
assert.equal(clusterControlImage.localTag, clusterControlLocalTag);
run(
docker,
[
'run',
'-d',
'--privileged',
'--network',
network,
...dockerLabels,
'--name',
server,
'--env',
`NO_PROXY=${noProxy}`,
'--env',
`no_proxy=${noProxy}`,
'--volume',
`${registriesConfig}:/etc/rancher/k3s/registries.yaml:ro`,
...nodeDataBindArgs.get(server),
'-p',
'127.0.0.1::6443',
K3S_IMAGE,
'server',
'--token',
clusterToken,
'--node-name',
server,
'--disable=traefik',
'--disable=servicelb',
'--kubelet-arg',
`eviction-hard=${K3S_EVICTION_HARD}`,
'--write-kubeconfig-mode=600',
'--tls-san=127.0.0.1',
],
{ capture: true, quiet: true },
);
createdContainers.add(server);
await waitFor('K3s API readiness', 180_000, () => {
const result = run(
docker,
['exec', server, 'kubectl', 'get', '--raw=/readyz'],
{ capture: true, quiet: true, allowFailure: true },
);
return result.status === 0 && result.stdout === 'ok'
? { ready: true, value: true }
: { ready: false, fact: result.stderr || result.stdout };
});
for (const agent of agents) {
run(
docker,
[
'run',
'-d',
'--privileged',
'--network',
network,
...dockerLabels,
'--name',
agent,
'--env',
`NO_PROXY=${noProxy}`,
'--env',
`no_proxy=${noProxy}`,
'--volume',
`${registriesConfig}:/etc/rancher/k3s/registries.yaml:ro`,
...nodeDataBindArgs.get(agent),
K3S_IMAGE,
'agent',
'--server',
`https://${server}:6443`,
'--token',
clusterToken,
'--node-name',
agent,
'--kubelet-arg',
`eviction-hard=${K3S_EVICTION_HARD}`,
],
{ capture: true, quiet: true },
);
createdContainers.add(agent);
}
const port = run(docker, ['port', server, '6443/tcp'], {
capture: true,
quiet: true,
}).stdout;
assert.match(port, /^127\.0\.0\.1:\d+$/);
const config = run(
docker,
['exec', server, 'cat', '/etc/rancher/k3s/k3s.yaml'],
{ capture: true, quiet: true },
).stdout.replace('https://127.0.0.1:6443', `https://${port}`);
fs.writeFileSync(kubeconfig, `${config}\n`, { mode: 0o600, flag: 'wx' });
const kubectl = (args, options = {}) =>
run(kubectlBinary, ['--kubeconfig', kubeconfig, ...args], options);
const kubectlJson = (args) =>
JSON.parse(
kubectl([...args, '-o', 'json'], {
capture: true,
quiet: true,
}).stdout,
);
const nodesReady = await waitFor('four K3s nodes', 240_000, () => {
const nodes = kubectlJson(['get', 'nodes']).items || [];
const ready = nodes.filter((node) =>
node.status.conditions?.some(
(condition) =>
condition.type === 'Ready' && condition.status === 'True',
),
);
return ready.length === 4
? { ready: true, value: ready }
: { ready: false, fact: `${ready.length}/4 Ready nodes` };
});
const versions = new Set(
nodesReady.value.map((node) => node.status.nodeInfo.kubeletVersion),
);
assert.equal(versions.size, 1);
assert.match([...versions][0], /^v1\.32\.8\+k3s1$/);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.certManager,
'cert-manager-controller',
architecture,
expected.certManager,
),
runtimeImages.certManager,
);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.certManagerCainjector,
'cert-manager-cainjector',
architecture,
expected.certManagerCainjector,
),
runtimeImages.certManagerCainjector,
);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.certManagerWebhook,
'cert-manager-webhook',
architecture,
expected.certManagerWebhook,
),
runtimeImages.certManagerWebhook,
);
kubectl(['apply', '--server-side', '-f', certManagerManifest.target]);
kubectl([
'wait',
'--for=condition=Established',
'crd/certificates.cert-manager.io',
'crd/issuers.cert-manager.io',
'--timeout=5m',
]);
for (const deployment of [
'cert-manager',
'cert-manager-cainjector',
'cert-manager-webhook',
]) {
kubectl([
'-n',
'cert-manager',
'rollout',
'status',
`deployment/${deployment}`,
'--timeout=10m',
]);
}
await waitFor('cert-manager webhook CA injection', 300_000, () => {
const configurations = [
kubectlJson([
'get',
'mutatingwebhookconfiguration',
'cert-manager-webhook',
]),
kubectlJson([
'get',
'validatingwebhookconfiguration',
'cert-manager-webhook',
]),
];
const ready = configurations.filter(webhookConfigurationHasCaBundle);
return ready.length === configurations.length
? { ready: true, value: ready.length }
: {
ready: false,
fact: `${String(ready.length)}/${String(
configurations.length,
)} webhook configurations have CA bundles`,
};
});
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.cnpg,
'cloudnative-pg',
architecture,
expected.cnpg,
),
runtimeImages.cnpg,
);
kubectl(['apply', '--server-side', '-f', cnpgManifest.target]);
kubectl([
'wait',
'--for=condition=Established',
'crd/clusters.postgresql.cnpg.io',
'--timeout=5m',
]);
kubectl([
'-n',
'cnpg-system',
'rollout',
'status',
'deployment/cnpg-controller-manager',
'--timeout=10m',
]);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.barman,
'barman-cloud',
architecture,
expected.barman,
),
runtimeImages.barman,
);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.barmanSidecar,
'barman-cloud-sidecar',
architecture,
expected.barmanSidecar,
),
runtimeImages.barmanSidecar,
);
kubectl(['apply', '--server-side', '-f', barmanManifest.target]);
kubectl([
'wait',
'--for=condition=Established',
'crd/objectstores.barmancloud.cnpg.io',
'--timeout=5m',
]);
kubectl([
'-n',
'cnpg-system',
'rollout',
'status',
'deployment/barman-cloud',
'--timeout=10m',
]);
kubectl([
'-n',
'cnpg-system',
'wait',
'--for=condition=Ready',
'certificate/barman-cloud-client',
'certificate/barman-cloud-server',
'--timeout=5m',
]);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.postgres,
'postgresql',
architecture,
expected.postgres,
),
runtimeImages.postgres,
);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.minio,
'minio',
architecture,
expected.minio,
),
runtimeImages.minio,
);
assert.equal(
mirrorPlatformImage(
docker,
registry,
IMAGES.minioClient,
'minio-client',
architecture,
expected.minioClient,
),
runtimeImages.minioClient,
);
const minioCredentials = Object.freeze({
root: Object.freeze({
accessKey: `QL3ROOT${crypto
.randomBytes(4)
.toString('hex')
.toUpperCase()}`,
secretKey: crypto.randomBytes(32).toString('base64url'),
}),
writer: Object.freeze({
accessKey: `QL3W${crypto.randomBytes(5).toString('hex').toUpperCase()}`,
secretKey: crypto.randomBytes(32).toString('base64url'),
}),
recovery: Object.freeze({
accessKey: `QL3R${crypto.randomBytes(5).toString('hex').toUpperCase()}`,
secretKey: crypto.randomBytes(32).toString('base64url'),
}),
});
const minioFixture = minioFixtureResources({
minioImage: runtimeImages.minio,
clientImage: runtimeImages.minioClient,
credentials: minioCredentials,
});
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(minioFixture.core)}\n`,
});
kubectl([
'-n',
'ql3-dr',
'wait',
'--for=condition=Ready',
'certificate/ql3-minio-ca',
'certificate/ql3-minio-server',
'--timeout=5m',
]);
kubectl([
'-n',
'ql3-dr',
'rollout',
'status',
'deployment/ql3-minio',
'--timeout=10m',
]);
kubectl([
'-n',
'ql3-dr',
'wait',
'--for=condition=Complete',
'job/ql3-minio-bootstrap',
'--timeout=10m',
]);
const bootstrapLog = kubectl(
['-n', 'ql3-dr', 'logs', 'job/ql3-minio-bootstrap'],
{ capture: true, quiet: true },
).stdout;
assert.match(bootstrapLog, /(?:^|\n)QL3_MINIO_AUTHORITY_READY(?:\n|$)/);
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(minioFixture.verifier)}\n`,
});
kubectl([
'-n',
'ql3-dr',
'wait',
'--for=condition=Complete',
'job/ql3-minio-recovery-authority',
'--timeout=5m',
]);
const recoveryAuthorityLog = kubectl(
['-n', 'ql3-dr', 'logs', 'job/ql3-minio-recovery-authority'],
{ capture: true, quiet: true },
).stdout;
assert.match(
recoveryAuthorityLog,
/(?:^|\n)QL3_RECOVERY_READ_ONLY_READY(?:\n|$)/,
);
const writerObjectStore = kubectlJson([
'-n',
'ql3-dr',
'get',
'objectstore/ql3-postgres-backup',
]);
const recoveryObjectStore = kubectlJson([
'-n',
'ql3-dr',
'get',
'objectstore/ql3-postgres-recovery-source',
]);
assert.equal(writerObjectStore.spec.retentionPolicy, '30d');
assert.equal(
writerObjectStore.spec.configuration.s3Credentials.accessKeyId.name,
'ql3-object-store-writer',
);
assert.equal(
recoveryObjectStore.spec.configuration.s3Credentials.accessKeyId.name,
'ql3-object-store-recovery',
);
assert.equal(recoveryObjectStore.spec.configuration.serverName, undefined);
assert.equal(
writerObjectStore.spec.configuration.endpointURL,
'https://ql3-minio.ql3-dr.svc:9000',
);
const sourceFixture = postgresSourceFixtureResources({
postgresImage: runtimeImages.postgres,
});
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(sourceFixture.cluster)}\n`,
});
const sourceReady = await waitFor(
'three-node PostgreSQL source cluster',
15 * 60_000,
() =>
postgresClusterRuntimeEvidence(
kubectlJson(['-n', 'ql3-dr', 'get', 'cluster/ql3-postgres']),
kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
'cnpg.io/cluster=ql3-postgres',
]).items,
),
);
const sourcePrimary = sourceReady.value.primary;
const sourcePostgresImages = sourceReady.value.pods.map((pod) =>
runtimeImageEvidence(pod, 'postgres', IMAGES.postgres, expected.postgres),
);
const sourceBarmanSidecarImages = sourceReady.value.pods.map((pod) =>
runtimeImageEvidence(
pod,
'plugin-barman-cloud',
IMAGES.barmanSidecar,
expected.barmanSidecar,
),
);
assert.ok(clusterControlImage);
const postgresRoleCredentials = Object.freeze(
Object.fromEntries(
POSTGRES_ROLE_NAMES.map((role) => [
role,
crypto.randomBytes(32).toString('base64url'),
]),
),
);
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(
postgresRoleSecretResources(postgresRoleCredentials),
)}\n`,
});
kubectl(['-n', 'ql3-dr', 'apply', '-f', POSTGRES_DATABASE_ROLE_MANIFEST]);
const roleNamesSql = POSTGRES_ROLE_NAMES.map((role) => `'${role}'`).join(
', ',
);
const roleCatalogSql = `SELECT json_build_object(
'roles', COALESCE((
SELECT json_agg(json_build_object(
'name', rolname,
'login', rolcanlogin,
'superuser', rolsuper,
'createdb', rolcreatedb,
'createrole', rolcreaterole,
'replication', rolreplication,
'bypassrls', rolbypassrls
) ORDER BY rolname)
FROM pg_roles
WHERE rolname IN (${roleNamesSql})
), '[]'::json)
)::text`;
const sourceRoles = await waitFor(
'thirteen reconciled PostgreSQL database roles',
10 * 60_000,
() => {
try {
const value = postgresRoleEvidence(
postgresQueryJson(kubectl, 'ql3-dr', sourcePrimary, roleCatalogSql)
.roles,
);
return { ready: true, value };
} catch (error) {
return {
ready: false,
fact: error instanceof Error ? error.message : String(error),
};
}
},
);
assert.equal(sourceRoles.value.length, 13);
postgresSql(
kubectl,
'ql3-dr',
sourcePrimary,
'ALTER DATABASE qinglong OWNER TO ql3_migration',
);
const migrationJob = postgresMigrationJobResource({
controlImage: clusterControlImage.runtimeImage,
});
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(migrationJob)}\n`,
});
kubectl([
'-n',
'ql3-dr',
'wait',
'--for=condition=Complete',
'job/ql3-cluster-migration',
'--timeout=10m',
]);
const migrationLog = kubectl(
['-n', 'ql3-dr', 'logs', 'job/ql3-cluster-migration'],
{ capture: true, quiet: true },
).stdout;
assert.match(migrationLog, /"event":"migration_completed"/);
const migrationPod = kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
'job-name=ql3-cluster-migration',
]).items[0];
const migrationImage = runtimeImageEvidence(
migrationPod,
'migration',
clusterControlImage.runtimeImage,
clusterControlImage.platformDigest,
);
const databaseContractSql = `SELECT json_build_object(
'migrationCount', (
SELECT count(*)::integer FROM ql3.schema_migrations
),
'controlCoreCapability', (
SELECT contract_version::integer
FROM ql3.schema_capabilities
WHERE contract_name = 'control-core'
),
'databaseOwner', (
SELECT pg_get_userbyid(datdba)
FROM pg_database
WHERE datname = current_database()
),
'postgresVersionNumber', current_setting('server_version_num')::integer,
'roles', COALESCE((
SELECT json_agg(json_build_object(
'name', rolname,
'login', rolcanlogin,
'superuser', rolsuper,
'createdb', rolcreatedb,
'createrole', rolcreaterole,
'replication', rolreplication,
'bypassrls', rolbypassrls
) ORDER BY rolname)
FROM pg_roles
WHERE rolname IN (${roleNamesSql})
), '[]'::json)
)::text`;
const sourceDatabaseContract = postgresDatabaseContractEvidence(
postgresQueryJson(kubectl, 'ql3-dr', sourcePrimary, databaseContractSql),
);
postgresSql(
kubectl,
'ql3-dr',
sourcePrimary,
`CREATE TABLE IF NOT EXISTS public.ql3_dr_marker (
marker_id text PRIMARY KEY,
created_at timestamptz NOT NULL DEFAULT clock_timestamp()
)`,
);
const recordMarker = (markerId) =>
postgresMarkerEvidence(
postgresQueryJson(
kubectl,
'ql3-dr',
sourcePrimary,
`WITH inserted AS (
INSERT INTO public.ql3_dr_marker (marker_id)
VALUES ('${markerId}')
RETURNING marker_id, created_at
)
SELECT json_build_object(
'id', marker_id,
'createdAt', to_char(
created_at AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
),
'wal', pg_walfile_name(pg_current_wal_lsn())
)::text
FROM inserted`,
),
);
const beforeMarker = recordMarker(crypto.randomUUID());
assert.ok(
Date.parse(beforeMarker.createdAt) <= Date.now(),
'before marker timestamp is in the future',
);
postgresSql(kubectl, 'ql3-dr', sourcePrimary, 'SELECT pg_switch_wal()');
const archiverSql = `SELECT json_build_object(
'archivedCount', archived_count::text,
'failedCount', failed_count::text,
'lastArchivedWal', last_archived_wal,
'lastArchivedTime', to_char(
last_archived_time AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
)
)::text FROM pg_stat_archiver`;
const walBeforeBackup = await waitFor(
'continuous WAL archive before base backup',
10 * 60_000,
() => ({
ready: true,
value: postgresArchiverEvidence(
postgresQueryJson(kubectl, 'ql3-dr', sourcePrimary, archiverSql),
),
}),
);
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(sourceFixture.backup)}\n`,
});
const baseBackup = await waitFor(
'plugin base backup completion',
15 * 60_000,
() =>
backupRuntimeEvidence(
kubectlJson([
'-n',
'ql3-dr',
'get',
'backup/ql3-postgres-base-backup',
]),
),
);
assert.notEqual(
baseBackup.value.targetPod,
sourcePrimary,
'prefer-standby backup unexpectedly ran on the primary',
);
const pitrTarget = postgresQueryJson(
kubectl,
'ql3-dr',
sourcePrimary,
`SELECT json_build_object(
'targetTime', to_char(
clock_timestamp() AT TIME ZONE 'UTC',
'YYYY-MM-DD"T"HH24:MI:SS.US"Z"'
)
)::text`,
).targetTime;
assert.match(pitrTarget, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/);
const afterMarker = recordMarker(crypto.randomUUID());
assert.notEqual(beforeMarker.id, afterMarker.id);
assert.ok(
Date.parse(beforeMarker.createdAt) < Date.parse(afterMarker.createdAt),
'DR markers must be strictly ordered',
);
assert.ok(
Date.parse(beforeMarker.createdAt) < Date.parse(pitrTarget) &&
Date.parse(pitrTarget) < Date.parse(afterMarker.createdAt),
'PITR target must be strictly between DR markers',
);
postgresSql(kubectl, 'ql3-dr', sourcePrimary, 'SELECT pg_switch_wal()');
const walAfterTarget = await waitFor(
'continuous WAL archive after PITR boundary',
10 * 60_000,
() => {
const evidence = postgresArchiverEvidence(
postgresQueryJson(kubectl, 'ql3-dr', sourcePrimary, archiverSql),
);
return {
ready:
evidence.archivedCount > walBeforeBackup.value.archivedCount &&
evidence.lastArchivedWal !== walBeforeBackup.value.lastArchivedWal,
value: evidence,
fact: `archived=${evidence.archivedCount} last=${evidence.lastArchivedWal}`,
};
},
);
const clientCertificateBefore = kubectlJson([
'-n',
'cnpg-system',
'get',
'certificate/barman-cloud-client',
]);
const serverCertificateBefore = kubectlJson([
'-n',
'cnpg-system',
'get',
'certificate/barman-cloud-server',
]);
const clientSecretBefore = kubectlJson([
'-n',
'cnpg-system',
'get',
'secret/barman-cloud-client-tls',
]);
const serverSecretBefore = kubectlJson([
'-n',
'cnpg-system',
'get',
'secret/barman-cloud-server-tls',
]);
const clientEvidenceBefore = certificateEvidence(clientSecretBefore);
const serverEvidenceBefore = certificateEvidence(serverSecretBefore);
const clientRevisionBefore = Number(
clientCertificateBefore.status?.revision,
);
const serverRevisionBefore = Number(
serverCertificateBefore.status?.revision,
);
assert.ok(clientRevisionBefore >= 1 && serverRevisionBefore >= 1);
const rotationPatch = JSON.stringify({
spec: {
duration: '24h',
renewBefore: '1h',
privateKey: { rotationPolicy: 'Always' },
},
});
for (const certificate of ['barman-cloud-client', 'barman-cloud-server']) {
kubectl([
'-n',
'cnpg-system',
'patch',
`certificate/${certificate}`,
'--type=merge',
'--patch',
rotationPatch,
]);
}
const rotatedCertificates = await waitFor(
'Barman mutual TLS certificate rotation',
10 * 60_000,
() => {
const clientCertificate = kubectlJson([
'-n',
'cnpg-system',
'get',
'certificate/barman-cloud-client',
]);
const serverCertificate = kubectlJson([
'-n',
'cnpg-system',
'get',
'certificate/barman-cloud-server',
]);
const clientSecret = kubectlJson([
'-n',
'cnpg-system',
'get',
'secret/barman-cloud-client-tls',
]);
const serverSecret = kubectlJson([
'-n',
'cnpg-system',
'get',
'secret/barman-cloud-server-tls',
]);
const clientEvidence = certificateEvidence(clientSecret);
const serverEvidence = certificateEvidence(serverSecret);
const clientRotation = certificateRotationEvidence(
clientEvidenceBefore,
clientEvidence,
clientRevisionBefore,
Number(clientCertificate.status?.revision),
);
const serverRotation = certificateRotationEvidence(
serverEvidenceBefore,
serverEvidence,
serverRevisionBefore,
Number(serverCertificate.status?.revision),
);
return {
ready: true,
value: {
clientSecret,
serverSecret,
clientEvidence,
serverEvidence,
clientRotation,
serverRotation,
},
};
},
);
const clientSecret = rotatedCertificates.value.clientSecret;
const serverSecret = rotatedCertificates.value.serverSecret;
const sourcePrimaryAfterRotation = kubectlJson([
'-n',
'ql3-dr',
'get',
'cluster/ql3-postgres',
]).status?.currentPrimary;
assert.match(
sourcePrimaryAfterRotation || '',
/^ql3-postgres-[1-9][0-9]*$/,
);
postgresSql(
kubectl,
'ql3-dr',
sourcePrimaryAfterRotation,
'SELECT pg_switch_wal()',
);
const walAfterRotation = await waitFor(
'continuous WAL archive after Barman certificate rotation',
10 * 60_000,
() => {
const evidence = postgresArchiverEvidence(
postgresQueryJson(
kubectl,
'ql3-dr',
sourcePrimaryAfterRotation,
archiverSql,
),
);
return {
ready:
evidence.archivedCount > walAfterTarget.value.archivedCount &&
evidence.lastArchivedWal !== walAfterTarget.value.lastArchivedWal,
value: evidence,
fact: `archived=${evidence.archivedCount} last=${evidence.lastArchivedWal}`,
};
},
);
const postRotationBackupResource = {
...sourceFixture.backup,
metadata: {
...sourceFixture.backup.metadata,
name: 'ql3-postgres-post-rotation-backup',
},
};
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(postRotationBackupResource)}\n`,
});
const postRotationBackup = await waitFor(
'post-rotation plugin base backup completion',
15 * 60_000,
() =>
backupRuntimeEvidence(
kubectlJson([
'-n',
'ql3-dr',
'get',
'backup/ql3-postgres-post-rotation-backup',
]),
),
);
assert.notEqual(
postRotationBackup.value.targetPod,
sourcePrimaryAfterRotation,
'post-rotation prefer-standby backup unexpectedly ran on the primary',
);
postgresSql(
kubectl,
'ql3-dr',
sourcePrimaryAfterRotation,
'SELECT pg_switch_wal()',
);
const walBeforeRestores = await waitFor(
'final continuous WAL archive before recovery drills',
10 * 60_000,
() => {
const evidence = postgresArchiverEvidence(
postgresQueryJson(
kubectl,
'ql3-dr',
sourcePrimaryAfterRotation,
archiverSql,
),
);
return {
ready:
evidence.archivedCount > walAfterRotation.value.archivedCount &&
evidence.lastArchivedWal !== walAfterRotation.value.lastArchivedWal,
value: evidence,
fact: `archived=${evidence.archivedCount} last=${evidence.lastArchivedWal}`,
};
},
);
const observedRpoSeconds = Math.max(
0,
(Date.now() - Date.parse(walBeforeRestores.value.lastArchivedTime)) /
1_000,
);
assert.ok(observedRpoSeconds <= 60);
const sourceClusterBeforeRestores = kubectlJson([
'-n',
'ql3-dr',
'get',
'cluster/ql3-postgres',
]);
const sourceUid = sourceClusterBeforeRestores.metadata.uid;
assert.match(sourceUid || '', /^[0-9a-f-]{36}$/);
const markerSql = `SELECT json_build_object(
'beforeMarkerPresent', EXISTS (
SELECT 1 FROM public.ql3_dr_marker
WHERE marker_id = '${beforeMarker.id}'
),
'afterMarkerPresent', EXISTS (
SELECT 1 FROM public.ql3_dr_marker
WHERE marker_id = '${afterMarker.id}'
)
)::text`;
const restoreCluster = async ({ clusterName, targetTime, expectAfter }) => {
const restore = postgresRestoreFixtureResources({
postgresImage: runtimeImages.postgres,
clusterName,
targetTime,
});
const startedAt = Date.now();
kubectl(['apply', '-f', '-'], { input: `${JSON.stringify(restore)}\n` });
const ready = await waitFor(
`${clusterName} three-node recovery`,
20 * 60_000,
() =>
postgresClusterRuntimeEvidence(
kubectlJson(['-n', 'ql3-dr', 'get', `cluster/${clusterName}`]),
kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
`cnpg.io/cluster=${clusterName}`,
]).items,
),
);
const databaseRtoMs = Date.now() - startedAt;
const observedCluster = kubectlJson([
'-n',
'ql3-dr',
'get',
`cluster/${clusterName}`,
]);
assert.equal(observedCluster.spec.plugins, undefined);
assert.deepEqual(
observedCluster.spec.externalClusters?.[0]?.plugin?.parameters,
{
barmanObjectName: 'ql3-postgres-recovery-source',
serverName: 'ql3-postgres',
},
);
const markers = restoreMarkerEvidence(
postgresQueryJson(kubectl, 'ql3-dr', ready.value.primary, markerSql),
expectAfter,
);
const databaseContract = postgresDatabaseContractEvidence(
postgresQueryJson(
kubectl,
'ql3-dr',
ready.value.primary,
databaseContractSql,
),
);
const applicationProbe = postgresRestoreApplicationProbeResources({
clusterName,
controlImage: clusterControlImage.runtimeImage,
apiCredentialPepper: crypto.randomBytes(32).toString('base64url'),
});
kubectl(['apply', '-f', '-'], {
input: `${JSON.stringify(applicationProbe)}\n`,
});
const applicationName = `ql3-dr-application-${
clusterName.endsWith('-latest') ? 'latest' : 'pitr'
}`;
const applicationReady = await waitFor(
`${clusterName} production application readiness`,
10 * 60_000,
() =>
postgresRestoreApplicationRuntimeEvidence(
kubectlJson([
'-n',
'ql3-dr',
'get',
`deployment/${applicationName}`,
]),
kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
`app.kubernetes.io/name=${applicationName}`,
]).items,
clusterName,
),
);
const applicationImage = runtimeImageEvidence(
applicationReady.value.pod,
'cluster-control',
clusterControlImage.runtimeImage,
clusterControlImage.platformDigest,
);
const applicationRtoMs = Date.now() - startedAt;
const images = ready.value.pods.map((pod) =>
runtimeImageEvidence(
pod,
'postgres',
IMAGES.postgres,
expected.postgres,
),
);
return Object.freeze({
cluster: clusterName,
sourceObjectStore: 'ql3-postgres-recovery-source',
sourceServerName: 'ql3-postgres',
targetWalArchiver: false,
instances: 3,
ready: true,
primary: ready.value.primary,
nodes: ready.value.nodes,
synchronousCommit: 'remote_apply',
synchronousStandbys: 1,
migrationCount: databaseContract.migrationCount,
controlCoreCapability: databaseContract.controlCoreCapability,
databaseOwner: databaseContract.databaseOwner,
roles: databaseContract.roles,
beforeMarkerPresent: markers.beforeMarkerPresent,
afterMarkerPresent: markers.afterMarkerPresent,
databaseRtoMs,
applicationRtoMs,
applicationReady: true,
applicationImage,
images: Object.freeze(images),
});
};
const latestRestore = await restoreCluster({
clusterName: 'ql3-postgres-restore-latest',
expectAfter: true,
});
kubectl([
'-n',
'ql3-dr',
'delete',
'deployment/ql3-dr-application-latest',
'secret/ql3-dr-application-latest-security',
'--wait=true',
'--timeout=5m',
]);
kubectl([
'-n',
'ql3-dr',
'delete',
'cluster/ql3-postgres-restore-latest',
'--wait=true',
'--timeout=10m',
]);
kubectl([
'-n',
'ql3-dr',
'delete',
'pvc',
'-l',
'cnpg.io/cluster=ql3-postgres-restore-latest',
'--ignore-not-found=true',
'--wait=true',
'--timeout=10m',
]);
const pitrRestore = await restoreCluster({
clusterName: 'ql3-postgres-restore-pitr',
targetTime: pitrTarget,
expectAfter: false,
});
const sourceClusterAfterRestores = kubectlJson([
'-n',
'ql3-dr',
'get',
'cluster/ql3-postgres',
]);
assert.equal(sourceClusterAfterRestores.metadata.uid, sourceUid);
assert.deepEqual(
sourceClusterAfterRestores.spec,
sourceClusterBeforeRestores.spec,
);
const sourceAfterRestores = postgresClusterRuntimeEvidence(
sourceClusterAfterRestores,
kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
'cnpg.io/cluster=ql3-postgres',
]).items,
);
assert.equal(sourceAfterRestores.ready, true);
const minioPod = kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
'app=ql3-minio',
]).items[0];
const minioImage = runtimeImageEvidence(
minioPod,
'minio',
IMAGES.minio,
expected.minio,
);
const minioClientPod = kubectlJson([
'-n',
'ql3-dr',
'get',
'pods',
'-l',
'job-name=ql3-minio-bootstrap',
]).items[0];
const minioClientImage = runtimeImageEvidence(
minioClientPod,
'mc',
IMAGES.minioClient,
expected.minioClient,
);
const certManagerPods = kubectlJson([
'-n',
'cert-manager',
'get',
'pods',
]).items;
const certManagerImageKeys = Object.freeze({
'cert-manager-controller': 'certManager',
'cert-manager-cainjector': 'certManagerCainjector',
'cert-manager-webhook': 'certManagerWebhook',
});
const certManagerByContainer = Object.fromEntries(
certManagerPods.flatMap((pod) =>
(pod.status.containerStatuses || [])
.filter((status) => certManagerImageKeys[status.name])
.map((status) => {
const imageKey = certManagerImageKeys[status.name];
return [
status.name,
runtimeImageEvidence(
pod,
status.name,
IMAGES[imageKey],
expected[imageKey],
),
];
}),
),
);
assert.deepEqual(
Object.keys(certManagerByContainer).sort(),
Object.keys(certManagerImageKeys).sort(),
);
const cnpgPod = kubectlJson([
'-n',
'cnpg-system',
'get',
'pods',
'-l',
'app.kubernetes.io/name=cloudnative-pg',
]).items[0];
const cnpgImage = runtimeImageEvidence(
cnpgPod,
'manager',
IMAGES.cnpg,
expected.cnpg,
);
const barmanPod = kubectlJson([
'-n',
'cnpg-system',
'get',
'pods',
'-l',
'app=barman-cloud',
]).items[0];
const barmanImage = runtimeImageEvidence(
barmanPod,
'barman-cloud',
IMAGES.barman,
expected.barman,
);
const minioCaSecret = kubectlJson([
'-n',
'ql3-dr',
'get',
'secret/ql3-minio-ca',
]);
const minioServerSecret = kubectlJson([
'-n',
'ql3-dr',
'get',
'secret/ql3-minio-tls',
]);
assert.deepEqual(Object.keys(clientSecret.data).sort(), [
'ca.crt',
'tls.crt',
'tls.key',
]);
assert.deepEqual(Object.keys(serverSecret.data).sort(), [
'ca.crt',
'tls.crt',
'tls.key',
]);
assert.equal(migrationImage.imageId, clusterControlImage.platformDigest);
assert.equal(
latestRestore.applicationImage.imageId,
clusterControlImage.platformDigest,
);
assert.equal(
pitrRestore.applicationImage.imageId,
clusterControlImage.platformDigest,
);
assert.equal(minioImage.imageId, expected.minio);
assert.equal(minioClientImage.imageId, expected.minioClient);
certificateEvidence(minioCaSecret);
certificateEvidence(minioServerSecret);
const restoreEvidence = (restore, targetTime) =>
Object.freeze({
cluster: restore.cluster,
sourceObjectStore: restore.sourceObjectStore,
sourceServerName: restore.sourceServerName,
sourceClusterUnmodified: true,
targetWalArchiver: false,
instances: restore.instances,
ready: restore.ready,
migrationCount: restore.migrationCount,
controlCoreCapability: restore.controlCoreCapability,
databaseOwner: restore.databaseOwner,
synchronousCommit: restore.synchronousCommit,
synchronousStandbys: restore.synchronousStandbys,
roles: restore.roles,
beforeMarkerPresent: restore.beforeMarkerPresent,
afterMarkerPresent: restore.afterMarkerPresent,
...(targetTime === undefined ? {} : { targetTime }),
});
const toSeconds = (milliseconds) =>
Number((milliseconds / 1_000).toFixed(3));
const report = auditedCloudNativePgDrEvidence({
schemaVersion: 1,
fixture: 'qinglong/cloudnativepg-disaster-recovery@v1',
observedAt: new Date().toISOString(),
sourceRevision,
platform: Object.freeze({
kubernetesVersion: K3S_VERSION,
architecture,
cloudNativePgVersion: CNPG_VERSION,
cloudNativePgImageId: cnpgImage.imageId,
postgresVersionNumber: sourceDatabaseContract.postgresVersionNumber,
postgresImageId: sourcePostgresImages[0].imageId,
barmanVersion: BARMAN_VERSION,
barmanControllerImageId: barmanImage.imageId,
barmanSidecarImageIds: Object.freeze(
sourceBarmanSidecarImages.map(({ imageId }) => imageId),
),
certManagerVersion: CERT_MANAGER_VERSION,
certManagerImageIds: Object.freeze([
certManagerByContainer['cert-manager-controller'].imageId,
certManagerByContainer['cert-manager-cainjector'].imageId,
certManagerByContainer['cert-manager-webhook'].imageId,
]),
}),
source: Object.freeze({
cluster: 'ql3-postgres',
backup: Object.freeze({
name: 'ql3-postgres-base-backup',
phase: baseBackup.value.phase,
startedAt: baseBackup.value.startedAt,
completedAt: baseBackup.value.stoppedAt,
beginWal: baseBackup.value.beginWal,
endWal: baseBackup.value.endWal,
}),
markers: Object.freeze({
before: beforeMarker,
after: afterMarker,
}),
wal: Object.freeze({
archiveHealthy: true,
continuous: true,
noGaps: true,
lastArchivedWal: walBeforeRestores.value.lastArchivedWal,
}),
}),
latestRestore: restoreEvidence(latestRestore),
pitrRestore: restoreEvidence(pitrRestore, pitrTarget),
certificateRotation: Object.freeze({
client: rotatedCertificates.value.clientRotation,
server: rotatedCertificates.value.serverRotation,
walArchivedDuringRotation: true,
backupCompletedAfterRotation: true,
latestRestoreCompletedAfterRotation: true,
pitrCompletedAfterRotation: true,
maxObservedInterruptionSeconds: toSeconds(
rotatedCertificates.elapsedMs,
),
}),
objectStoreAuthority: Object.freeze({
sourceObjectStore: 'ql3-postgres-backup',
recoveryObjectStore: 'ql3-postgres-recovery-source',
sourceWriterIdentitySha256: `sha256:${sha256(
Buffer.from(minioCredentials.writer.accessKey, 'utf8'),
)}`,
recoveryReaderIdentitySha256: `sha256:${sha256(
Buffer.from(minioCredentials.recovery.accessKey, 'utf8'),
)}`,
recoveryReadOnly: true,
versioning: true,
immutability: true,
lifecycleDays: 45,
}),
serviceLevels: Object.freeze({
targetMaxRpoSeconds: 60,
observedRpoSeconds: Number(observedRpoSeconds.toFixed(3)),
targetMaxDatabaseRtoSeconds: 1_200,
latestDatabaseRtoSeconds: toSeconds(latestRestore.databaseRtoMs),
pitrDatabaseRtoSeconds: toSeconds(pitrRestore.databaseRtoMs),
targetMaxApplicationRtoSeconds: 1_800,
latestApplicationRtoSeconds: toSeconds(latestRestore.applicationRtoMs),
pitrApplicationRtoSeconds: toSeconds(pitrRestore.applicationRtoMs),
}),
gates: Object.freeze({
latestRestore: true,
pointInTimeRestore: true,
schemaAndRoles: true,
sourceIsolation: true,
certificateRotation: true,
serviceLevels: true,
passed: true,
}),
});
if (evidenceReportPath !== undefined) {
writePrivateEvidenceReport(evidenceReportPath, report);
}
process.stdout.write(`${JSON.stringify(report)}\n`);
} catch (error) {
try {
for (const container of nodes) {
const stateResult = run(
docker,
['inspect', '--format', '{{json .State}}', container],
{ capture: true, quiet: true, allowFailure: true },
);
if (stateResult.status !== 0) continue;
const state = JSON.parse(stateResult.stdout);
const logs = run(docker, ['logs', '--tail', '80', container], {
capture: true,
quiet: true,
allowFailure: true,
});
const diagnostic = {
container,
state: {
status: state.Status,
running: state.Running,
oomKilled: state.OOMKilled,
exitCode: state.ExitCode,
error: state.Error,
finishedAt: state.FinishedAt,
},
logs: redactRuntimeText(
[logs.stdout, logs.stderr].filter(Boolean).join('\n'),
[clusterToken],
).slice(-16_384),
};
process.stderr.write(
`container-diagnostic ${JSON.stringify(diagnostic)}\n`,
);
}
if (fs.existsSync(kubeconfig)) {
run(
kubectlBinary,
['--kubeconfig', kubeconfig, 'get', 'pods', '-A', '-o', 'wide'],
{ quiet: true, allowFailure: true },
);
}
} catch {
// Diagnostics cannot replace the original failure.
}
throw error;
} finally {
if (keep) {
process.stderr.write(
`preserving ${prefix}; kubeconfig=${kubeconfig}; network=${network}\n`,
);
} else {
for (const container of [...createdContainers].reverse()) {
run(docker, ['rm', '-f', '-v', container], {
capture: true,
quiet: true,
allowFailure: true,
});
}
if (networkCreated) {
run(docker, ['network', 'rm', network], {
capture: true,
quiet: true,
allowFailure: true,
});
}
for (const image of builtImages) {
run(docker, ['image', 'rm', '-f', image], {
capture: true,
quiet: true,
allowFailure: true,
});
}
fs.rmSync(temporary, { recursive: true, force: true });
}
}
}
if (require.main === module) {
main().catch((error) => {
process.stderr.write(
`${
error instanceof Error ? error.stack || error.message : String(error)
}\n`,
);
process.exitCode = 1;
});
}
module.exports = {
assertCloudNativePgDrRunnerCapacity,
auditedCloudNativePgDrEvidence,
backupRuntimeEvidence,
buildClusterControlImage,
certificateEvidence,
certificateRotationEvidence,
digestOnlyReference,
imageIdDigest,
kubernetesSecret,
minioFixtureResources,
mirrorPlatformImage,
parseEvidenceReportPath,
platformDigestFromImageIndex,
podReady,
preflightPrivateEvidenceReportPath,
privateDockerDataBindArgs,
postgresArchiverEvidence,
postgresClusterRuntimeEvidence,
postgresDatabaseContractEvidence,
postgresMigrationJobResource,
postgresMarkerEvidence,
postgresRoleEvidence,
postgresRoleSecretResources,
postgresRestoreFixtureResources,
postgresRestoreApplicationProbeResources,
postgresRestoreApplicationRuntimeEvidence,
postgresSourceFixtureResources,
postgresQueryJson,
postgresSql,
redactRuntimeText,
replaceExactlyOnce,
restoreMarkerEvidence,
reviewedManifest,
runtimeImageEvidence,
webhookConfigurationHasCaBundle,
writePrivateEvidenceReport,
};