From 89aaec5f96ccb139bd495549e56479444f38ec7d Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Thu, 17 Sep 2026 17:33:28 -0400 Subject: [PATCH] Add read-only PostgreSQL demotion preparation --- plugins/postgresql/manifest.json | 3 +- plugins/postgresql/plugin.py | 111 +++++++++++++++++++++++++++++++ tests/test-demote-prepare.py | 89 +++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100755 tests/test-demote-prepare.py diff --git a/plugins/postgresql/manifest.json b/plugins/postgresql/manifest.json index e6badca..a19b696 100644 --- a/plugins/postgresql/manifest.json +++ b/plugins/postgresql/manifest.json @@ -5,7 +5,8 @@ "actions": [ "monitor", "notify", - "notify.target" + "notify.target", + "demote.prepare" ], "required_config": [ "PGHOST", diff --git a/plugins/postgresql/plugin.py b/plugins/postgresql/plugin.py index ee77512..041aed7 100644 --- a/plugins/postgresql/plugin.py +++ b/plugins/postgresql/plugin.py @@ -529,3 +529,114 @@ def notify_target(config, runtime_state, request_parameters): "node": node_name, "age_seconds": age, } + + +def demote_prepare(config, runtime_state, request_parameters): + """ + Read-only demotion preparation. + + Validates the latest Pacemaker demotion notification against this local + daemon and reports the successor. PostgreSQL mutation remains outside this + action until candidate and WAL checks are migrated. + """ + del request_parameters + + node_name = str( + config.get("PACEMAKER_NODE_NAME", "") + ).strip().lower() + + if not node_name: + raise ValueError( + "PACEMAKER_NODE_NAME is required for demote.prepare" + ) + + notification = _postgres_runtime_state( + runtime_state + ).get("notification") + + if not isinstance(notification, dict): + return { + "ocf_rc": 1, + "ready": False, + "reason": "notification_missing", + "node": node_name, + } + + if notification.get("type") != "pre": + return { + "ocf_rc": 1, + "ready": False, + "reason": "notification_type_invalid", + "node": node_name, + } + + if notification.get("operation") != "demote": + return { + "ocf_rc": 1, + "ready": False, + "reason": "notification_operation_invalid", + "node": node_name, + } + + received_at = notification.get("received_at") + + if not isinstance(received_at, (int, float)): + return { + "ocf_rc": 1, + "ready": False, + "reason": "notification_timestamp_invalid", + "node": node_name, + } + + age = time.time() - received_at + + if age < 0 or age > NOTIFY_TARGET_MAX_AGE_SECONDS: + return { + "ocf_rc": 1, + "ready": False, + "reason": "notification_stale", + "age_seconds": age, + "node": node_name, + } + + target_node = str( + notification.get("demote_uname", "") + ).strip().lower() + + successor_node = str( + notification.get("promote_uname", "") + ).strip().lower() + + if target_node != node_name: + return { + "ocf_rc": 1, + "ready": False, + "reason": "local_node_not_target", + "node": node_name, + "target_node": target_node, + "successor_node": successor_node, + } + + if not successor_node: + return { + "ocf_rc": 1, + "ready": False, + "reason": "successor_missing", + "node": node_name, + } + + return { + "ocf_rc": 0, + "ready": True, + "mutation": False, + "node": node_name, + "target_node": target_node, + "successor_node": successor_node, + "age_seconds": age, + "checks": { + "notification": True, + "local_target": True, + "successor_selected": True, + "postgresql_mutated": False, + }, + } diff --git a/tests/test-demote-prepare.py b/tests/test-demote-prepare.py new file mode 100755 index 0000000..d082ad5 --- /dev/null +++ b/tests/test-demote-prepare.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 + +import importlib.util +import sys +import time +import types +from pathlib import Path + +fake_psycopg2 = types.ModuleType("psycopg2") +fake_psycopg2.Error = Exception +fake_psycopg2.OperationalError = OSError + +fake_extras = types.ModuleType("psycopg2.extras") +fake_extras.RealDictCursor = object +fake_psycopg2.extras = fake_extras + +sys.modules["psycopg2"] = fake_psycopg2 +sys.modules["psycopg2.extras"] = fake_extras + +spec = importlib.util.spec_from_file_location( + "postgresql_plugin", + Path("plugins/postgresql/plugin.py"), +) +plugin = importlib.util.module_from_spec(spec) +spec.loader.exec_module(plugin) + +runtime_state = { + "postgresql": { + "notification": { + "type": "pre", + "operation": "demote", + "demote_uname": "david-eve", + "promote_uname": "david-prodesk", + "received_at": time.time(), + }, + }, +} + +result = plugin.demote_prepare( + {"PACEMAKER_NODE_NAME": "david-eve"}, + runtime_state, + {}, +) + +assert result["ocf_rc"] == 0 +assert result["ready"] is True +assert result["mutation"] is False +assert result["successor_node"] == "david-prodesk" +print("valid_prepare accepted") + +result = plugin.demote_prepare( + {"PACEMAKER_NODE_NAME": "david-macbookpro"}, + runtime_state, + {}, +) + +assert result["ocf_rc"] == 1 +assert result["reason"] == "local_node_not_target" +print("wrong_target rejected") + +runtime_state["postgresql"]["notification"]["received_at"] = ( + time.time() - 121 +) + +result = plugin.demote_prepare( + {"PACEMAKER_NODE_NAME": "david-eve"}, + runtime_state, + {}, +) + +assert result["ocf_rc"] == 1 +assert result["reason"] == "notification_stale" +print("stale_notification rejected") + +runtime_state["postgresql"]["notification"]["received_at"] = time.time() +runtime_state["postgresql"]["notification"]["promote_uname"] = "" + +result = plugin.demote_prepare( + {"PACEMAKER_NODE_NAME": "david-eve"}, + runtime_state, + {}, +) + +assert result["ocf_rc"] == 1 +assert result["reason"] == "successor_missing" +print("missing_successor rejected") + +print() +print("DEMOTE_PREPARE_TEST_PASS=1")