#!/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, };