From 06c106c57ca2db2387a90f0d98ec7b1761af7c43 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Fri, 18 Sep 2026 09:43:35 -0400 Subject: [PATCH] Handle PostgreSQL self-restart demotion preflight --- plugins/postgresql/plugin.py | 62 +++++++++++++++++++++++++++++++++++ tests/test-demote-database.py | 53 ++++++++++++++++++++++++++++++ tests/test-demote-prepare.py | 60 +++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+) diff --git a/plugins/postgresql/plugin.py b/plugins/postgresql/plugin.py index 310e449..6e77ebb 100644 --- a/plugins/postgresql/plugin.py +++ b/plugins/postgresql/plugin.py @@ -802,6 +802,47 @@ def demote_prepare(config, runtime_state, request_parameters): "successor_node": successor_node, } + promoted_nodes = { + str(name).strip().lower() + for name in str( + notification.get("promoted_uname", "") + ).split() + if str(name).strip() + } + + if successor_node == node_name: + if node_name not in promoted_nodes: + return { + "ocf_rc": 1, + "ready": False, + "reason": "self_restart_not_current_primary", + "node": node_name, + "target_node": target_node, + "successor_node": successor_node, + } + + return { + "ocf_rc": 0, + "ready": False, + "identity_validated": True, + "reason": "local_restart_checks_pending", + "mutation": False, + "node": node_name, + "target_node": target_node, + "successor_node": successor_node, + "handoff_mode": "local_restart", + "final_handoff_required": False, + "age_seconds": age, + "checks": { + "notification": True, + "local_target": True, + "self_successor": True, + "local_currently_promoted": True, + "postgresql_mutated": False, + "database_checks": False, + }, + } + if not successor_node: return { "ocf_rc": 1, @@ -885,6 +926,7 @@ def demote_database(config, runtime_state, request_parameters): if preparation.get("reason") not in ( "database_checks_pending", + "local_restart_checks_pending", ): return preparation @@ -898,6 +940,26 @@ def demote_database(config, runtime_state, request_parameters): "local": local["ha"], } + if preparation.get("reason") == "local_restart_checks_pending": + return { + "ocf_rc": 0, + "ready": True, + "mutation": False, + "final_handoff_required": False, + "handoff_mode": "local_restart", + "reason": "local_restart_demotion_ready", + "node": preparation["node"], + "target_node": preparation["target_node"], + "successor_node": preparation["successor_node"], + "checks": { + "notification": True, + "local_target": True, + "local_primary": True, + "self_successor": True, + "remote_handoff_required": False, + }, + } + successor_node = preparation["successor_node"] peers = parse_replication_peers( config.get("PG_REPLICATION_PEERS", "") diff --git a/tests/test-demote-database.py b/tests/test-demote-database.py index 96cb717..7cf6ce7 100755 --- a/tests/test-demote-database.py +++ b/tests/test-demote-database.py @@ -137,3 +137,56 @@ assert result["reason"] == "successor_unreachable" print("successor_unreachable rejected") print("DEMOTE_DATABASE_FAILURE_TEST_PASS=1") + +print() +print("===== SELF-RESTART DATABASE GATE =====") + +plugin.monitor = lambda config, runtime_state: { + "ha": {"ocf_rc": plugin.OCF_RUNNING_PROMOTED}, + "primary": {"current_wal_lsn": "14/1C07A920"}, +} + +self_restart_state = { + "postgresql": { + "notification": { + "type": "pre", + "operation": "demote", + "demote_uname": "david-prodesk", + "promote_uname": "david-prodesk", + "promoted_uname": "david-prodesk", + "unpromoted_uname": "david-eve", + "received_at": time.time(), + }, + }, +} + +connect_called = False + +def must_not_connect(**kwargs): + global connect_called + connect_called = True + raise AssertionError( + "self-restart demotion must not probe a remote successor" + ) + +plugin.psycopg2.connect = must_not_connect + +result = plugin.demote_database( + config, + self_restart_state, + {}, +) + +assert result["ocf_rc"] == 0 +assert result["ready"] is True +assert result["mutation"] is False +assert result["final_handoff_required"] is False +assert result["handoff_mode"] == "local_restart" +assert result["reason"] == "local_restart_demotion_ready" +assert result["successor_node"] == "david-prodesk" +assert result["checks"]["local_primary"] is True +assert result["checks"]["remote_handoff_required"] is False +assert connect_called is False + +print("self_restart_database accepted") +print("remote_probe_skipped accepted") diff --git a/tests/test-demote-prepare.py b/tests/test-demote-prepare.py index fbc9475..4fa9fe7 100755 --- a/tests/test-demote-prepare.py +++ b/tests/test-demote-prepare.py @@ -124,3 +124,63 @@ result = plugin.demote_prepare( assert result["ocf_rc"] == 1 assert result["reason"] == "successor_not_configured" print("unconfigured_successor rejected") + +print() +print("===== SELF-RESTART PREPARE =====") + +self_restart_state = { + "postgresql": { + "notification": { + "type": "pre", + "operation": "demote", + "demote_uname": "david-eve", + "promote_uname": "david-eve", + "promoted_uname": "david-eve", + "unpromoted_uname": "david-prodesk david-macbookpro", + "received_at": time.time(), + }, + }, +} + +result = plugin.demote_prepare( + { + "PACEMAKER_NODE_NAME": "david-eve", + "PG_REPLICATION_PEERS": ( + "david-prodesk=192.168.1.4:5432," + "david-eve=192.168.1.3:5432" + ), + }, + self_restart_state, + {}, +) + +assert result["ocf_rc"] == 0 +assert result["ready"] is False +assert result["identity_validated"] is True +assert result["reason"] == "local_restart_checks_pending" +assert result["handoff_mode"] == "local_restart" +assert result["final_handoff_required"] is False +assert result["successor_node"] == "david-eve" +assert result["checks"]["self_successor"] is True +assert result["checks"]["local_currently_promoted"] is True + +print("self_restart_prepare accepted") + +self_restart_state["postgresql"]["notification"]["promoted_uname"] = "" + +result = plugin.demote_prepare( + { + "PACEMAKER_NODE_NAME": "david-eve", + "PG_REPLICATION_PEERS": ( + "david-prodesk=192.168.1.4:5432," + "david-eve=192.168.1.3:5432" + ), + }, + self_restart_state, + {}, +) + +assert result["ocf_rc"] == 1 +assert result["reason"] == "self_restart_not_current_primary" + +print("false_self_restart rejected")