Merge pull request #31760 from NousResearch/hermes/hermes-bf5898da
feat(docker)!: s6-overlay container supervision (salvage of #30136)
This commit is contained in:
@@ -0,0 +1,578 @@
|
||||
"""Tests for hermes_cli.container_boot — the cont-init.d-time
|
||||
reconciliation that recreates per-profile gateway s6 service slots
|
||||
from the persistent profiles directory.
|
||||
|
||||
These tests run against a fake $HERMES_HOME under tmp_path; no real
|
||||
s6 supervision tree is required. The in-container integration test
|
||||
covering end-to-end "docker restart" survival lives in
|
||||
tests/docker/test_container_restart.py.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.container_boot import (
|
||||
ReconcileAction,
|
||||
reconcile_profile_gateways,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures + helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_profile(
|
||||
hermes_home: Path,
|
||||
name: str,
|
||||
*,
|
||||
state: str | None,
|
||||
with_pid: bool = False,
|
||||
config: bool = True,
|
||||
) -> Path:
|
||||
"""Create a fake profile directory under hermes_home/profiles/<name>/."""
|
||||
p = hermes_home / "profiles" / name
|
||||
p.mkdir(parents=True)
|
||||
if config:
|
||||
# SOUL.md is what the reconciler keys on — it's always seeded by
|
||||
# `hermes profile create`. See container_boot._render_run_script.
|
||||
(p / "SOUL.md").write_text("# fake profile\n")
|
||||
if state is not None:
|
||||
(p / "gateway_state.json").write_text(json.dumps({
|
||||
"gateway_state": state, "timestamp": 1234567890,
|
||||
}))
|
||||
if with_pid:
|
||||
(p / "gateway.pid").write_text(json.dumps(
|
||||
{"pid": 99999, "host": "old-container"},
|
||||
))
|
||||
(p / "processes.json").write_text("[]")
|
||||
return p
|
||||
|
||||
|
||||
def _seed_default_root(
|
||||
hermes_home: Path,
|
||||
*,
|
||||
state: str | None = None,
|
||||
with_pid: bool = False,
|
||||
) -> None:
|
||||
"""Populate gateway_state.json / stale runtime files at the
|
||||
HERMES_HOME root (the implicit default profile)."""
|
||||
if state is not None:
|
||||
(hermes_home / "gateway_state.json").write_text(json.dumps({
|
||||
"gateway_state": state, "timestamp": 1234567890,
|
||||
}))
|
||||
if with_pid:
|
||||
(hermes_home / "gateway.pid").write_text(json.dumps(
|
||||
{"pid": 99999, "host": "old-container"},
|
||||
))
|
||||
(hermes_home / "processes.json").write_text("[]")
|
||||
|
||||
|
||||
def _named_actions(actions: list[ReconcileAction]) -> list[ReconcileAction]:
|
||||
"""Drop the always-present default-profile action so tests that
|
||||
only care about named profiles can assert against a clean list."""
|
||||
return [a for a in actions if a.profile != "default"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_running_profile_is_registered_and_autostarted(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="coder", prior_state="running", action="started",
|
||||
)]
|
||||
svc = scandir / "gateway-coder"
|
||||
assert (svc / "run").exists()
|
||||
assert (svc / "run").stat().st_mode & 0o111 # executable
|
||||
assert (svc / "type").read_text().strip() == "longrun"
|
||||
# Auto-start means no down-marker.
|
||||
assert not (svc / "down").exists()
|
||||
|
||||
|
||||
def test_stopped_profile_is_registered_but_not_started(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "writer", state="stopped")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="writer", prior_state="stopped", action="registered",
|
||||
)]
|
||||
# down marker tells s6-svscan to NOT start the service.
|
||||
assert (scandir / "gateway-writer" / "down").exists()
|
||||
|
||||
|
||||
def test_startup_failed_does_not_autostart(tmp_path: Path) -> None:
|
||||
"""Avoid crash-loop on restart when the gateway was failing to boot."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "broken", state="startup_failed")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
named = _named_actions(actions)
|
||||
assert named[0].action == "registered"
|
||||
assert (scandir / "gateway-broken" / "down").exists()
|
||||
|
||||
|
||||
def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
|
||||
"""`starting` means the gateway died mid-boot last time; treat as
|
||||
failed, not as a candidate for auto-restart."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "unlucky", state="starting")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
named = _named_actions(actions)
|
||||
assert named[0].action == "registered"
|
||||
|
||||
|
||||
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
|
||||
assert (profile / "gateway.pid").exists()
|
||||
assert (profile / "processes.json").exists()
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert not (profile / "gateway.pid").exists()
|
||||
assert not (profile / "processes.json").exists()
|
||||
|
||||
|
||||
def test_profile_without_state_file_is_registered_but_not_started(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A freshly-created profile that's never been started: register
|
||||
its slot but don't auto-start."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "fresh", state=None)
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="fresh", prior_state=None, action="registered",
|
||||
)]
|
||||
assert (scandir / "gateway-fresh" / "down").exists()
|
||||
|
||||
|
||||
def test_directory_without_marker_file_is_skipped(tmp_path: Path) -> None:
|
||||
"""A stray dir under profiles/ that isn't actually a profile (no
|
||||
SOUL.md — the marker the reconciler keys on) should be skipped."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
# Create a profile dir but without SOUL.md
|
||||
(tmp_path / "profiles" / "stray").mkdir(parents=True)
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == []
|
||||
assert not (scandir / "gateway-stray").exists()
|
||||
|
||||
|
||||
def test_corrupt_state_file_treated_as_no_prior_state(tmp_path: Path) -> None:
|
||||
"""If gateway_state.json is malformed JSON, don't blow up the whole
|
||||
reconciliation — register the slot in the down state."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
profile = _make_profile(tmp_path, "junk", state="running")
|
||||
(profile / "gateway_state.json").write_text("{ not valid json")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
named = _named_actions(actions)
|
||||
assert named[0].action == "registered" # not "started"
|
||||
assert (scandir / "gateway-junk" / "down").exists()
|
||||
|
||||
|
||||
def test_reconcile_log_is_written(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "a", state="running")
|
||||
_make_profile(tmp_path, "b", state="stopped")
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
log = (tmp_path / "logs" / "container-boot.log").read_text()
|
||||
assert "profile=a" in log
|
||||
assert "action=started" in log
|
||||
assert "profile=b" in log
|
||||
assert "action=registered" in log
|
||||
|
||||
|
||||
def test_reconcile_log_rotates_when_size_exceeded(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When container-boot.log exceeds _LOG_ROTATE_BYTES, the existing
|
||||
file is rotated to .1 before the new entries are appended."""
|
||||
from hermes_cli import container_boot
|
||||
|
||||
# Tighten the threshold so we don't have to write 256 KiB.
|
||||
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
|
||||
|
||||
log_path = tmp_path / "logs" / "container-boot.log"
|
||||
log_path.parent.mkdir()
|
||||
log_path.write_text("X" * 300) # already over the threshold
|
||||
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
rotated = tmp_path / "logs" / "container-boot.log.1"
|
||||
assert rotated.exists(), "expected previous log to be rotated to .1"
|
||||
assert rotated.read_text().startswith("X" * 300)
|
||||
# The new entries land in a fresh container-boot.log (no leftover Xs).
|
||||
new_contents = log_path.read_text()
|
||||
assert "X" not in new_contents
|
||||
assert "profile=coder" in new_contents
|
||||
|
||||
|
||||
def test_reconcile_log_does_not_rotate_below_threshold(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A small existing log is appended to in place; no .1 is created."""
|
||||
from hermes_cli import container_boot
|
||||
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 10_000_000)
|
||||
|
||||
log_path = tmp_path / "logs" / "container-boot.log"
|
||||
log_path.parent.mkdir()
|
||||
log_path.write_text("previous entry\n")
|
||||
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert not (tmp_path / "logs" / "container-boot.log.1").exists()
|
||||
contents = log_path.read_text()
|
||||
assert contents.startswith("previous entry\n")
|
||||
assert "profile=coder" in contents
|
||||
|
||||
|
||||
def test_reconcile_log_rotation_overwrites_existing_dot1(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Rotating again replaces the prior .1 — we keep at most one
|
||||
rotated file (soft cap of ~2 × threshold)."""
|
||||
from hermes_cli import container_boot
|
||||
monkeypatch.setattr(container_boot, "_LOG_ROTATE_BYTES", 200)
|
||||
|
||||
log_dir = tmp_path / "logs"; log_dir.mkdir()
|
||||
(log_dir / "container-boot.log.1").write_text("OLD ROTATION")
|
||||
(log_dir / "container-boot.log").write_text("Y" * 300)
|
||||
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
# .1 now contains the previous .log (Ys), not OLD ROTATION.
|
||||
rotated = (log_dir / "container-boot.log.1").read_text()
|
||||
assert "OLD ROTATION" not in rotated
|
||||
assert rotated.startswith("Y" * 300)
|
||||
|
||||
|
||||
def test_dry_run_makes_no_filesystem_changes(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=True,
|
||||
)
|
||||
|
||||
# The action list is still produced...
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="coder", prior_state="running", action="started",
|
||||
)]
|
||||
# ...but nothing on disk was touched.
|
||||
assert (profile / "gateway.pid").exists() # not removed under dry_run
|
||||
assert not (scandir / "gateway-coder").exists()
|
||||
assert not (tmp_path / "logs" / "container-boot.log").exists()
|
||||
|
||||
|
||||
def test_missing_profiles_root_still_registers_default_slot(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When $HERMES_HOME/profiles doesn't exist (fresh install), the
|
||||
reconciliation should still register a gateway-default slot for
|
||||
the root profile and return without raising. Previously this
|
||||
returned an empty list; the default slot is now always present
|
||||
so `hermes gateway start` (no -p) has somewhere to land."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
assert actions == [ReconcileAction(
|
||||
profile="default", prior_state=None, action="registered",
|
||||
)]
|
||||
assert (scandir / "gateway-default").is_dir()
|
||||
assert (scandir / "gateway-default" / "down").exists()
|
||||
|
||||
|
||||
def test_invalid_profile_name_in_directory_raises(tmp_path: Path) -> None:
|
||||
"""A profile dir whose name doesn't match validate_profile_name's
|
||||
rules (uppercase, etc.) must surface as a hard error rather than
|
||||
silently produce an invalid s6 service dir."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "BadName", state="running")
|
||||
with pytest.raises(ValueError):
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
def test_register_service_publishes_atomically(tmp_path: Path) -> None:
|
||||
"""The reconciler should build the new service dir in a sibling
|
||||
tmp directory and rename it into place — never leaving a half-
|
||||
populated slot visible to a concurrent s6-svscan rescan.
|
||||
|
||||
We verify the invariant indirectly: after a clean reconcile, the
|
||||
target directory exists with all required files, and no sibling
|
||||
.tmp leftovers remain. (Atomic publication is the only way to
|
||||
achieve both with mkdir + write.)
|
||||
"""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
# No leftover tmp dir.
|
||||
leftover = list(scandir.glob("*.tmp"))
|
||||
assert leftover == [], f"leftover tmp directories: {leftover}"
|
||||
|
||||
# Target is fully populated.
|
||||
svc = scandir / "gateway-coder"
|
||||
assert (svc / "type").exists()
|
||||
assert (svc / "run").exists()
|
||||
assert (svc / "log" / "run").exists()
|
||||
|
||||
|
||||
def test_register_service_overwrites_existing_slot(tmp_path: Path) -> None:
|
||||
"""A second reconciliation pass cleanly replaces an existing
|
||||
slot (the tmp+rename publication overwrites the previous one)."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
profile = _make_profile(tmp_path, "coder", state="running")
|
||||
|
||||
# First pass.
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
first_run = (scandir / "gateway-coder" / "run").read_text()
|
||||
|
||||
# Mutate the profile state so the run-script changes (extra_env
|
||||
# rendering would differ if we wired profile config through, but
|
||||
# for now just exercise the overwrite path).
|
||||
(profile / "gateway_state.json").write_text(
|
||||
'{"gateway_state": "stopped"}',
|
||||
)
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
# Slot still exists, no .tmp remnants.
|
||||
assert (scandir / "gateway-coder" / "run").read_text() == first_run
|
||||
assert list(scandir.glob("*.tmp")) == []
|
||||
# Down marker now present (state went from running → stopped).
|
||||
assert (scandir / "gateway-coder" / "down").exists()
|
||||
|
||||
|
||||
def test_register_service_cleans_up_stale_tmp_dir(tmp_path: Path) -> None:
|
||||
"""If a previous interrupted run left a .tmp sibling directory,
|
||||
a fresh reconcile must clean it up rather than failing on mkdir."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
# Simulate a leftover from an interrupted run.
|
||||
stale_tmp = scandir / "gateway-coder.tmp"
|
||||
stale_tmp.mkdir()
|
||||
(stale_tmp / "stale-file").write_text("garbage")
|
||||
|
||||
_make_profile(tmp_path, "coder", state="running")
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert not stale_tmp.exists()
|
||||
assert (scandir / "gateway-coder" / "run").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default-profile slot — always registered (PR #30136 review item I1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_default_slot_always_registered_on_empty_home(tmp_path: Path) -> None:
|
||||
"""Bare HERMES_HOME with nothing under it still produces a
|
||||
gateway-default slot (down state)."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert actions == [ReconcileAction(
|
||||
profile="default", prior_state=None, action="registered",
|
||||
)]
|
||||
svc = scandir / "gateway-default"
|
||||
assert svc.is_dir()
|
||||
assert (svc / "run").exists()
|
||||
assert (svc / "down").exists()
|
||||
|
||||
|
||||
def test_default_slot_run_script_omits_profile_flag(tmp_path: Path) -> None:
|
||||
"""The default slot's run script must NOT pass `-p default` —
|
||||
that would resolve to $HERMES_HOME/profiles/default/ instead of
|
||||
the root profile. It must call `hermes gateway run` directly."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
run = (scandir / "gateway-default" / "run").read_text()
|
||||
assert "hermes gateway run" in run
|
||||
assert "-p default" not in run
|
||||
assert "-p 'default'" not in run
|
||||
|
||||
|
||||
def test_default_slot_autostarts_when_root_state_running(tmp_path: Path) -> None:
|
||||
"""gateway_state.json at the HERMES_HOME root with state=running
|
||||
means the default slot auto-starts on container boot."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_seed_default_root(tmp_path, state="running")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
default_action = next(a for a in actions if a.profile == "default")
|
||||
assert default_action.prior_state == "running"
|
||||
assert default_action.action == "started"
|
||||
assert not (scandir / "gateway-default" / "down").exists()
|
||||
|
||||
|
||||
def test_default_slot_does_not_autostart_when_root_state_stopped(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_seed_default_root(tmp_path, state="stopped")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
default_action = next(a for a in actions if a.profile == "default")
|
||||
assert default_action.action == "registered"
|
||||
assert (scandir / "gateway-default" / "down").exists()
|
||||
|
||||
|
||||
def test_default_slot_does_not_autostart_when_root_state_startup_failed(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Crash-loop guard applies to the default slot too."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_seed_default_root(tmp_path, state="startup_failed")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
default_action = next(a for a in actions if a.profile == "default")
|
||||
assert default_action.action == "registered"
|
||||
|
||||
|
||||
def test_default_slot_cleans_up_stale_runtime_files_at_root(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""gateway.pid and processes.json at the HERMES_HOME root (left
|
||||
over from the previous container's default gateway) must be
|
||||
swept the same way as for named profiles."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_seed_default_root(tmp_path, state="running", with_pid=True)
|
||||
assert (tmp_path / "gateway.pid").exists()
|
||||
|
||||
reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert not (tmp_path / "gateway.pid").exists()
|
||||
assert not (tmp_path / "processes.json").exists()
|
||||
|
||||
|
||||
def test_default_slot_appears_before_named_profiles(tmp_path: Path) -> None:
|
||||
"""The action list is ordered: default first, then named profiles
|
||||
in directory order. Operators and the boot-log reader rely on
|
||||
this ordering being stable."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "z-last-alphabetically", state="stopped")
|
||||
_make_profile(tmp_path, "a-first-alphabetically", state="stopped")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert [a.profile for a in actions] == [
|
||||
"default",
|
||||
"a-first-alphabetically",
|
||||
"z-last-alphabetically",
|
||||
]
|
||||
|
||||
|
||||
def test_profiles_default_subdir_is_skipped_with_warning(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A user-created profiles/default/ collides with the reserved
|
||||
root-profile slot — the named entry is skipped (with a warning)
|
||||
so we don't double-register gateway-default."""
|
||||
import logging
|
||||
caplog.set_level(logging.WARNING)
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "default", state="running")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
# Only the root-profile default slot appears — not the colliding
|
||||
# named profile.
|
||||
default_actions = [a for a in actions if a.profile == "default"]
|
||||
assert len(default_actions) == 1
|
||||
# And the warning surfaces so operators know the named profile
|
||||
# was ignored.
|
||||
assert any(
|
||||
"profiles/default/" in record.message for record in caplog.records
|
||||
)
|
||||
@@ -0,0 +1,335 @@
|
||||
"""Tests for the Phase 4 s6 dispatch helper in hermes_cli.gateway.
|
||||
|
||||
`_dispatch_via_service_manager_if_s6` decides whether a
|
||||
`hermes gateway start/stop/restart` invocation should be routed to
|
||||
the in-container S6ServiceManager instead of falling through to the
|
||||
host systemd/launchd/windows code path.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class _CallRecorder:
|
||||
"""Minimal stand-in for S6ServiceManager."""
|
||||
kind = "s6"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, str]] = []
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
self.calls.append(("start", name))
|
||||
|
||||
def stop(self, name: str) -> None:
|
||||
self.calls.append(("stop", name))
|
||||
|
||||
def restart(self, name: str) -> None:
|
||||
self.calls.append(("restart", name))
|
||||
|
||||
|
||||
def test_dispatch_returns_false_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When the environment isn't s6 (host run), the helper must
|
||||
return False and not invoke a manager — callers continue with
|
||||
their existing systemd/launchd/windows path."""
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
|
||||
)
|
||||
# Should not even attempt to construct a manager.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail("manager should not be constructed on host"),
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start", profile="x") is False
|
||||
|
||||
|
||||
def test_dispatch_returns_true_and_calls_start_on_s6(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start", profile="coder") is True
|
||||
assert rec.calls == [("start", "gateway-coder")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action,expected", [
|
||||
("start", "start"),
|
||||
("stop", "stop"),
|
||||
("restart", "restart"),
|
||||
])
|
||||
def test_dispatch_translates_action_to_manager_method(
|
||||
monkeypatch: pytest.MonkeyPatch, action: str, expected: str,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6(action, profile="x") is True
|
||||
assert rec.calls == [(expected, "gateway-x")]
|
||||
|
||||
|
||||
def test_dispatch_unknown_action_returns_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""An unrecognized action (e.g. 'install') must not silently
|
||||
succeed — return False so the host code path handles it."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("install", profile="x") is False
|
||||
assert rec.calls == []
|
||||
|
||||
|
||||
def test_dispatch_defaults_profile_to_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When profile is None, the helper resolves it via _profile_arg().
|
||||
With no profile context set anywhere, that resolves to "default"."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _CallRecorder()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._profile_suffix", lambda: "",
|
||||
)
|
||||
assert gw._dispatch_via_service_manager_if_s6("start") is True
|
||||
assert rec.calls == [("start", "gateway-default")]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _dispatch_all_via_service_manager_if_s6 — --all under s6
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ListingRecorder(_CallRecorder):
|
||||
"""_CallRecorder that also exposes a profile list."""
|
||||
|
||||
def __init__(self, profiles: list[str]) -> None:
|
||||
super().__init__()
|
||||
self._profiles = profiles
|
||||
|
||||
def list_profile_gateways(self) -> list[str]:
|
||||
return list(self._profiles)
|
||||
|
||||
|
||||
def test_dispatch_all_returns_false_on_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "systemd",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail("manager should not be constructed on host"),
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is False
|
||||
|
||||
|
||||
def test_dispatch_all_iterates_every_profile_on_stop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder(["coder", "writer", "assistant"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
assert rec.calls == [
|
||||
("stop", "gateway-coder"),
|
||||
("stop", "gateway-writer"),
|
||||
("stop", "gateway-assistant"),
|
||||
]
|
||||
out = capsys.readouterr().out
|
||||
assert "Stopped 3 profile gateway(s)" in out
|
||||
|
||||
|
||||
def test_dispatch_all_iterates_every_profile_on_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder(["coder", "writer"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("restart") is True
|
||||
assert rec.calls == [
|
||||
("restart", "gateway-coder"),
|
||||
("restart", "gateway-writer"),
|
||||
]
|
||||
out = capsys.readouterr().out
|
||||
assert "Restarted 2 profile gateway(s)" in out
|
||||
|
||||
|
||||
def test_dispatch_all_handles_partial_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""A failure on one profile must not skip the others; the helper
|
||||
reports each failure and the success count."""
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
class _FailOnWriter(_ListingRecorder):
|
||||
def stop(self, name: str) -> None:
|
||||
if name == "gateway-writer":
|
||||
raise RuntimeError("supervise FIFO permission denied")
|
||||
super().stop(name)
|
||||
|
||||
rec = _FailOnWriter(["coder", "writer", "assistant"])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
# The two successful ones were called; writer raised before recording.
|
||||
assert ("stop", "gateway-coder") in rec.calls
|
||||
assert ("stop", "gateway-assistant") in rec.calls
|
||||
assert ("stop", "gateway-writer") not in rec.calls
|
||||
out = capsys.readouterr().out
|
||||
assert "Stopped 2 profile gateway(s)" in out
|
||||
assert "Could not stop gateway-writer" in out
|
||||
assert "supervise FIFO permission denied" in out
|
||||
|
||||
|
||||
def test_dispatch_all_empty_list_reports_and_returns_true(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""With no profile gateways registered the helper still claims the
|
||||
dispatch (returns True) and prints a friendly message — the host
|
||||
fallback would just pkill nothing, which isn't useful inside a
|
||||
container."""
|
||||
from hermes_cli import gateway as gw
|
||||
rec = _ListingRecorder([])
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: rec,
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("stop") is True
|
||||
assert rec.calls == []
|
||||
assert "No profile gateways" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_dispatch_all_unknown_action_returns_false(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""`start --all` is not a supported CLI surface; the helper must
|
||||
fall through to the host code path rather than no-op."""
|
||||
from hermes_cli import gateway as gw
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: pytest.fail(
|
||||
"manager should not be constructed for unsupported --all action",
|
||||
),
|
||||
)
|
||||
assert gw._dispatch_all_via_service_manager_if_s6("start") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Friendly error rendering — GatewayNotRegisteredError / S6CommandError
|
||||
# (PR #30136 review item I2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dispatch_renders_gateway_not_registered_friendly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""`hermes -p typo gateway start` should print a clear message and
|
||||
exit 1 — not dump a traceback at the user."""
|
||||
from hermes_cli import gateway as gw
|
||||
from hermes_cli.service_manager import GatewayNotRegisteredError
|
||||
|
||||
class _RaisesMissing:
|
||||
kind = "s6"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
raise GatewayNotRegisteredError("typo")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesMissing(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
gw._dispatch_via_service_manager_if_s6("start", profile="typo")
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "no such gateway 'typo'" in out
|
||||
assert "hermes profile create typo" in out
|
||||
# And critically: no traceback prefix.
|
||||
assert "Traceback" not in out
|
||||
|
||||
|
||||
def test_dispatch_renders_s6_command_error_friendly(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
capsys: pytest.CaptureFixture,
|
||||
) -> None:
|
||||
"""An s6-svc failure (e.g. EACCES on the supervise FIFO) should
|
||||
surface the stderr inline, not as an opaque traceback."""
|
||||
from hermes_cli import gateway as gw
|
||||
from hermes_cli.service_manager import S6CommandError
|
||||
|
||||
class _RaisesS6Error:
|
||||
kind = "s6"
|
||||
|
||||
def start(self, name: str) -> None:
|
||||
raise S6CommandError(
|
||||
service=name,
|
||||
action="start",
|
||||
returncode=111,
|
||||
stderr="s6-svc: fatal: Permission denied",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: _RaisesS6Error(),
|
||||
)
|
||||
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
gw._dispatch_via_service_manager_if_s6("start", profile="coder")
|
||||
assert excinfo.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "rc=111" in out
|
||||
assert "Permission denied" in out
|
||||
assert "Traceback" not in out
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Tests for the Phase 4 s6 hooks in hermes_cli.profiles.
|
||||
|
||||
Specifically: _maybe_register_gateway_service,
|
||||
_maybe_unregister_gateway_service. The integration with
|
||||
create_profile and delete_profile is covered indirectly by the
|
||||
existing TestCreateProfile and TestDeleteProfile classes in
|
||||
tests/hermes_cli/test_profiles.py; here we only exercise the new
|
||||
helper surface that doesn't touch the filesystem.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.profiles import (
|
||||
_maybe_register_gateway_service,
|
||||
_maybe_unregister_gateway_service,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _maybe_register_gateway_service / _maybe_unregister_gateway_service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _HostManager:
|
||||
"""Mimics a host backend that doesn't support runtime registration."""
|
||||
kind = "systemd"
|
||||
|
||||
def supports_runtime_registration(self) -> bool:
|
||||
return False
|
||||
|
||||
def register_profile_gateway(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("host backend register_profile_gateway should not be called")
|
||||
|
||||
def unregister_profile_gateway(self, *args: Any, **kwargs: Any) -> None:
|
||||
raise AssertionError("host backend unregister_profile_gateway should not be called")
|
||||
|
||||
|
||||
class _S6Manager:
|
||||
"""Mimics S6ServiceManager just enough for the hooks."""
|
||||
kind = "s6"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.registered: list[str] = []
|
||||
self.unregistered: list[str] = []
|
||||
self.raise_on_register: Exception | None = None
|
||||
self.raise_on_unregister: Exception | None = None
|
||||
|
||||
def supports_runtime_registration(self) -> bool:
|
||||
return True
|
||||
|
||||
def register_profile_gateway(
|
||||
self, profile: str, *,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> None:
|
||||
if self.raise_on_register is not None:
|
||||
raise self.raise_on_register
|
||||
self.registered.append(profile)
|
||||
|
||||
def unregister_profile_gateway(self, profile: str) -> None:
|
||||
if self.raise_on_unregister is not None:
|
||||
raise self.raise_on_unregister
|
||||
self.unregistered.append(profile)
|
||||
|
||||
|
||||
def _patch_detect_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Pretend we're inside an s6 container so the host short-circuit
|
||||
in :func:`_maybe_register_gateway_service` /
|
||||
:func:`_maybe_unregister_gateway_service` doesn't fire.
|
||||
|
||||
Without this, ``detect_service_manager()`` runs its real
|
||||
implementation (host Linux/macOS in CI), returns ``"systemd"`` or
|
||||
``"launchd"``, and the hooks return early before reaching the
|
||||
patched ``get_service_manager``. Each s6-call-through test
|
||||
explicitly opts into this so the host-no-op tests can still
|
||||
exercise the early-return path.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager",
|
||||
lambda: "s6",
|
||||
)
|
||||
|
||||
|
||||
def test_register_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# NOTE: deliberately DO NOT patch detect_service_manager — we want
|
||||
# the real host detection to kick in and short-circuit before
|
||||
# get_service_manager is ever called. The lambda below is a
|
||||
# defense-in-depth assertion that get_service_manager is never
|
||||
# reached on host.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
)
|
||||
# Should NOT raise the AssertionError from _HostManager.register
|
||||
_maybe_register_gateway_service("hostprof")
|
||||
|
||||
|
||||
def test_register_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
)
|
||||
_maybe_register_gateway_service("coder")
|
||||
assert mgr.registered == ["coder"]
|
||||
|
||||
|
||||
def test_register_swallows_duplicate_value_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A pre-existing s6 registration (from container-boot reconcile)
|
||||
is a benign condition — register must not propagate ValueError."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_register = ValueError("already registered")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
)
|
||||
# Should NOT raise
|
||||
_maybe_register_gateway_service("coder")
|
||||
|
||||
|
||||
def test_register_swallows_arbitrary_error(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""Even an unexpected exception from the manager must not bring
|
||||
down `hermes profile create` — print and continue."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_register = RuntimeError("svscanctl exploded")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
)
|
||||
_maybe_register_gateway_service("coder")
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not register" in captured.out
|
||||
|
||||
|
||||
def test_register_swallows_no_backend_runtime_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When `get_service_manager()` raises RuntimeError (no backend
|
||||
detected), the hook must silently no-op."""
|
||||
_patch_detect_s6(monkeypatch)
|
||||
def _no_backend() -> None:
|
||||
raise RuntimeError("no supported service manager detected")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", _no_backend,
|
||||
)
|
||||
# Should NOT raise
|
||||
_maybe_register_gateway_service("anywhere")
|
||||
|
||||
|
||||
def test_register_silent_when_detect_throws(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""If detect_service_manager itself raises (e.g. a partial s6
|
||||
install on a host machine), the hook must stay silent — no
|
||||
confusing s6 warning printed to a user who has never touched a
|
||||
container."""
|
||||
def _broken_detect() -> str:
|
||||
raise RuntimeError("detection blew up")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", _broken_detect,
|
||||
)
|
||||
# If get_service_manager is reached, the test will assert via
|
||||
# _HostManager.register. It must NOT be reached.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
)
|
||||
_maybe_register_gateway_service("anywhere")
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not register" not in captured.out
|
||||
assert captured.out == ""
|
||||
|
||||
|
||||
def test_unregister_noop_on_host(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Same as test_register_noop_on_host: rely on real host detection.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager",
|
||||
lambda: _HostManager(),
|
||||
)
|
||||
_maybe_unregister_gateway_service("hostprof")
|
||||
|
||||
|
||||
def test_unregister_calls_through_on_s6(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
)
|
||||
_maybe_unregister_gateway_service("coder")
|
||||
assert mgr.unregistered == ["coder"]
|
||||
|
||||
|
||||
def test_unregister_swallows_errors(
|
||||
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
_patch_detect_s6(monkeypatch)
|
||||
mgr = _S6Manager()
|
||||
mgr.raise_on_unregister = RuntimeError("svc gone weird")
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.get_service_manager", lambda: mgr,
|
||||
)
|
||||
_maybe_unregister_gateway_service("coder")
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not unregister" in captured.out
|
||||
@@ -0,0 +1,793 @@
|
||||
"""Tests for hermes_cli.service_manager — the abstract ServiceManager
|
||||
protocol, the detect_service_manager() entry point, and the host-side
|
||||
adapter wrappers (Systemd / Launchd / Windows).
|
||||
|
||||
The s6 backend is added in Phase 3; its tests live alongside the
|
||||
implementation in this same file once that phase ships.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.service_manager import (
|
||||
LaunchdServiceManager,
|
||||
S6ServiceManager,
|
||||
ServiceManager,
|
||||
ServiceManagerKind,
|
||||
SystemdServiceManager,
|
||||
WindowsServiceManager,
|
||||
detect_service_manager,
|
||||
get_service_manager,
|
||||
validate_profile_name,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_profile_name
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_profile_name_accepts_valid_names() -> None:
|
||||
# Smoke: known-good names should not raise.
|
||||
validate_profile_name("coder")
|
||||
validate_profile_name("my-profile")
|
||||
validate_profile_name("assistant_v2")
|
||||
validate_profile_name("a")
|
||||
validate_profile_name("0")
|
||||
validate_profile_name("0abc")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"", # empty
|
||||
"Coder", # uppercase
|
||||
"foo/bar", # path traversal
|
||||
"../escape", # path traversal
|
||||
"-leading-dash", # leading dash (s6 reads as a flag)
|
||||
"_leading_underscore", # leading underscore
|
||||
"name with spaces", # whitespace
|
||||
"name.with.dots", # punctuation
|
||||
"a" * 252, # too long
|
||||
],
|
||||
)
|
||||
def test_validate_profile_name_rejects_invalid(bad: str) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
validate_profile_name(bad)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_service_manager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_service_manager_returns_known_value() -> None:
|
||||
"""Without mocking, the function must still return one of the
|
||||
advertised literals — anything else means a new platform branch
|
||||
was added without updating ServiceManagerKind."""
|
||||
result = detect_service_manager()
|
||||
assert result in ("systemd", "launchd", "windows", "s6", "none")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _s6_running — must work for unprivileged users, not just root
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _patch_s6_paths(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
comm: str | OSError | None,
|
||||
basedir_is_dir: bool,
|
||||
) -> None:
|
||||
"""Stub /proc/1/comm and /run/s6/basedir for _s6_running tests."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
real_read_text = _Path.read_text
|
||||
real_is_dir = _Path.is_dir
|
||||
|
||||
def fake_read_text(self, *args, **kwargs): # type: ignore[override]
|
||||
if str(self) == "/proc/1/comm":
|
||||
if isinstance(comm, OSError):
|
||||
raise comm
|
||||
if comm is None:
|
||||
raise FileNotFoundError(2, "No such file or directory")
|
||||
return comm + "\n"
|
||||
return real_read_text(self, *args, **kwargs)
|
||||
|
||||
def fake_is_dir(self): # type: ignore[override]
|
||||
if str(self) == "/run/s6/basedir":
|
||||
return basedir_is_dir
|
||||
return real_is_dir(self)
|
||||
|
||||
monkeypatch.setattr(_Path, "read_text", fake_read_text)
|
||||
monkeypatch.setattr(_Path, "is_dir", fake_is_dir)
|
||||
|
||||
|
||||
def test_s6_running_true_when_comm_and_basedir_match(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import _s6_running
|
||||
|
||||
_patch_s6_paths(monkeypatch, comm="s6-svscan", basedir_is_dir=True)
|
||||
assert _s6_running() is True
|
||||
|
||||
|
||||
def test_s6_running_false_when_comm_is_wrong(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import _s6_running
|
||||
|
||||
# systemd as PID 1, basedir present from some stray s6 install
|
||||
_patch_s6_paths(monkeypatch, comm="systemd", basedir_is_dir=True)
|
||||
assert _s6_running() is False
|
||||
|
||||
|
||||
def test_s6_running_false_when_basedir_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import _s6_running
|
||||
|
||||
# The comm matches but the basedir is missing — e.g. an unrelated
|
||||
# process happens to be named "s6-svscan"
|
||||
_patch_s6_paths(monkeypatch, comm="s6-svscan", basedir_is_dir=False)
|
||||
assert _s6_running() is False
|
||||
|
||||
|
||||
def test_s6_running_false_when_comm_unreadable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: /proc/1/exe was unreadable to UID 10000 and
|
||||
resolve() silently returned the unresolved path, making detection
|
||||
always-False inside the container under the hermes user. The new
|
||||
probe must FAIL CLOSED — not raise — when /proc/1/comm can't be
|
||||
read.
|
||||
"""
|
||||
from hermes_cli.service_manager import _s6_running
|
||||
|
||||
_patch_s6_paths(
|
||||
monkeypatch,
|
||||
comm=PermissionError(13, "Permission denied"),
|
||||
basedir_is_dir=True,
|
||||
)
|
||||
assert _s6_running() is False
|
||||
|
||||
|
||||
def test_s6_running_handles_missing_proc(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""On macOS / Windows / WSL-without-procfs, /proc/1/comm doesn't
|
||||
exist. Must return False, not raise."""
|
||||
from hermes_cli.service_manager import _s6_running
|
||||
|
||||
_patch_s6_paths(monkeypatch, comm=None, basedir_is_dir=False)
|
||||
assert _s6_running() is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend wrappers — kind + registration unsupported on hosts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_systemd_manager_kind_and_registration_unsupported() -> None:
|
||||
mgr = SystemdServiceManager()
|
||||
assert mgr.kind == "systemd"
|
||||
assert mgr.supports_runtime_registration() is False
|
||||
with pytest.raises(NotImplementedError):
|
||||
mgr.register_profile_gateway("foo")
|
||||
with pytest.raises(NotImplementedError):
|
||||
mgr.unregister_profile_gateway("foo")
|
||||
assert mgr.list_profile_gateways() == []
|
||||
# Protocol conformance — runtime_checkable lets us assert this.
|
||||
assert isinstance(mgr, ServiceManager)
|
||||
|
||||
|
||||
def test_launchd_manager_kind_and_registration_unsupported() -> None:
|
||||
mgr = LaunchdServiceManager()
|
||||
assert mgr.kind == "launchd"
|
||||
assert mgr.supports_runtime_registration() is False
|
||||
with pytest.raises(NotImplementedError):
|
||||
mgr.register_profile_gateway("foo")
|
||||
assert mgr.list_profile_gateways() == []
|
||||
assert isinstance(mgr, ServiceManager)
|
||||
|
||||
|
||||
def test_windows_manager_kind_and_registration_unsupported() -> None:
|
||||
mgr = WindowsServiceManager()
|
||||
assert mgr.kind == "windows"
|
||||
assert mgr.supports_runtime_registration() is False
|
||||
with pytest.raises(NotImplementedError):
|
||||
mgr.register_profile_gateway("foo")
|
||||
assert isinstance(mgr, ServiceManager)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle delegation — wrappers must call through to module-level fns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_systemd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
called: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.systemd_start", lambda: called.append("start"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.systemd_stop", lambda: called.append("stop"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.systemd_restart", lambda: called.append("restart"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._probe_systemd_service_running",
|
||||
lambda *a, **kw: (False, True),
|
||||
)
|
||||
mgr = SystemdServiceManager()
|
||||
mgr.start("ignored")
|
||||
mgr.stop("ignored")
|
||||
mgr.restart("ignored")
|
||||
assert called == ["start", "stop", "restart"]
|
||||
assert mgr.is_running("ignored") is True
|
||||
|
||||
|
||||
def test_launchd_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
called: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.launchd_start", lambda: called.append("start"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.launchd_stop", lambda: called.append("stop"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.launchd_restart", lambda: called.append("restart"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway._probe_launchd_service_running", lambda: False,
|
||||
)
|
||||
mgr = LaunchdServiceManager()
|
||||
mgr.start("ignored")
|
||||
mgr.stop("ignored")
|
||||
mgr.restart("ignored")
|
||||
assert called == ["start", "stop", "restart"]
|
||||
assert mgr.is_running("ignored") is False
|
||||
|
||||
|
||||
def test_windows_manager_lifecycle_delegates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
called: list[str] = []
|
||||
# Force-import the submodule so monkeypatch's attribute lookup
|
||||
# against the `hermes_cli` package succeeds — gateway_windows is
|
||||
# imported lazily inside the wrapper and may not yet be loaded.
|
||||
import hermes_cli.gateway_windows # noqa: F401
|
||||
|
||||
class _FakeWindowsModule:
|
||||
@staticmethod
|
||||
def start() -> None: called.append("start")
|
||||
@staticmethod
|
||||
def stop() -> None: called.append("stop")
|
||||
@staticmethod
|
||||
def restart() -> None: called.append("restart")
|
||||
@staticmethod
|
||||
def is_installed() -> bool: return True
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.find_gateway_pids",
|
||||
lambda **kw: [12345],
|
||||
)
|
||||
mgr = WindowsServiceManager()
|
||||
mgr.start("ignored")
|
||||
mgr.stop("ignored")
|
||||
mgr.restart("ignored")
|
||||
assert called == ["start", "stop", "restart"]
|
||||
assert mgr.is_running("ignored") is True
|
||||
|
||||
|
||||
def test_windows_manager_is_running_false_when_not_installed(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import hermes_cli.gateway_windows # noqa: F401
|
||||
|
||||
class _FakeWindowsModule:
|
||||
@staticmethod
|
||||
def is_installed() -> bool: return False
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.gateway.find_gateway_pids",
|
||||
lambda **kw: [12345], # PIDs would otherwise vote "running"
|
||||
)
|
||||
assert WindowsServiceManager().is_running("ignored") is False
|
||||
|
||||
|
||||
def test_windows_manager_install_forwards_kwargs(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
import hermes_cli.gateway_windows # noqa: F401
|
||||
|
||||
class _FakeWindowsModule:
|
||||
@staticmethod
|
||||
def install(*, force, start_now, start_on_login, elevated_handoff) -> None:
|
||||
captured["force"] = force
|
||||
captured["start_now"] = start_now
|
||||
captured["start_on_login"] = start_on_login
|
||||
captured["elevated_handoff"] = elevated_handoff
|
||||
|
||||
monkeypatch.setattr("hermes_cli.gateway_windows", _FakeWindowsModule)
|
||||
WindowsServiceManager().install(
|
||||
force=True, start_now=True, start_on_login=False, elevated_handoff=True,
|
||||
)
|
||||
assert captured == {
|
||||
"force": True,
|
||||
"start_now": True,
|
||||
"start_on_login": False,
|
||||
"elevated_handoff": True,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_service_manager factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kind,cls",
|
||||
[
|
||||
("systemd", SystemdServiceManager),
|
||||
("launchd", LaunchdServiceManager),
|
||||
("windows", WindowsServiceManager),
|
||||
],
|
||||
)
|
||||
def test_get_service_manager_returns_correct_backend(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
kind: ServiceManagerKind,
|
||||
cls: type,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: kind,
|
||||
)
|
||||
assert isinstance(get_service_manager(), cls)
|
||||
|
||||
|
||||
def test_get_service_manager_raises_when_unsupported(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "none",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="no supported service manager"):
|
||||
get_service_manager()
|
||||
|
||||
|
||||
def test_get_service_manager_returns_s6_instance(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The s6 backend ships in Phase 3 — the factory must return an
|
||||
S6ServiceManager when running inside a container."""
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.service_manager.detect_service_manager", lambda: "s6",
|
||||
)
|
||||
assert isinstance(get_service_manager(), S6ServiceManager)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S6ServiceManager — unit tests against a tmp-path scandir (no real s6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s6_scandir(tmp_path):
|
||||
"""Empty scandir for the S6ServiceManager tests."""
|
||||
d = tmp_path / "service"
|
||||
d.mkdir()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_subprocess_run(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Capture subprocess.run calls + always return success. Lets the
|
||||
S6ServiceManager tests run on hosts that don't have s6-svc /
|
||||
s6-svscanctl installed.
|
||||
|
||||
Records are normalized: leading ``/command/`` is stripped from
|
||||
cmd[0] so assertions can match on the bare s6-svc / s6-svstat /
|
||||
s6-svscanctl name regardless of whether the manager calls them
|
||||
via absolute path or bare name."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def _fake(cmd, **kw):
|
||||
import subprocess as _sp
|
||||
seq = list(cmd) if isinstance(cmd, (list, tuple)) else [str(cmd)]
|
||||
if seq and seq[0].startswith("/command/"):
|
||||
seq[0] = seq[0][len("/command/"):]
|
||||
calls.append(seq)
|
||||
return _sp.CompletedProcess(cmd, 0, "", "")
|
||||
|
||||
monkeypatch.setattr("subprocess.run", _fake)
|
||||
return calls
|
||||
|
||||
|
||||
def test_s6_manager_kind_and_supports_registration() -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager()
|
||||
assert mgr.kind == "s6"
|
||||
assert mgr.supports_runtime_registration() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _seed_supervise_skeleton — unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The skeleton helper pre-creates the dirs and FIFOs that s6-supervise
|
||||
# would otherwise create as root mode 0700, locking out the
|
||||
# unprivileged hermes user from every lifecycle op. These tests run
|
||||
# against tmp_path and assert the produced layout — the live-container
|
||||
# verification (against real s6-svc / s6-svstat) lives in
|
||||
# tests/docker/test_s6_profile_gateway_integration.py.
|
||||
|
||||
|
||||
def test_seed_supervise_skeleton_creates_expected_layout(tmp_path) -> None:
|
||||
"""Verifies the dirs + FIFO + modes the helper lays down."""
|
||||
import stat
|
||||
|
||||
from hermes_cli.service_manager import _seed_supervise_skeleton
|
||||
|
||||
svc_dir = tmp_path / "gateway-foo"
|
||||
svc_dir.mkdir()
|
||||
|
||||
_seed_supervise_skeleton(svc_dir)
|
||||
|
||||
# Top-level event/ — s6-svlisten1 event subscription dir.
|
||||
event = svc_dir / "event"
|
||||
assert event.is_dir(), "missing top-level event/"
|
||||
assert stat.S_IMODE(event.stat().st_mode) == 0o3730, (
|
||||
f"event/ mode = {oct(event.stat().st_mode)}, want 03730"
|
||||
)
|
||||
|
||||
# supervise/ dir.
|
||||
supervise = svc_dir / "supervise"
|
||||
assert supervise.is_dir(), "missing supervise/"
|
||||
assert stat.S_IMODE(supervise.stat().st_mode) == 0o755
|
||||
|
||||
# supervise/event/.
|
||||
supervise_event = supervise / "event"
|
||||
assert supervise_event.is_dir(), "missing supervise/event/"
|
||||
assert stat.S_IMODE(supervise_event.stat().st_mode) == 0o3730
|
||||
|
||||
# supervise/control FIFO.
|
||||
control = supervise / "control"
|
||||
assert control.exists(), "missing supervise/control FIFO"
|
||||
assert stat.S_ISFIFO(control.stat().st_mode), (
|
||||
"supervise/control must be a FIFO"
|
||||
)
|
||||
assert stat.S_IMODE(control.stat().st_mode) == 0o660
|
||||
|
||||
|
||||
def test_seed_supervise_skeleton_handles_log_subservice(tmp_path) -> None:
|
||||
"""When a log/ subdir exists, its supervise tree also gets seeded.
|
||||
|
||||
Without this, ``unregister_profile_gateway``'s rmtree would EACCES
|
||||
on the logger's root-owned supervise dir even after the parent
|
||||
slot's supervise/ was hermes-owned.
|
||||
"""
|
||||
import stat
|
||||
|
||||
from hermes_cli.service_manager import _seed_supervise_skeleton
|
||||
|
||||
svc_dir = tmp_path / "gateway-foo"
|
||||
svc_dir.mkdir()
|
||||
(svc_dir / "log").mkdir() # logger subdir present
|
||||
|
||||
_seed_supervise_skeleton(svc_dir)
|
||||
|
||||
# Logger's own supervise tree is seeded the same way.
|
||||
log_event = svc_dir / "log" / "event"
|
||||
log_supervise = svc_dir / "log" / "supervise"
|
||||
log_supervise_event = log_supervise / "event"
|
||||
log_control = log_supervise / "control"
|
||||
|
||||
assert log_event.is_dir()
|
||||
assert stat.S_IMODE(log_event.stat().st_mode) == 0o3730
|
||||
assert log_supervise.is_dir()
|
||||
assert log_supervise_event.is_dir()
|
||||
assert log_control.exists() and stat.S_ISFIFO(log_control.stat().st_mode)
|
||||
|
||||
|
||||
def test_seed_supervise_skeleton_skips_when_no_log_subservice(tmp_path) -> None:
|
||||
"""If log/ isn't present, no logger skeleton is created."""
|
||||
from hermes_cli.service_manager import _seed_supervise_skeleton
|
||||
|
||||
svc_dir = tmp_path / "gateway-foo"
|
||||
svc_dir.mkdir()
|
||||
|
||||
_seed_supervise_skeleton(svc_dir)
|
||||
|
||||
assert not (svc_dir / "log").exists(), (
|
||||
"helper must not synthesize a log/ subdir on its own"
|
||||
)
|
||||
|
||||
|
||||
def test_seed_supervise_skeleton_is_idempotent(tmp_path) -> None:
|
||||
"""Calling the helper twice on the same dir is a no-op the second time.
|
||||
|
||||
Important because s6-supervise may have already opened the FIFO
|
||||
when a re-register / reconcile happens; double-creation would
|
||||
error out. The helper short-circuits on existence.
|
||||
"""
|
||||
from hermes_cli.service_manager import _seed_supervise_skeleton
|
||||
|
||||
svc_dir = tmp_path / "gateway-foo"
|
||||
svc_dir.mkdir()
|
||||
|
||||
_seed_supervise_skeleton(svc_dir)
|
||||
_seed_supervise_skeleton(svc_dir) # must not raise
|
||||
|
||||
|
||||
def test_s6_register_creates_service_dir_and_triggers_scan(
|
||||
s6_scandir, fake_subprocess_run,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
mgr.register_profile_gateway("coder")
|
||||
|
||||
svc_dir = s6_scandir / "gateway-coder"
|
||||
assert svc_dir.is_dir()
|
||||
assert (svc_dir / "type").read_text().strip() == "longrun"
|
||||
|
||||
run_path = svc_dir / "run"
|
||||
assert run_path.is_file()
|
||||
assert run_path.stat().st_mode & 0o111 # executable
|
||||
run_text = run_path.read_text()
|
||||
assert "hermes -p coder gateway run" in run_text
|
||||
assert "s6-setuidgid hermes" in run_text
|
||||
|
||||
log_run = svc_dir / "log" / "run"
|
||||
assert log_run.is_file()
|
||||
log_text = log_run.read_text()
|
||||
# CRITICAL: HERMES_HOME must be a runtime env-var expansion, NOT
|
||||
# a Python-substituted absolute path. Negative-assert the wrong
|
||||
# form so future regressions are caught.
|
||||
assert "$HERMES_HOME" in log_text
|
||||
assert "logs/gateways/coder" in log_text
|
||||
assert "/opt/data/logs/gateways/coder" not in log_text, (
|
||||
"log_dir was hard-coded; must use ${HERMES_HOME} at run time"
|
||||
)
|
||||
|
||||
# s6-svscanctl -a was invoked against the scandir
|
||||
assert any(
|
||||
cmd[0] == "s6-svscanctl" and "-a" in cmd
|
||||
and str(s6_scandir) in cmd
|
||||
for cmd in fake_subprocess_run
|
||||
), f"s6-svscanctl -a not invoked; saw: {fake_subprocess_run}"
|
||||
|
||||
|
||||
def test_s6_register_extra_env_is_quoted(s6_scandir, fake_subprocess_run) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
mgr.register_profile_gateway(
|
||||
"x", extra_env={"FOO": "bar baz", "QUOTED": "a'b"},
|
||||
)
|
||||
run_text = (s6_scandir / "gateway-x" / "run").read_text()
|
||||
# shlex.quote should have wrapped both values
|
||||
assert "export FOO='bar baz'" in run_text
|
||||
assert "export QUOTED='a'\"'\"'b'" in run_text
|
||||
|
||||
|
||||
def test_s6_register_rejects_invalid_profile_name(s6_scandir) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
with pytest.raises(ValueError):
|
||||
mgr.register_profile_gateway("Bad/Name")
|
||||
|
||||
|
||||
def test_s6_register_rejects_duplicate(s6_scandir, fake_subprocess_run) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
(s6_scandir / "gateway-coder").mkdir(parents=True)
|
||||
with pytest.raises(ValueError, match="already registered"):
|
||||
mgr.register_profile_gateway("coder")
|
||||
|
||||
|
||||
def test_s6_register_rolls_back_on_svscanctl_failure(
|
||||
s6_scandir, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If s6-svscanctl fails the service dir must be cleaned up so the
|
||||
next register call doesn't see a stale duplicate."""
|
||||
import subprocess as _sp
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
|
||||
def _fail_scanctl(cmd, **kw):
|
||||
# Manager calls s6-svscanctl by absolute path; match on basename.
|
||||
if cmd[0].endswith("/s6-svscanctl"):
|
||||
return _sp.CompletedProcess(cmd, 1, "", "rescan failed")
|
||||
return _sp.CompletedProcess(cmd, 0, "", "")
|
||||
monkeypatch.setattr("subprocess.run", _fail_scanctl)
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
with pytest.raises(RuntimeError, match="s6-svscanctl failed"):
|
||||
mgr.register_profile_gateway("coder")
|
||||
assert not (s6_scandir / "gateway-coder").exists()
|
||||
|
||||
|
||||
def test_s6_unregister_removes_service_dir(
|
||||
s6_scandir, fake_subprocess_run,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
svc_dir = s6_scandir / "gateway-coder"
|
||||
svc_dir.mkdir(parents=True)
|
||||
(svc_dir / "type").write_text("longrun\n")
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
mgr.unregister_profile_gateway("coder")
|
||||
|
||||
# s6-svc -d was issued
|
||||
assert any(
|
||||
cmd[0] == "s6-svc" and "-d" in cmd
|
||||
for cmd in fake_subprocess_run
|
||||
)
|
||||
# Service dir was removed
|
||||
assert not svc_dir.exists()
|
||||
# Rescan was triggered
|
||||
assert any(cmd[0] == "s6-svscanctl" for cmd in fake_subprocess_run)
|
||||
|
||||
|
||||
def test_s6_unregister_absent_profile_is_noop(s6_scandir) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
# Should NOT raise even though "ghost" doesn't exist
|
||||
S6ServiceManager(scandir=s6_scandir).unregister_profile_gateway("ghost")
|
||||
|
||||
|
||||
def test_s6_list_profile_gateways(s6_scandir) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
# Three gateway profiles + one unrelated service + one hidden dir
|
||||
(s6_scandir / "gateway-coder").mkdir()
|
||||
(s6_scandir / "gateway-assistant").mkdir()
|
||||
(s6_scandir / "gateway-writer").mkdir()
|
||||
(s6_scandir / "s6-linux-init-shutdownd").mkdir() # filtered out
|
||||
(s6_scandir / ".lock").mkdir() # filtered out (hidden)
|
||||
|
||||
profiles = sorted(S6ServiceManager(scandir=s6_scandir).list_profile_gateways())
|
||||
assert profiles == ["assistant", "coder", "writer"]
|
||||
|
||||
|
||||
def test_s6_list_profile_gateways_empty_when_scandir_missing(tmp_path) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
missing = tmp_path / "does-not-exist"
|
||||
assert S6ServiceManager(scandir=missing).list_profile_gateways() == []
|
||||
|
||||
|
||||
def test_s6_lifecycle_dispatches_to_s6_svc(
|
||||
s6_scandir, fake_subprocess_run,
|
||||
) -> None:
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
# _run_svc now verifies the slot exists before invoking s6-svc, so
|
||||
# we have to pre-seed the dir. In real use the slot is created by
|
||||
# register_profile_gateway or the cont-init.d reconciler.
|
||||
(s6_scandir / "gateway-coder").mkdir()
|
||||
mgr.start("gateway-coder")
|
||||
mgr.stop("gateway-coder")
|
||||
mgr.restart("gateway-coder")
|
||||
|
||||
flags = [c[1] for c in fake_subprocess_run if c[0] == "s6-svc"]
|
||||
assert flags == ["-u", "-d", "-t"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle errors — friendly messages, not raw CalledProcessError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_lifecycle_raises_gateway_not_registered_for_missing_slot(
|
||||
s6_scandir, fake_subprocess_run,
|
||||
) -> None:
|
||||
"""When the service slot doesn't exist, the lifecycle methods
|
||||
must raise GatewayNotRegisteredError BEFORE invoking s6-svc, so
|
||||
the user sees a clear 'no such gateway' message instead of an
|
||||
opaque CalledProcessError stacktrace."""
|
||||
from hermes_cli.service_manager import (
|
||||
GatewayNotRegisteredError,
|
||||
S6ServiceManager,
|
||||
)
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
# No gateway-typo/ directory exists — slot is missing.
|
||||
with pytest.raises(GatewayNotRegisteredError) as excinfo:
|
||||
mgr.start("gateway-typo")
|
||||
assert excinfo.value.profile == "typo"
|
||||
assert excinfo.value.service == "gateway-typo"
|
||||
msg = str(excinfo.value)
|
||||
assert "'typo'" in msg
|
||||
assert "hermes profile create typo" in msg
|
||||
# And critically: s6-svc was NOT invoked.
|
||||
assert not any(c[0] == "s6-svc" for c in fake_subprocess_run)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action,method_name", [
|
||||
("start", "start"),
|
||||
("stop", "stop"),
|
||||
("restart", "restart"),
|
||||
])
|
||||
def test_all_lifecycle_methods_check_for_missing_slot(
|
||||
s6_scandir,
|
||||
fake_subprocess_run,
|
||||
action: str,
|
||||
method_name: str,
|
||||
) -> None:
|
||||
"""start/stop/restart all check for missing slots the same way."""
|
||||
from hermes_cli.service_manager import (
|
||||
GatewayNotRegisteredError,
|
||||
S6ServiceManager,
|
||||
)
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
with pytest.raises(GatewayNotRegisteredError):
|
||||
getattr(mgr, method_name)("gateway-absent")
|
||||
|
||||
|
||||
def test_gateway_not_registered_unprefixed_service_name(s6_scandir) -> None:
|
||||
"""If the caller passes a name without the 'gateway-' prefix (the
|
||||
Protocol allows arbitrary service names), the error still carries
|
||||
that name verbatim as the 'profile' so error messages don't
|
||||
accidentally strip user-provided text."""
|
||||
from hermes_cli.service_manager import (
|
||||
GatewayNotRegisteredError,
|
||||
S6ServiceManager,
|
||||
)
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
with pytest.raises(GatewayNotRegisteredError) as excinfo:
|
||||
mgr.start("not-prefixed")
|
||||
assert excinfo.value.profile == "not-prefixed"
|
||||
|
||||
|
||||
def test_lifecycle_raises_s6_command_error_on_subprocess_failure(
|
||||
s6_scandir, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""When s6-svc itself fails (non-zero exit) — e.g. EACCES on the
|
||||
supervise control FIFO — the lifecycle methods translate the
|
||||
CalledProcessError into a named S6CommandError carrying the
|
||||
return code and stderr."""
|
||||
import subprocess as _sp
|
||||
from hermes_cli.service_manager import S6CommandError, S6ServiceManager
|
||||
|
||||
# Pre-create the slot so we reach the s6-svc call.
|
||||
(s6_scandir / "gateway-coder").mkdir()
|
||||
|
||||
def _fail(cmd, **kw):
|
||||
raise _sp.CalledProcessError(
|
||||
returncode=111,
|
||||
cmd=cmd,
|
||||
stderr="s6-svc: fatal: unable to control supervise/control: "
|
||||
"Permission denied\n",
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _fail)
|
||||
|
||||
mgr = S6ServiceManager(scandir=s6_scandir)
|
||||
with pytest.raises(S6CommandError) as excinfo:
|
||||
mgr.start("gateway-coder")
|
||||
assert excinfo.value.service == "gateway-coder"
|
||||
assert excinfo.value.action == "start"
|
||||
assert excinfo.value.returncode == 111
|
||||
assert "Permission denied" in excinfo.value.stderr
|
||||
assert "Permission denied" in str(excinfo.value)
|
||||
assert "rc=111" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_s6_is_running_parses_svstat(
|
||||
s6_scandir, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import subprocess as _sp
|
||||
from hermes_cli.service_manager import S6ServiceManager
|
||||
|
||||
def _svstat(cmd, **kw):
|
||||
if cmd[0].endswith("/s6-svstat"):
|
||||
return _sp.CompletedProcess(cmd, 0, "up (pid 42) 17 seconds\n", "")
|
||||
return _sp.CompletedProcess(cmd, 0, "", "")
|
||||
monkeypatch.setattr("subprocess.run", _svstat)
|
||||
assert S6ServiceManager(scandir=s6_scandir).is_running("gateway-coder") is True
|
||||
|
||||
def _svstat_down(cmd, **kw):
|
||||
if cmd[0].endswith("/s6-svstat"):
|
||||
return _sp.CompletedProcess(cmd, 0, "down 5 seconds\n", "")
|
||||
return _sp.CompletedProcess(cmd, 0, "", "")
|
||||
monkeypatch.setattr("subprocess.run", _svstat_down)
|
||||
assert S6ServiceManager(scandir=s6_scandir).is_running("gateway-coder") is False
|
||||
Reference in New Issue
Block a user