diff --git a/checksums/SHA256SUMS b/checksums/SHA256SUMS index 7cf4e54..9d49246 100644 --- a/checksums/SHA256SUMS +++ b/checksums/SHA256SUMS @@ -15,14 +15,15 @@ d7d2fd7ab651d1f2f026bdb11d62e2c0c6ce6ee3128c9814d09550cf4304b3f2 ocf/loxthelion 3a27ac9c63c61310da8b8711fa2c4400b37ea8eae2ee2a79e28bc63180b06c7c ocf/loxthelion/managed-container.d/container.sh 70431e0d390a3612339c7b5887286c1be4d6b305cbe8898e7895a211c26b6087 ocf/loxthelion/managed-container.d/engine.sh 6c92c095ce10ac95d843fdc1c49e31c19491ca22fdac960affd8649606d76d82 ocf/loxthelion/managed-container.d/lifecycles/systemd.sh -c5d99182ce1e4cbff4fc7127a0727776729210ae7616ac6cfad2e6967d50bb8c ocf/loxthelion/managed-container.d/proxy.sh -16a060b64288de59ab60a4e2cb0ee233db8969f86b07f758003a38cda78e65c1 ocf/loxthelion/managed-container.d/proxy_client.py +5f3528932ea72ef42c18465e7f31f9a430407e8d4ff7c03c705fe8cae39dc26e ocf/loxthelion/managed-container.d/proxy.sh +fb01359a84062ec3e910a8b27e460953a0ae1641a69167b3497f2594911d2a00 ocf/loxthelion/managed-container.d/proxy_client.py f82e2571d309bce8e4d7e6aee6b61b68feb7d99dc85bb8b688b6123c13ccf6a7 ocf/loxthelion/managed-container.d/runtimes/podman.sh 4a99efea3d4a185e0630db89cfacc28aa8777bad9c747794ae69f70d87cd0327 pacemaker/README.md a42bb59a058ffeb59c1baa96e403856910bbbaea98f9104ff93daf4b5970c780 postgres-host/postgres-ha-bootstrap 957a5a72ed277d0216e070abb80733483be3d421b9fa488e27fdd75bb2919548 postgres-host/postgres-ha-peer-config.sh deb09e3ae40629f43b262ba4f9a7ebf59a1dca3da9d8c94478f670aea28bd0e1 quadlet/postgres.container 04a125068677bbee6e71d313ca9d95d35faa32851b79de781cd7b9f466561445 quadlet/postgres.env.example -45f398c0e494c86efb92bfa9381617c7dbb4b929c9efb5ff86473d2dc6fe729e tests/run-all.sh +0fb32ab5a9a51044ae9e4ae75b7d70bfb7f5e640c41a3d0de02277c9a8c44c91 tests/run-all.sh 5e5d4fdd2b6c7e0b8b146de7aad06c1f96b221a10fec525b00d40e0d85709fb0 tests/test-entrypoint-structure.sh +b83f0415c09f0be34c51722370e5b07c51b9a9756d305d3b3f50827f484aa4dd tests/test-proxy-client-actions.py c3b988d7b2b1b138bb2530df9172f96bb0f5edc2f1561f38f0055695f4126134 tests/test-proxy-protocol.sh diff --git a/ocf/loxthelion/managed-container.d/proxy.sh b/ocf/loxthelion/managed-container.d/proxy.sh index 063ed9f..747a5c9 100755 --- a/ocf/loxthelion/managed-container.d/proxy.sh +++ b/ocf/loxthelion/managed-container.d/proxy.sh @@ -51,6 +51,7 @@ mc_proxy_monitor() "${MC_PROXY_CLIENT:-${MC_PROXY_CLIENT_DEFAULT}}" \ "${socket_path}" \ "${resource}" \ + "monitor" \ "${MC_PROXY_RC_TRANSPORT}" \ "${MC_PROXY_RC_HARD_FAILURE}" )" diff --git a/ocf/loxthelion/managed-container.d/proxy_client.py b/ocf/loxthelion/managed-container.d/proxy_client.py index 351de30..659b1ad 100755 --- a/ocf/loxthelion/managed-container.d/proxy_client.py +++ b/ocf/loxthelion/managed-container.d/proxy_client.py @@ -9,18 +9,18 @@ PROTOCOL_VERSION = 1 VALID_OCF_RCS = {0, 1, 7, 8} -def main(): - if len(sys.argv) != 5: - raise SystemExit(11) +class TransportFailure(Exception): + pass - socket_path = sys.argv[1] - resource = sys.argv[2] - transport_failure = int(sys.argv[3]) - protocol_failure = int(sys.argv[4]) +class ProtocolFailure(Exception): + pass + + +def send_request(socket_path, resource, action): request = { "protocol_version": PROTOCOL_VERSION, - "action": "monitor", + "action": action, "resource": resource, } @@ -35,7 +35,10 @@ def main(): sock.connect(socket_path) sock.sendall( - json.dumps(request).encode("utf-8") + json.dumps( + request, + separators=(",", ":"), + ).encode("utf-8") + b"\n" ) @@ -56,49 +59,60 @@ def main(): socket.timeout, ConnectionError, OSError, - ): - raise SystemExit(transport_failure) + ) as exc: + raise TransportFailure from exc - # A connection was established. From this point onward, absence of a - # valid response is a protocol/result failure rather than transport loss. + return raw + + +def decode_response(raw): if not raw: - raise SystemExit(protocol_failure) + raise ProtocolFailure try: - obj = json.loads( + response = json.loads( raw.decode("utf-8").strip() ) except ( UnicodeDecodeError, json.JSONDecodeError, - ): - raise SystemExit(protocol_failure) + ) as exc: + raise ProtocolFailure from exc - if not isinstance(obj, dict): - raise SystemExit(protocol_failure) + if not isinstance(response, dict): + raise ProtocolFailure - if obj.get("protocol_version") != PROTOCOL_VERSION: - raise SystemExit(protocol_failure) + return response - if obj.get("ok") is not True: - raise SystemExit(protocol_failure) - if obj.get("resource") != resource: - raise SystemExit(protocol_failure) +def validate_response(response, resource, action): + if response.get("protocol_version") != PROTOCOL_VERSION: + raise ProtocolFailure - if obj.get("action") != "monitor": - raise SystemExit(protocol_failure) + if response.get("ok") is not True: + raise ProtocolFailure - result = obj.get("result") + if response.get("resource") != resource: + raise ProtocolFailure + if response.get("action") != action: + raise ProtocolFailure + + if "result" not in response: + raise ProtocolFailure + + return response["result"] + + +def format_monitor_result(result): if not isinstance(result, dict): - raise SystemExit(protocol_failure) + raise ProtocolFailure ha = result.get("ha") if not isinstance(ha, dict): - raise SystemExit(protocol_failure) + raise ProtocolFailure try: ocf_rc = int(ha.get("ocf_rc")) @@ -106,18 +120,18 @@ def main(): except ( TypeError, ValueError, - ): - raise SystemExit(protocol_failure) + ) as exc: + raise ProtocolFailure from exc if ocf_rc not in VALID_OCF_RCS: - raise SystemExit(protocol_failure) + raise ProtocolFailure receiver = result.get("wal_receiver") or {} if not isinstance(receiver, dict): - raise SystemExit(protocol_failure) + raise ProtocolFailure - payload = "|".join([ + return "|".join([ str(ocf_rc), str(ha.get("health", "unknown")), str(ha.get("ha_role", "unknown")), @@ -139,7 +153,62 @@ def main(): str(receiver.get("sender_host") or ""), ]) - print(payload) + +def format_result(action, result): + if action == "monitor": + return format_monitor_result(result) + + return json.dumps( + result, + separators=(",", ":"), + sort_keys=True, + ) + + +def main(): + if len(sys.argv) != 6: + raise SystemExit(11) + + socket_path = sys.argv[1] + resource = sys.argv[2] + action = sys.argv[3] + + try: + transport_failure = int(sys.argv[4]) + protocol_failure = int(sys.argv[5]) + + except ValueError: + raise SystemExit(11) + + if not resource or not action: + raise SystemExit(protocol_failure) + + try: + raw = send_request( + socket_path, + resource, + action, + ) + + except TransportFailure: + raise SystemExit(transport_failure) + + try: + response = decode_response(raw) + result = validate_response( + response, + resource, + action, + ) + output = format_result( + action, + result, + ) + + except ProtocolFailure: + raise SystemExit(protocol_failure) + + print(output) if __name__ == "__main__": diff --git a/tests/run-all.sh b/tests/run-all.sh index bed031e..abf639d 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -20,6 +20,10 @@ echo echo ">>> Proxy protocol contract" ./tests/test-proxy-protocol.sh +echo +echo ">>> Generic proxy client actions" +python3 ./tests/test-proxy-client-actions.py + echo echo ">>> Python syntax" diff --git a/tests/test-proxy-client-actions.py b/tests/test-proxy-client-actions.py new file mode 100755 index 0000000..a1f215a --- /dev/null +++ b/tests/test-proxy-client-actions.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 + +import json +from pathlib import Path +import socket +import subprocess +import sys +import tempfile +import threading + + +ROOT = Path(__file__).resolve().parents[1] +CLIENT = ( + ROOT + / "ocf" + / "loxthelion" + / "managed-container.d" + / "proxy_client.py" +) + + +def require(condition, message): + if not condition: + raise SystemExit("FAIL: " + message) + + +def run_exchange(response, action="promote"): + with tempfile.TemporaryDirectory() as temp_dir: + socket_path = str( + Path(temp_dir) / "managed-containerd.sock" + ) + + listener = socket.socket( + socket.AF_UNIX, + socket.SOCK_STREAM, + ) + listener.bind(socket_path) + listener.listen(1) + + captured = {} + errors = [] + + def serve_once(): + try: + conn, _ = listener.accept() + + with conn: + raw = b"" + + while b"\n" not in raw: + chunk = conn.recv(65536) + + if not chunk: + break + + raw += chunk + + captured["request"] = json.loads( + raw.decode("utf-8").strip() + ) + + conn.sendall( + json.dumps(response).encode("utf-8") + + b"\n" + ) + + except Exception as exc: + errors.append(exc) + + finally: + listener.close() + + thread = threading.Thread( + target=serve_once, + daemon=True, + ) + thread.start() + + completed = subprocess.run( + [ + sys.executable, + str(CLIENT), + socket_path, + "postgresql-ha", + action, + "10", + "11", + ], + text=True, + capture_output=True, + check=False, + ) + + thread.join(timeout=5) + + require( + not thread.is_alive(), + "test server did not finish", + ) + require( + not errors, + f"test server failed: {errors}", + ) + + return completed, captured["request"] + + +print("===== GENERIC PROXY CLIENT ACTIONS =====") + +valid_response = { + "protocol_version": 1, + "ok": True, + "resource": "postgresql-ha", + "action": "promote", + "result": { + "state": "promoted", + "changed": True, + }, +} + +completed, request = run_exchange( + valid_response, + action="promote", +) + +require(completed.returncode == 0, "generic action failed") +require( + completed.stdout.strip() + == '{"changed":true,"state":"promoted"}', + "generic result was not emitted as canonical JSON", +) +require( + request == { + "protocol_version": 1, + "action": "promote", + "resource": "postgresql-ha", + }, + "generic request envelope is incorrect", +) + +print("generic_action expected=0 actual=0") + +wrong_action_response = dict(valid_response) +wrong_action_response["action"] = "demote" + +completed, _ = run_exchange( + wrong_action_response, + action="promote", +) + +require( + completed.returncode == 11, + "mismatched action was not a protocol failure", +) + +print("wrong_action_echo expected=11 actual=11") + +with tempfile.TemporaryDirectory() as temp_dir: + missing_socket = str( + Path(temp_dir) / "missing.sock" + ) + + completed = subprocess.run( + [ + sys.executable, + str(CLIENT), + missing_socket, + "postgresql-ha", + "promote", + "10", + "11", + ], + text=True, + capture_output=True, + check=False, + ) + +require( + completed.returncode == 10, + "missing socket was not a transport failure", +) + +print("generic_transport expected=10 actual=10") + +print() +print("PROXY_CLIENT_ACTION_TEST_PASS=1")