Files
hermes-agent/tests/gateway/test_host_attach_lifecycle.py

296 lines
14 KiB
Python

"""The gateway lifecycle verbs mean "the ONE host process", not "this profile's gateway".
Invariants, all against a REAL live process standing in for the owner:
* ATTACH requires a LIVE ``identify`` answer. A record on its own proves only that an owner
exists — believing its ``profiles`` list parked a second profile's supervised unit against a
served set that process had not committed to (and, with multiplex off, never would).
* ``--replace`` signals the owner instead of standing down.
* ``--force`` starts without asking the owner anything.
* The claim-time record carries NO served set; it is filled in once the channel answers.
The record is written as raw JSON, not via ``HostRecord(...)``, so these tests collect and RUN
against a tree without the ``home`` field and fail on the OUTCOME rather than on a TypeError.
"""
import asyncio
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
from gateway import host_attach, host_rendezvous as hr
from gateway import run as gateway_run
def _reset_probe_memo() -> None:
"""Drop the host-gateway memo when the tree HAS one.
``getattr`` on purpose: a tree without the memo must still run these tests and fail on the
OUTCOME, never error at fixture setup — an import tripwire proves nothing about behaviour.
"""
getattr(host_attach, "invalidate_host_gateway_cache", lambda: None)()
@pytest.fixture(autouse=True)
def _no_memo():
"""The probe is memoized for a couple of seconds; each arrangement starts from cold."""
_reset_probe_memo()
yield
_reset_probe_memo()
@pytest.fixture
def owner_pid(tmp_path, monkeypatch):
"""A REAL live process standing in for the host gateway (liveness is proved, not stubbed)."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
try:
yield child.pid
finally:
child.terminate()
child.wait(timeout=10)
def _publish(pid: int, home: Path, profiles: tuple[str, ...]) -> None:
payload = {
"role": hr.ROLE_GATEWAY,
"home": str(home),
"pid": pid,
"createTime": hr.process_create_time(pid),
"host": "",
"port": None,
"protocolVersion": hr.HOST_PROTOCOL_VERSION,
"tokenFingerprint": "",
"profiles": list(profiles),
"updatedAt": "",
}
path = hr.record_path(hr.ROLE_GATEWAY)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def _answer_identify(monkeypatch, pid: int, home: Path, served: list[str]) -> None:
"""Make the owner's control socket answer ``identify`` — the ONLY source of a served set."""
monkeypatch.setattr(
"gateway.control_socket.identify_gateway",
lambda dialled, **kw: (
{"pid": pid, "hermes_home": str(home), "served_profiles": served}
if Path(dialled) == home else None))
def test_a_record_only_owner_never_produces_attach(tmp_path, monkeypatch, owner_pid):
"""Owner present, control socket silent: the served set is UNKNOWN, so never ATTACH.
The claim-time record lands before the socket binds, so during a boot race a second profile's
supervised unit read "I am already served" from a process that had committed to nothing — and
exited 78, which parks the unit for good.
"""
ours = tmp_path / "root" / "profiles" / "other"
_publish(owner_pid, tmp_path / "root", ("default", "other"))
monkeypatch.setattr(host_attach, "ATTACH_CHANNEL_WAIT_S", 0.0)
monkeypatch.setattr("gateway.control_socket.identify_gateway", lambda *a, **k: None)
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles", lambda *a, **k: None)
decision = host_attach.decide(ours)
assert decision.outcome == host_attach.REFUSE
# ...and it is a RUNTIME observation, so a supervisor must retry rather than park the unit.
assert decision.transient is True
assert decision.owner is not None and decision.owner.served_known is False
def test_attach_needs_a_live_identify_answer(tmp_path, monkeypatch, owner_pid):
owner_home = tmp_path / "root"
ours = owner_home / "profiles" / "other"
_publish(owner_pid, owner_home, ()) # the record claims nothing; the socket answers
_answer_identify(monkeypatch, owner_pid, owner_home, ["default", "other"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: ours)
def _never(*a, **k):
raise AssertionError("a second gateway was started for an already-served profile")
monkeypatch.setattr(gateway_run, "_start_gateway_replace_existing_instance", _never)
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is True
def test_run_for_an_unserved_profile_rescans_then_attaches(tmp_path, monkeypatch, owner_pid):
owner_home = tmp_path / "root"
ours = owner_home / "profiles" / "other"
_publish(owner_pid, owner_home, ("default",))
_answer_identify(monkeypatch, owner_pid, owner_home, ["default"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: ours)
asked: list[Path] = []
def _rescan(home, *, timeout=8.0):
asked.append(Path(home))
return {"multiplex": True, "served_profiles": ["default", "other"]}
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles", _rescan)
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is True
assert asked == [owner_home], "the rescan must reach the OWNER's home, not ours"
def test_host_gateway_refuses_when_it_will_not_serve_the_profile(tmp_path, monkeypatch, owner_pid):
"""A MULTIPLEXING owner whose roster still excludes us after a rescan is the permanent refusal."""
owner_home = tmp_path / "root"
_publish(owner_pid, owner_home, ("default",))
_answer_identify(monkeypatch, owner_pid, owner_home, ["default"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: owner_home / "profiles" / "other")
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles",
lambda home, timeout=8.0: {"multiplex": True, "served_profiles": ["default"]})
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is False
def test_a_standalone_owner_is_the_per_profile_topology_not_a_refusal(tmp_path, monkeypatch, owner_pid, caplog):
"""The owner answers ``multiplex: False``: it is a per-profile gateway, not a multiplexer that
excluded us. Refusing here (exit 78 → launchd parks the unit) took every other profile's
supervised gateway down at boot on a one-process-per-profile fleet. Start as before."""
owner_home = tmp_path / "root" / "profiles" / "tank"
_publish(owner_pid, owner_home, ("tank",))
_answer_identify(monkeypatch, owner_pid, owner_home, ["tank"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: tmp_path / "root" / "profiles" / "nous")
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles",
lambda home, timeout=8.0: {"multiplex": False, "served_profiles": ["tank"]})
with caplog.at_level("INFO", logger="gateway.host_attach"):
assert host_attach.decide(tmp_path / "root" / "profiles" / "nous").outcome == host_attach.START
assert any("migrate --multiplex" in r.getMessage() for r in caplog.records), "the converge hint is logged"
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is None
def test_replace_starts_beside_a_standalone_owner_it_does_not_belong_to(tmp_path, monkeypatch, owner_pid):
"""Generated launchd/s6 units all run ``gateway run --replace``. When ANOTHER profile's standalone
gateway holds the host lock, ``--replace`` must not target it: that owner never serves us, the
ownership guard refuses to signal it, and the gateway exits, so every unit but the lock holder
respawn-storms. It must start beside the owner exactly as the non-replace path does."""
owner_home = tmp_path / "root" / "profiles" / "tank"
_publish(owner_pid, owner_home, ("tank",))
_answer_identify(monkeypatch, owner_pid, owner_home, ["tank"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: tmp_path / "root" / "profiles" / "nous")
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles",
lambda home, timeout=8.0: {"multiplex": False, "served_profiles": ["tank"]})
signalled: list[int] = []
async def _replace(pid, replace):
signalled.append(pid)
return False # what the ownership guard answers for another profile's gateway
monkeypatch.setattr(gateway_run, "_start_gateway_replace_existing_instance", _replace)
assert host_attach.decide(tmp_path / "root" / "profiles" / "nous", replace=True).outcome == host_attach.START
assert asyncio.run(gateway_run._host_attach_or_none(replace=True)) is None
assert signalled == [], "--replace must not target a standalone owner that does not serve this profile"
def test_replace_still_targets_an_owner_whose_served_set_is_not_known_yet(tmp_path, monkeypatch, owner_pid):
"""Boot race: the claim-time record carries no served set until the owner's channel answers.
``--replace`` must keep its authority over that owner rather than fall into the attach path
and stand down; the per-target ownership guard still decides whether it may be signalled."""
owner_home = tmp_path / "root"
_publish(owner_pid, owner_home, ()) # record only: no identify answer, served set unknown
decision = host_attach.decide(owner_home / "profiles" / "other", replace=True)
assert decision.outcome == host_attach.REPLACE_HOST
assert decision.owner is not None and decision.owner.pid == owner_pid
def test_replace_signals_the_owner_instead_of_standing_down(tmp_path, monkeypatch, owner_pid):
"""``gateway run --replace`` against a live owner must REACH it — the branch was dead code."""
owner_home = tmp_path / "root"
_publish(owner_pid, owner_home, ("default", "other"))
_answer_identify(monkeypatch, owner_pid, owner_home, ["default", "other"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: owner_home / "profiles" / "other")
signalled: list[int] = []
async def _replace(pid, replace):
signalled.append(pid)
return True
monkeypatch.setattr(gateway_run, "_start_gateway_replace_existing_instance", _replace)
assert asyncio.run(gateway_run._host_attach_or_none(replace=True)) is None
assert signalled == [owner_pid]
def test_force_starts_without_consulting_the_owner(tmp_path, monkeypatch, owner_pid):
"""``--force`` printed the starting banner and then attached anyway. It must START."""
owner_home = tmp_path / "root"
_publish(owner_pid, owner_home, ("default", "other"))
_answer_identify(monkeypatch, owner_pid, owner_home, ["default", "other"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: owner_home / "profiles" / "other")
assert asyncio.run(gateway_run._host_attach_or_none(replace=False, force=True)) is None
def test_the_claim_time_record_publishes_no_served_set(tmp_path, monkeypatch):
"""Claim time is too early to know the served set; publishing a guess strands other profiles."""
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: tmp_path / "root")
monkeypatch.setattr(hr, "served_profiles",
lambda **kw: pytest.fail("the claim must not guess a served set"))
try:
gateway_run._claim_host_gateway_role()
record = hr.read_record(hr.ROLE_GATEWAY)
assert record is not None and record.pid == os.getpid()
assert record.profiles == ()
finally:
hr.release_host_lock(hr.ROLE_GATEWAY)
hr.clear_record(hr.ROLE_GATEWAY)
def test_served_profiles_ignores_the_retired_opt_out_but_honours_an_explicit_argument(monkeypatch):
"""``served_profiles()`` forced ``multiplex=True`` and claimed profiles it would never serve,
so it learned to read ``gateway.multiplex_profiles``. That key is now RETIRED as a topology
opt-out: reading it here was the last place an explicit ``false`` still narrowed the record,
which is why the CLI reported "standalone, serving default" while the runtime multiplexed.
The caller's explicit argument — the RUNTIME verdict — still decides."""
asked: list[bool] = []
def _roster(*, multiplex):
asked.append(multiplex)
return ([("default", Path("/x")), ("other", Path("/y"))] if multiplex
else [("default", Path("/x"))])
monkeypatch.setattr("hermes_cli.profiles.profiles_to_serve", _roster)
monkeypatch.setattr(
"hermes_cli.gateway_multiplex_mode.explicit_multiplex_flag", lambda home: False)
assert hr.served_profiles() == ("default", "other")
assert hr.served_profiles(multiplex=False) == ("default",)
assert asked == [True, False]
@pytest.mark.platforms("posix") # POSIX ownership check; Windows has no st_uid to compare
def test_a_foreign_record_is_not_a_record(tmp_path, monkeypatch, owner_pid):
"""A record this OS user did not write must never decide our lifecycle (forgery/DoS)."""
_publish(owner_pid, tmp_path / "root", ("default", "other"))
real_uid = os.getuid()
monkeypatch.setattr(os, "getuid", lambda: real_uid + 1)
assert hr.read_record(hr.ROLE_GATEWAY) is None
assert host_attach.host_gateway() is None
def test_the_default_profile_arriving_second_starts_beside_a_standalone_named_owner(tmp_path, monkeypatch, owner_pid):
"""The field shape of #118282: after a fleet restart a NAMED standalone unit claimed the host first and
the DEFAULT gateway arrived second. Refusing it exited 78 and its system unit crash-looped; the default
profile is a peer in a per-profile fleet, not a latecomer to a multiplexer."""
root = tmp_path / "root"
owner_home = root / "profiles" / "agent-ops"
_publish(owner_pid, owner_home, ("agent-ops",))
_answer_identify(monkeypatch, owner_pid, owner_home, ["agent-ops"])
monkeypatch.setattr(gateway_run, "get_hermes_home", lambda: root)
monkeypatch.setattr("gateway.control_socket.rescan_gateway_profiles",
lambda home, timeout=8.0: {"multiplex": False, "served_profiles": ["agent-ops"]})
decision = host_attach.decide(root)
assert host_attach.profile_name_for_home(root) == "default"
assert decision.outcome == host_attach.START
assert asyncio.run(gateway_run._host_attach_or_none(replace=False)) is None