2fb1e62b2e
`_guard_named_profile_under_multiplexer` correctly refuses a named-profile gateway while the default gateway is multiplexing — starting a second one would double-bind that profile's platforms. The refusal is right; its exit code was not. The refusal is decided entirely by configuration (`multiplex_profiles` plus the allowlist), so it is permanent: no number of retries can change the answer. Exiting 1 made it look transient to a service manager. That matters because this module generates the systemd unit, and the template pairs `Restart=always` / `RestartSec=5` with `StartLimitIntervalSec=0` — it deliberately trades systemd's generic start-rate limiter for the specific `RestartPreventExitStatus=GATEWAY_FATAL_CONFIG_EXIT_CODE` backstop declared three lines below it. Returning 1 left that backstop unarmed with the limiter already disabled, so a correct, permanent refusal became an unbounded restart loop. Observed on a host running `multiplex_profiles: true` with a leftover per-profile unit: 136 refusals in ~13 minutes, stopped only by hand. `GATEWAY_FATAL_CONFIG_EXIT_CODE` (78, EX_CONFIG) is this codebase's existing answer for exactly this case — `gateway/restart.py` documents it as the fatal configuration error that the s6 finish script translates into 125 "permanent failure" (#51228). This adopts that contract rather than inventing one, so the fix also works on s6 hosts, not just systemd. After: one refusal, `status=78/CONFIG`, `NRestarts=0`, unit settles in `failed`. Also strengthens the two guard tests. They asserted `pytest.raises(SystemExit, match="1")`, but `match=` is a regex search over `str(exc)`, so it passed for 1, 21, 100 and 111 alike — it read like an exit-code assertion while pinning nothing. They now assert `excinfo.value.code == GATEWAY_FATAL_CONFIG_EXIT_CODE`. The exit code is the contract here: it is the only thing that tells a supervisor the failure is permanent.
126 lines
4.7 KiB
Python
126 lines
4.7 KiB
Python
"""Phase 4: lifecycle guard + per-profile observability."""
|
|
import pytest
|
|
|
|
from gateway.config import GatewayConfig
|
|
from gateway.restart import GATEWAY_FATAL_CONFIG_EXIT_CODE
|
|
|
|
|
|
class TestServedProfilesStatus:
|
|
def test_write_and_read_served_profiles(self, tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
import importlib
|
|
import gateway.status as status
|
|
importlib.reload(status)
|
|
try:
|
|
status.write_runtime_status(
|
|
gateway_state="running", served_profiles=["default", "coder"]
|
|
)
|
|
rec = status.read_runtime_status()
|
|
assert rec.get("served_profiles") == ["default", "coder"]
|
|
finally:
|
|
importlib.reload(status)
|
|
|
|
|
|
def test_cron_profile_homes_follow_allowlist(tmp_path, monkeypatch):
|
|
"""The helper wired into in-process cron returns only selected profiles."""
|
|
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
default_home = tmp_path / ".hermes"
|
|
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
|
for name in ("worker", "guest"):
|
|
(default_home / "profiles" / name).mkdir(parents=True)
|
|
|
|
import gateway.run as gateway_run
|
|
|
|
homes = gateway_run._multiplex_profile_homes(
|
|
GatewayConfig(
|
|
multiplex_profiles=True,
|
|
multiplex_profile_allowlist=["worker"],
|
|
)
|
|
)
|
|
|
|
assert [name for name, _home in homes] == ["default", "worker"]
|
|
|
|
|
|
class TestNamedProfileMultiplexerGuard:
|
|
"""_guard_named_profile_under_multiplexer is inert unless all conditions hold."""
|
|
|
|
|
|
def test_force_bypasses(self, monkeypatch):
|
|
from hermes_cli import gateway as gw
|
|
# Even if it looks like a named profile, force returns immediately.
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
|
gw._guard_named_profile_under_multiplexer(force=True)
|
|
|
|
def test_inert_when_no_default_gateway_running(self, monkeypatch, tmp_path):
|
|
from hermes_cli import gateway as gw
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
|
monkeypatch.setattr(
|
|
"hermes_constants.get_default_hermes_root", lambda: tmp_path
|
|
)
|
|
# No gateway.pid in tmp_path => no running default gateway => no raise.
|
|
gw._guard_named_profile_under_multiplexer(force=False)
|
|
|
|
def _fake_running_default_gateway(self, monkeypatch, tmp_path):
|
|
"""Make the guard believe a live default gateway exists at tmp_path."""
|
|
from hermes_cli import gateway as gw
|
|
import gateway.status as status
|
|
|
|
monkeypatch.setattr(gw, "_profile_suffix", lambda: "coder")
|
|
monkeypatch.setattr(
|
|
"hermes_constants.get_default_hermes_root", lambda: tmp_path
|
|
)
|
|
(tmp_path / "gateway.pid").write_text("12345", encoding="utf-8")
|
|
monkeypatch.setattr(status, "_read_pid_record", lambda p: {"pid": 12345})
|
|
monkeypatch.setattr(status, "_pid_from_record", lambda rec: 12345)
|
|
monkeypatch.setattr(status, "_pid_exists", lambda pid: True)
|
|
|
|
def test_unset_allowlist_preserves_historical_guard(self, monkeypatch, tmp_path):
|
|
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
|
(tmp_path / "config.yaml").write_text(
|
|
"gateway:\n multiplex_profiles: true\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from hermes_cli import gateway as gw
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
gw._guard_named_profile_under_multiplexer(force=False)
|
|
assert excinfo.value.code == GATEWAY_FATAL_CONFIG_EXIT_CODE
|
|
|
|
def test_served_profile_is_still_guarded(self, monkeypatch, tmp_path):
|
|
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
|
(tmp_path / "config.yaml").write_text(
|
|
"gateway:\n"
|
|
" multiplex_profiles: true\n"
|
|
" multiplex_profile_allowlist:\n"
|
|
" - Coder\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from hermes_cli import gateway as gw
|
|
|
|
with pytest.raises(SystemExit) as excinfo:
|
|
gw._guard_named_profile_under_multiplexer(force=False)
|
|
assert excinfo.value.code == GATEWAY_FATAL_CONFIG_EXIT_CODE
|
|
|
|
@pytest.mark.parametrize(
|
|
"allowlist_yaml",
|
|
["[]", "[worker]", "coder"],
|
|
)
|
|
def test_unserved_profile_may_run_standalone(
|
|
self, monkeypatch, tmp_path, allowlist_yaml
|
|
):
|
|
self._fake_running_default_gateway(monkeypatch, tmp_path)
|
|
(tmp_path / "config.yaml").write_text(
|
|
"gateway:\n"
|
|
" multiplex_profiles: true\n"
|
|
f" multiplex_profile_allowlist: {allowlist_yaml}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
from hermes_cli import gateway as gw
|
|
|
|
gw._guard_named_profile_under_multiplexer(force=False)
|
|
|
|
|