Files
hermes-agent/tests/hermes_cli/test_gateway_multiplex_lifecycle.py

255 lines
12 KiB
Python

"""Per-profile lifecycle never stops the shared host or hides an installed profile."""
from pathlib import Path
from types import SimpleNamespace
import pytest
@pytest.fixture
def homes(tmp_path, monkeypatch):
from hermes_cli import profiles
root = tmp_path / '.hermes'
secondary = root / 'profiles' / 'worker'
secondary.mkdir(parents=True)
(secondary / 'config.yaml').write_text('model: {default: worker-model}\n')
(root / 'config.yaml').write_text('gateway: {multiplex_profiles: true}\n')
monkeypatch.setattr(Path, 'home', lambda: tmp_path)
monkeypatch.setenv('HERMES_HOME', str(root))
monkeypatch.setattr(profiles, 'get_active_profile_name', lambda: 'default')
return root, secondary
@pytest.mark.parametrize('include_parked', [False, True])
def test_parked_is_not_served_but_remains_installed(homes, include_parked):
from hermes_cli import profiles
root, secondary = homes
(secondary / 'gateway.parked').write_text('provisioned offline\n')
if include_parked:
actual = profiles.profiles_to_serve(True, include_parked=True)
else:
actual = profiles.profiles_to_serve(True)
assert dict(actual) == ({'default': root, 'worker': secondary} if include_parked else {'default': root})
assert profiles.parked_marker_path(secondary) == secondary / 'gateway.parked'
assert profiles.profile_is_parked(secondary)
@pytest.mark.parametrize('multiplex', [False, True])
def test_default_marker_is_ignored_with_one_warning(homes, caplog, multiplex):
from hermes_cli.profiles import profiles_to_serve
root, _ = homes
(root / 'gateway.parked').touch()
for _ in range(2):
assert 'default' in dict(profiles_to_serve(multiplex))
warnings = [r for r in caplog.records if 'gateway.parked' in r.message]
assert len(warnings) == 1
assert warnings[0].levelname == 'WARNING'
@pytest.mark.parametrize('verb', ['stop', 'start', 'restart'])
def test_cli_lifecycle_orders_marker_before_socket(homes, monkeypatch, capsys, verb):
from hermes_cli import gateway as gw
from gateway import control_socket
root, secondary = homes
marker = secondary / 'gateway.parked'
owner = SimpleNamespace(home=root, profile_label='default', profiles=('default', 'worker'),
describe=lambda: 'test host')
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
monkeypatch.setattr(gw, '_refuse_from_inside_gateway', lambda *a: None)
monkeypatch.setattr(gw, 'find_gateway_pids', lambda **kw: [])
monkeypatch.setattr(gw, '_served_by_another_host_gateway', lambda *a: owner)
monkeypatch.setattr(gw, 'named_profile_served_by_running_multiplexer', lambda *a: True)
monkeypatch.setattr(gw, '_host_multiplexer_for_all_verb', lambda: owner)
calls = []
def unserve(home, name):
assert home == root and name == 'worker'
assert marker.exists() is (verb == 'stop')
calls.append('unserve')
return {'unserved': name, 'served_profiles': ['default']}
def serve(home, name):
assert home == root and name == 'worker'
assert not marker.exists()
calls.append('serve')
return {'served': name, 'served_profiles': ['default', name]}
monkeypatch.setattr(control_socket, 'request_unserve_profile', unserve, raising=False)
monkeypatch.setattr(control_socket, 'request_serve_profile_hot', serve, raising=False)
if verb == 'start':
marker.touch()
# Precedence: a standalone profile is never parked, even while a stale host record lists it.
from hermes_cli.gateway_profile_lifecycle import profile_lifecycle
(secondary / 'config.yaml').write_text('gateway: {standalone: true}\n')
assert profile_lifecycle(verb, SimpleNamespace()) is False and calls == []
assert marker.exists() is (verb == 'start')
(secondary / 'config.yaml').write_text('model: {default: worker-model}\n')
getattr(gw, '_cmd_' + verb)(SimpleNamespace())
assert calls == {'stop': ['unserve'], 'start': ['serve'], 'restart': ['unserve', 'serve']}[verb]
assert marker.exists() is (verb == 'stop')
output = capsys.readouterr().out
assert {'stop': "Profile 'worker' parked; its bots and cron are stopped.",
'start': "Profile 'worker' served", 'restart': "Profile 'worker' restarted"}[verb] in output
def test_restart_after_stop_unparks_and_serves_the_profile(homes, monkeypatch, capsys):
from hermes_cli import gateway as gw
from gateway import control_socket
root, secondary = homes
marker = secondary / 'gateway.parked'
marker.touch()
owner = SimpleNamespace(home=root, profile_label='default', profiles=('default',),
describe=lambda: 'test host')
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
monkeypatch.setattr(gw, '_refuse_from_inside_gateway', lambda *a: None)
monkeypatch.setattr(gw, 'find_gateway_pids', lambda **kw: [])
monkeypatch.setattr(gw, '_served_by_another_host_gateway', lambda *a: None)
monkeypatch.setattr(gw, 'named_profile_served_by_running_multiplexer', lambda *a: False)
monkeypatch.setattr(gw, '_host_multiplexer_for_all_verb', lambda: owner)
calls = []
def serve(home, name):
assert home == root and name == 'worker' and not marker.exists()
calls.append('serve')
return {'served': name, 'served_profiles': ['default', name]}
def standalone(*args, **kwargs):
raise AssertionError('restart fell through to the standalone gateway path')
monkeypatch.setattr(control_socket, 'request_serve_profile_hot', serve)
monkeypatch.setattr(gw, '_guard_named_profile_under_multiplexer', standalone)
gw._cmd_restart(SimpleNamespace())
assert calls == ['serve']
assert not marker.exists()
assert "Profile 'worker' served by the host gateway." in capsys.readouterr().out
def test_force_restart_keeps_parked_profile_gateway_ownership(homes, monkeypatch):
from hermes_cli import gateway as gw
_, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
def host_restart(*args, **kwargs):
raise AssertionError('forced gateway restart was routed through the host')
monkeypatch.setattr(gw, '_served_by_another_host_gateway', lambda *args: None)
monkeypatch.setattr(gw, 'named_profile_served_by_running_multiplexer', lambda: False)
monkeypatch.setattr(gw, '_host_multiplexer_for_all_verb', host_restart)
monkeypatch.setattr(gw, 'find_gateway_pids', lambda **kwargs: [4242])
from hermes_cli.gateway_profile_lifecycle import profile_lifecycle
assert profile_lifecycle('restart', SimpleNamespace()) is False
def test_parked_status_and_topology_keep_roster(homes, monkeypatch, capsys):
from hermes_cli import gateway as gw, profiles
from hermes_cli.web_server_gateway import _collect_profile_gateway_topology
root, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
monkeypatch.setattr(profiles, 'get_active_profile_name', lambda: 'worker')
monkeypatch.setattr(profiles, '_check_gateway_running', lambda home: False)
gw._cmd_status(SimpleNamespace())
assert 'parked (hermes -p worker gateway start)' in capsys.readouterr().out
topology = _collect_profile_gateway_topology()
assert topology['profiles'] == ['default', 'worker']
assert topology['parked_profiles'] == ['worker']
def test_parked_profile_keeps_implicit_host_multiplexed(homes, monkeypatch):
from gateway.config import GatewayConfig
from hermes_cli import gateway_migrate
from hermes_cli.gateway_multiplex_mode import resolve_multiplex_mode
_, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setattr(gateway_migrate, '_host_supports_migration', lambda: None)
monkeypatch.setattr(gateway_migrate, 'build_migration_plan', lambda: SimpleNamespace(
standalone_secondaries=[], blocked=False))
config = GatewayConfig()
assert resolve_multiplex_mode(config).enabled
def test_dashboard_exposes_parked_profile_and_start_unparks_it(homes, monkeypatch):
from types import SimpleNamespace
from fastapi.testclient import TestClient
from gateway import host_attach
from hermes_cli import web_server, profiles
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
root, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setattr(profiles, '_check_gateway_running', lambda home: False)
with TestClient(web_server.app) as client:
response = client.get('/api/status')
assert response.status_code == 200
assert response.json()['parked_profiles'] == ['worker']
# The Start button: refused while no host can unpark it, allowed (spawns `-p worker gateway start`)
# once the host multiplexer is live; a parked profile is not served, so this is not the served path.
monkeypatch.setattr(host_attach, 'host_gateway', lambda: None)
assert multiplexed_profile_refusal('worker', 'start')
monkeypatch.setattr(host_attach, 'host_gateway', lambda: SimpleNamespace(home=root, pid=1))
assert multiplexed_profile_refusal('worker', 'start') is None
@pytest.mark.parametrize('host_running', [False, True])
def test_start_unparks_without_host_rendezvous(homes, monkeypatch, capsys, host_running):
from hermes_cli import gateway as gw, gateway_multiplex_served as served
from gateway import control_socket
root, secondary = homes
marker = secondary / 'gateway.parked'
marker.touch()
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
monkeypatch.setattr(gw, '_host_multiplexer_for_all_verb', lambda: None)
monkeypatch.setattr(served, 'live_default_gateway_pid', lambda: 42 if host_running else None)
calls = []
def serve(home, name):
assert home == root and name == 'worker' and not marker.exists()
calls.append('serve')
return {'served': name}
def normal_start(**kw):
assert not marker.exists()
calls.append('normal')
monkeypatch.setattr(control_socket, 'request_serve_profile_hot', serve)
monkeypatch.setattr(gw, '_guard_named_profile_under_multiplexer', normal_start)
monkeypatch.setattr(gw, '_dispatch_via_service_manager_if_s6', lambda *a: True)
gw._cmd_start(SimpleNamespace())
assert not marker.exists()
assert calls == (['serve'] if host_running else ['normal'])
def test_default_status_distinguishes_served_and_parked(homes, monkeypatch, capsys):
from hermes_cli import gateway as gw
from hermes_cli.gateway_profile_lifecycle import print_parked_status
root, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'default')
monkeypatch.setattr(gw, 'host_multiplexer_serving', lambda: SimpleNamespace(
home=root, profiles=('default',)))
assert print_parked_status() is False
output = capsys.readouterr().out
assert 'Served profiles: default' in output
assert "Profile 'worker': parked" in output
def test_parked_status_still_reports_a_forced_gateway(homes, monkeypatch, capsys):
from hermes_cli import gateway as gw, profiles
root, secondary = homes
(secondary / 'gateway.parked').touch()
monkeypatch.setenv('HERMES_HOME', str(secondary))
monkeypatch.setattr(gw, '_current_profile_name', lambda: 'worker')
monkeypatch.setattr(profiles, 'get_active_profile_name', lambda: 'worker')
monkeypatch.setattr(gw, 'get_gateway_runtime_snapshot', lambda system=False: gw.GatewayRuntimeSnapshot(
manager='manual', gateway_pids=(4242,)))
monkeypatch.setattr(gw, 'named_profile_served_by_running_multiplexer', lambda: False)
monkeypatch.setattr(gw, '_installed_service_kind_for', lambda _check: None)
gw._cmd_status(SimpleNamespace())
output = capsys.readouterr().out
assert 'parked (hermes -p worker gateway start)' in output
assert 'Gateway is running (PID: 4242)' in output