Files
hermes-agent/tests/hermes_cli/test_gateway_migrate_multiplex.py
teknium1 4cb2c8ae76 fix(review): a named profile's restart --all never runs the root gateway in-process
MAJOR: `hermes -p X gateway restart --all` with no installed service re-entered
`run_gateway(replace=True)` IN the CLI process. `_home_env` swapped only HERMES_HOME
while os.environ already held X's dotenv (loaded at CLI startup) and gateway/run.py's
root .env load does not clear inherited keys, so the default gateway came up with X's
TELEGRAM_BOT_TOKEN / OPENAI_API_KEY. And any child spawned inside the swap (Windows
`start --all`, the launchd/detached fallback, migrate's secondary spawns) read the
swapped home as the launch profile, so `served_profile_child_env` neither stripped nor
scrubbed X's env.

- `_home_env` pins the process's launch home for the swap (`pin_process_hermes_home`),
  so routed-home decisions inside it keep the real identity; `strip_launch_profile_env`
  reads the residue list from that pinned home too.
- `_restart_all_as_host` spawns the root detached (`_spawn_detached_gateway`, scrubbed
  base env + the root's own secret scope, `gateway run --replace`) when the CLI runs
  under a named profile; the default profile keeps its foreground run. The Desktop
  update hand-off's `gateway start --all` under a named active profile is still served.
- test: `test_named_profile_restart_all_with_host_down_restarts_the_default_root` now
  asserts the child env carries none of the named profile's keys and `run_gateway` is
  never entered (red on the PR head: "the root ran IN the named profile's process").

MINORS:
- (a) `gateway_migrate._service_op('enable')` raises on a non-zero `systemctl enable`;
  a fresh apply treats it as a preflight refusal before the flag/manifest write, a
  resume reports it and converges. New `test_migrate_refuses_when_the_survivor_cannot_
  be_enabled` (red on head: exit 0 with the ⚠ line).
- (b) `systemd_install` already-current branch warns when `systemctl enable` fails.
- (c) `remove_system_systemd_unit` reports a failed `systemctl stop` instead of ✓.
2026-09-28 04:04:23 -07:00

991 lines
54 KiB
Python

"""``hermes gateway migrate``: preflight verdicts, apply/rollback bookkeeping, and the update hook.
Service layer is faked through the module's ``_installed_services`` / ``_service_op`` seams (the same
shape ``hermes gateway install`` tests use); the default gateway boot is faked by writing the
``served_profiles`` record the real multiplexer writes. Blockers reuse the gateway's own credential
fingerprint and port-binding predicates, so the tests assert verdict → effect, not internal lists.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from types import SimpleNamespace
import pytest
import hermes_constants
from hermes_cli import gateway_migrate as gm
# Captured before any fixture fakes the seam: the one test that drives the real service leg.
_REAL_SERVICE_OP = gm._service_op
@pytest.fixture
def fleet(tmp_path, monkeypatch):
"""default + coder + ops; both secondaries run a 'live' standalone gateway with a systemd unit."""
root = tmp_path / "hermes"
for sub in ("profiles/coder", "profiles/ops"):
(root / sub).mkdir(parents=True)
(root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
(root / ".env").write_text("TELEGRAM_BOT_TOKEN=111111:default-token\n", encoding="utf-8")
(root / "profiles/coder/.env").write_text("TELEGRAM_BOT_TOKEN=222222:coder-token\n", encoding="utf-8")
(root / "profiles/ops/.env").write_text("DISCORD_BOT_TOKEN=ops-discord-333333\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.delenv("GATEWAY_MULTIPLEX_PROFILES", raising=False)
for name in ("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "API_SERVER_KEY", "WEBHOOK_ENABLED"):
monkeypatch.delenv(name, raising=False)
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
state = SimpleNamespace(
# profile -> installed unit(s); a tuple is one unit, a list is every installed unit.
services={"coder": ("systemd", False), "ops": ("systemd", False)},
pids={"coder": 4101, "ops": 4102},
ops=[],
refused_at_start={},
# (profile, kind, system) of units whose boot enablement survived; secondaries boot-start,
# the pre-existing default unit starts DISABLED unless a test/migration enables it.
enabled={("coder", "systemd", False), ("ops", "systemd", False)},
)
def _service_op(kind, system, verb, home, *, run_as_user=None):
name = _name(home)
state.ops.append((name, verb))
if verb == "start" and name != "default":
# What the real `hermes -p <name> gateway run` checks first: is a live multiplexer
# still recorded as serving me? (exit 78 if so — the unit is then parked for good).
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
state.refused_at_start[name] = named_profile_served_by_running_multiplexer(name)
if verb == "uninstall":
remaining = [u for u in _units(state.services.get(name)) if u != (kind, system)]
if remaining:
state.services[name] = remaining
else:
state.services.pop(name, None)
state.enabled.discard((name, kind, system))
elif verb == "install":
state.services[name] = (kind, system)
state.enabled.add((name, kind, system))
elif verb == "enable":
state.enabled.add((name, kind, system))
elif verb in ("start", "restart") and name == "default":
(root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)}))
runtime_path = root / "gateway_state.json"
runtime = json.loads(runtime_path.read_text()) if runtime_path.exists() else {}
runtime.update({"pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running"})
# Multiplex startup records ownership; standalone startup historically preserved the
# old key, which is the stale-state half of #109473's rollback failure.
if _config_flag(root):
runtime["served_profiles"] = ["default", "coder", "ops"]
runtime_path.write_text(json.dumps(runtime))
import gateway.status as status
# The default gateway the fixture "starts" is this process; the served probe verifies identity.
monkeypatch.setattr(status, "_read_process_cmdline", lambda pid: "hermes gateway run")
monkeypatch.setattr(gm, "_installed_services", lambda home: _units(state.services.get(_name(home))))
monkeypatch.setattr(gm, "_live_gateway_pid", lambda home: state.pids.get(_name(home)))
monkeypatch.setattr(gm, "_service_op", _service_op)
monkeypatch.setattr(gm, "_stop_gateway_process", lambda home: state.pids.pop(_name(home), None))
monkeypatch.setattr(gm, "_spawn_detached_gateway", lambda home: _fake_spawn(state, home))
# The compensator's liveness proof reads what a started gateway actually wrote, so a start that
# returns without producing a gateway (the ExecMainStatus=75 respawn loop) is NOT "restored".
monkeypatch.setattr(gm, "_wait_for_live_gateway",
lambda home, timeout: _pid_of_started_gateway(home))
monkeypatch.setattr(gm, "_host_supports_migration", lambda: None)
# Part of the faked service layer: the real check asks systemd's questions (root, NSS user).
monkeypatch.setattr(gm, "_preflight_apply", lambda plan, target, run_as_user: None)
# Identity inputs are pinned too, or the verdict depends on the HOST: the fake pids 4101/4102 may
# name a real root-owned process in /proc on a CI runner (a spurious "UNIX privilege boundary"
# blocker), and the user-scope unit path lives under the real $HOME. The whole fleet runs as this
# user with no unit files on disk unless a test writes some (it repoints _SYSTEM_UNIT_DIR itself).
from hermes_cli import gateway as gw
from hermes_cli import gateway_migrate_guards as guards
monkeypatch.setattr(guards, "_pid_uid", lambda pid: root.stat().st_uid if pid in state.pids.values() else None)
_unit_path = gw.get_systemd_unit_path
monkeypatch.setattr(gw, "get_systemd_unit_path", lambda system=False: _unit_path(system=True) if system
else tmp_path / "user-units" / f"{gw.get_service_name()}.service")
state.root = root
return state
def _fake_spawn(state, home: Path) -> bool:
root = state.root
(root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)}))
return True
def _pid_of_started_gateway(home: Path):
marker = home / "gateway.pid"
if not marker.exists():
return None
try:
return json.loads(marker.read_text()).get("pid")
except ValueError:
return None
def _name(home: Path) -> str:
return hermes_constants.profile_name_for_home(home) or "default"
def _units(recorded) -> list:
if recorded is None:
return []
return list(recorded) if isinstance(recorded, list) else [recorded]
_real_preflight = gm._preflight_apply
def _config_flag(root: Path):
import hermes_yaml as yaml
raw = yaml.safe_load((root / "config.yaml").read_text(encoding="utf-8")) or {}
return (raw.get("gateway") or {}).get("multiplex_profiles")
def test_dry_run_and_blocked_preflight_change_nothing(fleet, capsys):
plan = gm.build_migration_plan()
assert not plan.blocked and plan.eligible_for_migration()
assert [p.name for p in plan.standalone_secondaries] == ["coder", "ops"]
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=True, yes=True)) # returns; no exit
assert "dry run" in capsys.readouterr().out
# Blocked: coder reuses the default's Telegram token -> the gateway's own fingerprint says duplicate.
(fleet.root / "profiles/coder/.env").write_text("TELEGRAM_BOT_TOKEN=111111:default-token\n", encoding="utf-8")
blocked = gm.build_migration_plan()
assert blocked.blocked and "profile_routes" in blocked.blockers[0] and "'coder'" in blocked.blockers[0]
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 1
assert fleet.ops == [] and fleet.services == {"coder": ("systemd", False), "ops": ("systemd", False)}
assert fleet.pids == {"coder": 4101, "ops": 4102} and _config_flag(fleet.root) is None
assert not (fleet.root / gm.MANIFEST_NAME).exists()
assert "nothing will be changed" in capsys.readouterr().out
def test_parked_profile_remains_in_migration_inventory(fleet):
home = fleet.root / "profiles/coder"
(home / "gateway.parked").touch()
plan = gm.build_migration_plan()
inventory = {p.name: p for p in plan.standalone_secondaries}
assert "coder" in inventory
assert inventory["coder"].home == home
assert inventory["coder"].pid == fleet.pids["coder"]
assert inventory["coder"].services == [("systemd", False)]
# Parking must not hide migration preflight conflicts either.
(home / ".env").write_text("TELEGRAM_BOT_TOKEN=111111:default-token\n", encoding="utf-8")
blocked = gm.build_migration_plan()
assert any("'coder'" in reason and "credential" in reason for reason in blocked.blockers)
def test_preflight_never_imports_a_parked_profiles_plugins(fleet):
"""A parked profile stays in the inventory and the duplicate-credential guard (above), but
loading its gateway config must not run its plugins' ``register()`` in the host process (#123386)."""
from hermes_cli.plugins import _reset_plugin_managers_for_tests
home = fleet.root / "profiles/coder"
(home / "gateway.parked").touch()
(home / "config.yaml").write_text("plugins:\n enabled: [p1]\n", encoding="utf-8")
marker = fleet.root / "p1-registered"
plugin = home / "plugins/p1"
plugin.mkdir(parents=True)
(plugin / "plugin.yaml").write_text("name: p1\n", encoding="utf-8")
(plugin / "__init__.py").write_text(
f"from pathlib import Path\n\ndef register(ctx):\n Path({str(marker)!r}).write_text('imported')\n",
encoding="utf-8",
)
_reset_plugin_managers_for_tests()
try:
plan = gm.build_migration_plan()
assert "coder" in {p.name for p in plan.standalone_secondaries}
assert not marker.exists(), "migration preflight imported a parked profile's plugin"
gm.duplicate_credential_findings() # doctor / gateway status read the same homes
assert not marker.exists()
finally:
_reset_plugin_managers_for_tests()
def test_migration_removes_parked_footprint_without_waiting_for_it_to_serve(fleet, monkeypatch):
home = fleet.root / "profiles/coder"
(home / "gateway.parked").touch()
service_op = gm._service_op
def boot_unparked_profiles(kind, system, verb, home, **kwargs):
service_op(kind, system, verb, home, **kwargs)
if _name(home) == "default" and verb in ("start", "restart"):
from hermes_cli.profiles import profiles_to_serve
path = fleet.root / "gateway_state.json"
runtime = json.loads(path.read_text())
runtime["served_profiles"] = [name for name, _ in profiles_to_serve(True)]
path.write_text(json.dumps(runtime))
monkeypatch.setattr(gm, "_service_op", boot_unparked_profiles)
plan = gm.build_migration_plan()
assert any("verify it serves 2 profiles" in line for line in gm.format_plan(plan, dry_run=True))
ok, manifest = _apply_capturing_manifest(plan, served_wait=0.1)
assert ok
assert "coder" not in fleet.pids and "coder" not in fleet.services
assert (home / "gateway.parked").exists()
assert {p["profile"] for p in manifest["secondaries"]} == {"coder", "ops"}
gm._write_manifest(fleet.root, manifest)
assert not gm.build_migration_plan().interrupted
def _apply_capturing_manifest(plan, *, served_wait=5.0, restore=False):
"""Apply, returning (ok, manifest-as-written). A CONFIRMED convergence deletes the manifest
(manifest present == unfinished), so the only way to inspect it is to read it mid-flight."""
seen = {}
real_wait = gm._wait_for_served
def _wait(default_home, expected, timeout):
seen["manifest"] = json.loads((default_home / gm.MANIFEST_NAME).read_text(encoding="utf-8"))
return real_wait(default_home, expected, timeout)
gm._wait_for_served = _wait
try:
ok = gm.apply_migration(plan, served_wait=served_wait)
finally:
gm._wait_for_served = real_wait
if restore: # put it back to drive the compensator, which only ever runs on an UNfinished apply
gm._write_manifest(plan.default_home, seen["manifest"])
return ok, seen["manifest"]
def test_apply_clears_the_manifest_on_success_and_the_compensator_restores(fleet, capsys):
plan = gm.build_migration_plan()
ok, manifest = _apply_capturing_manifest(plan)
assert ok is True
assert not (fleet.root / gm.MANIFEST_NAME).exists(), "a confirmed migration is not 'interrupted'"
assert {s["profile"] for s in manifest["secondaries"]} == {"coder", "ops"}
assert all(s["service"] == {"kind": "systemd", "system": False} for s in manifest["secondaries"])
assert _config_flag(fleet.root) is True
assert "coder" not in fleet.services and "ops" not in fleet.services and fleet.pids == {}
# The default is brought up on the SAME service manager the secondaries used.
assert fleet.services["default"] == ("systemd", False)
assert ("default", "install") in fleet.ops and ("default", "start") in fleet.ops
assert "serves 3 profiles" in capsys.readouterr().out
# Idempotent: a second run sees the live multiplexer and refuses cleanly.
again = gm.build_migration_plan()
assert again.already_multiplexed and gm.apply_migration(again) is True
runtime_path = fleet.root / "gateway_state.json"
runtime = json.loads(runtime_path.read_text(encoding="utf-8"))
runtime["platforms"] = {
"telegram": {"state": "connected"},
"coder:telegram": {"state": "connected"},
"ops:discord": {"state": "connected"},
}
runtime_path.write_text(json.dumps(runtime), encoding="utf-8")
fleet.ops.clear()
gm._write_manifest(fleet.root, manifest) # the compensator only ever runs on a FAILED apply
assert gm.rollback_migration(fleet.root) is True
assert _config_flag(fleet.root) is False
# ONE gateway, not a fleet: the flipped host lock refuses N per-profile gateways, and the
# compensator clears the served record first, so every secondary it used to start would lose
# the flock race and respawn at exit 75 forever.
assert fleet.services == {"default": ("systemd", False)}
assert [op for op in fleet.ops if op[0] != "default"] == []
assert fleet.ops[-1] == ("default", "restart")
assert "coder, ops" in capsys.readouterr().out
runtime = json.loads(runtime_path.read_text(encoding="utf-8"))
assert runtime["served_profiles"] == []
assert runtime["platforms"] == {"telegram": {"state": "connected"}}
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
assert named_profile_served_by_running_multiplexer("coder") is False
assert not (fleet.root / gm.MANIFEST_NAME).exists()
def test_migration_preserves_root_system_service_user_for_default_install(fleet, monkeypatch):
"""A root-owned secondary system unit must be replaced with an explicit root unit."""
fleet.services["coder"] = ("systemd", True)
monkeypatch.setattr(
gm,
"_systemd_service_user",
lambda home, services: "root" if _name(home) == "coder" and ("systemd", True) in services else None,
)
plan = gm.build_migration_plan()
root_secondary = next(p for p in plan.standalone_secondaries if p.name == "coder")
assert root_secondary.run_as_user == "root"
installs = []
def _service_op(kind, system, verb, home, *, run_as_user=None):
if _name(home) == "default" and verb == "install":
installs.append((kind, system, run_as_user))
fleet.services.pop(_name(home), None) if verb == "uninstall" else None
if verb == "install":
fleet.services[_name(home)] = (kind, system)
if verb in ("start", "restart") and _name(home) == "default":
(fleet.root / "gateway_state.json").write_text(json.dumps({
"served_profiles": ["default", "coder", "ops"]}))
monkeypatch.setattr(gm, "_service_op", _service_op)
monkeypatch.setattr(gm, "_wait_for_served", lambda *args: ["default", "coder", "ops"])
assert gm.apply_migration(plan, served_wait=0.1) is True
assert installs == [("systemd", True, "root")]
def test_rollback_that_cannot_bring_the_gateway_back_reports_failure_and_keeps_the_manifest(
fleet, monkeypatch, capsys):
"""A service-manager ``start`` that returns is not proof of a live gateway. Returning True on
it printed '✓ Restored' over a unit respawning every 5s at ExecMainStatus=75."""
ok, _manifest = _apply_capturing_manifest(gm.build_migration_plan(), restore=True)
assert ok is True
fleet.ops.clear()
real_op = gm._service_op
def _silent_start(kind, system, verb, home, *, run_as_user=None):
if verb in ("start", "restart") and _name(home) == "default":
(fleet.root / "gateway.pid").unlink(missing_ok=True)
return # the unit "starts" and produces nothing (ExecMainStatus=75, respawning)
real_op(kind, system, verb, home, run_as_user=run_as_user)
monkeypatch.setattr(gm, "_service_op", _silent_start)
monkeypatch.setattr(gm, "_COMPENSATOR_WAIT_SECONDS", 0.5)
assert gm.rollback_migration(fleet.root) is False
out = capsys.readouterr().out
assert "no gateway confirmed serving this host" in out and "Compensation incomplete" in out
assert _config_flag(fleet.root) is False
assert [op for op in fleet.ops if op[0] != "default"] == [], "no secondary may be rebuilt"
assert (fleet.root / gm.MANIFEST_NAME).exists()
def test_rollback_never_respawns_a_detached_secondary(fleet, monkeypatch):
"""Detached gateways have no supervisor to loop, but they lose the same flock race and simply
die — leaving the operator a process that vanished. The compensator spawns the default only."""
gm._write_manifest(fleet.root, {"version": 1, "flag_was": False, "default": {"service": None}, "secondaries": [
{"profile": n, "home": str(fleet.root / "profiles" / n), "pid": 4100, "service": None} for n in ("coder", "ops")]})
fleet.services.clear()
fleet.pids = {}
spawned = []
def _spawn(home):
spawned.append(_name(home))
fleet.pids[_name(home)] = 5000 + len(spawned)
return _fake_spawn(fleet, home)
monkeypatch.setattr(gm, "_spawn_detached_gateway", _spawn)
assert gm.rollback_migration(fleet.root) is True
assert spawned == ["default"]
def test_malformed_manifest_is_refused_before_any_mutation(fleet, capsys):
(fleet.root / gm.MANIFEST_NAME).write_text(
json.dumps({"version": 1, "flag_was": False, "default": [], "secondaries": [{"home": "x"}]}), encoding="utf-8")
assert gm.rollback_migration(fleet.root) is False
assert _config_flag(fleet.root) is None and fleet.ops == []
assert "fix or delete the manifest" in capsys.readouterr().out
def test_secondary_port_binder_is_notice_with_ingress_and_blocker_without(fleet, monkeypatch):
"""The verdict follows the adapter's ``serves_profile_prefix`` declaration, not a hardcoded list."""
(fleet.root / "profiles/ops/.env").write_text(
"DISCORD_BOT_TOKEN=ops-discord-333333\nAPI_SERVER_KEY=ops-api-key-abcdef\n", encoding="utf-8")
(fleet.root / "profiles/ops/config.yaml").write_text(
"platforms:\n api_server:\n enabled: true\n extra:\n port: 9999\n", encoding="utf-8")
plan = gm.build_migration_plan()
assert not plan.blocked, plan.blockers
assert any("api_server" in n and "/p/ops/" in n for n in plan.notices), plan.notices
monkeypatch.setattr(gm, "platform_serves_profile_prefix", lambda value: False)
plan = gm.build_migration_plan()
assert plan.blocked and "api_server" in plan.blockers[0] and "/p/ops/" in plan.blockers[0]
def test_serves_profile_prefix_is_read_from_adapter_classes():
from gateway.platforms.api_server import APIServerAdapter
from gateway.platforms.webhook import WebhookAdapter
assert APIServerAdapter.serves_profile_prefix and WebhookAdapter.serves_profile_prefix
assert gm.platform_serves_profile_prefix("api_server") is True
assert gm.platform_serves_profile_prefix("webhook") is True
# Every adapter that binds through shared_ingress.bind_listener is served at /p/<profile>/
# for a secondary, so migrate must report it as a notice, never a blocker.
for platform in ("sms", "line", "teams", "bluebubbles", "whatsapp_cloud", "msgraph_webhook"):
assert gm.platform_serves_profile_prefix(platform) is True, platform
# An outbound-only adapter never declares it (and never needs to).
assert gm.platform_serves_profile_prefix("telegram") is False
def test_update_hook_migrates_when_unblocked_and_only_warns_when_blocked(fleet, capsys):
fleet.services["default"] = ("systemd", False) # same service domain as the secondaries
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "Migrating per-profile gateways" in out and "serves 3 profiles" in out
assert _config_flag(fleet.root) is True and not (fleet.root / gm.MANIFEST_NAME).exists()
# Blocked fleet: warning block with the fix + one-liner; nothing changes.
for f in ("gateway.pid", "gateway_state.json", gm.MANIFEST_NAME):
(fleet.root / f).unlink(missing_ok=True)
(fleet.root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
fleet.services.update({"coder": ("systemd", False), "default": ("systemd", False)})
fleet.pids.update({"coder": 4101}); fleet.ops.clear()
(fleet.root / "profiles/coder/.env").write_text("TELEGRAM_BOT_TOKEN=111111:default-token\n", encoding="utf-8")
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert gm.MIGRATE_COMMAND in out and "profile_routes" in out
assert fleet.ops == [] and _config_flag(fleet.root) is None
def test_update_hook_never_touches_single_profile_or_already_multiplexed(fleet, capsys):
fleet.services.clear(); fleet.pids.clear() # secondaries exist but run no gateway of their own
gm.maybe_auto_migrate_after_update()
assert capsys.readouterr().out == "" and _config_flag(fleet.root) is None
@pytest.mark.parametrize(
("secondary_service", "secondary_uid", "secondary_home", "expected"),
[
(("systemd", True), 1000, "profiles/coder", "different service domain"),
(("launchd", False), 1000, "profiles/coder", "different service domain"),
(("systemd", False), 2000, "profiles/coder", "UNIX privilege boundary"),
(("systemd", False), 1000, "external", "outside"),
],
)
def test_update_hook_refuses_to_cross_service_user_or_home_boundary(
fleet, capsys, monkeypatch, secondary_service, secondary_uid, secondary_home, expected,
):
"""#109954: the unattended hook must not fold a secondary that sits behind a kernel-enforced
boundary (other service domain, other UNIX user, HERMES_HOME outside profiles/). It prints the
boundary + the explicit command and touches nothing; the dry-run plan shows the same finding as a
notice and the explicit command stays available."""
fleet.services["default"] = ("systemd", False)
fleet.services["ops"] = secondary_service
ops_home = fleet.root.parent / "external-ops" if secondary_home == "external" else fleet.root / secondary_home
monkeypatch.setattr(
gm, "_gateway_identity",
lambda home, pid, service: (secondary_uid if _name(home) == "ops" else 1000, ops_home if _name(home) == "ops" else home),
raising=False, # absent on the pre-fix module: the test must then fail on behaviour, not on the seam
)
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert expected in out and gm.MIGRATE_COMMAND in out and "'ops'" in out
assert fleet.ops == [] and fleet.pids == {"coder": 4101, "ops": 4102}
assert fleet.services == {"default": ("systemd", False), "coder": ("systemd", False), "ops": secondary_service}
assert _config_flag(fleet.root) is None and not (fleet.root / gm.MANIFEST_NAME).exists()
plan = gm.build_migration_plan()
assert not plan.blocked and any(expected in n for n in plan.notices)
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=True, yes=True))
assert expected in capsys.readouterr().out
def test_update_hook_still_migrates_same_user_same_scope_profiles_under_the_default_tree(fleet, capsys, monkeypatch):
"""The guard is a boundary check, not a kill switch: one user, one service domain, everything under
profiles/ (the shape `hermes profile create` produces) still auto-migrates."""
fleet.services["default"] = ("systemd", False)
monkeypatch.setattr(gm, "_gateway_identity", lambda home, pid, service: (1000, home), raising=False)
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "Migrating per-profile gateways" in out and "serves 3 profiles" in out
assert _config_flag(fleet.root) is True and ("ops", "uninstall") in fleet.ops
def test_update_hook_folds_a_unit_less_default_when_every_secondary_shares_one_manager(fleet, capsys, monkeypatch):
"""The #118097 fleet: the default profile never had a gateway unit, every secondary runs under the
SAME manager (all launchd, one macOS user). That is one service domain, and the manager the plan
elects as the target (``target_service_kind``) is the reference the guard must agree with — not the
default's empty unit list, which turned every such fleet into "blockers" instead of a fold.
Controls: a default unit under another manager, and two managers among the secondaries, still refuse."""
from hermes_cli.gateway_migrate_guards import auto_migration_blockers
monkeypatch.setattr(gm, "_gateway_identity", lambda home, pid, service: (1000, home), raising=False)
fleet.services.update({"coder": ("launchd", False), "ops": ("launchd", False)})
assert "default" not in fleet.services
plan = gm.build_migration_plan()
assert plan.target_service_kind() == ("launchd", False)
assert auto_migration_blockers(plan) == []
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "serves 3 profiles" in out and _config_flag(fleet.root) is True
assert ("coder", "uninstall") in fleet.ops and ("ops", "uninstall") in fleet.ops
assert ("default", "install") in fleet.ops and fleet.services == {"default": ("launchd", False)}
# Control 1: the default HAS a unit under another manager -> still a different service domain.
fleet.services.update({"default": ("systemd", False), "coder": ("launchd", False), "ops": ("launchd", False)})
fleet.pids.update({"coder": 4101, "ops": 4102})
blockers = auto_migration_blockers(gm.build_migration_plan())
assert blockers and all("different service domain" in b for b in blockers)
# Control 2: unit-less default, secondaries under two managers -> the non-elected one refuses.
fleet.services.pop("default")
fleet.services.update({"coder": ("launchd", False), "ops": ("systemd", False)})
blockers = auto_migration_blockers(gm.build_migration_plan())
assert len(blockers) == 1 and "'ops'" in blockers[0] and "different service domain" in blockers[0]
def test_auto_multiplex_migration_false_opts_out_of_the_update_hook_but_not_the_explicit_command(fleet, capsys):
"""``gateway.auto_multiplex_migration: false`` is a durable opt-out: an otherwise-eligible fleet is
left alone by ``hermes update`` (no output, no ops, no flag flip), while the operator typing
``migrate --multiplex`` still migrates. Only the nested key counts."""
assert gm.build_migration_plan().eligible_for_migration() # would migrate but for the flag
(fleet.root / "config.yaml").write_text(
"model:\n default: x\nauto_multiplex_migration: false\ngateway:\n auto_multiplex_migration: false\n",
encoding="utf-8")
gm.maybe_auto_migrate_after_update()
assert capsys.readouterr().out == ""
assert fleet.ops == [] and _config_flag(fleet.root) is None
assert fleet.services == {"coder": ("systemd", False), "ops": ("systemd", False)}
assert fleet.pids == {"coder": 4101, "ops": 4102}
assert not (fleet.root / gm.MANIFEST_NAME).exists()
# A top-level alias is NOT honoured; absent and an explicit true keep the automatic behaviour.
from hermes_cli.gateway_migrate_guards import auto_migration_opted_out
(fleet.root / "config.yaml").write_text(
"model:\n default: x\nauto_multiplex_migration: false\n", encoding="utf-8")
assert auto_migration_opted_out(fleet.root) is False
(fleet.root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
assert auto_migration_opted_out(fleet.root) is False
(fleet.root / "config.yaml").write_text(
"model:\n default: x\ngateway:\n auto_multiplex_migration: true\n", encoding="utf-8")
assert auto_migration_opted_out(fleet.root) is False
# The opt-out governs the AUTOMATIC path only: an explicit --multiplex is an explicit request.
(fleet.root / "config.yaml").write_text(
"model:\n default: x\ngateway:\n auto_multiplex_migration: false\n", encoding="utf-8")
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 0
assert _config_flag(fleet.root) is True and not (fleet.root / gm.MANIFEST_NAME).exists()
def test_explicit_migrate_with_no_standalone_secondaries_still_flips_flag_and_restarts_default(fleet, capsys, monkeypatch):
"""The user typed --multiplex: 'nothing to migrate' + flag left off was a no-op the user did not ask
for. The update hook keeps its no-op (previous test); the explicit command proceeds."""
fleet.services.clear(); fleet.pids.clear()
def _detached(home): # no service manager anywhere -> detached start writes the served record
fleet.services["default-detached"] = True
(fleet.root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(fleet.root)}))
(fleet.root / "gateway_state.json").write_text(json.dumps({
"pid": os.getpid(), "hermes_home": str(fleet.root), "gateway_state": "running",
"served_profiles": ["default", "coder", "ops"]}))
return True
monkeypatch.setattr(gm, "_spawn_detached_gateway", _detached)
assert not gm.build_migration_plan().standalone_secondaries
seen = {}
real_wait = gm._wait_for_served
monkeypatch.setattr(gm, "_wait_for_served", lambda home, exp, t: (
seen.setdefault("m", json.loads((home / gm.MANIFEST_NAME).read_text(encoding="utf-8"))),
real_wait(home, exp, t))[1])
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 0
manifest = seen["m"]
assert fleet.services.pop("default-detached") is True
assert _config_flag(fleet.root) is True
assert not (fleet.root / gm.MANIFEST_NAME).exists()
assert manifest["secondaries"] == [] and manifest["flag_was"] is False
assert ("default", "install") not in fleet.ops
out = capsys.readouterr().out
assert "serves 3 profiles" in out
# That same manifest is what the compensator would have used: flag restored, nothing to reinstall.
gm._write_manifest(fleet.root, manifest)
assert gm.rollback_migration(fleet.root) is True and _config_flag(fleet.root) is False
def test_failed_default_bringup_compensates_to_one_detached_gateway(fleet, monkeypatch, capsys):
"""#110850: the last step (install/start the default) is the one that can fail after the
destructive ones. It must not leave the flag on with no gateway anywhere. The compensator can
no longer rebuild the fleet (the flipped host lock refuses it), and the recorded service
manager is the very thing that just failed — so it falls back to ONE detached gateway."""
real_op = gm._service_op
def _refusing(kind, system, verb, home, *, run_as_user=None):
if verb == "install" and _name(home) == "default":
raise ValueError("Refusing to install the gateway system service as root; pass --run-as-user root")
real_op(kind, system, verb, home, run_as_user=run_as_user)
monkeypatch.setattr(gm, "_service_op", _refusing)
assert gm.apply_migration(gm.build_migration_plan(), served_wait=0.1) is False
out = capsys.readouterr().out
assert "Restoring one host gateway" in out and "Compensated: one host gateway is running" in out
assert "falling back to a detached gateway" in out
assert _config_flag(fleet.root) is False
# Compensation is a single gateway; the removed secondaries are named, not rebuilt.
assert "coder, ops" in out and "NOT reinstalled" in out
assert "coder" not in fleet.services and "ops" not in fleet.services
assert not (fleet.root / gm.MANIFEST_NAME).exists()
# One gateway, no fleet left: a re-run is a clean convergence, never a refusal or a rollback.
after = gm.build_migration_plan()
assert not after.blocked and after.standalone_secondaries == []
def test_interrupted_apply_is_resumed_from_the_manifest_not_short_circuited(fleet, monkeypatch, capsys):
"""Flag flipped, secondaries gone, default never came up (the process died mid-apply): the re-run
must finish the migration from the manifest — with the recorded User= — instead of reporting
'already multiplexed' over a fleet with no gateway at all."""
fleet.services["coder"] = ("systemd", True)
monkeypatch.setattr(gm, "_systemd_service_user", lambda home, services: "root" if _name(home) == "coder" else None)
with pytest.MonkeyPatch.context() as dying:
dying.setattr(gm, "_restart_default", lambda *a, **k: (_ for _ in ()).throw(KeyboardInterrupt()))
with pytest.raises(KeyboardInterrupt):
gm.apply_migration(gm.build_migration_plan(), served_wait=0.1)
assert _config_flag(fleet.root) is True and "coder" not in fleet.services and "default" not in fleet.services
installs = []
real_op = gm._service_op
def _recording(kind, system, verb, home, *, run_as_user=None):
if verb == "install":
installs.append((_name(home), kind, system, run_as_user))
real_op(kind, system, verb, home, run_as_user=run_as_user)
monkeypatch.setattr(gm, "_service_op", _recording)
plan = gm.build_migration_plan()
assert plan.interrupted and not plan.already_multiplexed
assert gm.apply_migration(plan, served_wait=5.0) is True
assert installs == [("default", "systemd", True, "root")]
assert "serves 3 profiles" in capsys.readouterr().out
def test_every_installed_unit_of_a_secondary_is_removed_and_recorded(fleet, capsys):
"""A profile carrying a user AND a system unit: both are stopped/uninstalled (recording only the
first found left the other live beside the multiplexer) and BOTH are recorded, so a resume knows
the full topology. The compensator no longer reinstalls them — one gateway per host."""
fleet.services["coder"] = [("systemd", False), ("systemd", True)]
plan = gm.build_migration_plan()
coder = next(p for p in plan.standalone_secondaries if p.name == "coder")
assert coder.services == [("systemd", False), ("systemd", True)]
ok, manifest = _apply_capturing_manifest(plan, restore=True)
assert ok is True
assert "coder" not in fleet.services
coder_rec = next(r for r in manifest["secondaries"] if r["profile"] == "coder")
assert [(s["kind"], s["system"]) for s in coder_rec["services"]] == [("systemd", False), ("systemd", True)]
capsys.readouterr()
assert gm.rollback_migration(fleet.root) is True
assert [op for op in fleet.ops if op[0] == "coder" and op[1] in ("install", "start")] == [], \
"the compensator restores one gateway, not a two-unit per-profile fleet"
# The unattended hook does not resolve an ambiguous two-unit topology on its own.
for f in (gm.MANIFEST_NAME, "gateway.pid", "gateway_state.json"):
(fleet.root / f).unlink(missing_ok=True)
(fleet.root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
fleet.services.update({"default": ("systemd", False), "coder": [("systemd", False), ("systemd", True)]})
fleet.pids.update({"coder": 4101, "ops": 4102}); fleet.ops.clear()
gm.maybe_auto_migrate_after_update()
assert "more than one installed service" in capsys.readouterr().out and fleet.ops == []
def test_unresolvable_system_unit_user_is_unknown_principal_not_directory_owner(fleet, tmp_path, monkeypatch, capsys):
"""A system unit pinned to a User= this host cannot resolve: the principal is unknown, never the
profile directory's owner, and unknown blocks the unattended path."""
from hermes_cli import gateway as gw
from hermes_cli.gateway_migrate_guards import gateway_identity
unit_dir = tmp_path / "system"; unit_dir.mkdir()
monkeypatch.setattr(gw, "_SYSTEM_UNIT_DIR", unit_dir)
coder_home = fleet.root / "profiles/coder"
with gm._home_env(coder_home):
gw.get_systemd_unit_path(system=True).write_text("[Service]\nUser=nobody-such-user-xyz\n", encoding="utf-8")
uid, _home = gateway_identity(coder_home, None, [("systemd", True)])
assert uid is None # NOT coder_home.stat().st_uid
# Same unit shape without a system unit keeps the directory-owner answer for user-scope gateways.
assert gateway_identity(coder_home, None, [("systemd", False)])[0] == coder_home.stat().st_uid
fleet.services.update({"default": ("systemd", True), "coder": ("systemd", True)})
monkeypatch.setattr(gm, "_gateway_identity",
lambda home, pid, services: (None if _name(home) == "coder" else 1000, home))
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "cannot be resolved" in out and gm.MIGRATE_COMMAND in out
assert fleet.ops == [] and _config_flag(fleet.root) is None
def test_opt_out_reads_effective_config_managed_false_wins_and_string_false_is_false(fleet, tmp_path, monkeypatch):
"""The opt-out authorizes an unattended destructive action, so it reads the same effective config
the CLI does: a managed ``false`` overrides the user's ``true``; a hand-written ``"false"`` string is
an opt-out, not a truthy value; the declared default keeps absent == opted in."""
from hermes_cli import config as cfg
from hermes_cli.config_defaults import DEFAULT_CONFIG
from hermes_cli.gateway_migrate_guards import auto_migration_opted_out
from hermes_cli import managed_scope
assert DEFAULT_CONFIG["gateway"]["auto_multiplex_migration"] is True
assert auto_migration_opted_out(fleet.root) is False # absent -> DEFAULT_CONFIG value
(fleet.root / "config.yaml").write_text(
"model:\n default: x\ngateway:\n auto_multiplex_migration: 'false'\n", encoding="utf-8")
assert auto_migration_opted_out(fleet.root) is True
(fleet.root / "config.yaml").write_text(
"model:\n default: x\ngateway:\n auto_multiplex_migration: true\n", encoding="utf-8")
assert auto_migration_opted_out(fleet.root) is False
managed = tmp_path / "managed"; managed.mkdir()
(managed / "config.yaml").write_text("gateway:\n auto_multiplex_migration: false\n", encoding="utf-8")
monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed))
managed_scope.invalidate_managed_cache()
with gm._home_env(fleet.root):
assert cfg.load_config()["gateway"]["auto_multiplex_migration"] is False
assert auto_migration_opted_out(fleet.root) is True
gm.maybe_auto_migrate_after_update()
assert fleet.ops == [] and _config_flag(fleet.root) is None
@pytest.mark.parametrize("default_unit_preinstalled", [False, True])
def test_interruption_after_the_default_unit_exists_is_still_interrupted_not_already_multiplexed(
fleet, monkeypatch, capsys, default_unit_preinstalled,
):
"""``systemd_install`` writes the unit before the start that can be killed; an existing stopped
default unit can be interrupted mid-restart. Either way the flag is on and a default unit exists
but nothing serves anyone: that is an interrupted migration to resume, not a completed one. Only
a LIVE multiplexer that recorded ``served_profiles`` counts as already multiplexed."""
if default_unit_preinstalled:
fleet.services["default"] = ("systemd", False)
real_op = gm._service_op
def _killed_at_start(kind, system, verb, home, *, run_as_user=None):
if verb in ("start", "restart") and _name(home) == "default":
raise KeyboardInterrupt()
real_op(kind, system, verb, home, run_as_user=run_as_user)
with pytest.MonkeyPatch.context() as dying:
dying.setattr(gm, "_service_op", _killed_at_start)
with pytest.raises(KeyboardInterrupt):
gm.apply_migration(gm.build_migration_plan(), served_wait=0.1)
assert _config_flag(fleet.root) is True and fleet.services == {"default": ("systemd", False)}
assert (fleet.root / gm.MANIFEST_NAME).exists()
plan = gm.build_migration_plan()
assert plan.interrupted and not plan.already_multiplexed
fleet.ops.clear()
assert gm.apply_migration(plan, served_wait=5.0) is True
assert fleet.ops[-1] == ("default", "restart") and "serves 3 profiles" in capsys.readouterr().out
# Postcondition met: the next plan sees the live multiplexer and stops.
assert gm.build_migration_plan().already_multiplexed
@pytest.mark.parametrize("failing_op", [("ops", "stop"), ("coder", "uninstall"), ("default", "flag")])
def test_failure_anywhere_in_the_destructive_phase_restores_the_removed_secondaries(fleet, monkeypatch, capsys, failing_op):
"""The compensation boundary covers the whole destructive phase, not only the default bring-up:
a later secondary's stop, a unit unlink/daemon-reload, or the flag write failing after an earlier
secondary was removed must put that secondary back (the manifest alone is not a restored fleet)."""
name, verb = failing_op
real_op = gm._service_op
def _failing(kind, system, verb_, home, *, run_as_user=None):
if (_name(home), verb_) == (name, verb):
raise RuntimeError(f"{name} {verb} failed")
real_op(kind, system, verb_, home, run_as_user=run_as_user)
monkeypatch.setattr(gm, "_service_op", _failing)
if verb == "flag":
real_flag = gm._write_multiplex_flag
monkeypatch.setattr(gm, "_write_multiplex_flag",
lambda home, value: (_ for _ in ()).throw(OSError("read-only config")) if value else real_flag(home, value))
assert gm.apply_migration(gm.build_migration_plan(), served_wait=0.1) is False
out = capsys.readouterr().out
assert "Restoring one host gateway" in out and "Compensated: one host gateway is running" in out
assert _config_flag(fleet.root) is not True
# Whatever the destructive phase had already removed stays removed: the compensator restores
# ONE gateway and never re-installs or re-starts a per-profile one.
assert [op for op in fleet.ops if op[0] != "default" and op[1] in ("install", "start")] == []
assert not (fleet.root / gm.MANIFEST_NAME).exists()
# Secondaries the phase had not reached yet are untouched, so a corrected re-run is a normal
# migration; the ones it had already removed are simply served by the restored host gateway.
after = gm.build_migration_plan()
assert not after.blocked and after.eligible_for_migration()
def test_known_bringup_refusal_is_rejected_before_any_secondary_is_touched(fleet, monkeypatch, capsys):
"""A system-unit fleet with no recorded User= run by root is the #110850 refusal: known from the plan,
so it is refused before a working gateway is stopped rather than discovered and rolled back."""
from hermes_cli import gateway as gw
fleet.services.update({"coder": ("systemd", True), "ops": ("systemd", True)})
monkeypatch.setattr(gm, "_systemd_service_user", lambda home, services: None)
monkeypatch.setattr(gm, "_preflight_apply", _real_preflight)
monkeypatch.setattr(gw, "_require_root_for_system_service", lambda action: None) # we are "root"
for var in ("SUDO_USER", "USER", "LOGNAME"):
monkeypatch.setenv(var, "root")
assert gm.apply_migration(gm.build_migration_plan(), served_wait=0.1) is False
out = capsys.readouterr().out
assert "before changing anything" in out and "--run-as-user root" in out
assert fleet.ops == [] and _config_flag(fleet.root) is None and not (fleet.root / gm.MANIFEST_NAME).exists()
def test_unknown_default_system_principal_blocks_the_update_hook(fleet, tmp_path, monkeypatch, capsys):
"""Mirror of the unknown-secondary case: the default's system unit names a User= this host cannot
resolve while both secondaries are known root system units. Folding INTO an unidentifiable
principal is the same boundary; known-same uid still folds, known-different still refuses."""
from hermes_cli import gateway as gw
from hermes_cli.gateway_migrate_guards import auto_migration_blockers, gateway_identity
unit_dir = tmp_path / "system"; unit_dir.mkdir()
monkeypatch.setattr(gw, "_SYSTEM_UNIT_DIR", unit_dir)
with gm._home_env(fleet.root):
gw.get_systemd_unit_path(system=True).write_text("[Service]\nUser=no-such-pr111062-user\n", encoding="utf-8")
for name in ("coder", "ops"):
with gm._home_env(fleet.root / "profiles" / name):
gw.get_systemd_unit_path(system=True).write_text("[Service]\nUser=root\n", encoding="utf-8")
fleet.services.update({"default": ("systemd", True), "coder": ("systemd", True), "ops": ("systemd", True)})
fleet.pids.clear() # stopped units everywhere: identity comes from User=, resolved for real
plan = gm.build_migration_plan()
assert plan.default.uid is None and {p.uid for p in plan.standalone_secondaries} == {0}
assert gateway_identity(fleet.root, None, [("systemd", True)])[0] is None
blockers = auto_migration_blockers(plan)
assert len(blockers) == 1 and "default gateway" in blockers[0] and "cannot be resolved" in blockers[0]
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "cannot be resolved" in out and gm.MIGRATE_COMMAND in out
assert fleet.ops == [] and _config_flag(fleet.root) is None
# Controls: same known uid folds; a different known uid refuses.
monkeypatch.setattr(gm, "_gateway_identity", lambda home, pid, services: (0, home))
assert auto_migration_blockers(gm.build_migration_plan()) == []
monkeypatch.setattr(gm, "_gateway_identity", lambda home, pid, services: (0 if _name(home) == "default" else 1000, home))
assert any("UNIX privilege boundary" in b for b in auto_migration_blockers(gm.build_migration_plan()))
# --------------------------------------------------------------------------- multiplex-only convergence
def test_migrate_leaves_exactly_one_host_unit_serving_every_profile_and_is_idempotent(fleet, capsys):
"""(a) Two per-profile units in, ONE host unit out, and a second run changes nothing.
The unit count is the contract: multiplex-only means one supervisor unit per host, not one per
profile, and the host gateway must report every profile as served.
"""
fleet.services["default"] = ("systemd", False)
assert len(fleet.services) == 3 # default + the two per-profile units
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 0
assert fleet.services == {"default": ("systemd", False)}
assert fleet.pids == {}
served = json.loads((fleet.root / "gateway_state.json").read_text(encoding="utf-8"))["served_profiles"]
assert sorted(served) == ["coder", "default", "ops"]
capsys.readouterr()
fleet.ops.clear()
plan = gm.build_migration_plan()
assert plan.already_multiplexed and not plan.standalone_secondaries
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True)) # returns, no exit
assert fleet.ops == [] and fleet.services == {"default": ("systemd", False)}
assert "already multiplexing" in capsys.readouterr().out
def test_half_migrated_host_converges_on_a_re_run(fleet, capsys):
"""(b) Flag already on, a per-profile unit + live gateway still there: the re-run must converge.
This is the upgrade shape — a migration that died after the flag write, or a unit an operator
reinstalled by hand. Reading the FLAG as "already multiplexed" made the re-run a no-op on
exactly the host that still had two gateways.
"""
(fleet.root / "config.yaml").write_text(
"model:\n default: x\ngateway:\n multiplex_profiles: true\n", encoding="utf-8")
fleet.services = {"default": ("systemd", False), "coder": ("systemd", False)}
fleet.pids = {"coder": 4101}
plan = gm.build_migration_plan()
assert plan.multiplex_flag_on is True
assert not plan.already_multiplexed, "a host with a live per-profile gateway is not converged"
assert [p.name for p in plan.standalone_secondaries] == ["coder"]
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 0
assert fleet.services == {"default": ("systemd", False)} and fleet.pids == {}
out = capsys.readouterr().out
assert "Half-migrated host" in out and "serves 3 profiles" in out
assert gm.build_migration_plan().already_multiplexed
def test_migrate_enables_the_survivor_before_it_removes_anything(fleet, capsys):
"""Systemctl recorder: a pre-existing, DISABLED survivor unit is enabled BEFORE the first
secondary uninstall (uninstall = stop + disable + wants-unlink), so an apply interrupted at any
later step still leaves a boot-startable gateway. Base restarted the survivor after the removals
and never enabled it: N boot-startable gateways became 0 while the migration reported ✓."""
fleet.services["default"] = ("systemd", False) # unit exists; NOT in fleet.enabled
assert ("default", "systemd", False) not in fleet.enabled
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 0
first_removal = min(i for i, op in enumerate(fleet.ops) if op[1] in ("stop", "uninstall"))
assert fleet.ops.index(("default", "enable")) < first_removal
# A reboot has exactly one gateway: the survivor, and none of the folded secondaries.
assert fleet.enabled == {("default", "systemd", False)}
assert fleet.ops[-1] == ("default", "restart") and "serves 3 profiles" in capsys.readouterr().out
def test_migrate_refuses_when_the_survivor_cannot_be_enabled(fleet, monkeypatch, capsys):
"""A failed `systemctl enable` is a preflight refusal, not a ⚠ line: continuing removed every
boot-startable secondary and reported ✓ Migrated over a host that comes back with no gateway."""
from hermes_cli import gateway as gw
fleet.services["default"] = ("systemd", False)
fake_op = gm._service_op
monkeypatch.setattr(gw, "_run_systemctl", lambda args, **kw: SimpleNamespace(returncode=1))
def _op(kind, system, verb, home, *, run_as_user=None):
if verb == "enable": # the real leg, over a systemctl that fails
return _REAL_SERVICE_OP(kind, system, verb, home, run_as_user=run_as_user)
fake_op(kind, system, verb, home, run_as_user=run_as_user)
monkeypatch.setattr(gm, "_service_op", _op)
with pytest.raises(SystemExit) as exc:
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=False, yes=True))
assert exc.value.code == 1
assert not any(op[1] in ("stop", "uninstall") for op in fleet.ops), "refusal must precede every removal"
assert fleet.pids == {"coder": 4101, "ops": 4102} and _config_flag(fleet.root) is None
assert not gm._manifest_path(fleet.root).exists()
assert "refused before changing anything" in capsys.readouterr().out
def test_plan_names_every_process_it_will_sigterm_before_it_signals_anything(fleet, capsys):
"""A running gateway is never stopped silently: the dry run names the pids and changes nothing."""
gm.cmd_migrate(SimpleNamespace(multiplex=True, dry_run=True, yes=True))
out = capsys.readouterr().out
assert "SIGTERM" in out and "coder (pid 4101)" in out and "ops (pid 4102)" in out
assert fleet.ops == [] and fleet.pids == {"coder": 4101, "ops": 4102}
def test_windows_task_detection_reads_both_the_task_and_the_startup_fallback(monkeypatch):
"""`hermes gateway install` falls back to a Startup-folder entry when it cannot register a
task; a migration that removed only the task would leave that entry launching a second
gateway at the next logon."""
from hermes_cli import gateway_windows as gww
for task, startup, expected in ((True, False, True), (False, True, True), (False, False, False)):
monkeypatch.setattr(gww, "is_task_registered", lambda t=task: t)
monkeypatch.setattr(gww, "is_startup_entry_installed", lambda s=startup: s)
assert gm._windows_task_installed() is expected
# A schtasks failure is not evidence of a second gateway: fail closed to "not installed".
monkeypatch.setattr(gww, "is_task_registered", lambda: (_ for _ in ()).throw(OSError("schtasks")))
assert gm._windows_task_installed() is False
def test_windows_is_migratable_and_only_s6_is_refused(monkeypatch):
"""The host predicate: Windows used to be a flat refusal with hand-migration instructions.
s6 stays refused -- its per-profile gateways are slots the container's own boot registers."""
from hermes_cli import gateway as gw
monkeypatch.setattr(gw, "_running_under_s6", lambda: False)
assert gm._host_supports_migration() is None
monkeypatch.setattr(gw, "_running_under_s6", lambda: True)
reason = gm._host_supports_migration()
assert reason is not None
def test_standalone_profile_is_listed_left_alone_and_not_a_fold_target(fleet):
"""A profile that opts itself out via `gateway.standalone: true` keeps its own gateway: it is
neither a blocker nor a fold target — the plan names it under standalone_by_config instead."""
root = fleet.root
(root / "profiles" / "ops" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
plan = gm.build_migration_plan()
assert "ops" not in [p.name for p in plan.profiles]
assert "ops" not in [p.name for p in plan.standalone_secondaries]
assert "coder" in [p.name for p in plan.standalone_secondaries]
assert plan.standalone_by_config == ("ops",)
payload = json.loads(json.dumps(plan.to_dict()))
assert payload["standalone_by_config"] == list(plan.standalone_by_config)
assert "ops" not in [p["profile"] for p in payload["profiles"]]
assert any("ops" in line for line in gm.format_plan(plan, dry_run=True))