From 71f55377824c493f89dd51b799d5f8b84880c69e Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Thu, 17 Sep 2026 11:10:52 -0400 Subject: [PATCH] Add PostgreSQL deployment tooling and release validation --- .gitignore | 14 + checksums/SHA256SUMS | 33 ++ pacemaker/README.md | 24 + postgres-host/postgres-ha-bootstrap | 572 +++++++++++++++++++++++ postgres-host/postgres-ha-peer-config.sh | 54 +++ quadlet/postgres.container | 28 ++ quadlet/postgres.env.example | 25 + tests/run-all.sh | 146 ++++++ 8 files changed, 896 insertions(+) create mode 100644 checksums/SHA256SUMS create mode 100644 pacemaker/README.md create mode 100755 postgres-host/postgres-ha-bootstrap create mode 100755 postgres-host/postgres-ha-peer-config.sh create mode 100644 quadlet/postgres.container create mode 100644 quadlet/postgres.env.example create mode 100755 tests/run-all.sh diff --git a/.gitignore b/.gitignore index dcaf737..f900f94 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,17 @@ __pycache__/ # Development candidate files *.new + +# Node-local PostgreSQL configuration / secrets +quadlet/postgres.env +**/secrets/ +*.secret +*.key + +# Pacemaker live/runtime snapshots +pacemaker/cib.xml +pacemaker/current-cluster-state.txt +pacemaker/constraints.txt +pacemaker/pcs-config.txt +pacemaker/postgresql-ha.txt +pacemaker/postgresql-ha-clone.txt diff --git a/checksums/SHA256SUMS b/checksums/SHA256SUMS new file mode 100644 index 0000000..a06ab9a --- /dev/null +++ b/checksums/SHA256SUMS @@ -0,0 +1,33 @@ +0675ddf1cadf497393404864370519c861ba4619787b086cf369e5b0113d6a44 ocf/loxthelion/ipvs-sync +5aa02fa065d1dba55486838fa4f0cd8eba982edaaf73cdc7d818223a09cbadc6 ocf/loxthelion/managed-container +10932919c4339b553c7c9dbf30b45a917c51c0a08f362e12678003d58b12fa82 ocf/loxthelion/managed-container.d/applications/postgresql/auth.sh +61d64b4a3a15c354f101ef34c5dc0425723f4204207d359b5377d2b9f04f6b23 ocf/loxthelion/managed-container.d/applications/postgresql/demote.sh +38323b217204132e572a9b1cfec8072773e502e49d6f0f96dfa2e565d332bbd1 ocf/loxthelion/managed-container.d/applications/postgresql/native_peers.sh +625e3b4fc6775164d351c98d1ef0e2c54cb2042c8a86015aa68649cb9c339260 ocf/loxthelion/managed-container.d/applications/postgresql/notify.sh +957a5a72ed277d0216e070abb80733483be3d421b9fa488e27fdd75bb2919548 ocf/loxthelion/managed-container.d/applications/postgresql/peer_config.sh +5091bc554915d1450fe939f94c0b0a370fecdb2ae5db9c4f0f55c340c0145519 ocf/loxthelion/managed-container.d/applications/postgresql/plugin.sh +c1d878dabf22ad3928d66a5542bda3c150e5e9b039926629e7c5fd3401d29be4 ocf/loxthelion/managed-container.d/applications/postgresql/postgres.sh +3a6c92e8fe7d0df2573b19be22dd707b89b1eaa8147bdc07ef84dcded51aaa31 ocf/loxthelion/managed-container.d/applications/postgresql/postgres.sh.pre-score-rejoin-reconcile-20260917-103742 +d7547d66e4960a6aadfdd5cb1270431009453c8b769d0fa21f0b06d7fe7df686 ocf/loxthelion/managed-container.d/applications/postgresql/postgres.sh.pre-streaming-rejoin-marker-fix-20260917-103610 +f482853a0c7992981e59b4ad78d1ee69851d5d60fdec1f695673228017dd8244 ocf/loxthelion/managed-container.d/applications/postgresql/promote.sh +5166fc3370f49ab14cd975ded4b03a74b9bd7dbff5762b41a15552c42f934ea7 ocf/loxthelion/managed-container.d/applications/postgresql/promotion_state.py +b0ebad749ed736f08ec748a693f12e8dc2541430e0d3db75bbb6b2b7e517f84b ocf/loxthelion/managed-container.d/applications/postgresql/rejoin.sh +0d3017fdca92b2dc5dba406065bc269622ce5140e3fce4a959ca95ce4a78e755 ocf/loxthelion/managed-container.d/applications/postgresql/replication.sh +d7d2fd7ab651d1f2f026bdb11d62e2c0c6ce6ee3128c9814d09550cf4304b3f2 ocf/loxthelion/managed-container.d/common.sh +2c897c3a21d924a1a865494cb5c76a3405e2de7fee55e41ccd7bee2511726927 ocf/loxthelion/managed-container.d/common.sh.pre-named-proxy-rc-20260917-100736 +3a27ac9c63c61310da8b8711fa2c4400b37ea8eae2ee2a79e28bc63180b06c7c ocf/loxthelion/managed-container.d/container.sh +70431e0d390a3612339c7b5887286c1be4d6b305cbe8898e7895a211c26b6087 ocf/loxthelion/managed-container.d/engine.sh +6c92c095ce10ac95d843fdc1c49e31c19491ca22fdac960affd8649606d76d82 ocf/loxthelion/managed-container.d/lifecycles/systemd.sh +259e8cafa5b6d3fb54896ab37ff3917942c62c51eaa6e9bfa8ccf3afc6a7327b ocf/loxthelion/managed-container.d/proxy.sh +d5ca2aceddd5c8c5467e0997b099118279b9f54b96b8275e3a33182e1b992093 ocf/loxthelion/managed-container.d/proxy.sh.pre-named-proxy-rc-20260917-100736 +f82e2571d309bce8e4d7e6aee6b61b68feb7d99dc85bb8b688b6123c13ccf6a7 ocf/loxthelion/managed-container.d/runtimes/podman.sh +8679baffebc77614fd80888d344ca55e11a13f84de6f096a2399c22a49c8cc6a ocf/loxthelion/managed-container.pre-entrypoint-selftest-20260917-105341 +01eb9b161c8e3db289f09bf63953be779921e15bdf8b1a4d8ce7e60032b826e4 ocf/loxthelion/managed-container.pre-named-proxy-rc-20260917-100736 +62ec256e108f40f91d626ea71e8945862aedb347e576a00e6bd1e15f9f363e70 ocf/loxthelion/managed-container.pre-proxy-load-fix-20260917-101231 +4a99efea3d4a185e0630db89cfacc28aa8777bad9c747794ae69f70d87cd0327 pacemaker/README.md +a42bb59a058ffeb59c1baa96e403856910bbbaea98f9104ff93daf4b5970c780 postgres-host/postgres-ha-bootstrap +957a5a72ed277d0216e070abb80733483be3d421b9fa488e27fdd75bb2919548 postgres-host/postgres-ha-peer-config.sh +deb09e3ae40629f43b262ba4f9a7ebf59a1dca3da9d8c94478f670aea28bd0e1 quadlet/postgres.container +04a125068677bbee6e71d313ca9d95d35faa32851b79de781cd7b9f466561445 quadlet/postgres.env.example +761e4d5166e26507f34b3276258bd012ddbd7c667020a9797542e08b8976891a tests/run-all.sh +5e5d4fdd2b6c7e0b8b146de7aad06c1f96b221a10fec525b00d40e0d85709fb0 tests/test-entrypoint-structure.sh diff --git a/pacemaker/README.md b/pacemaker/README.md new file mode 100644 index 0000000..a8af8cb --- /dev/null +++ b/pacemaker/README.md @@ -0,0 +1,24 @@ +# Pacemaker integration + +The files generated by `pcs config`, `crm_mon`, and `cibadmin --query` +are intentionally not tracked here. + +Those exports contain point-in-time cluster state, transient node +attributes, operation history, and temporary constraints, so they are +diagnostic snapshots rather than canonical configuration. + +The `managed-container` OCF resource agent itself is under: + + ocf/loxthelion/ + +Current PostgreSQL production design: + +- promotable clone +- clone-max=3 +- clone-node-max=1 +- promoted-max=1 +- promoted-node-max=1 +- notify=true +- target-role=Promoted +- PostgreSQL VIP colocated with the Promoted instance +- promotion score controlled by the resource agent diff --git a/postgres-host/postgres-ha-bootstrap b/postgres-host/postgres-ha-bootstrap new file mode 100755 index 0000000..0ac1d21 --- /dev/null +++ b/postgres-host/postgres-ha-bootstrap @@ -0,0 +1,572 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +log() { + printf '[postgres-ha-bootstrap] %s\n' "$*" >&2 +} + +die() { + log "ERROR: $*" + exit 1 +} + +require_var() { + local name="$1" + [[ -n "${!name:-}" ]] || die "Required environment variable $name is not set" +} + +require_file() { + local path="$1" + [[ -r "$path" ]] || die "Required file is not readable: $path" +} + +as_postgres() { + runuser -u postgres -- "$@" +} + +# PostgreSQL replication slot names are intentionally restricted here +# so values can safely be used in SQL and configuration. +validate_slot_name() { + [[ "$1" =~ ^[a-z0-9_]+$ ]] || + die "Invalid replication slot name: $1" +} + +install_secret_for_postgres() { + local source="$1" + local destination="$2" + + require_file "$source" + + install \ + -o postgres \ + -g postgres \ + -m 0600 \ + "$source" \ + "$destination" +} + +pgdata_is_initialized() { + [[ -s "$PGDATA/PG_VERSION" ]] && + [[ -s "$PGDATA/global/pg_control" ]] +} + +pgdata_is_empty() { + [[ ! -d "$PGDATA" ]] && return 0 + + local first + first="$(find "$PGDATA" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null || true)" + [[ -z "$first" ]] +} + +marker_path() { + printf '%s/%s\n' \ + "$PGDATA" \ + "${POSTGRES_BOOTSTRAP_MARKER:-.loxthelion-ha-initialized}" +} + +write_managed_config() { + local config="$PGDATA/postgres-ha.conf" + + cat > "$config" <> "$PGDATA/postgresql.conf" <<'EOF_CONFIG' + +# Loxthelion PostgreSQL HA configuration +include_if_exists = 'postgres-ha.conf' +EOF_CONFIG + fi +} + +write_managed_hba() { + local hba="$PGDATA/pg_hba.conf" + local tmp + + tmp="$(mktemp)" + + # Remove a previous block generated by this script. + awk ' + /^# BEGIN LOXTHELION POSTGRES HA$/ { skip=1; next } + /^# END LOXTHELION POSTGRES HA$/ { skip=0; next } + !skip { print } + ' "$hba" > "$tmp" + + cat >> "$tmp" < "$hba" + rm -f "$tmp" + + chown postgres:postgres "$hba" + chmod 0600 "$hba" +} + +create_persistent_pgpass() { + require_var POSTGRES_PRIMARY_HOST + require_var POSTGRES_PRIMARY_PORT + require_var POSTGRES_REPLICATION_USER + require_var POSTGRES_REPLICATION_PASSWORD_FILE + + require_file "$POSTGRES_REPLICATION_PASSWORD_FILE" + + local password + password="$(cat "$POSTGRES_REPLICATION_PASSWORD_FILE")" + + mkdir -p /var/lib/postgresql + chown postgres:postgres /var/lib/postgresql + + if [[ -n "${POSTGRES_REPLICATION_PEERS:-}" ]]; then + local endpoints hosts ports host port + endpoints=$(mc_peer_endpoints "$POSTGRES_REPLICATION_PEERS" "$POSTGRES_NODE_NAME") || die "Invalid replication peer map" + hosts=${endpoints%|*}; ports=${endpoints#*|} + [[ "$password" != *$'\n'* && "$password" != *$'\r'* ]] || die "Invalid multiline replication password" + password=${password//\\/\\\\}; password=${password//:/\\:} + (umask 077; : > /var/lib/postgresql/.pgpass) + while [[ -n "$hosts" ]]; do + host=${hosts%%,*}; port=${ports%%,*} + printf '%s:%s:*:%s:%s\n' "$host" "$port" "$POSTGRES_REPLICATION_USER" "$password" >> /var/lib/postgresql/.pgpass + [[ "$hosts" == *,* ]] || break + hosts=${hosts#*,}; ports=${ports#*,} + done + else + printf '%s:%s:*:%s:%s\n' \ + "$POSTGRES_PRIMARY_HOST" \ + "$POSTGRES_PRIMARY_PORT" \ + "$POSTGRES_REPLICATION_USER" \ + "$password" \ + > /var/lib/postgresql/.pgpass + + fi + + chown postgres:postgres /var/lib/postgresql/.pgpass + chmod 0600 /var/lib/postgresql/.pgpass +} + +grant_rewind_privileges() { + require_var POSTGRES_USER + require_var POSTGRES_REPLICATION_USER + + log "Ensuring pg_rewind privileges for $POSTGRES_REPLICATION_USER" + + as_postgres psql \ + --username="$POSTGRES_USER" \ + --dbname=postgres \ + --set=ON_ERROR_STOP=1 \ + --set=repl_user="$POSTGRES_REPLICATION_USER" <<'SQL' +GRANT EXECUTE +ON FUNCTION pg_catalog.pg_ls_dir(text, boolean, boolean) +TO :"repl_user"; + +GRANT EXECUTE +ON FUNCTION pg_catalog.pg_stat_file(text, boolean) +TO :"repl_user"; + +GRANT EXECUTE +ON FUNCTION pg_catalog.pg_read_binary_file(text) +TO :"repl_user"; + +GRANT EXECUTE +ON FUNCTION pg_catalog.pg_read_binary_file(text, bigint, bigint, boolean) +TO :"repl_user"; +SQL +} + + +ensure_local_ha_slots() { + [[ -n "${POSTGRES_HA_SLOTS:-}" ]] || { + log "POSTGRES_HA_SLOTS is empty; no local HA slots requested" + return 0 + } + + local raw_slot slot slot_type + + while IFS= read -r raw_slot; do + slot="${raw_slot//[[:space:]]/}" + + [[ -n "$slot" ]] || continue + + validate_slot_name "$slot" + + slot_type="$( + as_postgres psql \ + --username="$POSTGRES_USER" \ + --dbname=postgres \ + --tuples-only \ + --no-align \ + --set=ON_ERROR_STOP=1 \ + --set=slot="$slot" <<'SQL' +SELECT slot_type +FROM pg_replication_slots +WHERE slot_name = :'slot'; +SQL + )" + + slot_type="${slot_type//[[:space:]]/}" + + case "$slot_type" in + physical) + log "Physical HA slot already exists: $slot" + ;; + + "") + log "Creating physical HA slot: $slot" + + as_postgres psql \ + --username="$POSTGRES_USER" \ + --dbname=postgres \ + --set=ON_ERROR_STOP=1 \ + --set=slot="$slot" <<'SQL' >/dev/null +SELECT pg_create_physical_replication_slot(:'slot'); +SQL + ;; + + *) + die "HA slot $slot exists but is type $slot_type" + ;; + esac + done < <( + printf '%s\n' "$POSTGRES_HA_SLOTS" | + tr ',' '\n' + ) +} + + +bootstrap_primary() { + require_var POSTGRES_DB + require_var POSTGRES_USER + require_var POSTGRES_REPLICATION_USER + require_var POSTGRES_HA_CIDR + require_var POSTGRES_PASSWORD_FILE + require_var POSTGRES_REPLICATION_PASSWORD_FILE + + require_file "$POSTGRES_PASSWORD_FILE" + require_file "$POSTGRES_REPLICATION_PASSWORD_FILE" + + pgdata_is_empty || + die "primary bootstrap requires an empty PGDATA" + + log "Initializing fresh PostgreSQL primary" + + mkdir -p "$PGDATA" + chown -R postgres:postgres /var/lib/postgresql + chmod 0700 "$PGDATA" + + local pwfile + pwfile="/tmp/postgres-bootstrap-password" + + install_secret_for_postgres \ + "$POSTGRES_PASSWORD_FILE" \ + "$pwfile" + + as_postgres initdb \ + -D "$PGDATA" \ + --username="$POSTGRES_USER" \ + --pwfile="$pwfile" \ + --auth-host=scram-sha-256 \ + --auth-local=trust \ + --data-checksums + + rm -f "$pwfile" + + write_managed_config + write_managed_hba + + log "Starting PostgreSQL temporarily for initial role/database setup" + + as_postgres pg_ctl \ + -D "$PGDATA" \ + -w \ + start + + trap 'as_postgres pg_ctl -D "$PGDATA" -m fast -w stop >/dev/null 2>&1 || true' RETURN + + local repl_password + repl_password="$(cat "$POSTGRES_REPLICATION_PASSWORD_FILE")" + + # psql variables keep the password out of SQL construction itself. + as_postgres psql \ + --username="$POSTGRES_USER" \ + --dbname=postgres \ + -v ON_ERROR_STOP=1 \ + --set=repl_user="$POSTGRES_REPLICATION_USER" \ + --set=repl_password="$repl_password" <<'SQL' +SELECT format( + 'CREATE ROLE %I WITH LOGIN REPLICATION PASSWORD %L', + :'repl_user', + :'repl_password' +) +WHERE NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = :'repl_user' +) +\gexec +SQL + + grant_rewind_privileges + ensure_local_ha_slots + + if ! as_postgres psql \ + --username="$POSTGRES_USER" \ + --dbname=postgres \ + --tuples-only \ + --no-align \ + --set=ON_ERROR_STOP=1 \ + --set=db="$POSTGRES_DB" <<'SQL' | grep -qx 1 +SELECT 1 +FROM pg_database +WHERE datname = :'db'; +SQL + then + as_postgres createdb \ + --username="$POSTGRES_USER" \ + --owner="$POSTGRES_USER" \ + "$POSTGRES_DB" + fi + + as_postgres pg_ctl \ + -D "$PGDATA" \ + -m fast \ + -w \ + stop + + trap - RETURN + + touch "$(marker_path)" + chown postgres:postgres "$(marker_path)" + chmod 0600 "$(marker_path)" + + log "Fresh primary bootstrap complete" +} + +ensure_remote_replication_slot() { + validate_slot_name "$POSTGRES_REPLICATION_SLOT" + + local slot_type + + slot_type="$( + as_postgres env \ + PGPASSFILE=/var/lib/postgresql/.pgpass \ + psql \ + --host="$POSTGRES_PRIMARY_HOST" \ + --port="$POSTGRES_PRIMARY_PORT" \ + --username="$POSTGRES_REPLICATION_USER" \ + --dbname=postgres \ + --tuples-only \ + --no-align \ + --set=ON_ERROR_STOP=1 \ + --command=" + SELECT slot_type + FROM pg_replication_slots + WHERE slot_name = '$POSTGRES_REPLICATION_SLOT'; + " | + tr -d '[:space:]' + )" + + case "$slot_type" in + physical) + log "Replication slot already exists: $POSTGRES_REPLICATION_SLOT" + ;; + + "") + log "Creating physical replication slot: $POSTGRES_REPLICATION_SLOT" + + as_postgres env \ + PGPASSFILE=/var/lib/postgresql/.pgpass \ + psql \ + --host="$POSTGRES_PRIMARY_HOST" \ + --port="$POSTGRES_PRIMARY_PORT" \ + --username="$POSTGRES_REPLICATION_USER" \ + --dbname=postgres \ + --set=ON_ERROR_STOP=1 \ + --command=" + SELECT pg_create_physical_replication_slot( + '$POSTGRES_REPLICATION_SLOT' + ); + " + ;; + + *) + die "Slot $POSTGRES_REPLICATION_SLOT exists but is not physical" + ;; + esac +} + +bootstrap_standby() { + require_var POSTGRES_PRIMARY_HOST + require_var POSTGRES_PRIMARY_PORT + require_var POSTGRES_REPLICATION_USER + require_var POSTGRES_REPLICATION_SLOT + require_var POSTGRES_APPLICATION_NAME + require_var POSTGRES_REPLICATION_PASSWORD_FILE + + pgdata_is_empty || + die "standby bootstrap requires an empty PGDATA" + + validate_slot_name "$POSTGRES_REPLICATION_SLOT" + + log "Preparing fresh standby from $POSTGRES_PRIMARY_HOST" + + mkdir -p "$PGDATA" + chmod 0700 "$PGDATA" + chown -R postgres:postgres /var/lib/postgresql + + create_persistent_pgpass + ensure_remote_replication_slot + + log "Running pg_basebackup" + + as_postgres env \ + PGPASSFILE=/var/lib/postgresql/.pgpass \ + pg_basebackup \ + --dbname="host=$POSTGRES_PRIMARY_HOST port=$POSTGRES_PRIMARY_PORT user=$POSTGRES_REPLICATION_USER application_name=$POSTGRES_APPLICATION_NAME passfile=/var/lib/postgresql/.pgpass" \ + --pgdata="$PGDATA" \ + --format=plain \ + --wal-method=stream \ + --slot="$POSTGRES_REPLICATION_SLOT" \ + --write-recovery-conf \ + --progress + + [[ -e "$PGDATA/standby.signal" ]] || + die "pg_basebackup completed without standby.signal" + + pgdata_is_initialized || + die "pg_basebackup did not produce a valid PostgreSQL data directory" + + touch "$(marker_path)" + chown postgres:postgres "$(marker_path)" + chmod 0600 "$(marker_path)" + + log "Fresh standby bootstrap complete" +} + +adopt_existing() { + pgdata_is_initialized || + die "POSTGRES_HA_MODE=existing requires a valid existing PostgreSQL cluster" + + log "Existing PostgreSQL cluster detected" + + write_managed_config + + # HBA management requires these only when supplied. + if [[ -n "${POSTGRES_REPLICATION_USER:-}" && + -n "${POSTGRES_HA_CIDR:-}" ]]; then + write_managed_hba + fi + + touch "$(marker_path)" + chown postgres:postgres "$(marker_path)" + chmod 0600 "$(marker_path)" + + log "Existing cluster adopted; no initdb or basebackup performed" +} + +validate_bootstrapped_cluster() { + pgdata_is_initialized || + die "Bootstrap marker exists but PGDATA is not a valid PostgreSQL cluster" + + local version + version="$(cat "$PGDATA/PG_VERSION")" + + [[ "$version" == "18" ]] || + die "Expected PostgreSQL major version 18, found $version" + + as_postgres pg_controldata "$PGDATA" >/dev/null || + die "pg_controldata validation failed" + + local pgdata_mode + pgdata_mode="$(stat -c '%a' "$PGDATA")" + + [[ "$pgdata_mode" == "700" || "$pgdata_mode" == "750" ]] || + die "PGDATA has invalid permissions: $pgdata_mode (expected 700 or 750)" + + [[ "$(stat -c '%U' "$PGDATA")" == "postgres" ]] || + die "PGDATA is not owned by postgres" + + log "Existing bootstrapped PostgreSQL 18 cluster validated" +} + +main() { + require_var PGDATA + if [[ -n "${POSTGRES_REPLICATION_PEERS:-}" ]]; then + require_var POSTGRES_NODE_NAME + require_var POSTGRES_APPLICATION_NAME + require_var POSTGRES_REPLICATION_USER + require_var POSTGRES_REPLICATION_SLOT + require_file /usr/local/libexec/postgres-ha-peer-config.sh + . /usr/local/libexec/postgres-ha-peer-config.sh + mc_peer_conninfo "$POSTGRES_REPLICATION_PEERS" "$POSTGRES_NODE_NAME" "$POSTGRES_REPLICATION_USER" "$POSTGRES_APPLICATION_NAME" >/dev/null || die "Invalid native peer configuration" + validate_slot_name "$POSTGRES_REPLICATION_SLOT" + # Basebackup slot creation still uses one explicitly selected stable source. + if [[ "${POSTGRES_HA_MODE:-}" == standby && ! -e "$(marker_path)" ]]; then + [[ ",$POSTGRES_REPLICATION_PEERS," == *"=$POSTGRES_PRIMARY_HOST:$POSTGRES_PRIMARY_PORT,"* ]] || die "Bootstrap source must be a mapped stable peer" + fi + fi + + local marker + marker="$(marker_path)" + + log "Node: ${POSTGRES_NODE_NAME:-unknown}" + log "Mode: ${POSTGRES_HA_MODE:-unset}" + log "PGDATA: $PGDATA" + + if [[ -e "$marker" ]]; then + validate_bootstrapped_cluster + else + case "${POSTGRES_HA_MODE:-}" in + primary) + bootstrap_primary + ;; + + standby) + bootstrap_standby + ;; + + existing) + adopt_existing + ;; + + *) + die "POSTGRES_HA_MODE must be primary, standby, or existing" + ;; + esac + fi + + [[ "$#" -gt 0 ]] || + die "No long-running container command supplied" + + log "Bootstrap gate passed; executing: $*" + if [[ -n "${POSTGRES_REPLICATION_PEERS:-}" ]]; then + create_persistent_pgpass + if [[ -e "$PGDATA/standby.signal" ]]; then + [[ ! -e "$PGDATA/postmaster.pid" ]] || die "Refusing native peer rewrite with a postmaster PID file" + mc_peer_write_recovery "$PGDATA" "$POSTGRES_REPLICATION_PEERS" "$POSTGRES_NODE_NAME" "$POSTGRES_REPLICATION_USER" "$POSTGRES_APPLICATION_NAME" "$POSTGRES_REPLICATION_SLOT" || die "Native peer recovery configuration failed" + chown postgres:postgres "$PGDATA/postgresql.auto.conf" "$PGDATA/standby.signal" + fi + fi + + exec "$@" +} + +main "$@" diff --git a/postgres-host/postgres-ha-peer-config.sh b/postgres-host/postgres-ha-peer-config.sh new file mode 100755 index 0000000..165e326 --- /dev/null +++ b/postgres-host/postgres-ha-peer-config.sh @@ -0,0 +1,54 @@ +# POSIX-compatible shared normalizer. Also mounted beside the bootstrap script. +# Inputs are data, never evaluated shell. IPv4/DNS endpoints only in API 1. +mc_peer_endpoints() +{ + LC_ALL=C awk -v peers="$1" -v self="$2" -v select_node="${3:-}" ' + BEGIN { + n=split(peers, entries, ","); self=tolower(self); select_node=tolower(select_node); + if(n<2 || n>32 || self=="") exit 1; + for(i=1;i<=n;i++) { + if(split(entries[i],pair,"=")!=2 || pair[1]!~/^[A-Za-z0-9_][A-Za-z0-9_.-]*$/) exit 1; + node=tolower(pair[1]); if(seen[node]++) exit 1; + if(split(pair[2],addr,":")!=2 || addr[1]!~/^[A-Za-z0-9][A-Za-z0-9.-]*$/ || addr[2]!~/^[0-9]+$/ || addr[2]+0<1 || addr[2]+0>65535) exit 1; + endpoint=tolower(addr[1]) ":" (addr[2]+0); if(addresses[endpoint]++) exit 1; + if(node==self) { own++; continue; } + if(select_node!="" && node!=select_node) continue; + hosts=hosts (count?",":"") addr[1]; ports=ports (count?",":"") (addr[2]+0); count++; + } + if(own!=1 || count<1 || (select_node!="" && count!=1)) exit 1; + print hosts "|" ports; + }' +} + +mc_peer_conninfo() +{ + # peers, self, replication user, application name + case "$3" in ''|*[!a-zA-Z0-9_.-]*) return 1;; esac + case "$4" in ''|*[!a-zA-Z0-9_.-]*) return 1;; esac + mc_peer_pair=$(mc_peer_endpoints "$1" "$2") || return 1 + printf 'host=%s port=%s user=%s application_name=%s target_session_attrs=primary connect_timeout=2\n' \ + "${mc_peer_pair%|*}" "${mc_peer_pair#*|}" "$3" "$4" +} + +mc_peer_write_recovery() +( + # PGDATA, peers, self, user, application, slot. No credentials in output. + set -eu + data=$1 + conn=$(mc_peer_conninfo "$2" "$3" "$4" "$5") || exit 1 + case "$6" in ''|*[!a-z0-9_]*) exit 1;; esac + [ ${#6} -le 63 ] || exit 1 + [ -d "$data" ] && [ -f "$data/postgresql.auto.conf" ] && [ ! -L "$data/postgresql.auto.conf" ] || exit 1 + [ ! -L "$data/standby.signal" ] || exit 1 + umask 077 + tmp=$(mktemp "$data/.peer-recovery.XXXXXXXX") + trap 'rm -f "$tmp"' EXIT HUP INT TERM + sed -e '/^[[:space:]]*primary_conninfo[[:space:]]*=/d' \ + -e '/^[[:space:]]*primary_slot_name[[:space:]]*=/d' "$data/postgresql.auto.conf" > "$tmp" + printf "primary_conninfo = '%s'\nprimary_slot_name = '%s'\n" "$conn" "$6" >> "$tmp" + chmod 600 "$tmp" + mv -f "$tmp" "$data/postgresql.auto.conf" + [ ! -L "$data/standby.signal" ] || exit 1 + touch "$data/standby.signal" + chmod 600 "$data/standby.signal" +) diff --git a/quadlet/postgres.container b/quadlet/postgres.container new file mode 100644 index 0000000..452d191 --- /dev/null +++ b/quadlet/postgres.container @@ -0,0 +1,28 @@ +[Unit] +Description=PostgreSQL HA Maintenance Container + +[Container] +Volume=/home/david/.local/libexec/postgres-ha-peer-config.sh:/usr/local/libexec/postgres-ha-peer-config.sh:ro,Z +ContainerName=postgres +Image=docker.io/library/postgres@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 + +Entrypoint=/usr/local/sbin/postgres-ha-bootstrap +Exec=sleep infinity + +RunInit=true +StopSignal=SIGTERM +StopTimeout=5 + +Network=postgres.network +PublishPort=5432:5432 + +Volume=postgres-data:/var/lib/postgresql:Z +Volume=/home/david/.local/libexec/postgres-ha-bootstrap:/usr/local/sbin/postgres-ha-bootstrap:ro,Z +Volume=/home/david/.config/postgres-ha/secrets:/run/secrets/postgres-ha:ro,Z + +EnvironmentFile=/home/david/.config/postgres-ha/postgres.env + +[Service] +SuccessExitStatus=143 +Restart=on-abnormal +TimeoutStartSec=0 diff --git a/quadlet/postgres.env.example b/quadlet/postgres.env.example new file mode 100644 index 0000000..da7dcba --- /dev/null +++ b/quadlet/postgres.env.example @@ -0,0 +1,25 @@ +PGDATA=/var/lib/postgresql/18/docker + +# Initial bootstrap role. Pacemaker/RA controls promotion afterward. +POSTGRES_HA_MODE=standby + +POSTGRES_REPLICATION_USER=replicator + +# Node-specific values: +POSTGRES_NODE_NAME=CHANGE_ME +POSTGRES_APPLICATION_NAME=CHANGE_ME +POSTGRES_REPLICATION_SLOT=ha_pg_CHANGE_ME + +# Stable cluster peer map: +POSTGRES_REPLICATION_PEERS=david-prodesk=192.168.1.4:5432,david-macbookpro=192.168.1.5:5432,david-eve=192.168.1.3:5432 + +# Initial/bootstrap upstream. The RA later determines the elected primary. +POSTGRES_PRIMARY_HOST=192.168.1.4 +POSTGRES_PRIMARY_PORT=5432 + +POSTGRES_HA_SLOTS=ha_pg_david_macbookpro,ha_pg_david_eve,ha_pg_david_prodesk + +# Secret contents are never stored here. +POSTGRES_REPLICATION_PASSWORD_FILE=/run/secrets/postgres-ha/replication_password + +POSTGRES_HA_REPLICATION_NETWORKS=192.168.1.3/32,192.168.1.4/32,192.168.1.5/32,10.89.0.0/16 diff --git a/tests/run-all.sh b/tests/run-all.sh new file mode 100755 index 0000000..d77a363 --- /dev/null +++ b/tests/run-all.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$( + cd "$(dirname "${BASH_SOURCE[0]}")/.." && + pwd +)" + +cd "$ROOT" + +echo "========================================" +echo " managed-container release test suite" +echo "========================================" + +echo +echo ">>> Entrypoint / shell structure" +./tests/test-entrypoint-structure.sh + +echo +echo ">>> Python syntax" + +while IFS= read -r -d '' file; do + python3 -m py_compile "$file" +done < <( + find ocf \ + -type f \ + -name '*.py' \ + -print0 +) + +echo "PYTHON_SYNTAX_OK=1" + +echo +echo ">>> Host helper shell syntax" + +for file in postgres-host/*; do + [[ -f "$file" ]] || continue + + case "$file" in + *.sh|*/postgres-ha-bootstrap) + bash -n "$file" + ;; + esac +done + +echo "HOST_HELPER_SYNTAX_OK=1" + +echo +echo ">>> Secret-content guard" + +python3 - <<'PY_SECRET_GUARD' +from pathlib import Path +import re +import sys + +roots = [Path("quadlet"), Path("postgres-host")] + +credential_key = re.compile( + r'^[A-Z0-9_]*(?:PASSWORD|PASSWD|SECRET|TOKEN)[A-Z0-9_]*$' +) + +private_key = re.compile( + r'-----BEGIN (?:OPENSSH|RSA|EC|DSA) PRIVATE KEY-----' +) + +errors = [] + +for root in roots: + if not root.exists(): + continue + + for path in root.rglob("*"): + if not path.is_file(): + continue + + try: + text = path.read_text(errors="replace") + except Exception as exc: + errors.append(f"{path}: unable to read: {exc}") + continue + + if private_key.search(text): + errors.append(f"{path}: contains private-key material") + + for lineno, raw in enumerate(text.splitlines(), 1): + line = raw.strip() + + if not line or line.startswith("#"): + continue + + # Only inspect configuration-style assignments whose KEY itself + # is uppercase and explicitly credential-related. + m = re.match( + r'^([A-Z][A-Z0-9_]*)\s*=\s*(.*)$', + line + ) + + if not m: + continue + + key, value = m.groups() + + if not credential_key.match(key): + continue + + value = value.strip() + + # Empty values are not embedded credentials. + if not value: + continue + + # References, secret-file paths, templates and placeholders + # are acceptable. + safe_prefixes = ( + "$", + "${", + "/run/secrets/", + "/etc/", + "<", + "CHANGE_ME", + ) + + unquoted = value.strip("'\"") + + if value.startswith(safe_prefixes) or unquoted.startswith(safe_prefixes): + continue + + errors.append( + f"{path}:{lineno}: possible literal credential assignment: {key}=..." + ) + +if errors: + for error in errors: + print(error, file=sys.stderr) + + print( + "ERROR: possible embedded credential material found", + file=sys.stderr + ) + sys.exit(1) + +print("SECRET_GUARD_OK=1") +PY_SECRET_GUARD + +echo +echo "ALL_TESTS_PASS=1"