Add PostgreSQL HA OCF agent modular baseline
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
===== HOST =====
|
||||
DAVID-ELITEDESK
|
||||
|
||||
===== CONTAINER =====
|
||||
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
|
||||
8fe2467e2f68 docker.io/library/postgres@sha256:4ef4dbc939d61acea57712655ddb4b4ab27419c913f94cca0cd57cb3ea3c2280 sleep infinity About an hour ago Up About an hour 5432/tcp postgres
|
||||
|
||||
===== POSTGRES ROLE =====
|
||||
t
|
||||
|
||||
===== WAL RECEIVER =====
|
||||
streaming|192.168.1.2|5432|ha_david_elitedesk|0/A000130|0/A000000|0/A000130
|
||||
|
||||
===== PGDATA =====
|
||||
PGDATA=/var/lib/postgresql/18/docker
|
||||
device=49 inode=20681933 owner=postgres:postgres mode=700 path=/var/lib/postgresql/18/docker
|
||||
Executable
+533
@@ -0,0 +1,533 @@
|
||||
#!/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" <<EOF_CONFIG
|
||||
# Managed by postgres-ha-bootstrap.
|
||||
# Do not place node-role-specific settings in this file.
|
||||
|
||||
listen_addresses = '*'
|
||||
wal_level = '${POSTGRES_WAL_LEVEL:-replica}'
|
||||
max_wal_senders = ${POSTGRES_MAX_WAL_SENDERS:-10}
|
||||
max_replication_slots = ${POSTGRES_MAX_REPLICATION_SLOTS:-10}
|
||||
hot_standby = ${POSTGRES_HOT_STANDBY:-on}
|
||||
password_encryption = 'scram-sha-256'
|
||||
EOF_CONFIG
|
||||
|
||||
chown postgres:postgres "$config"
|
||||
chmod 0600 "$config"
|
||||
|
||||
if ! grep -Eq \
|
||||
"^[[:space:]]*include_if_exists[[:space:]]*=[[:space:]]*'postgres-ha\.conf'" \
|
||||
"$PGDATA/postgresql.conf"; then
|
||||
|
||||
cat >> "$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" <<EOF_HBA
|
||||
|
||||
# BEGIN LOXTHELION POSTGRES HA
|
||||
host replication ${POSTGRES_REPLICATION_USER} ${POSTGRES_HA_CIDR} scram-sha-256
|
||||
host postgres ${POSTGRES_REPLICATION_USER} ${POSTGRES_HA_CIDR} scram-sha-256
|
||||
# END LOXTHELION POSTGRES HA
|
||||
EOF_HBA
|
||||
|
||||
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
|
||||
|
||||
printf '%s:%s:*:%s:%s\n' \
|
||||
"$POSTGRES_PRIMARY_HOST" \
|
||||
"$POSTGRES_PRIMARY_PORT" \
|
||||
"$POSTGRES_REPLICATION_USER" \
|
||||
"$password" \
|
||||
> /var/lib/postgresql/.pgpass
|
||||
|
||||
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
|
||||
|
||||
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: $*"
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+441
@@ -0,0 +1,441 @@
|
||||
#!/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" <<EOF_CONFIG
|
||||
# Managed by postgres-ha-bootstrap.
|
||||
# Do not place node-role-specific settings in this file.
|
||||
|
||||
listen_addresses = '*'
|
||||
wal_level = '${POSTGRES_WAL_LEVEL:-replica}'
|
||||
max_wal_senders = ${POSTGRES_MAX_WAL_SENDERS:-10}
|
||||
max_replication_slots = ${POSTGRES_MAX_REPLICATION_SLOTS:-10}
|
||||
hot_standby = ${POSTGRES_HOT_STANDBY:-on}
|
||||
password_encryption = 'scram-sha-256'
|
||||
EOF_CONFIG
|
||||
|
||||
chown postgres:postgres "$config"
|
||||
chmod 0600 "$config"
|
||||
|
||||
if ! grep -Eq \
|
||||
"^[[:space:]]*include_if_exists[[:space:]]*=[[:space:]]*'postgres-ha\.conf'" \
|
||||
"$PGDATA/postgresql.conf"; then
|
||||
|
||||
cat >> "$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" <<EOF_HBA
|
||||
|
||||
# BEGIN LOXTHELION POSTGRES HA
|
||||
host replication ${POSTGRES_REPLICATION_USER} ${POSTGRES_HA_CIDR} scram-sha-256
|
||||
host postgres ${POSTGRES_REPLICATION_USER} ${POSTGRES_HA_CIDR} scram-sha-256
|
||||
# END LOXTHELION POSTGRES HA
|
||||
EOF_HBA
|
||||
|
||||
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
|
||||
|
||||
printf '%s:%s:*:%s:%s\n' \
|
||||
"$POSTGRES_PRIMARY_HOST" \
|
||||
"$POSTGRES_PRIMARY_PORT" \
|
||||
"$POSTGRES_REPLICATION_USER" \
|
||||
"$password" \
|
||||
> /var/lib/postgresql/.pgpass
|
||||
|
||||
chown postgres:postgres /var/lib/postgresql/.pgpass
|
||||
chmod 0600 /var/lib/postgresql/.pgpass
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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: $*"
|
||||
exec "$@"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,26 @@
|
||||
[Unit]
|
||||
Description=PostgreSQL HA Maintenance Container
|
||||
|
||||
[Container]
|
||||
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=host
|
||||
|
||||
Volume=postgres-bootstrap-test:/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
|
||||
@@ -0,0 +1,44 @@
|
||||
# PostgreSQL data
|
||||
PGDATA=/var/lib/postgresql/18/docker
|
||||
|
||||
# Bootstrap behavior:
|
||||
# primary = initialize a new primary
|
||||
# standby = clone a new standby
|
||||
# existing = adopt an already-existing PostgreSQL data directory
|
||||
POSTGRES_HA_MODE=primary
|
||||
|
||||
POSTGRES_NODE_NAME=DAVID-ELITEDESK
|
||||
|
||||
# Initial database/admin account
|
||||
POSTGRES_DB=david
|
||||
POSTGRES_USER=david
|
||||
|
||||
# Replication
|
||||
POSTGRES_REPLICATION_USER=replicator
|
||||
POSTGRES_REPLICATION_SLOT=ha_david_elitedesk
|
||||
POSTGRES_APPLICATION_NAME=DAVID-ELITEDESK
|
||||
|
||||
# Used by standby mode
|
||||
POSTGRES_PRIMARY_HOST=192.168.1.6
|
||||
POSTGRES_PRIMARY_PORT=5432
|
||||
|
||||
# Networks allowed to perform streaming replication
|
||||
POSTGRES_HA_CIDR=192.168.1.0/24
|
||||
|
||||
# PostgreSQL HA settings
|
||||
POSTGRES_WAL_LEVEL=replica
|
||||
POSTGRES_MAX_WAL_SENDERS=10
|
||||
POSTGRES_MAX_REPLICATION_SLOTS=10
|
||||
POSTGRES_HOT_STANDBY=on
|
||||
|
||||
# Bootstrap state
|
||||
POSTGRES_BOOTSTRAP_MARKER=.loxthelion-ha-initialized
|
||||
|
||||
# Mounted secret locations
|
||||
POSTGRES_PASSWORD_FILE=/run/secrets/postgres-ha/postgres_password
|
||||
POSTGRES_REPLICATION_PASSWORD_FILE=/run/secrets/postgres-ha/replication_password
|
||||
|
||||
# Physical replication slots expected on every writable primary
|
||||
POSTGRES_HA_SLOTS=ha_david_elitedesk,ha_david_dv6
|
||||
POSTGRES_PASSWORD=<REDACTED>
|
||||
POSTGRES_REPLICATION_PASSWORD=<REDACTED>
|
||||
Reference in New Issue
Block a user