# Sourced by managed-container; uses the shared OCF and parameter environment. pg_command() { local command="$1" mc_runtime_exec \ --user "${PG_OS_USER}" \ "${CONTAINER_NAME}" \ sh -c "${command}" } pgdata_path() { pg_command ' if [ -z "$PGDATA" ]; then exit 1 fi printf "%s\n" "$PGDATA" ' } pg_running() { pg_command ' BINDIR="$(pg_config --bindir)" || exit 1 "$BINDIR/pg_ctl" status -D "$PGDATA" >/dev/null 2>&1 ' } pg_ready() { mc_runtime_exec \ --user "${PG_OS_USER}" \ "${CONTAINER_NAME}" \ pg_isready -q \ -U "${DB_USER}" \ -d "${DB_NAME}" } pg_role() { mc_runtime_exec \ --user "${PG_OS_USER}" \ "${CONTAINER_NAME}" \ psql \ -U "${DB_USER}" \ -d "${DB_NAME}" \ -Atqc 'SELECT pg_is_in_recovery();' } pg_wal_receiver_status() { mc_runtime_exec \ --user "${PG_OS_USER}" \ "${CONTAINER_NAME}" \ psql \ -U "${DB_USER}" \ -d "${DB_NAME}" \ -Atqc "SELECT status FROM pg_stat_wal_receiver LIMIT 1;" } pg_streaming() { [ "$(pg_wal_receiver_status 2>/dev/null || true)" = "streaming" ] } pg_wal_receiver_sender() { mc_runtime_exec \ --user "${PG_OS_USER}" \ "${CONTAINER_NAME}" \ psql \ -U "${DB_USER}" \ -d "${DB_NAME}" \ -Atqc " SELECT sender_host || '|' || sender_port FROM pg_stat_wal_receiver WHERE status = 'streaming' LIMIT 1; " } mc_pg_rejoin_running_standby() { local primary_info primary_rc primary_node primary_host primary_port [ -n "${OCF_RESKEY_replication_peers:-}" ] || return 1 primary_info="$(mc_pg_find_remote_primary 2>/dev/null)" primary_rc=$? case "${primary_rc}" in 0) primary_node="${primary_info%%|*}" primary_info="${primary_info#*|}" primary_host="${primary_info%%|*}" primary_port="${primary_info#*|}" ocf_log warn \ "Standby is not streaming while remote primary ${primary_node} (${primary_host}:${primary_port}) is available; forcing managed rejoin" if pg_running; then if ! pg_command ' BINDIR="$(pg_config --bindir)" || exit 1 exec "$BINDIR/pg_ctl" \ -D "$PGDATA" \ stop -m fast -w ' then ocf_exit_reason \ "Unable to stop non-streaming standby before managed rejoin" return "${OCF_ERR_GENERIC}" fi fi mc_pg_require_replication_slot || return "$?" REJOIN_SOURCE_HOST="${primary_host}" REJOIN_SOURCE_PORT="${primary_port}" OCF_RESKEY_rejoin_source_host="${primary_host}" OCF_RESKEY_rejoin_source_port="${primary_port}" if ! managed_rejoin; then ocf_exit_reason \ "Managed rejoin of non-streaming standby against ${primary_node} failed" return "${OCF_ERR_GENERIC}" fi pg_clear_rejoin_pending >/dev/null 2>&1 || true return "${OCF_SUCCESS}" ;; 1) # No writable peer exists. This is a normal failover-election # condition; leave the standby running Unpromoted and let the # promotion scorer decide whether it is eligible. return 1 ;; 2) ocf_exit_reason \ "Multiple writable PostgreSQL peers detected while repairing non-streaming standby" return "${OCF_ERR_GENERIC}" ;; *) ocf_exit_reason \ "Unable to determine PostgreSQL peer-primary state while repairing non-streaming standby" return "${OCF_ERR_GENERIC}" ;; esac } pg_rejoin_pending() { pg_command 'test -f "$PGDATA/.loxthelion-ha-rejoin-required"' } pg_mark_rejoin_pending() { pg_command ' umask 077 : > "$PGDATA/.loxthelion-ha-rejoin-required" chmod 600 "$PGDATA/.loxthelion-ha-rejoin-required" ' } pg_clear_rejoin_pending() { pg_command 'rm -f "$PGDATA/.loxthelion-ha-rejoin-required"' } mc_pg_reconcile_rejoin_marker() { local primary_info primary_rc primary_node primary_host primary_port local receiver_sender [ -n "${OCF_RESKEY_replication_peers:-}" ] || return 1 primary_info="$(mc_pg_find_remote_primary 2>/dev/null)" primary_rc=$? [ "${primary_rc}" -eq 0 ] || return 1 primary_node="${primary_info%%|*}" primary_info="${primary_info#*|}" primary_host="${primary_info%%|*}" primary_port="${primary_info#*|}" receiver_sender="$(pg_wal_receiver_sender 2>/dev/null || true)" if ! pg_streaming; then return 1 fi if [ "${receiver_sender}" != "${primary_host}|${primary_port}" ]; then return 1 fi if ! pg_clear_rejoin_pending; then ocf_log warn \ "Standby is streaming from elected primary ${primary_node}, but rejoin-required marker could not be cleared" return 1 fi ocf_log info \ "Standby is verified streaming from elected primary ${primary_node} (${primary_host}:${primary_port}); cleared provisional rejoin marker" return 0 } pg_has_standby_signal() { pg_command 'test -f "$PGDATA/standby.signal"' } managed_monitor() { local role if ! service_running; then ocf_log debug "User service ${SERVICE_NAME} is not active" return "${OCF_NOT_RUNNING}" fi if ! container_running; then ocf_log warn \ "Service ${SERVICE_NAME} is active but container ${CONTAINER_NAME} is not running" return "${OCF_NOT_RUNNING}" fi if ! pg_running; then ocf_log debug \ "Container ${CONTAINER_NAME} is running but PostgreSQL is stopped" return "${OCF_NOT_RUNNING}" fi if ! pg_ready; then ocf_exit_reason \ "PostgreSQL process exists but pg_isready failed" return "${OCF_ERR_GENERIC}" fi role="$(pg_role 2>/dev/null)" || { ocf_exit_reason \ "PostgreSQL is accepting connections but role query failed" return "${OCF_ERR_GENERIC}" } case "${role}" in f) ocf_log debug \ "PostgreSQL healthy: role=primary (promoted)" return "${OCF_RUNNING_PROMOTED}" ;; t) # A former primary may be started provisionally in recovery when # no writable peer exists yet, solely so Pacemaker can decide # which Unpromoted instance to promote. Once another node becomes # primary, force recovery of this provisional instance through a # stop/start cycle so start() can pg_rewind it against the winner. if pg_rejoin_pending && [ -n "${OCF_RESKEY_replication_peers:-}" ]; then local primary_info primary_rc primary_info="$(mc_pg_find_remote_primary 2>/dev/null)" primary_rc=$? case "${primary_rc}" in 0) ocf_exit_reason \ "A remote primary is now available (${primary_info%%|*}); provisional former-primary standby requires rejoin" return "${OCF_ERR_GENERIC}" ;; 2) ocf_exit_reason \ "Multiple writable PostgreSQL peers detected while monitoring provisional standby" return "${OCF_ERR_GENERIC}" ;; esac fi if pg_streaming; then ocf_log debug \ "PostgreSQL healthy: role=standby (unpromoted), WAL receiver=streaming" return "${OCF_SUCCESS}" fi # A standby can legitimately lose its receiver while the cluster # has no writable primary (for example during failover election). # Do not declare that condition unhealthy: promotion eligibility # is decided separately by the promotion-scoring evidence. If a # writable primary already exists, however, a non-streaming # standby is broken and must be repaired rather than reported OK. if [ -n "${OCF_RESKEY_replication_peers:-}" ]; then local primary_info primary_rc primary_info="$(mc_pg_find_remote_primary 2>/dev/null)" primary_rc=$? case "${primary_rc}" in 0) ocf_exit_reason \ "PostgreSQL standby is in recovery but WAL receiver is not streaming while remote primary ${primary_info%%|*} is available" return "${OCF_ERR_GENERIC}" ;; 1) ocf_log debug \ "PostgreSQL standby is disconnected and no remote primary is currently identifiable; leaving promotion eligibility to Pacemaker scoring" return "${OCF_SUCCESS}" ;; 2) ocf_exit_reason \ "Multiple writable PostgreSQL peers detected while monitoring standby" return "${OCF_ERR_GENERIC}" ;; *) ocf_exit_reason \ "Unable to determine PostgreSQL peer-primary state while monitoring standby" return "${OCF_ERR_GENERIC}" ;; esac fi # Backward-compatible behavior when native peer discovery is not # configured: role-aware monitoring cannot prove an upstream. ocf_log debug \ "PostgreSQL healthy: role=standby (unpromoted); native peer discovery is not configured" return "${OCF_SUCCESS}" ;; *) ocf_exit_reason \ "Unexpected pg_is_in_recovery() result: '${role}'" return "${OCF_ERR_GENERIC}" ;; esac } managed_start() { local i monitor_rc managed_monitor monitor_rc=$? case "${monitor_rc}" in "${OCF_SUCCESS}") ocf_log info \ "PostgreSQL resource already running unpromoted" return "${OCF_SUCCESS}" ;; "${OCF_RUNNING_PROMOTED}") ocf_exit_reason \ "Refusing start: PostgreSQL is already primary; promotion must occur through promote()" return "${OCF_ERR_GENERIC}" ;; "${OCF_NOT_RUNNING}") # Expected for a stopped resource. Continue. ;; *) # If start() is called for an already-running standby that is in # recovery but cannot stream from an existing primary, repair it # instead of simply returning the monitor failure. This catches a # previous partial rejoin whose standby.signal survived restart. if service_running && container_running && pg_running; then local existing_role existing_role="$(pg_role 2>/dev/null || true)" if [ "${existing_role}" = "t" ] && ! pg_streaming; then if mc_pg_rejoin_running_standby; then managed_monitor return $? fi fi fi ocf_exit_reason \ "Cannot start unhealthy PostgreSQL instance (monitor rc=${monitor_rc})" return "${monitor_rc}" ;; esac ocf_log info \ "Ensuring container service ${SERVICE_NAME} is running" if ! service_running; then if ! mc_lifecycle_start "${SERVICE_NAME}" then ocf_exit_reason \ "Failed to start ${SERVICE_NAME}" return "${OCF_ERR_GENERIC}" fi fi for i in $(seq 1 20); do container_running && break sleep 0.5 done if ! container_running; then ocf_exit_reason \ "Container ${CONTAINER_NAME} did not become running" return "${OCF_ERR_GENERIC}" fi if ! pg_running; then # # OCF promotable-resource rule: # start() must produce an Unpromoted instance. Missing standby.signal # therefore never authorizes a primary start. It means either: # * a former primary can be rewound against an existing winner, or # * no winner exists yet, in which case place this PGDATA into # recovery and let Pacemaker decide whether to promote it. # if pg_rejoin_pending || ! pg_has_standby_signal; then local primary_info primary_rc primary_node primary_host primary_port if [ -z "${OCF_RESKEY_replication_peers:-}" ]; then ocf_exit_reason \ "Cannot safely recover PGDATA without standby.signal: replication_peers is required for automatic primary discovery" return "${OCF_ERR_CONFIGURED}" fi # At this point the maintenance container is running, so resolve # the exact configured slot from PostgreSQL/container state before # any rewind or recovery configuration is written. mc_pg_require_replication_slot || return "$?" primary_info="$(mc_pg_find_remote_primary 2>/dev/null)" primary_rc=$? case "${primary_rc}" in 0) primary_node="${primary_info%%|*}" primary_info="${primary_info#*|}" primary_host="${primary_info%%|*}" primary_port="${primary_info#*|}" ocf_log info \ "Existing remote primary ${primary_node} (${primary_host}:${primary_port}) found; rejoining local PGDATA before start" REJOIN_SOURCE_HOST="${primary_host}" REJOIN_SOURCE_PORT="${primary_port}" OCF_RESKEY_rejoin_source_host="${primary_host}" OCF_RESKEY_rejoin_source_port="${primary_port}" if ! managed_rejoin; then ocf_exit_reason \ "Automatic rejoin against ${primary_node} failed" return "${OCF_ERR_GENERIC}" fi pg_clear_rejoin_pending >/dev/null 2>&1 || true managed_monitor return $? ;; 1) ocf_log info \ "No remote primary is currently identifiable; preparing local PGDATA as Unpromoted and leaving promotion exclusively to Pacemaker" if ! mc_pg_write_peer_recovery; then ocf_exit_reason \ "Unable to place PGDATA into native standby recovery while no primary exists" return "${OCF_ERR_GENERIC}" fi if ! pg_mark_rejoin_pending; then ocf_exit_reason \ "Unable to mark provisional standby as requiring rejoin if another node wins promotion" return "${OCF_ERR_GENERIC}" fi ;; 2) ocf_exit_reason \ "Multiple writable PostgreSQL peers detected; refusing start" return "${OCF_ERR_GENERIC}" ;; *) ocf_exit_reason \ "Unable to determine PostgreSQL peer-primary state" return "${OCF_ERR_GENERIC}" ;; esac fi ocf_log info \ "Starting PostgreSQL as an unpromoted standby inside ${CONTAINER_NAME}" if ! pg_command ' BINDIR="$(pg_config --bindir)" || exit 1 exec "$BINDIR/pg_ctl" \ -D "$PGDATA" \ -l "$PGDATA/postgresql.log" \ start -w ' then ocf_exit_reason "pg_ctl start failed" return "${OCF_ERR_GENERIC}" fi fi managed_monitor monitor_rc=$? case "${monitor_rc}" in "${OCF_SUCCESS}") ocf_log info \ "PostgreSQL start completed in unpromoted state" return "${OCF_SUCCESS}" ;; "${OCF_RUNNING_PROMOTED}") ocf_exit_reason \ "PostgreSQL became primary during start; stopping it to preserve OCF role semantics" pg_command ' BINDIR="$(pg_config --bindir)" || exit 1 exec "$BINDIR/pg_ctl" \ -D "$PGDATA" \ stop -m fast -w ' >/dev/null 2>&1 || true return "${OCF_ERR_GENERIC}" ;; *) # standby.signal can exist while the standby is still unusable # (for example, its required WAL was removed after a failed # primary repair). If a writable peer exists, route the instance # through managed_rejoin(), whose guarded basebackup fallback can # rebuild PGDATA when rewind alone cannot establish streaming. if pg_running; then local final_role final_role="$(pg_role 2>/dev/null || true)" if [ "${final_role}" = "t" ] && ! pg_streaming; then if mc_pg_rejoin_running_standby; then managed_monitor return $? fi fi fi return "${monitor_rc}" ;; esac } managed_stop() { local i if container_running && pg_running; then ocf_log info \ "Stopping PostgreSQL cleanly inside ${CONTAINER_NAME}" if ! pg_command ' BINDIR="$(pg_config --bindir)" || exit 1 exec "$BINDIR/pg_ctl" \ -D "$PGDATA" \ stop -m fast -w ' then ocf_exit_reason "pg_ctl stop failed" return "${OCF_ERR_GENERIC}" fi fi if service_running; then ocf_log info \ "Stopping container service ${SERVICE_NAME}" if ! mc_lifecycle_stop "${SERVICE_NAME}" then ocf_exit_reason \ "Failed to stop ${SERVICE_NAME}" return "${OCF_ERR_GENERIC}" fi fi for i in $(seq 1 20); do if ! service_running && ! container_running; then return "${OCF_SUCCESS}" fi sleep 0.5 done if service_running; then ocf_exit_reason \ "${SERVICE_NAME} remained active after stop" return "${OCF_ERR_GENERIC}" fi if container_running; then ocf_exit_reason \ "${CONTAINER_NAME} remained running after stop" return "${OCF_ERR_GENERIC}" fi return "${OCF_SUCCESS}" } # Conservative scoring eligibility only; this does not replace role detection, # fencing, the promotion action, or the existing controlled-demote WAL checks. # This strict predicate remains unchanged; disconnected policy is separate below. mc_pg_promotion_eligible() { local eligible # A provisional former primary must never become promotion-eligible. # It may run in recovery so Pacemaker can observe it, but it must first # complete a real rejoin/rewind against the elected primary. if pg_rejoin_pending; then return 1 fi eligible="$( mc_runtime_exec_bounded 3s --user "${PG_OS_USER}" \ --env 'PGOPTIONS=-c statement_timeout=2000' \ "${CONTAINER_NAME}" \ psql -X -v ON_ERROR_STOP=1 -U "${DB_USER}" -d "${DB_NAME}" -Atqc " SELECT CASE WHEN pg_is_in_recovery() THEN NOT pg_is_wal_replay_paused() AND EXISTS ( SELECT 1 FROM pg_stat_wal_receiver WHERE status = 'streaming' AND flushed_lsn IS NOT NULL AND pg_last_wal_replay_lsn() >= flushed_lsn ) ELSE false END; " 2>/dev/null )" || return 1 [ "${eligible}" = t ] } # One observation supplies both strict connected eligibility and disconnected # evidence. PostgreSQL restart time deliberately invalidates prior observations, # including a new basebackup sharing the same PostgreSQL system identifier. mc_pg_standby_observation() { mc_runtime_exec_bounded 3s --user "${PG_OS_USER}" \ --env 'PGOPTIONS=-c statement_timeout=2000' \ "${CONTAINER_NAME}" \ psql -X -v ON_ERROR_STOP=1 -U "${DB_USER}" -d "${DB_NAME}" -Atqc " WITH s AS MATERIALIZED ( SELECT pg_is_in_recovery() AS recovery, pg_last_wal_replay_lsn() AS replay, r.status AS receiver, r.flushed_lsn AS flush FROM (SELECT 1) AS singleton LEFT JOIN pg_stat_wal_receiver r ON true ) SELECT json_build_object( 'identity', json_build_array( (SELECT system_identifier::text FROM pg_control_system()), current_setting('data_directory'), extract(epoch FROM pg_postmaster_start_time())::text), 'recovery', recovery, 'paused', CASE WHEN recovery THEN pg_is_wal_replay_paused() ELSE true END, 'receiver', receiver, 'replay', replay::text, 'flush', flush::text, 'strict', CASE WHEN recovery THEN NOT pg_is_wal_replay_paused() AND receiver = 'streaming' AND flush IS NOT NULL AND replay >= flush ELSE false END) FROM s; " 2>/dev/null } mc_pg_standby_score() { local observation # Hard promotion veto for provisional former-primary PGDATA. Returning # -INFINITY here ensures Pacemaker cannot choose this instance while the # rejoin-required marker exists, even though monitor may report it as a # healthy Unpromoted standby. if pg_rejoin_pending; then if ! mc_pg_reconcile_rejoin_marker; then printf '%s\n' '-INFINITY' return 0 fi fi observation="$(mc_pg_standby_observation)" || return 1 # The helper is root-owned alongside the modules; state never enters the # rootless container. A failed/slow helper always withdraws eligibility. printf '%s' "${observation}" | timeout --kill-after=1s 1s python3 \ "${MC_APPLICATION_DIR}/promotion_state.py" \ "${MC_PROMOTION_RESOURCE_INSTANCE:-}" \ "${SERVICE_USER}" "${SERVICE_NAME}" "${CONTAINER_NAME}" \ "${OCF_RESKEY_disconnected_max_age:-300}" }