mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:58:17 +08:00
318 lines
10 KiB
JavaScript
318 lines
10 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
'use strict';
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const yaml = require('js-yaml');
|
|
|
|
const COMPONENT_DIRECTORY =
|
|
'deploy/kubernetes/ql3-cluster/components/barman-cloud-backup';
|
|
const RESTORE_DIRECTORY =
|
|
'deploy/kubernetes/ql3-cluster/operations/cloudnative-pg-restore';
|
|
const PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io';
|
|
const BACKUP_OBJECT_STORE = 'ql3-postgres-backup';
|
|
const RECOVERY_OBJECT_STORE = 'ql3-postgres-recovery-source';
|
|
const POSTGRES_IMAGE =
|
|
'ghcr.io/cloudnative-pg/postgresql:18.4-minimal-trixie@sha256:24d229d801663f95b584416f8ebdfad4849b1a3fa4cfcf95a7f026df7aa6e22d';
|
|
|
|
function finding(code, detail) {
|
|
return Object.freeze({ code, detail });
|
|
}
|
|
|
|
function readYaml(readFile, root, relativePath) {
|
|
return yaml.load(readFile(path.join(root, relativePath), 'utf8'));
|
|
}
|
|
|
|
function exactPlugin(plugin, objectStore) {
|
|
return (
|
|
plugin?.name === PLUGIN_NAME &&
|
|
plugin?.isWALArchiver === true &&
|
|
plugin?.parameters?.barmanObjectName === objectStore &&
|
|
Object.keys(plugin.parameters).length === 1
|
|
);
|
|
}
|
|
|
|
function exactRecoveryPlugin(plugin) {
|
|
return (
|
|
plugin?.name === PLUGIN_NAME &&
|
|
plugin?.parameters?.barmanObjectName === RECOVERY_OBJECT_STORE &&
|
|
plugin?.parameters?.serverName === 'ql3-postgres' &&
|
|
Object.keys(plugin.parameters).length === 2
|
|
);
|
|
}
|
|
|
|
function assertComponent(readFile, root, findings) {
|
|
const kustomization = readYaml(
|
|
readFile,
|
|
root,
|
|
`${COMPONENT_DIRECTORY}/kustomization.yaml`,
|
|
);
|
|
if (
|
|
kustomization?.apiVersion !== 'kustomize.config.k8s.io/v1alpha1' ||
|
|
kustomization?.kind !== 'Component' ||
|
|
JSON.stringify(kustomization?.resources) !==
|
|
JSON.stringify(['scheduled-backup.yaml']) ||
|
|
JSON.stringify(kustomization?.patches) !==
|
|
JSON.stringify([{ path: 'cluster-plugin-patch.yaml' }])
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_BACKUP_COMPONENT',
|
|
'the backup capability must remain an explicit cluster-only Kustomize Component',
|
|
),
|
|
);
|
|
}
|
|
const serialized = JSON.stringify(kustomization);
|
|
if (
|
|
serialized.includes('object-store') ||
|
|
serialized.includes('private-overlay')
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_BACKUP_PRIVATE_INPUT',
|
|
'provider ObjectStore examples and private overlays must never be applied by the shared Component',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertWalAndSchedule(readFile, root, findings) {
|
|
const patch = readYaml(
|
|
readFile,
|
|
root,
|
|
`${COMPONENT_DIRECTORY}/cluster-plugin-patch.yaml`,
|
|
);
|
|
if (
|
|
patch?.apiVersion !== 'postgresql.cnpg.io/v1' ||
|
|
patch?.kind !== 'Cluster' ||
|
|
patch?.metadata?.name !== 'ql3-postgres' ||
|
|
patch?.spec?.plugins?.length !== 1 ||
|
|
!exactPlugin(patch.spec.plugins[0], BACKUP_OBJECT_STORE) ||
|
|
patch?.spec?.backup !== undefined
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_WAL_ARCHIVER',
|
|
'the source cluster must use exactly one CNPG-I Barman WAL archiver and no deprecated in-tree backup',
|
|
),
|
|
);
|
|
}
|
|
|
|
const schedule = readYaml(
|
|
readFile,
|
|
root,
|
|
`${COMPONENT_DIRECTORY}/scheduled-backup.yaml`,
|
|
);
|
|
if (
|
|
schedule?.apiVersion !== 'postgresql.cnpg.io/v1' ||
|
|
schedule?.kind !== 'ScheduledBackup' ||
|
|
schedule?.metadata?.name !== 'ql3-postgres-daily' ||
|
|
schedule?.metadata?.namespace !== 'qinglong3-system' ||
|
|
schedule?.spec?.schedule !== '0 0 0 * * *' ||
|
|
schedule?.spec?.backupOwnerReference !== 'self' ||
|
|
schedule?.spec?.immediate !== false ||
|
|
schedule?.spec?.suspend !== false ||
|
|
schedule?.spec?.target !== 'prefer-standby' ||
|
|
schedule?.spec?.cluster?.name !== 'ql3-postgres' ||
|
|
schedule?.spec?.method !== 'plugin' ||
|
|
schedule?.spec?.pluginConfiguration?.name !== PLUGIN_NAME ||
|
|
Object.keys(schedule?.spec?.pluginConfiguration || {}).length !== 1
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_BASE_BACKUP_SCHEDULE',
|
|
'daily standby-preferred base backups must use the CNPG-I plugin explicitly',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertObjectStoreExample(readFile, root, findings) {
|
|
const objectStore = readYaml(
|
|
readFile,
|
|
root,
|
|
`${COMPONENT_DIRECTORY}/object-store.s3.example.yaml`,
|
|
);
|
|
const configuration = objectStore?.spec?.configuration;
|
|
if (
|
|
objectStore?.apiVersion !== 'barmancloud.cnpg.io/v1' ||
|
|
objectStore?.kind !== 'ObjectStore' ||
|
|
objectStore?.metadata?.name !== BACKUP_OBJECT_STORE ||
|
|
objectStore?.metadata?.namespace !== 'qinglong3-system' ||
|
|
objectStore?.spec?.retentionPolicy !== '30d' ||
|
|
!configuration?.destinationPath?.startsWith(
|
|
's3://REPLACE_WITH_VERSIONED_LOCKED_BUCKET/',
|
|
) ||
|
|
!configuration?.endpointURL?.startsWith('https://REPLACE_WITH_') ||
|
|
configuration?.s3Credentials?.accessKeyId?.name !==
|
|
'ql3-postgres-backup-object-store' ||
|
|
configuration?.s3Credentials?.accessKeyId?.key !== 'ACCESS_KEY_ID' ||
|
|
configuration?.s3Credentials?.secretAccessKey?.name !==
|
|
'ql3-postgres-backup-object-store' ||
|
|
configuration?.s3Credentials?.secretAccessKey?.key !==
|
|
'ACCESS_SECRET_KEY' ||
|
|
configuration?.wal?.compression !== 'lz4' ||
|
|
configuration?.wal?.encryption !== 'AES256' ||
|
|
configuration?.wal?.maxParallel !== 2 ||
|
|
configuration?.data?.compression !== 'lz4' ||
|
|
configuration?.data?.encryption !== 'AES256'
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_OBJECT_STORE_CONTRACT',
|
|
'the private ObjectStore schema must require HTTPS, placeholders, retention, encryption and bounded compression/concurrency',
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
JSON.stringify(objectStore).includes('REPLACE_WITH_SECRET_MANAGER_VALUE') ||
|
|
objectStore?.stringData ||
|
|
objectStore?.data
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_OBJECT_STORE_SECRET_BOUNDARY',
|
|
'the ObjectStore example may reference credentials but must not contain secret material',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function assertRestore(readFile, root, findings) {
|
|
const kustomization = readYaml(
|
|
readFile,
|
|
root,
|
|
`${RESTORE_DIRECTORY}/kustomization.yaml`,
|
|
);
|
|
if (
|
|
kustomization?.kind !== 'Kustomization' ||
|
|
kustomization?.namespace !== 'qinglong3-system' ||
|
|
JSON.stringify(kustomization?.resources) !==
|
|
JSON.stringify(['restore-cluster.yaml'])
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_RESTORE_OPERATION',
|
|
'restore must remain a separate explicit operation with no provider ObjectStore example applied',
|
|
),
|
|
);
|
|
}
|
|
|
|
const cluster = readYaml(
|
|
readFile,
|
|
root,
|
|
`${RESTORE_DIRECTORY}/restore-cluster.yaml`,
|
|
);
|
|
const spec = cluster?.spec;
|
|
const origin = spec?.externalClusters?.[0];
|
|
if (
|
|
cluster?.apiVersion !== 'postgresql.cnpg.io/v1' ||
|
|
cluster?.kind !== 'Cluster' ||
|
|
cluster?.metadata?.name !== 'ql3-postgres-restore' ||
|
|
cluster?.metadata?.annotations?.['cnpg.io/skipEmptyWalArchiveCheck'] !==
|
|
undefined ||
|
|
spec?.instances !== 3 ||
|
|
spec?.imageName !== POSTGRES_IMAGE ||
|
|
spec?.enableSuperuserAccess !== false ||
|
|
spec?.bootstrap?.recovery?.source !== 'ql3-postgres-origin' ||
|
|
Object.keys(spec?.bootstrap?.recovery || {}).length !== 1 ||
|
|
spec?.externalClusters?.length !== 1 ||
|
|
origin?.name !== 'ql3-postgres-origin' ||
|
|
!exactRecoveryPlugin(origin?.plugin) ||
|
|
spec?.plugins !== undefined
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_RESTORE_ISOLATION',
|
|
'recovery must bootstrap a distinct digest-pinned cluster from a read-only source without archive-check bypass or source-store writes',
|
|
),
|
|
);
|
|
}
|
|
if (
|
|
spec?.postgresql?.parameters?.synchronous_commit !== 'remote_apply' ||
|
|
spec?.postgresql?.synchronous?.method !== 'any' ||
|
|
spec?.postgresql?.synchronous?.number !== 1 ||
|
|
spec?.postgresql?.synchronous?.dataDurability !== 'required' ||
|
|
spec?.postgresql?.synchronous?.failoverQuorum !== true ||
|
|
spec?.affinity?.podAntiAffinityType !== 'required' ||
|
|
spec?.affinity?.topologyKey !== 'kubernetes.io/hostname' ||
|
|
JSON.stringify(spec?.managed?.services?.disabledDefaultServices) !==
|
|
JSON.stringify(['r', 'ro'])
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_RESTORE_HA',
|
|
'the restored cluster must re-enter the reviewed three-node synchronous HA envelope',
|
|
),
|
|
);
|
|
}
|
|
|
|
const sourceStore = readYaml(
|
|
readFile,
|
|
root,
|
|
`${RESTORE_DIRECTORY}/object-store.s3.example.yaml`,
|
|
);
|
|
const sourceConfiguration = sourceStore?.spec?.configuration;
|
|
if (
|
|
sourceStore?.metadata?.name !== RECOVERY_OBJECT_STORE ||
|
|
sourceConfiguration?.s3Credentials?.accessKeyId?.name !==
|
|
'ql3-postgres-restore-object-store' ||
|
|
sourceConfiguration?.s3Credentials?.secretAccessKey?.name !==
|
|
'ql3-postgres-restore-object-store' ||
|
|
sourceConfiguration?.wal?.maxParallel !== 4 ||
|
|
sourceConfiguration?.serverName !== undefined ||
|
|
sourceStore?.spec?.retentionPolicy !== undefined ||
|
|
sourceConfiguration?.data !== undefined
|
|
) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_RECOVERY_SOURCE',
|
|
'the recovery ObjectStore must use a distinct credential authority and remain excluded from retention/write policy',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
function auditCloudNativePgBackup(options = {}) {
|
|
const root = path.resolve(options.root ?? path.join(__dirname, '..'));
|
|
const readFile = options.readFile ?? fs.readFileSync;
|
|
const findings = [];
|
|
try {
|
|
assertComponent(readFile, root, findings);
|
|
assertWalAndSchedule(readFile, root, findings);
|
|
assertObjectStoreExample(readFile, root, findings);
|
|
assertRestore(readFile, root, findings);
|
|
} catch (error) {
|
|
findings.push(
|
|
finding(
|
|
'QL3_CNPG_BACKUP_AUDIT_UNAVAILABLE',
|
|
error instanceof Error ? error.message : 'unknown audit failure',
|
|
),
|
|
);
|
|
}
|
|
return Object.freeze({
|
|
schemaVersion: 1,
|
|
plugin: PLUGIN_NAME,
|
|
sourceCluster: 'ql3-postgres',
|
|
restoreCluster: 'ql3-postgres-restore',
|
|
baseBackupSchedule: '0 0 0 * * *',
|
|
retentionPolicy: '30d',
|
|
findings: Object.freeze(findings),
|
|
compatible: findings.length === 0,
|
|
});
|
|
}
|
|
|
|
if (require.main === module) {
|
|
const report = auditCloudNativePgBackup();
|
|
process.stdout.write(`${JSON.stringify(report)}\n`);
|
|
if (!report.compatible) process.exitCode = 1;
|
|
}
|
|
|
|
module.exports = {
|
|
BACKUP_OBJECT_STORE,
|
|
PLUGIN_NAME,
|
|
RECOVERY_OBJECT_STORE,
|
|
auditCloudNativePgBackup,
|
|
};
|