"""Shared helpers for xceadmin's live E2E test suite (task 6.2's ``test_e2e_live_push.py`` and task 6.3's ``test_e2e_live_ha_push.py``) -- binary discovery, ephemeral port allocation, waiting for a listener to come up, and the ``versionTag=.../ackedCount=...`` stdout parser every live ``config push`` invocation needs to check. Extracted here (task 6.3) rather than duplicated a second time: task 6.3's own text asks whether task 6.2's fixture/helpers can be reused for the multi-node case rather than duplicated. These four helpers (binary discovery, port allocation, port-wait, the versionTag/ackedCount regex) are identical text in both files pre-extraction -- genuine repetition, not speculative generality -- so they move here and both test files import from it. The two files' *fixtures* (``live_controller`` vs. ``live_ha_cluster``) stay separate: single-node vs. 3-node deploy/launch/election-wait/cleanup sequences differ enough (peer strings, staggered multi-process start, primary-election polling) that merging them would trade a real readability loss for a smaller one. Not a test module itself (no ``test_*`` functions/classes), so pytest's default collection (``test_*.py``/``*_test.py``) leaves it alone; it is picked up as an ordinary same-directory import instead (this project's xceadmin/tests/ has no ``__init__.py``, so pytest's default "prepend" import mode puts this directory on ``sys.path`` for every test module in it -- the same mechanism that already lets every test file here import ``conftest.py``'s fixtures without a package-qualified path). """ from __future__ import annotations import re import socket import time from pathlib import Path SRC_DIR = Path(__file__).resolve().parents[2] REPO_CONFIGS_DIR = SRC_DIR / "configs" DEPLOY_HA_SH = SRC_DIR / "tools" / "deploy-ha.sh" # Mirrors deploy-ha.sh's own xceadmin/controller discovery order # (src/tools/deploy-ha.sh) -- xce.build (asan, the default profile) first, # then release, then debug. CONTROLLER_CANDIDATES = [ SRC_DIR / "xce.build" / "controller" / "xce_controller", SRC_DIR / "build.release" / "controller" / "xce_controller", SRC_DIR / "build.dbg" / "controller" / "xce_controller", ] XCEADMIN_CANDIDATES = [ SRC_DIR / "xce.build" / "xceadmin" / ".venv" / "bin" / "xceadmin", SRC_DIR / "build.release" / "xceadmin" / ".venv" / "bin" / "xceadmin", SRC_DIR / "build.dbg" / "xceadmin" / ".venv" / "bin" / "xceadmin", ] READY_TIMEOUT_S = 20.0 VERSION_TAG_RE = re.compile(r"versionTag=(\d+)\s+ackedCount=(\d+)") def find_first_executable(candidates: list[Path]) -> Path | None: for candidate in candidates: if candidate.is_file() and (candidate.stat().st_mode & 0o111): return candidate return None def free_tcp_port() -> int: """A port that was free at bind-time -- the same best-effort ephemeral- port trick used to avoid hardcoding a fixed port a test could collide on. The bind-then-immediately-close TOCTOU window is acceptable for a single-shot test deploy. """ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.bind(("127.0.0.1", 0)) return probe.getsockname()[1] def free_tcp_ports(count: int) -> list[int]: """``count`` distinct ports from :func:`free_tcp_port`, de-duplicated across the batch (a multi-node deploy needs several at once; calling :func:`free_tcp_port` in a bare loop can occasionally return the same port twice since each call binds-then-immediately-releases before the next one binds). Returned in ascending order for a stable, readable ctrl-1/ctrl-2/ctrl-3 assignment. """ ports: set[int] = set() while len(ports) < count: ports.add(free_tcp_port()) return sorted(ports) def wait_for_port(host: str, port: int, timeout_s: float) -> None: deadline = time.monotonic() + timeout_s last_err: OSError | None = None while time.monotonic() < deadline: try: with socket.create_connection((host, port), timeout=1.0): return except OSError as exc: last_err = exc time.sleep(0.2) raise TimeoutError(f"controller never opened {host}:{port}: {last_err}")