fix(gateway): a named profile cannot install a new standalone gateway without --force
`hermes -p <name> gateway install|start` (and `run`, `restart`, the setup flows through `ensure_gateway_service`) refused only while a live multiplexer already served the profile. On a host with no multiplexer running -- or one that had not rescanned yet -- the same command wrote a brand-new per-profile unit: a fleet member the multiplex-only topology (#118273) no longer supports. `_named_profile_refused_under_multiplexer` now refuses every `<root>/profiles/<name>` home that has no service of its own: the served form keeps naming the owner PID and `gateway restart`; the new form points at `hermes gateway install` (default profile) and `hermes gateway migrate --multiplex`. `--force` stays the single escape (UNIX-user / out-of-tree HERMES_HOME fleets), and a `--force`-installed service stays startable without it so its supervisor (ExecStart carries no --force) keeps relaunching it. The dashboard `/api/gateway/start` twin (`multiplexed_profile_refusal`) applies the same rule and text; `stop` is unchanged. Tests: red on base for the no-multiplexer refusal (exit 78, no unit written, A->B->A over two profile homes, dashboard shape); controls for the default's own install, the served-by-PID form, and the --force path; the #111958 setup test now asserts the unserved named profile gets no unit either. Completes #118273 (item B). Part of #109417
This commit is contained in:
@@ -3690,7 +3690,9 @@ def _served_profile_needs_no_service() -> bool:
|
||||
Shared by ``hermes setup gateway`` / ``hermes setup`` / ``hermes import`` (``ensure_gateway_service``)
|
||||
and the ``hermes gateway setup`` wizard. See #111958."""
|
||||
if not named_profile_served_by_running_multiplexer():
|
||||
return False
|
||||
# Not served (yet): a named profile still gets no service of its own — same rule and text
|
||||
# as `gateway install`, so `hermes -p X setup` cannot grow a fleet member the verb refuses.
|
||||
return _named_profile_refused_under_multiplexer()
|
||||
print_success(
|
||||
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
|
||||
)
|
||||
@@ -3700,39 +3702,61 @@ def _served_profile_needs_no_service() -> bool:
|
||||
|
||||
|
||||
def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
|
||||
"""Print the served-profile refusal and return True when a named-profile gateway must not start:
|
||||
a multiplexing default gateway already serves it (a second one would double-bind its platforms: two
|
||||
pollers on one token, port fights). ``--force`` overrides. Shared by ``run`` and the service verbs
|
||||
(``start``/``install``/``restart``): a refusal only inside ``gateway run`` leaves the service manager
|
||||
to discover it — systemd parks the unit on exit 78 while the CLI prints "started"; launchd
|
||||
(KeepAlive, no exit-status gating) respawns it every ThrottleInterval forever."""
|
||||
"""Print the refusal and return True when a NAMED profile must not get a gateway of its own.
|
||||
|
||||
One gateway per host serves every profile, so a ``<root>/profiles/<name>`` home never installs or
|
||||
starts a standalone gateway: either the host gateway already serves it (a second one would
|
||||
double-bind its platforms: two pollers on one token, port fights) or no host gateway runs yet and
|
||||
the DEFAULT profile is where it is installed. Refusing only the served case let a host with no
|
||||
multiplexer running (or one that had not rescanned yet) grow a brand-new per-profile fleet member.
|
||||
``--force`` is the one escape (a fleet split across UNIX users or a ``HERMES_HOME`` outside
|
||||
``profiles/``); a service it already installed stays startable without it. Shared by ``run`` and the service verbs (``start``/``install``/``restart``): a
|
||||
refusal only inside ``gateway run`` leaves the service manager to discover it — systemd parks the
|
||||
unit on exit 78 while the CLI prints "started"; launchd (KeepAlive, no exit-status gating)
|
||||
respawns it every ThrottleInterval forever."""
|
||||
if force:
|
||||
return False
|
||||
try:
|
||||
suffix = _current_profile_name()
|
||||
from hermes_constants import profile_name_for_home
|
||||
# A unit/plist/task already registered for this home was installed with --force: that fleet
|
||||
# member (and the supervisor relaunching it, whose ExecStart carries no --force) is not NEW.
|
||||
new_standalone = (profile_name_for_home(get_hermes_home()) not in (None, "default")
|
||||
and not _is_service_installed())
|
||||
except Exception:
|
||||
return False
|
||||
owner = _served_by_another_host_gateway()
|
||||
if owner is None and not named_profile_served_by_running_multiplexer():
|
||||
served = owner is not None or named_profile_served_by_running_multiplexer()
|
||||
if not served and not new_standalone:
|
||||
return False
|
||||
|
||||
print_error(
|
||||
f"The host gateway already serves profile '{suffix}'."
|
||||
)
|
||||
if owner is not None:
|
||||
print(f" {owner.describe()}")
|
||||
if served:
|
||||
print_error(f"The host gateway already serves profile '{suffix}'.")
|
||||
if owner is not None:
|
||||
print(f" {owner.describe()}")
|
||||
else:
|
||||
print_error(f"Profile '{suffix}' does not get a gateway of its own.")
|
||||
print(
|
||||
" Exactly one gateway per host is the inbound process for every\n"
|
||||
" profile. Starting a separate gateway for this profile would\n"
|
||||
" double-bind its platforms (two pollers on one bot token, port\n"
|
||||
" conflicts).\n"
|
||||
)
|
||||
print(" Manage the host gateway instead:")
|
||||
if served:
|
||||
print(" Manage the host gateway instead:")
|
||||
print()
|
||||
print(f" hermes -p {owner.profile_label if owner is not None else 'default'} gateway restart")
|
||||
else:
|
||||
print(" Install or start the host gateway from the default profile; it serves this one too:")
|
||||
print()
|
||||
print(" hermes gateway install")
|
||||
print()
|
||||
print(" Or fold an existing per-profile fleet onto one host gateway:")
|
||||
print()
|
||||
print(" hermes gateway migrate --multiplex")
|
||||
print()
|
||||
print(f" hermes -p {owner.profile_label if owner is not None else 'default'} gateway restart")
|
||||
print()
|
||||
print(" Pass --force to start a separate profile gateway anyway (not")
|
||||
print(" recommended while the host gateway is running).")
|
||||
print(" A separate per-profile gateway (for a fleet split across UNIX users or a")
|
||||
print(f" HERMES_HOME outside profiles/) needs --force: hermes -p {suffix} gateway install --force")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -529,19 +529,33 @@ def _profile_is_multiplexed(profile: str) -> bool:
|
||||
|
||||
|
||||
def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[str]:
|
||||
"""Refusal text for ``gateway start``/``stop`` on a profile the live default multiplexer serves and
|
||||
that has no gateway of its own (a ``--force``-started separate one is managed normally), else None.
|
||||
The spawned ``hermes -p X gateway <verb>`` would only print exit-78 / "no gateway running for this
|
||||
profile" into an action log nobody reads while the UI shows the verb as done."""
|
||||
"""Refusal text for ``gateway start``/``stop`` on a named profile with no gateway of its own (a
|
||||
``--force``-started separate one is managed normally), else None. ``stop`` is refused only when the
|
||||
live default multiplexer serves the profile; ``start`` is refused for every named profile — one
|
||||
host gateway serves every profile, so a new per-profile gateway is never the answer (the CLI twin
|
||||
``_named_profile_refused_under_multiplexer`` exits 78 into an action log nobody reads while the UI
|
||||
shows the verb as done)."""
|
||||
requested = _own_profile_selector(profile) or ""
|
||||
if not requested or requested.lower() in {"current", "default"} or not _profile_is_multiplexed(requested):
|
||||
if not requested or requested.lower() in {"current", "default"}:
|
||||
return None
|
||||
served = _profile_is_multiplexed(requested)
|
||||
if not served and verb != "start":
|
||||
return None
|
||||
from hermes_cli.profiles import _check_gateway_running
|
||||
from hermes_cli.web_server_profiles import _resolve_profile_dir
|
||||
if _check_gateway_running(_resolve_profile_dir(requested)):
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
if _check_gateway_running(profile_dir):
|
||||
return None
|
||||
return (f"The default gateway already serves profile '{requested}' as a multiplexer; "
|
||||
f"{verb} it from the default profile instead of a separate gateway for this profile.")
|
||||
if served:
|
||||
return (f"The default gateway already serves profile '{requested}' as a multiplexer; "
|
||||
f"{verb} it from the default profile instead of a separate gateway for this profile.")
|
||||
from hermes_cli.gateway_migrate import _installed_services
|
||||
if _installed_services(profile_dir):
|
||||
return None # a --force-installed fleet member is not NEW; its own service is started normally
|
||||
return (f"Profile '{requested}' does not get a gateway of its own: one host gateway serves every "
|
||||
f"profile. Install or start it from the default profile (hermes gateway install), or fold an "
|
||||
f"existing per-profile fleet with `hermes gateway migrate --multiplex`; "
|
||||
f"`hermes -p {requested} gateway install --force` starts a separate one anyway.")
|
||||
|
||||
|
||||
def _restart_gateway_after(profile: Optional[str], *, what: str, label: str) -> dict[str, Any]:
|
||||
|
||||
@@ -103,8 +103,10 @@ def test_setup_wizard_skips_service_install_for_profile_served_by_multiplexer(
|
||||
|
||||
def test_setup_gateway_service_step_skips_install_for_served_profile(served_root, monkeypatch, capsys):
|
||||
"""``hermes -p <profile> setup gateway`` (and ``hermes setup`` / ``hermes import``) reach the service
|
||||
step through ``ensure_gateway_service``: a served profile gets the multiplexer note and no unit/plist,
|
||||
while a profile the live record does not list is still installed (#111958)."""
|
||||
step through ``ensure_gateway_service``: a served profile gets the multiplexer note and no unit/plist
|
||||
(#111958), and a named profile the live record does not list gets no unit/plist either — one host
|
||||
gateway serves every profile, so setup must not grow a standalone fleet member that
|
||||
``gateway install`` refuses (#109417)."""
|
||||
import hermes_cli.gateway as gw
|
||||
|
||||
calls: list[str] = []
|
||||
@@ -121,7 +123,8 @@ def test_setup_gateway_service_step_skips_install_for_served_profile(served_root
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(served_root / "profiles" / "other")) # not in the live record
|
||||
assert gw.ensure_gateway_service(context="setup") is True
|
||||
assert calls == ["install", "start"]
|
||||
assert calls == []
|
||||
assert "Profile 'other' does not get a gateway of its own" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_recycled_pid_does_not_lend_a_stale_record_its_served_profiles(served_root):
|
||||
|
||||
125
tests/hermes_cli/test_gateway_no_new_standalone_profile.py
Normal file
125
tests/hermes_cli/test_gateway_no_new_standalone_profile.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""A named profile cannot create a NEW standalone gateway — multiplexer running or not (#109417).
|
||||
|
||||
One gateway per host serves every profile. ``hermes -p X gateway install|start`` used to refuse only
|
||||
while a live multiplexer already served X; on a host with no multiplexer running (or one that had not
|
||||
rescanned yet) it wrote a brand-new per-profile unit. Now every named profile is refused without
|
||||
``--force`` and pointed at ``hermes gateway install`` (default) / ``hermes gateway migrate --multiplex``;
|
||||
``--force`` stays the one escape and a ``--force``-installed service stays startable without it. The
|
||||
dashboard ``/api/gateway/start`` twin (``multiplexed_profile_refusal``) applies the same rule.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import contextlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quiet_host(tmp_path, monkeypatch):
|
||||
"""Two named profiles under one root, no gateway running anywhere, systemd units under tmp."""
|
||||
import hermes_cli.gateway as gw
|
||||
import hermes_constants
|
||||
|
||||
root = tmp_path / "hermes"
|
||||
(root / "config.yaml").parent.mkdir(parents=True)
|
||||
(root / "config.yaml").write_text("model: {default: x}\n")
|
||||
for name in ("coder", "ops"):
|
||||
(root / "profiles" / name).mkdir(parents=True)
|
||||
(root / "profiles" / name / "config.yaml").write_text("{}\n")
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg"))
|
||||
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
||||
monkeypatch.setattr(gw, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gw, "_service_backend", lambda: "systemd")
|
||||
monkeypatch.setattr(gw, "refuses_container_user_scope_install", lambda system: False)
|
||||
monkeypatch.setattr(gw, "_dispatch_via_service_manager_if_s6", lambda v: False)
|
||||
monkeypatch.setattr(gw, "is_managed", lambda: False)
|
||||
monkeypatch.setattr(gw, "is_termux", lambda: False)
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(gw, "_install_systemd_from_cli", lambda *a, **k: calls.append("install"))
|
||||
monkeypatch.setattr(gw, "_service_call", lambda backend, v, system: calls.append(v))
|
||||
|
||||
def use(profile: str | None) -> None:
|
||||
home = root if profile is None else root / "profiles" / profile
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
hermes_constants._default_hermes_root_memo = None
|
||||
|
||||
return root, calls, use
|
||||
|
||||
|
||||
def _run(fn, **ns):
|
||||
out = io.StringIO()
|
||||
with contextlib.redirect_stdout(out), pytest.raises(SystemExit) as exc:
|
||||
fn(argparse.Namespace(system=False, all=False, run_as_user=None, **ns))
|
||||
return exc.value.code, out.getvalue()
|
||||
|
||||
|
||||
def test_named_profile_install_and_start_refuse_without_force_when_no_multiplexer_runs(quiet_host):
|
||||
import hermes_cli.gateway as gw
|
||||
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
|
||||
root, calls, use = quiet_host
|
||||
|
||||
# A -> B -> A: the refusal names the right profile from each home and installs nothing.
|
||||
for profile in ("coder", "ops", "coder"):
|
||||
use(profile)
|
||||
for verb in ("install", "start"):
|
||||
code, out = _run(getattr(gw, f"_cmd_{verb}"), force=False)
|
||||
assert code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE, (profile, verb)
|
||||
assert f"Profile '{profile}' does not get a gateway of its own" in out
|
||||
assert "hermes gateway install" in out and "hermes gateway migrate --multiplex" in out
|
||||
assert f"hermes -p {profile} gateway install --force" in out
|
||||
assert "already serves" not in out # nothing is running: this is the no-multiplexer form
|
||||
assert not gw.get_systemd_unit_path(system=False).exists()
|
||||
# Dashboard twin: same rule, same pointers, `stop` untouched (nothing serves the profile).
|
||||
refusal = multiplexed_profile_refusal(profile, "start")
|
||||
assert refusal and f"'{profile}'" in refusal and "migrate --multiplex" in refusal and "--force" in refusal
|
||||
assert multiplexed_profile_refusal(profile, "stop") is None
|
||||
assert calls == []
|
||||
|
||||
# Control: the default profile's own install is unchanged.
|
||||
use(None)
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
gw._cmd_install(argparse.Namespace(system=False, all=False, run_as_user=None, force=False))
|
||||
assert calls == ["install"]
|
||||
|
||||
# Control: a live multiplexer serving the profile still prints the served-by form.
|
||||
use("coder")
|
||||
(root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(root)}))
|
||||
(root / "gateway_state.json").write_text(json.dumps(
|
||||
{"pid": os.getpid(), "hermes_home": str(root), "gateway_state": "running",
|
||||
"served_profiles": ["default", "coder"]}))
|
||||
import gateway.status as status
|
||||
real_cmdline = status._read_process_cmdline
|
||||
status._read_process_cmdline = lambda pid: (
|
||||
"python -m hermes_cli.main gateway run" if pid == os.getpid() else real_cmdline(pid))
|
||||
try:
|
||||
code, out = _run(gw._cmd_install, force=False)
|
||||
finally:
|
||||
status._read_process_cmdline = real_cmdline
|
||||
assert code == gw.GATEWAY_FATAL_CONFIG_EXIT_CODE
|
||||
assert "The host gateway already serves profile 'coder'" in out and "gateway restart" in out
|
||||
|
||||
|
||||
def test_force_installs_a_separate_profile_gateway_and_its_service_stays_startable(quiet_host):
|
||||
import hermes_cli.gateway as gw
|
||||
from hermes_cli.web_server_gateway import multiplexed_profile_refusal
|
||||
root, calls, use = quiet_host
|
||||
use("coder")
|
||||
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
gw._cmd_install(argparse.Namespace(system=False, all=False, run_as_user=None, force=True))
|
||||
assert calls == ["install"]
|
||||
|
||||
# The --force-installed fleet member is not NEW: its supervisor (ExecStart carries no --force)
|
||||
# and a plain `gateway start` must keep reaching it, CLI and dashboard alike.
|
||||
unit = gw.get_systemd_unit_path(system=False)
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text("[Service]\n")
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
gw._cmd_start(argparse.Namespace(system=False, all=False, run_as_user=None, force=False))
|
||||
assert calls == ["install", "start"]
|
||||
assert multiplexed_profile_refusal("coder", "start") is None
|
||||
Reference in New Issue
Block a user