diff --git a/daemon/server.py b/daemon/server.py index ef38cb8..047d8af 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -16,6 +16,7 @@ SOCKET_PATH = os.environ.get( server_socket = None registry = None +pacemaker_resource_id = None PROTOCOL_VERSION = 1 @@ -32,6 +33,38 @@ def protocol_response(payload): return response +def load_pacemaker_resource_binding( + current_registry, + environ=None, +): + """ + Load and validate the Pacemaker resource identity accepted by this + daemon instance. + + RESOURCE_ID identifies the registry entry. PACEMAKER_RESOURCE_ID is a + separate control-plane binding and must resolve to that registry. + """ + if environ is None: + environ = os.environ + + resource_id = environ.get( + "PACEMAKER_RESOURCE_ID" + ) + + if not resource_id: + raise RegistryError( + "PACEMAKER_RESOURCE_ID is not configured" + ) + + if resource_id not in current_registry.resources: + raise RegistryError( + "PACEMAKER_RESOURCE_ID does not identify " + f"a registered resource: {resource_id}" + ) + + return resource_id + + def handle_request(req): if not isinstance(req, dict): return protocol_response({ @@ -65,11 +98,17 @@ def handle_request(req): return protocol_response({ "ok": True, "daemon": "managed-containerd", + "pacemaker_resource": ( + pacemaker_resource_id + ), }) if action == "registry.list": return protocol_response({ "ok": True, + "pacemaker_resource": ( + pacemaker_resource_id + ), "resources": registry.list_resources(), }) @@ -82,6 +121,18 @@ def handle_request(req): "error": "resource is required", }) + if resource != pacemaker_resource_id: + return protocol_response({ + "ok": False, + "error_code": "resource_binding_mismatch", + "error": ( + "requested resource does not match " + "the configured Pacemaker binding" + ), + "resource": resource, + "action": action, + }) + return protocol_response( registry.execute( resource, @@ -118,8 +169,12 @@ def shutdown(signum, frame): def serve(): global server_socket global registry + global pacemaker_resource_id registry = Registry() + pacemaker_resource_id = ( + load_pacemaker_resource_binding(registry) + ) os.makedirs( os.path.dirname(SOCKET_PATH), @@ -151,6 +206,12 @@ def serve(): flush=True, ) + print( + "Pacemaker resource binding: " + + pacemaker_resource_id, + flush=True, + ) + try: while True: conn, _ = server_socket.accept() diff --git a/postgres.env b/postgres.env index 11044fd..dd32d44 100644 --- a/postgres.env +++ b/postgres.env @@ -1,4 +1,5 @@ RESOURCE_ID=postgresql-ha +PACEMAKER_RESOURCE_ID=postgresql-ha PLUGIN=postgresql MANAGED_CONTAINERD_SOCKET=/run/managed-containerd/managed-containerd.sock diff --git a/tests/test-resource-binding.py b/tests/test-resource-binding.py new file mode 100755 index 0000000..545103d --- /dev/null +++ b/tests/test-resource-binding.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 + +import sys + +sys.path.insert(0, "daemon") + +import server +from registry import RegistryError + + +class FakeRegistry: + def __init__(self): + self.resources = { + "postgresql-ha": object(), + } + self.executions = [] + + def list_resources(self): + return { + "postgresql-ha": { + "resource": "postgresql-ha", + } + } + + def execute(self, resource, action): + self.executions.append( + (resource, action) + ) + + return { + "ok": True, + "resource": resource, + "plugin": "postgresql", + "action": action, + "result": { + "accepted": True, + }, + } + + +def require(condition, message): + if not condition: + raise SystemExit("FAIL: " + message) + + +def require_registry_error(callback, message): + try: + callback() + + except RegistryError: + return + + raise SystemExit("FAIL: " + message) + + +registry = FakeRegistry() + +print("===== PACEMAKER RESOURCE BINDING =====") + +require_registry_error( + lambda: server.load_pacemaker_resource_binding( + registry, + {}, + ), + "missing binding was accepted", +) + +print("missing_binding rejected") + +require_registry_error( + lambda: server.load_pacemaker_resource_binding( + registry, + { + "PACEMAKER_RESOURCE_ID": "other-resource", + }, + ), + "unknown binding was accepted", +) + +print("unknown_binding rejected") + +binding = server.load_pacemaker_resource_binding( + registry, + { + "PACEMAKER_RESOURCE_ID": "postgresql-ha", + }, +) + +require( + binding == "postgresql-ha", + "valid binding was not loaded", +) + +print("valid_binding accepted") + +server.registry = registry +server.pacemaker_resource_id = binding + +ping = server.handle_request({ + "protocol_version": 1, + "action": "ping", +}) + +require(ping["ok"] is True, "ping failed") +require( + ping["pacemaker_resource"] == "postgresql-ha", + "ping did not report the binding", +) +require( + ping["protocol_version"] == 1, + "ping lost the protocol version", +) + +print("ping_binding reported") + +response = server.handle_request({ + "protocol_version": 1, + "resource": "postgresql-ha", + "action": "monitor", +}) + +require( + response["ok"] is True, + "matching resource was rejected", +) +require( + registry.executions + == [("postgresql-ha", "monitor")], + "matching request was not executed exactly once", +) + +print("matching_resource executed") + +response = server.handle_request({ + "protocol_version": 1, + "resource": "different-resource", + "action": "monitor", +}) + +require( + response["ok"] is False, + "mismatched resource was accepted", +) +require( + response["error_code"] + == "resource_binding_mismatch", + "wrong mismatch classification", +) +require( + response["resource"] + == "different-resource", + "rejected resource was not echoed", +) +require( + response["action"] == "monitor", + "rejected action was not echoed", +) +require( + registry.executions + == [("postgresql-ha", "monitor")], + "mismatched request reached the registry", +) +require( + response["protocol_version"] == 1, + "mismatch response lost protocol metadata", +) + +print("mismatched_resource rejected") + +print() +print("RESOURCE_BINDING_TEST_PASS=1")