Add read-only PostgreSQL demotion preparation

This commit is contained in:
Lyncoln
2026-09-17 17:33:28 -04:00
parent 942457e48f
commit 89aaec5f96
3 changed files with 202 additions and 1 deletions
+2 -1
View File
@@ -5,7 +5,8 @@
"actions": [ "actions": [
"monitor", "monitor",
"notify", "notify",
"notify.target" "notify.target",
"demote.prepare"
], ],
"required_config": [ "required_config": [
"PGHOST", "PGHOST",
+111
View File
@@ -529,3 +529,114 @@ def notify_target(config, runtime_state, request_parameters):
"node": node_name, "node": node_name,
"age_seconds": age, "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,
},
}
+89
View File
@@ -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")