mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 00:38:14 +08:00
312 lines
9.6 KiB
JavaScript
312 lines
9.6 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const FIXTURE = 'qinglong/postgresql-ha-contract@v1';
|
|
const SHA256_PATTERN = /^(?:sha256:)?[0-9a-f]{64}$/;
|
|
const EXPECTED_LIMITATIONS = Object.freeze([
|
|
'test-only TCP endpoint is not a production operator or proxy',
|
|
'Docker replication-link partition plus a test-only promotion guard is not production operator or infrastructure STONITH evidence',
|
|
'single-standby remote_apply prioritizes acknowledged-write durability and blocks mutation availability until synchronous redundancy is restored',
|
|
'domain COMMIT-response-loss faults are injected at the PostgresClient boundary, not by dropping raw PostgreSQL protocol packets',
|
|
]);
|
|
const REQUIRED_TIMELINE_STATES = Object.freeze([
|
|
'primary_ready',
|
|
'standby_streaming',
|
|
'synchronous_remote_apply_ready',
|
|
'replication_partition_and_promotion_guard_verified',
|
|
'old_primary_fenced_and_admission_withdrawn',
|
|
'standby_promoted_old_primary_rejoined_endpoint_switched',
|
|
'two_fresh_control_replicas_ready',
|
|
]);
|
|
const FORBIDDEN_MATERIAL = Object.freeze([
|
|
'postgresql://',
|
|
'ql3_migration_test',
|
|
'ql3_runtime_test',
|
|
'ql3_ai_maintenance_test',
|
|
'ql3_ai_credential_manager_test',
|
|
'ql3_ai_credential_tester_test',
|
|
'ql3_admin_test',
|
|
'ql3_automation_manager_test',
|
|
'ql3_approval_manager_test',
|
|
'ql3_package_manager_test',
|
|
'ql3_package_executor_test',
|
|
'ql3_worker_credential_manager_test',
|
|
'ql3_worker_credential_executor_test',
|
|
'ql3_worker_ingress_test',
|
|
'ql3w_',
|
|
]);
|
|
|
|
function isObject(value) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
(Object.getPrototypeOf(value) === Object.prototype ||
|
|
Object.getPrototypeOf(value) === null)
|
|
);
|
|
}
|
|
|
|
function reportPath(argv) {
|
|
const args = argv[0] === '--' ? argv.slice(1) : argv;
|
|
if (
|
|
args.length !== 1 ||
|
|
!args[0].startsWith('--report=') ||
|
|
!path.isAbsolute(args[0].slice('--report='.length))
|
|
) {
|
|
throw new Error(
|
|
'usage: ql3-postgres-ha-evidence-audit --report=/absolute/private-report.json',
|
|
);
|
|
}
|
|
const filePath = args[0].slice('--report='.length);
|
|
if (
|
|
path.normalize(filePath) !== filePath ||
|
|
path.parse(filePath).root === filePath
|
|
) {
|
|
throw new Error('PostgreSQL HA report path must be normalized');
|
|
}
|
|
return filePath;
|
|
}
|
|
|
|
function readPrivateReport(filePath) {
|
|
const stat = fs.lstatSync(filePath);
|
|
const uid = process.geteuid?.();
|
|
if (
|
|
!Number.isSafeInteger(uid) ||
|
|
!stat.isFile() ||
|
|
stat.isSymbolicLink() ||
|
|
fs.realpathSync(filePath) !== filePath ||
|
|
(stat.uid !== 0 && stat.uid !== uid) ||
|
|
(stat.mode & 0o777) !== 0o600 ||
|
|
stat.size < 2 ||
|
|
stat.size > 4 * 1024 * 1024
|
|
) {
|
|
throw new Error(
|
|
'PostgreSQL HA report must be a canonical private regular file',
|
|
);
|
|
}
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
function auditPostgresHaEvidence(report) {
|
|
const findings = [];
|
|
const add = (condition, code) => {
|
|
if (!condition) findings.push(code);
|
|
};
|
|
add(isObject(report), 'REPORT_SHAPE_INVALID');
|
|
if (!isObject(report)) return Object.freeze({ compatible: false, findings });
|
|
|
|
add(report.schemaVersion === 1, 'SCHEMA_VERSION_INVALID');
|
|
add(report.fixture === FIXTURE, 'FIXTURE_INVALID');
|
|
const postgres = report.postgres;
|
|
add(isObject(postgres), 'POSTGRES_EVIDENCE_MISSING');
|
|
if (isObject(postgres)) {
|
|
add(
|
|
typeof postgres.image === 'string' &&
|
|
/^postgres:18(?:[.@][A-Za-z0-9_:+./-]+)?$/.test(postgres.image),
|
|
'POSTGRES_IMAGE_INVALID',
|
|
);
|
|
add(
|
|
typeof postgres.imageId === 'string' &&
|
|
SHA256_PATTERN.test(postgres.imageId),
|
|
'POSTGRES_IMAGE_ID_INVALID',
|
|
);
|
|
add(
|
|
Array.isArray(postgres.repoDigests) && postgres.repoDigests.length > 0,
|
|
'POSTGRES_REPO_DIGESTS_MISSING',
|
|
);
|
|
add(
|
|
postgres.architecture === 'x64' || postgres.architecture === 'arm64',
|
|
'POSTGRES_ARCHITECTURE_INVALID',
|
|
);
|
|
add(
|
|
Number.isSafeInteger(postgres.versionNumber) &&
|
|
postgres.versionNumber >= 180000 &&
|
|
postgres.versionNumber < 190000,
|
|
'POSTGRES_VERSION_INVALID',
|
|
);
|
|
}
|
|
|
|
const replication = report.replication;
|
|
add(isObject(replication), 'REPLICATION_EVIDENCE_MISSING');
|
|
if (isObject(replication)) {
|
|
add(replication.mode === 'physical-streaming', 'REPLICATION_MODE_INVALID');
|
|
add(
|
|
replication.synchronousCommit === 'remote_apply',
|
|
'SYNCHRONOUS_COMMIT_INVALID',
|
|
);
|
|
add(
|
|
replication.synchronousStandbyBeforePartition?.state === 'streaming',
|
|
'SYNCHRONOUS_STANDBY_NOT_STREAMING',
|
|
);
|
|
add(
|
|
replication.synchronousStandbyBeforePartition?.syncState === 'sync',
|
|
'SYNCHRONOUS_STANDBY_NOT_SYNC',
|
|
);
|
|
add(
|
|
Number.isSafeInteger(replication.initialPrimaryTimeline) &&
|
|
replication.initialPrimaryTimeline >= 1,
|
|
'INITIAL_TIMELINE_INVALID',
|
|
);
|
|
add(
|
|
Number.isSafeInteger(replication.promotedPrimaryTimeline) &&
|
|
replication.promotedPrimaryTimeline >
|
|
replication.initialPrimaryTimeline,
|
|
'PROMOTION_TIMELINE_INVALID',
|
|
);
|
|
add(replication.oldPrimaryFenced === true, 'OLD_PRIMARY_NOT_FENCED');
|
|
add(replication.promotedWritable === true, 'PROMOTED_PRIMARY_NOT_WRITABLE');
|
|
}
|
|
|
|
const controls = report.controlReplicas;
|
|
add(isObject(controls), 'CONTROL_REPLICA_EVIDENCE_MISSING');
|
|
if (isObject(controls)) {
|
|
add(
|
|
Array.isArray(controls.beforePromotion) &&
|
|
controls.beforePromotion.length === 2,
|
|
'OLD_CONTROL_REPLICA_COUNT_INVALID',
|
|
);
|
|
add(
|
|
Array.isArray(controls.afterPromotion) &&
|
|
controls.afterPromotion.length === 2,
|
|
'FRESH_CONTROL_REPLICA_COUNT_INVALID',
|
|
);
|
|
add(
|
|
Array.isArray(controls.oldAvailabilityAfterFailure) &&
|
|
controls.oldAvailabilityAfterFailure.length === 2 &&
|
|
controls.oldAvailabilityAfterFailure.every(
|
|
(entry) => entry?.availability === 'unavailable',
|
|
),
|
|
'OLD_CONTROL_REPLICAS_NOT_FENCED',
|
|
);
|
|
add(
|
|
controls.oldActivationsRecoveredInPlace === false,
|
|
'OLD_ACTIVATION_RECOVERED_IN_PLACE',
|
|
);
|
|
add(controls.freshActivationsReady === 2, 'FRESH_ACTIVATIONS_NOT_READY');
|
|
}
|
|
|
|
const partition = report.networkPartition;
|
|
add(isObject(partition), 'PARTITION_EVIDENCE_MISSING');
|
|
if (isObject(partition)) {
|
|
add(
|
|
partition.promotionRejectedWhileOldPrimaryWritable === true,
|
|
'PROMOTION_GUARD_NOT_PROVEN',
|
|
);
|
|
add(
|
|
partition.commitClientObservedFailure === true,
|
|
'PARTITIONED_COMMIT_WAS_NOT_REJECTED',
|
|
);
|
|
add(partition.acknowledgedWriteLost === false, 'ACKNOWLEDGED_WRITE_LOSS');
|
|
add(
|
|
partition.replicatedToPromotionCandidate === 0 &&
|
|
partition.promotedPrimaryRows === 0,
|
|
'PARTITIONED_WRITE_REACHED_PROMOTED_PRIMARY',
|
|
);
|
|
add(
|
|
partition.unacknowledgedLocalCommitDiscarded === true,
|
|
'UNACKNOWLEDGED_WRITE_NOT_DISCARDED',
|
|
);
|
|
}
|
|
|
|
const rejoin = report.oldPrimaryRejoin;
|
|
add(isObject(rejoin), 'OLD_PRIMARY_REJOIN_EVIDENCE_MISSING');
|
|
if (isObject(rejoin)) {
|
|
add(
|
|
rejoin.method === 'pg_rewind --write-recovery-conf',
|
|
'REJOIN_METHOD_INVALID',
|
|
);
|
|
add(
|
|
rejoin.rewindExitStatus === 0 && rejoin.inRecovery === true,
|
|
'OLD_PRIMARY_NOT_REWOUND',
|
|
);
|
|
add(
|
|
rejoin.streaming === true && rejoin.synchronousState === 'sync',
|
|
'REJOIN_NOT_SYNCHRONOUS',
|
|
);
|
|
add(
|
|
rejoin.rejoinedAsWritablePrimary === false,
|
|
'REJOINED_PRIMARY_WRITABLE',
|
|
);
|
|
}
|
|
|
|
add(
|
|
report.durability?.unexpectedDomainSideEffects === 0,
|
|
'UNEXPECTED_DOMAIN_SIDE_EFFECTS',
|
|
);
|
|
add(
|
|
report.transactionWindows?.ambiguousCommit?.transparentReplayAllowed ===
|
|
false,
|
|
'AMBIGUOUS_COMMIT_TRANSPARENT_REPLAY_ALLOWED',
|
|
);
|
|
add(
|
|
report.transactionWindows?.ambiguousCommit?.durableRowsAfterPromotion === 1,
|
|
'AMBIGUOUS_COMMIT_NOT_DURABLE',
|
|
);
|
|
add(
|
|
report.transactionWindows?.writeBeforeCommit?.durableRowsAfterPromotion ===
|
|
0,
|
|
'UNCOMMITTED_WRITE_SURVIVED',
|
|
);
|
|
|
|
const gates = report.gates;
|
|
add(isObject(gates), 'GATES_MISSING');
|
|
if (isObject(gates)) {
|
|
const values = Object.values(gates);
|
|
add(values.length >= 100, 'GATE_SET_TOO_SMALL');
|
|
add(
|
|
values.length > 0 && values.every((value) => value === true),
|
|
'GATE_FAILED',
|
|
);
|
|
add(gates.passed === true, 'FINAL_GATE_FAILED');
|
|
}
|
|
|
|
add(
|
|
Array.isArray(report.limitations) &&
|
|
JSON.stringify(report.limitations) ===
|
|
JSON.stringify(EXPECTED_LIMITATIONS),
|
|
'LIMITATIONS_DRIFTED',
|
|
);
|
|
const timeline = Array.isArray(report.timeline) ? report.timeline : [];
|
|
add(timeline.length > 0, 'TIMELINE_MISSING');
|
|
let lastIndex = -1;
|
|
for (const state of REQUIRED_TIMELINE_STATES) {
|
|
const index = timeline.findIndex((entry) => entry?.state === state);
|
|
add(
|
|
index > lastIndex,
|
|
`TIMELINE_${state.toUpperCase()}_MISSING_OR_OUT_OF_ORDER`,
|
|
);
|
|
if (index >= 0) lastIndex = index;
|
|
}
|
|
|
|
const serialized = JSON.stringify(report);
|
|
for (const forbidden of FORBIDDEN_MATERIAL) {
|
|
add(!serialized.includes(forbidden), 'PRIVATE_MATERIAL_PRESENT');
|
|
}
|
|
return Object.freeze({
|
|
compatible: findings.length === 0,
|
|
findings: Object.freeze(findings),
|
|
});
|
|
}
|
|
|
|
function main(argv = process.argv.slice(2)) {
|
|
const filePath = reportPath(argv);
|
|
const result = auditPostgresHaEvidence(readPrivateReport(filePath));
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
if (!result.compatible) process.exitCode = 1;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
process.stderr.write(
|
|
`ql3 PostgreSQL HA evidence audit failed: ${error.stack || error}\n`,
|
|
);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
module.exports = { EXPECTED_LIMITATIONS, auditPostgresHaEvidence };
|