Add read-only PostgreSQL notification target action
This commit is contained in:
+2
-1
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
Executable
+80
@@ -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")
|
||||
Reference in New Issue
Block a user