#!/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 elif mode == "delegate": response["result"] = { "delegated": True, "backend": "container", "container_runtime": "podman", } 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 delegation delegate 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"