Files
qinglong/scripts/ql3-cloudnativepg-deployment-audit.cjs
T

573 lines
18 KiB
JavaScript

#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const yaml = require('js-yaml');
const OPERATOR_DIRECTORY =
'deploy/kubernetes/ql3-cluster/operators/cloudnative-pg';
const RUNTIME_PATCH =
'deploy/kubernetes/ql3-cluster/overlays/cloudnative-pg/postgres-runtime-patch.yaml';
const RUNTIME_KUSTOMIZATION =
'deploy/kubernetes/ql3-cluster/overlays/cloudnative-pg/kustomization.yaml';
const MIGRATION_PATCH =
'deploy/kubernetes/ql3-cluster/operations/cloudnative-pg/migrate-job-patch.yaml';
const MIGRATION_KUSTOMIZATION =
'deploy/kubernetes/ql3-cluster/operations/cloudnative-pg/kustomization.yaml';
const PRIMARY_DNS = 'ql3-postgres-rw.qinglong3-system.svc';
const APPLICATION_IMAGE_NAME = 'qinglong3-cluster-control';
const APPLICATION_IMAGE_REPOSITORY =
'registry.example.com/qinglong/qinglong3-cluster-control';
const FAIL_CLOSED_IMAGE_DIGEST = `sha256:${'0'.repeat(64)}`;
const POSTGRES_IMAGE =
'ghcr.io/cloudnative-pg/postgresql:18.4-minimal-trixie@sha256:24d229d801663f95b584416f8ebdfad4849b1a3fa4cfcf95a7f026df7aa6e22d';
const OPERATOR_IMAGE =
'ghcr.io/cloudnative-pg/cloudnative-pg:1.30.0@sha256:a2701eb97cdd2a34b1fdb2cb51987f544b706e40bec72ae7146cd8580efefebb';
const EXPECTED_ROLES = Object.freeze({
ql3_admin: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-admin-auth',
}),
ql3_ai_maintenance: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-ai-maintenance-auth',
}),
ql3_ai_credential_manager: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-ai-credential-manager-auth',
}),
ql3_ai_credential_tester: Object.freeze({
connectionLimit: 2,
secret: 'ql3-postgres-ai-credential-tester-auth',
}),
ql3_automation_manager: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-automation-manager-auth',
}),
ql3_approval_manager: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-approval-manager-auth',
}),
ql3_migration: Object.freeze({
connectionLimit: 2,
secret: 'ql3-postgres-migration-auth',
}),
ql3_package_executor: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-package-executor-auth',
}),
ql3_package_manager: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-package-manager-auth',
}),
ql3_runtime: Object.freeze({
connectionLimit: 32,
secret: 'ql3-postgres-runtime-auth',
}),
ql3_worker_credential_executor: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-worker-credential-executor-auth',
}),
ql3_worker_credential_manager: Object.freeze({
connectionLimit: 4,
secret: 'ql3-postgres-worker-credential-manager-auth',
}),
ql3_worker_ingress: Object.freeze({
connectionLimit: 32,
secret: 'ql3-postgres-worker-ingress-auth',
}),
});
function finding(code, detail) {
return Object.freeze({ code, detail });
}
function loadDocuments(source) {
const result = [];
yaml.loadAll(source, (document) => {
if (document) result.push(document);
});
return result;
}
function envByName(container) {
return new Map(
(Array.isArray(container?.env) ? container.env : []).map((entry) => [
entry?.name,
entry,
]),
);
}
function exactSecretRef(entry, secret, key) {
return (
entry?.valueFrom?.secretKeyRef?.name === secret &&
entry?.valueFrom?.secretKeyRef?.key === key
);
}
function exactFailClosedApplicationImage(kustomization) {
return (
Array.isArray(kustomization?.images) &&
kustomization.images.length === 1 &&
kustomization.images[0]?.name === APPLICATION_IMAGE_NAME &&
kustomization.images[0]?.newName === APPLICATION_IMAGE_REPOSITORY &&
kustomization.images[0]?.digest === FAIL_CLOSED_IMAGE_DIGEST &&
!Object.hasOwn(kustomization.images[0], 'newTag')
);
}
function assertOperatorLock(readFile, root, findings) {
const lock = JSON.parse(
readFile(path.join(root, OPERATOR_DIRECTORY, 'operator-lock.json'), 'utf8'),
);
if (
lock?.schemaVersion !== 1 ||
lock?.operator?.name !== 'cloudnative-pg' ||
lock?.operator?.version !== '1.30.0' ||
lock?.operator?.image !== OPERATOR_IMAGE ||
lock?.operator?.platforms?.['linux/amd64'] !==
'sha256:091d306935cfdf646debfe78010d59ebfb572150eb6eb922b0203873c0c68841' ||
lock?.operator?.platforms?.['linux/arm64'] !==
'sha256:6c7926147fd23a053dea6605d61c013d43bfd411be3532e7500ef2d2b68bb98c'
) {
findings.push(
finding(
'QL3_CNPG_OPERATOR_LOCK',
'CloudNativePG 1.30.0 must remain locked to its reviewed multi-platform OCI digest',
),
);
}
if (
lock?.operator?.releaseManifest !==
'https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.yaml' ||
lock?.operator?.releaseManifestSha256 !==
'sha256:f8bede43fe4ee0d478c2355b204a36876b2ae4faac60f2a9452280b293da3b88' ||
lock?.operator?.signatureBundle !==
'https://github.com/cloudnative-pg/cloudnative-pg/releases/download/v1.30.0/cnpg-1.30.0.sigstore.json' ||
lock?.operand?.postgresqlVersion !== '18.4' ||
lock?.operand?.image !== POSTGRES_IMAGE ||
lock?.operand?.platforms?.['linux/amd64'] !==
'sha256:67e56bcbe50a58e60509815dd89e58effc5f331ab844f66331d945cd42131e8d' ||
lock?.operand?.platforms?.['linux/arm64'] !==
'sha256:2adbb634a1c0af8cb036e81200aaa7c8b62517bf5e501699926b337bd9f863f1'
) {
findings.push(
finding(
'QL3_CNPG_SUPPLY_CHAIN_LOCK',
'operator release inputs and PostgreSQL 18.4 operand must remain digest locked',
),
);
}
}
function assertCluster(readFile, root, findings) {
const cluster = yaml.load(
readFile(path.join(root, OPERATOR_DIRECTORY, 'cluster.yaml'), 'utf8'),
);
const spec = cluster?.spec;
const synchronous = spec?.postgresql?.synchronous;
if (
cluster?.apiVersion !== 'postgresql.cnpg.io/v1' ||
cluster?.kind !== 'Cluster' ||
cluster?.metadata?.name !== 'ql3-postgres' ||
spec?.instances !== 3 ||
spec?.imageName !== POSTGRES_IMAGE ||
spec?.enableSuperuserAccess !== false
) {
findings.push(
finding(
'QL3_CNPG_CLUSTER_BASELINE',
'the production cluster must use three instances, disabled superuser access and the locked operand',
),
);
}
if (
synchronous?.method !== 'any' ||
synchronous?.number !== 1 ||
synchronous?.dataDurability !== 'required' ||
synchronous?.failoverQuorum !== true ||
spec?.postgresql?.parameters?.synchronous_commit !== 'remote_apply'
) {
findings.push(
finding(
'QL3_CNPG_DURABILITY',
'the HA profile must require one remote-applied synchronous replica and failover quorum',
),
);
}
if (
spec?.affinity?.enablePodAntiAffinity !== 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_FAILURE_DOMAIN',
'database instances must occupy distinct nodes and expose only the primary-tracking rw service',
),
);
}
if (
spec?.resources?.requests?.cpu !== '250m' ||
spec?.resources?.requests?.memory !== '512Mi' ||
spec?.resources?.limits?.cpu !== '2' ||
spec?.resources?.limits?.memory !== '1Gi' ||
spec?.storage?.size !== '20Gi' ||
spec?.walStorage?.size !== '5Gi'
) {
findings.push(
finding(
'QL3_CNPG_RESOURCE_ENVELOPE',
'database CPU, memory, data and WAL envelopes must remain explicit',
),
);
}
}
function assertRolesAndDatabase(readFile, root, findings) {
const roles = loadDocuments(
readFile(
path.join(root, OPERATOR_DIRECTORY, 'database-roles.yaml'),
'utf8',
),
);
const seen = new Set();
for (const role of roles) {
const expected = EXPECTED_ROLES[role?.spec?.name];
const spec = role?.spec;
if (
role?.apiVersion !== 'postgresql.cnpg.io/v1' ||
role?.kind !== 'DatabaseRole' ||
!expected ||
seen.has(spec?.name) ||
spec?.cluster?.name !== 'ql3-postgres' ||
spec?.login !== true ||
spec?.superuser !== false ||
spec?.createdb !== false ||
spec?.createrole !== false ||
spec?.replication !== false ||
spec?.bypassrls !== false ||
spec?.connectionLimit !== expected?.connectionLimit ||
spec?.databaseRoleReclaimPolicy !== 'retain' ||
spec?.passwordSecret?.name !== expected?.secret
) {
findings.push(
finding(
'QL3_CNPG_DATABASE_ROLE',
'DatabaseRole resources must match the thirteen fixed least-privilege identities',
),
);
break;
}
seen.add(spec.name);
}
if (
seen.size !== Object.keys(EXPECTED_ROLES).length ||
Object.keys(EXPECTED_ROLES).some((name) => !seen.has(name))
) {
findings.push(
finding(
'QL3_CNPG_DATABASE_ROLE_SET',
'exactly the thirteen reviewed migration, runtime, AI maintenance, AI credential management/testing, admin, automation, Approval, package and Worker roles are required',
),
);
}
const database = yaml.load(
readFile(path.join(root, OPERATOR_DIRECTORY, 'database.yaml'), 'utf8'),
);
if (
database?.apiVersion !== 'postgresql.cnpg.io/v1' ||
database?.kind !== 'Database' ||
database?.spec?.cluster?.name !== 'ql3-postgres' ||
database?.spec?.name !== 'qinglong' ||
database?.spec?.owner !== 'ql3_migration'
) {
findings.push(
finding(
'QL3_CNPG_DATABASE_OWNER',
'the qinglong database must be owned only by the migration role',
),
);
}
}
function assertSecretBoundary(readFile, root, findings) {
const kustomization = yaml.load(
readFile(path.join(root, OPERATOR_DIRECTORY, 'kustomization.yaml'), 'utf8'),
);
const resources = kustomization?.resources;
if (
JSON.stringify(resources) !==
JSON.stringify([
'cluster.yaml',
'database-roles.yaml',
'database.yaml',
]) ||
resources?.includes('credentials.example.yaml')
) {
findings.push(
finding(
'QL3_CNPG_SECRET_APPLICATION_BOUNDARY',
'credential examples must never be part of the operator kustomization',
),
);
}
const secrets = loadDocuments(
readFile(
path.join(root, OPERATOR_DIRECTORY, 'credentials.example.yaml'),
'utf8',
),
);
const expectedUsers = new Map([
['ql3-postgres-migration-auth', 'ql3_migration'],
['ql3-postgres-ai-maintenance-auth', 'ql3_ai_maintenance'],
['ql3-postgres-ai-credential-manager-auth', 'ql3_ai_credential_manager'],
['ql3-postgres-ai-credential-tester-auth', 'ql3_ai_credential_tester'],
['ql3-postgres-runtime-auth', 'ql3_runtime'],
['ql3-postgres-admin-auth', 'ql3_admin'],
['ql3-postgres-automation-manager-auth', 'ql3_automation_manager'],
['ql3-postgres-approval-manager-auth', 'ql3_approval_manager'],
['ql3-postgres-worker-ingress-auth', 'ql3_worker_ingress'],
['ql3-postgres-package-manager-auth', 'ql3_package_manager'],
['ql3-postgres-package-executor-auth', 'ql3_package_executor'],
[
'ql3-postgres-worker-credential-manager-auth',
'ql3_worker_credential_manager',
],
[
'ql3-postgres-worker-credential-executor-auth',
'ql3_worker_credential_executor',
],
]);
for (const secret of secrets) {
const name = secret?.metadata?.name;
if (name === 'ql3-cluster-control-runtime') {
if (
secret?.type !== 'Opaque' ||
secret?.stringData?.['api-credential-pepper'] !==
'REPLACE_WITH_CANONICAL_32_BYTE_BASE64URL'
) {
findings.push(
finding(
'QL3_CNPG_RUNTIME_SECRET_EXAMPLE',
'runtime Secret example may contain only the placeholder credential pepper',
),
);
}
continue;
}
if (name === 'ql3-cluster-worker-ingress') {
const data = secret?.stringData;
const keys =
data && typeof data === 'object' ? Object.keys(data).sort() : [];
if (
secret?.type !== 'Opaque' ||
JSON.stringify(keys) !==
JSON.stringify([
'artifact-s3-bucket',
'artifact-s3-encryption',
'artifact-s3-region',
'client-ca.crt',
'tls.crt',
'tls.key',
'worker-credential-pepper',
]) ||
data?.['artifact-s3-encryption'] !== 's3' ||
keys
.filter((key) => key !== 'artifact-s3-encryption')
.some((key) => !String(data[key]).includes('REPLACE_WITH_'))
) {
findings.push(
finding(
'QL3_CNPG_WORKER_INGRESS_SECRET_EXAMPLE',
'Worker ingress Secret example must contain only reviewed placeholders',
),
);
}
continue;
}
if (
secret?.type !== 'kubernetes.io/basic-auth' ||
secret?.stringData?.username !== expectedUsers.get(name) ||
secret?.stringData?.password !== 'REPLACE_WITH_SECRET_MANAGER_VALUE'
) {
findings.push(
finding(
'QL3_CNPG_DATABASE_SECRET_EXAMPLE',
'database Secret examples must contain only fixed usernames and non-secret placeholders',
),
);
}
expectedUsers.delete(name);
}
if (
expectedUsers.size !== 0 ||
secrets.filter(
(secret) => secret?.metadata?.name === 'ql3-cluster-control-runtime',
).length !== 1 ||
secrets.filter(
(secret) => secret?.metadata?.name === 'ql3-cluster-worker-ingress',
).length !== 1
) {
findings.push(
finding(
'QL3_CNPG_SECRET_EXAMPLE_SET',
'the example must describe all thirteen database credentials and the reviewed workload Secrets',
),
);
}
}
function assertRuntimeBinding(readFile, root, findings) {
const patch = yaml.load(readFile(path.join(root, RUNTIME_PATCH), 'utf8'));
const kustomization = yaml.load(
readFile(path.join(root, RUNTIME_KUSTOMIZATION), 'utf8'),
);
const podSpec = patch?.spec?.template?.spec;
const container = podSpec?.containers?.find(
(candidate) => candidate?.name === 'cluster-control',
);
const env = envByName(container);
const caVolume = podSpec?.volumes?.find(
(candidate) => candidate?.name === 'postgres-runtime-ca',
);
if (
!exactFailClosedApplicationImage(kustomization) ||
env.get('QL3_POSTGRES_RUNTIME_URL')?.$patch !== 'delete' ||
env.get('QL3_POSTGRES_RUNTIME_HOST')?.value !== PRIMARY_DNS ||
env.get('QL3_POSTGRES_RUNTIME_PORT')?.value !== '5432' ||
env.get('QL3_POSTGRES_RUNTIME_DATABASE')?.value !== 'qinglong' ||
!exactSecretRef(
env.get('QL3_POSTGRES_RUNTIME_USER'),
'ql3-postgres-runtime-auth',
'username',
) ||
!exactSecretRef(
env.get('QL3_POSTGRES_RUNTIME_PASSWORD'),
'ql3-postgres-runtime-auth',
'password',
) ||
env.get('QL3_POSTGRES_TLS_SERVERNAME')?.value !== PRIMARY_DNS ||
caVolume?.secret?.secretName !== 'ql3-postgres-ca' ||
JSON.stringify(caVolume?.secret?.items) !==
JSON.stringify([{ key: 'ca.crt', path: 'ca.crt' }])
) {
findings.push(
finding(
'QL3_CNPG_RUNTIME_BINDING',
'cluster-control must use discrete runtime credentials, the primary service DNS and operator CA',
),
);
}
}
function assertMigrationBinding(readFile, root, findings) {
const operations = yaml.load(
readFile(path.join(root, MIGRATION_PATCH), 'utf8'),
);
const kustomization = yaml.load(
readFile(path.join(root, MIGRATION_KUSTOMIZATION), 'utf8'),
);
const envOperation = operations?.find(
(operation) =>
operation?.op === 'replace' &&
operation?.path === '/spec/template/spec/containers/0/env',
);
const caOperation = operations?.find(
(operation) =>
operation?.op === 'replace' &&
operation?.path === '/spec/template/spec/volumes/1/secret/secretName',
);
const caKeyOperation = operations?.find(
(operation) =>
operation?.op === 'replace' &&
operation?.path === '/spec/template/spec/volumes/1/secret/items/0/key',
);
const env = new Map(
(Array.isArray(envOperation?.value) ? envOperation.value : []).map(
(entry) => [entry?.name, entry],
),
);
if (
!exactFailClosedApplicationImage(kustomization) ||
env.has('QL3_POSTGRES_MIGRATION_URL') ||
env.has('QL3_POSTGRES_RUNTIME_URL') ||
env.has('QL3_API_CREDENTIAL_PEPPER') ||
env.get('QL3_POSTGRES_MIGRATION_HOST')?.value !== PRIMARY_DNS ||
env.get('QL3_POSTGRES_MIGRATION_PORT')?.value !== '5432' ||
env.get('QL3_POSTGRES_MIGRATION_DATABASE')?.value !== 'qinglong' ||
!exactSecretRef(
env.get('QL3_POSTGRES_MIGRATION_USER'),
'ql3-postgres-migration-auth',
'username',
) ||
!exactSecretRef(
env.get('QL3_POSTGRES_MIGRATION_PASSWORD'),
'ql3-postgres-migration-auth',
'password',
) ||
env.get('QL3_POSTGRES_TLS_MODE')?.value !== 'verify-full' ||
env.get('QL3_POSTGRES_TLS_SERVERNAME')?.value !== PRIMARY_DNS ||
caOperation?.value !== 'ql3-postgres-ca' ||
caKeyOperation?.value !== 'ca.crt'
) {
findings.push(
finding(
'QL3_CNPG_MIGRATION_BINDING',
'the one-shot migration must use only discrete migration credentials and operator TLS authority',
),
);
}
}
function auditCloudNativePgDeployment(options = {}) {
const root = path.resolve(options.root ?? path.join(__dirname, '..'));
const readFile = options.readFile ?? fs.readFileSync;
const findings = [];
try {
assertOperatorLock(readFile, root, findings);
assertCluster(readFile, root, findings);
assertRolesAndDatabase(readFile, root, findings);
assertSecretBoundary(readFile, root, findings);
assertRuntimeBinding(readFile, root, findings);
assertMigrationBinding(readFile, root, findings);
} catch (error) {
findings.push(
finding(
'QL3_CNPG_AUDIT_UNAVAILABLE',
error instanceof Error ? error.message : 'unknown audit failure',
),
);
}
return Object.freeze({
schemaVersion: 1,
operator: 'cloudnative-pg',
operatorVersion: '1.30.0',
postgresqlVersion: '18.4',
instances: 3,
primaryService: PRIMARY_DNS,
roles: Object.freeze(Object.keys(EXPECTED_ROLES).sort()),
findings: Object.freeze(findings),
compatible: findings.length === 0,
});
}
if (require.main === module) {
const report = auditCloudNativePgDeployment();
process.stdout.write(`${JSON.stringify(report)}\n`);
if (!report.compatible) process.exitCode = 1;
}
module.exports = {
EXPECTED_ROLES,
OPERATOR_IMAGE,
POSTGRES_IMAGE,
PRIMARY_DNS,
auditCloudNativePgDeployment,
};