From 9df5af71c0544e20034d9207c7dcd7caae9ef730 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Thu, 17 Sep 2026 11:30:26 -0400 Subject: [PATCH] Require managed-containerd protocol v1 --- checksums/SHA256SUMS | 5 +- ocf/loxthelion/managed-container.d/proxy.sh | 14 + tests/run-all.sh | 4 + tests/test-proxy-protocol.sh | 270 ++++++++++++++++++++ 4 files changed, 291 insertions(+), 2 deletions(-) create mode 100755 tests/test-proxy-protocol.sh diff --git a/checksums/SHA256SUMS b/checksums/SHA256SUMS index 23df3fb..9f1f6f9 100644 --- a/checksums/SHA256SUMS +++ b/checksums/SHA256SUMS @@ -15,12 +15,13 @@ 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 -259e8cafa5b6d3fb54896ab37ff3917942c62c51eaa6e9bfa8ccf3afc6a7327b ocf/loxthelion/managed-container.d/proxy.sh +9f934833ac7d1ad845244f2fe9c3ce3d93787e36f3c36b0c5896364cbd82fd77 ocf/loxthelion/managed-container.d/proxy.sh 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 -93f8b5c4a0c0e390fadeb680848156d198d356086574793032c5d2286c90c86b tests/run-all.sh +45f398c0e494c86efb92bfa9381617c7dbb4b929c9efb5ff86473d2dc6fe729e tests/run-all.sh 5e5d4fdd2b6c7e0b8b146de7aad06c1f96b221a10fec525b00d40e0d85709fb0 tests/test-entrypoint-structure.sh +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 bf09682..606572f 100755 --- a/ocf/loxthelion/managed-container.d/proxy.sh +++ b/ocf/loxthelion/managed-container.d/proxy.sh @@ -146,7 +146,10 @@ path, resource = sys.argv[1], sys.argv[2] TRANSPORT_FAILURE = int(sys.argv[3]) PROTOCOL_FAILURE = int(sys.argv[4]) +PROTOCOL_VERSION = 1 + request = { + "protocol_version": PROTOCOL_VERSION, "action": "monitor", "resource": resource, } @@ -189,9 +192,20 @@ except (UnicodeDecodeError, json.JSONDecodeError): if not isinstance(obj, dict): raise SystemExit(PROTOCOL_FAILURE) +# The daemon is reachable at this point, so any protocol mismatch is a hard +# control-plane failure. It must never be hidden by cached application health. +if obj.get("protocol_version") != PROTOCOL_VERSION: + raise SystemExit(PROTOCOL_FAILURE) + if not obj.get("ok"): raise SystemExit(PROTOCOL_FAILURE) +if obj.get("resource") != resource: + raise SystemExit(PROTOCOL_FAILURE) + +if obj.get("action") != "monitor": + raise SystemExit(PROTOCOL_FAILURE) + result = obj.get("result") if not isinstance(result, dict): raise SystemExit(PROTOCOL_FAILURE) diff --git a/tests/run-all.sh b/tests/run-all.sh index 06d8954..bed031e 100755 --- a/tests/run-all.sh +++ b/tests/run-all.sh @@ -16,6 +16,10 @@ echo echo ">>> Entrypoint / shell structure" ./tests/test-entrypoint-structure.sh +echo +echo ">>> Proxy protocol contract" +./tests/test-proxy-protocol.sh + echo echo ">>> Python syntax" diff --git a/tests/test-proxy-protocol.sh b/tests/test-proxy-protocol.sh new file mode 100755 index 0000000..f27c72b --- /dev/null +++ b/tests/test-proxy-protocol.sh @@ -0,0 +1,270 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$( + cd "$(dirname "${BASH_SOURCE[0]}")/.." && + pwd +)" + +PROXY="$ROOT/ocf/loxthelion/managed-container.d/proxy.sh" + +TMPDIR_TEST="$(mktemp -d)" +trap 'rm -rf "$TMPDIR_TEST"' EXIT + +export OCF_RESOURCE_INSTANCE="postgresql-ha" +export MC_PROXY_STATE_DIR="$TMPDIR_TEST/state" + +readonly MC_PROXY_RC_TRANSPORT=10 +readonly MC_PROXY_RC_HARD_FAILURE=11 + +OCF_SUCCESS=0 +OCF_RUNNING_PROMOTED=8 + +ocf_log() +{ + : +} + +# shellcheck source=/dev/null +source "$PROXY" + + +start_fake_daemon() +{ + local mode="$1" + local socket_path="$2" + + python3 - "$mode" "$socket_path" <<'PY' & +import json +import os +import socket +import struct +import sys +import time + +mode, path = sys.argv[1], sys.argv[2] + +try: + os.unlink(path) +except FileNotFoundError: + pass + +server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +server.bind(path) +server.listen(1) + +conn, _ = server.accept() + +with conn: + raw = b"" + + while b"\n" not in raw: + chunk = conn.recv(65536) + + if not chunk: + break + + raw += chunk + + if mode == "timeout": + time.sleep(5) + raise SystemExit(0) + + if mode == "empty": + # Clean EOF after a successful request exchange. This is a reachable + # daemon that failed to produce a protocol response, so it is a hard + # protocol/result failure rather than a transport failure. + raise SystemExit(0) + + if mode == "reset": + # Force an abortive close instead of a clean EOF. On Linux this should + # surface to the client as a connection-reset transport error. + conn.setsockopt( + socket.SOL_SOCKET, + socket.SO_LINGER, + struct.pack("ii", 1, 0), + ) + raise SystemExit(0) + + if mode == "malformed": + conn.sendall(b"{not-json}\n") + raise SystemExit(0) + + req = json.loads(raw.decode("utf-8").strip()) + + # Every structured-response test simultaneously verifies that the + # authoritative proxy sent the v1 request envelope. + if ( + req.get("protocol_version") != 1 + or req.get("action") != "monitor" + or req.get("resource") != "postgresql-ha" + ): + response = { + "ok": False, + "protocol_version": 1, + "error": "bad request envelope", + } + else: + response = { + "ok": True, + "protocol_version": 1, + "resource": "postgresql-ha", + "action": "monitor", + "result": { + "ha": { + "ocf_rc": 0, + "health": "healthy", + "ha_role": "unpromoted", + "state": "streaming", + "promotion_eligible": True, + }, + "role": "standby", + "streaming": True, + "wal_receiver": { + "slot_name": "ha_pg_test", + "sender_host": "192.0.2.1", + }, + }, + } + + if mode == "missing_protocol": + response.pop("protocol_version", None) + + elif mode == "wrong_protocol": + response["protocol_version"] = 999 + + elif mode == "wrong_resource": + response["resource"] = "some-other-resource" + + elif mode == "wrong_action": + response["action"] = "start" + + elif mode == "daemon_error": + response = { + "ok": False, + "protocol_version": 1, + "error_code": "request_error", + "error": "synthetic daemon error", + } + + elif mode == "bad_rc": + response["result"]["ha"]["ocf_rc"] = 42 + + conn.sendall( + json.dumps(response).encode("utf-8") + b"\n" + ) + +server.close() +PY + + FAKE_PID=$! + + # Wait until the fake daemon has actually created its socket. + local i + for i in {1..100}; do + [[ -S "$socket_path" ]] && return 0 + sleep 0.01 + done + + echo "ERROR: fake daemon failed to create socket: $socket_path" >&2 + return 1 +} + + +run_case() +{ + local name="$1" + local mode="$2" + local expected_rc="$3" + + local socket_path="$TMPDIR_TEST/${name}.sock" + local output="" + local actual_rc=0 + + rm -f "$socket_path" + + if [[ "$mode" == "missing_socket" ]]; then + export MC_PROXY_SOCKET="$socket_path" + + elif [[ "$mode" == "refused" ]]; then + # Leave a Unix socket inode behind with no listening process. + # The shell-level -S check succeeds, but connect() must fail with + # ECONNREFUSED/OSError and therefore classify as transport failure. + python3 - "$socket_path" <<'PY_REFUSED' +import os +import socket +import sys + +path = sys.argv[1] + +try: + os.unlink(path) +except FileNotFoundError: + pass + +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.bind(path) +s.close() +PY_REFUSED + + export MC_PROXY_SOCKET="$socket_path" + + else + start_fake_daemon "$mode" "$socket_path" + export MC_PROXY_SOCKET="$socket_path" + fi + + set +e + output="$(mc_proxy_monitor)" + actual_rc=$? + set -e + + case "$mode" in + missing_socket|refused) + ;; + *) + wait "${FAKE_PID:-}" 2>/dev/null || true + ;; + esac + + printf '%-22s expected=%-2s actual=%-2s' \ + "$name" \ + "$expected_rc" \ + "$actual_rc" + + if [[ -n "$output" ]]; then + printf ' output=%s' "$output" + fi + + printf '\n' + + if [[ "$actual_rc" -ne "$expected_rc" ]]; then + echo "FAIL: $name returned $actual_rc, expected $expected_rc" >&2 + return 1 + fi +} + + +echo "===== PROXY PROTOCOL CLASSIFICATION =====" + +run_case valid valid 0 +run_case missing_protocol missing_protocol 11 +run_case wrong_protocol wrong_protocol 11 +run_case wrong_resource wrong_resource 11 +run_case wrong_action wrong_action 11 +run_case daemon_error daemon_error 11 +run_case malformed_json malformed 11 +run_case invalid_ocf_rc bad_rc 11 +run_case empty_response empty 11 + +# AF_UNIX abortive close after a completed request is observed on this +# platform as EOF, not ECONNRESET. A reachable peer that supplies no +# response is therefore correctly classified as a hard protocol failure. +run_case abortive_close reset 11 + +run_case missing_socket missing_socket 10 +run_case refused_socket refused 10 +run_case timeout timeout 10 + +echo +echo "PROXY_PROTOCOL_TEST_PASS=1"