From 6f90b66700873ab84e71427467a09a20482a20b4 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Thu, 17 Sep 2026 16:51:47 -0400 Subject: [PATCH] Add read-only PostgreSQL notification target action --- daemon/registry.py | 3 +- plugins/postgresql/manifest.json | 6 ++- plugins/postgresql/plugin.py | 79 +++++++++++++++++++++++++++++++ tests/test-notify-target.py | 80 ++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+), 3 deletions(-) create mode 100755 tests/test-notify-target.py diff --git a/daemon/registry.py b/daemon/registry.py index e05840e..b1c1079 100644 --- a/daemon/registry.py +++ b/daemon/registry.py @@ -96,7 +96,8 @@ class Plugin: f"plugin {self.name} does not support action {action}" ) - handler = getattr(self.module, action, None) + handler_name = action.replace(".", "_") + handler = getattr(self.module, handler_name, None) if handler is None: raise RegistryError( diff --git a/plugins/postgresql/manifest.json b/plugins/postgresql/manifest.json index e3c30c1..e6badca 100644 --- a/plugins/postgresql/manifest.json +++ b/plugins/postgresql/manifest.json @@ -4,7 +4,8 @@ "module": "plugin.py", "actions": [ "monitor", - "notify" + "notify", + "notify.target" ], "required_config": [ "PGHOST", @@ -15,6 +16,7 @@ "PG_REPLICATION_PEERS" ], "optional_config": [ - "PG_RECONNECT_GRACE_SECONDS" + "PG_RECONNECT_GRACE_SECONDS", + "PACEMAKER_NODE_NAME" ] } diff --git a/plugins/postgresql/plugin.py b/plugins/postgresql/plugin.py index 3c73e3f..76655db 100644 --- a/plugins/postgresql/plugin.py +++ b/plugins/postgresql/plugin.py @@ -435,3 +435,82 @@ def notify(config, runtime_state, request_parameters): "ocf_rc": OCF_SUCCESS, "state": dict(state), } + + +NOTIFY_TARGET_MAX_AGE_SECONDS = 120 + + +def notify_target(config, runtime_state, request_parameters): + """ + Report whether the local daemon is the target of the last notification. + + This is deliberately read-only. Promotion and demotion execution remain + separate actions until target selection is proven in production. + """ + 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 notify.target" + ) + + notification = _postgres_runtime_state( + runtime_state + ).get("notification") + + if not isinstance(notification, dict): + return { + "ocf_rc": 1, + "target": False, + "reason": "notification_missing", + "node": node_name, + } + + received_at = notification.get("received_at") + + if not isinstance(received_at, (int, float)): + return { + "ocf_rc": 1, + "target": 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, + "target": False, + "reason": "notification_stale", + "age_seconds": age, + "node": node_name, + } + + operation = notification.get("operation") + + if operation == "demote": + target_node = notification.get("demote_uname", "").strip().lower() + elif operation == "promote": + target_node = notification.get("promote_uname", "").strip().lower() + else: + return { + "ocf_rc": 1, + "target": False, + "reason": "operation_unsupported", + "operation": operation, + "node": node_name, + } + + return { + "ocf_rc": 0, + "target": target_node == node_name, + "operation": operation, + "target_node": target_node, + "node": node_name, + "age_seconds": age, + } diff --git a/tests/test-notify-target.py b/tests/test-notify-target.py new file mode 100755 index 0000000..50917e1 --- /dev/null +++ b/tests/test-notify-target.py @@ -0,0 +1,80 @@ +#!/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) + + +def require(condition, message): + if not condition: + raise SystemExit("FAIL: " + message) + + +print("===== NOTIFY TARGET ACTION =====") + +config = { + "PACEMAKER_NODE_NAME": "david-macbookpro", +} + +runtime_state = { + "postgresql": { + "notification": { + "operation": "demote", + "demote_uname": "david-macbookpro", + "promote_uname": "david-prodesk", + "received_at": time.time(), + }, + }, +} + +result = plugin.notify_target(config, runtime_state, {}) +require(result["ocf_rc"] == 0, "fresh target query failed") +require(result["target"] is True, "local demote target was not detected") +print("matching_target accepted") + +config["PACEMAKER_NODE_NAME"] = "david-eve" +result = plugin.notify_target(config, runtime_state, {}) +require(result["target"] is False, "mismatched target was accepted") +print("mismatched_target rejected") + +runtime_state["postgresql"]["notification"]["received_at"] = ( + time.time() - 121 +) +result = plugin.notify_target( + {"PACEMAKER_NODE_NAME": "david-macbookpro"}, + runtime_state, + {}, +) +require(result["reason"] == "notification_stale", "stale notification accepted") +print("stale_notification rejected") + +result = plugin.notify_target( + {"PACEMAKER_NODE_NAME": "david-macbookpro"}, + {}, + {}, +) +require(result["reason"] == "notification_missing", "missing notification accepted") +print("missing_notification rejected") + +print() +print("NOTIFY_TARGET_TEST_PASS=1")