feat(gateway): gateway.standalone opts a named profile out of the host multiplexer
A named profile that authors `gateway.standalone: true` in its own config.yaml runs its own gateway again, the pre-multiplex topology, while the default gateway keeps serving every other profile. Topology becomes something the operator authors per profile instead of something the box infers from boot state, which is what a fleet running per-profile gateways lost when `gateway.multiplex_profiles: false` was retired. Changed - hermes_cli/profiles.py: `profile_is_standalone(home)` reads the profile's own config.yaml (memo by file signature, tolerant of malformed yaml, always False for the default profile with one warning). `profiles_to_serve()` excludes standalone profiles; roster callers that mean "every installed profile" (plugin deps, Windows update, launch policy, dashboard listing and topology) pass `include_standalone=True`. - gateway/host_attach.py: `standalone_attach_decision` starts a standalone profile's gateway beside the host multiplexer once every live gateway confirms it does not serve that profile; refuses with a rescan message while one still does. Used by the initial attach check and the lock-losing race. - hermes_cli/gateway_multiplex_mode.py: a standalone launcher never becomes the host multiplexer (`STANDALONE_PROFILE_REASON`), including callers that supply an explicit GatewayConfig. - hermes_cli/gateway.py, web_server_gateway.py: `hermes -p X gateway install/start/run` proceeds without --force for a standalone profile; the refusal text for other profiles points at the opt-out; status shows "standalone (gateway.standalone: true)" and the default lists skipped profiles. - gateway/run_profile_reconcile.py: the host does not re-adopt a profile whose own gateway is live (removing the key while it runs no longer double-binds). - hermes_cli/gateway_migrate.py: standalone profiles are neither blocker nor fold target; the plan lists them as "standalone by config". - gateway/run.py: one INFO line per standalone profile at host boot. Tests: two-home E2E through real loaders and resolve_multiplex_mode, decide() with fake host records for both arms, lock-losing branch, reconcile guard, migrate plan, refusal predicate both ways, topology, memo and malformed-yaml contracts. All red on base.
This commit is contained in:
committed by
Teknium
parent
550d74c62f
commit
0238c9d740
@@ -272,8 +272,60 @@ def _refuse_message(gateway: HostGateway, profile: str) -> str:
|
||||
f" Or start one anyway: hermes gateway run --force")
|
||||
|
||||
|
||||
def standalone_rescan_message(profile: str) -> str:
|
||||
return (
|
||||
f"The host gateway still serves profile '{profile}'; gateway.standalone is not live yet. "
|
||||
"Wait for the host gateway to rescan (<=30s), or send the rescan-profiles control verb "
|
||||
"to the host gateway before starting this profile's gateway.")
|
||||
|
||||
|
||||
def _coexisting_gateways(owner: Optional[HostGateway]):
|
||||
"""A standalone lock owner can hide a multiplexer launched beside it.
|
||||
|
||||
Use the existing per-home liveness and control channels, not the single host
|
||||
record, to ask every running profile gateway what it actually serves.
|
||||
"""
|
||||
from gateway.status import live_gateway_pid_for_home
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
|
||||
seen = {os.getpid()}
|
||||
if owner is not None:
|
||||
seen.add(owner.pid)
|
||||
yield owner
|
||||
for _name, home in profiles_to_serve(True, include_standalone=True):
|
||||
pid = live_gateway_pid_for_home(home)
|
||||
if pid is None or pid in seen:
|
||||
continue
|
||||
seen.add(pid)
|
||||
peer = HostGateway(pid, home, (), served_known=False)
|
||||
identity = _identify(home)
|
||||
if isinstance(identity, dict) and _identity_matches(identity, peer, home):
|
||||
peer = HostGateway(pid, home, _served_from_identity(identity),
|
||||
standalone=identity.get("multiplex") is False)
|
||||
yield peer
|
||||
|
||||
|
||||
def standalone_attach_decision(our_home: Path, owner: Optional[HostGateway]) -> Optional[HostAttachDecision]:
|
||||
"""An opt-out permits coexistence only after every live gateway confirms we are unserved.
|
||||
|
||||
Shared by the initial attach check and the lock-losing race check.
|
||||
"""
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
|
||||
if not profile_is_standalone(our_home):
|
||||
return None
|
||||
profile = profile_name_for_home(our_home)
|
||||
for peer in _coexisting_gateways(owner):
|
||||
if not peer.served_known:
|
||||
return HostAttachDecision(REFUSE, _unknown_served_message(peer, profile), peer, transient=True)
|
||||
if peer.serves(profile):
|
||||
return HostAttachDecision(REFUSE, standalone_rescan_message(profile), peer, transient=True)
|
||||
logger.info("Profile '%s' is standalone by config; starting beside the host multiplexer", profile)
|
||||
return HostAttachDecision(START, "", owner)
|
||||
|
||||
|
||||
def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
|
||||
"""Attach, rescan-then-attach, replace or refuse — never a second gateway beside a multiplexer.
|
||||
"""Attach, rescan-then-attach, replace or refuse; configured standalone profiles may coexist.
|
||||
|
||||
Never raises: a broken probe degrades to ``START``, i.e. exactly the pre-rendezvous behaviour.
|
||||
"""
|
||||
@@ -284,11 +336,15 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
|
||||
logger.debug("host gateway probe failed; starting as before", exc_info=True)
|
||||
return HostAttachDecision(START, "")
|
||||
if gateway is None or gateway.pid == os.getpid():
|
||||
return HostAttachDecision(START, "")
|
||||
return standalone_attach_decision(our_home, None) or HostAttachDecision(START, "")
|
||||
if replace:
|
||||
# --replace is explicit authority over the host role; the target is the host process,
|
||||
# whichever home launched it.
|
||||
return HostAttachDecision(REPLACE_HOST, "", gateway)
|
||||
if gateway.served_known:
|
||||
standalone = standalone_attach_decision(our_home, gateway)
|
||||
if standalone is not None:
|
||||
return standalone
|
||||
if gateway.serves(profile):
|
||||
return HostAttachDecision(ATTACH, attach_message(gateway, profile), gateway, transient=True)
|
||||
if not gateway.served_known:
|
||||
@@ -298,6 +354,9 @@ def decide(our_home: Path, *, replace: bool = False) -> HostAttachDecision:
|
||||
if waited is None:
|
||||
return HostAttachDecision(START, "")
|
||||
gateway = waited
|
||||
standalone = standalone_attach_decision(our_home, gateway)
|
||||
if standalone is not None:
|
||||
return standalone
|
||||
if gateway.serves(profile):
|
||||
return HostAttachDecision(ATTACH, attach_message(gateway, profile), gateway, transient=True)
|
||||
try:
|
||||
|
||||
@@ -3482,9 +3482,14 @@ class GatewayRunner(
|
||||
# With multiplex_profiles on, load under the default profile secret scope so bot tokens in its
|
||||
# .env resolve as secondary profiles' do; explicit config= injection (tests) is left untouched.
|
||||
# See #64674.
|
||||
# An injected config (tests, ``gateway run --config``) is taken verbatim: an unset flag there
|
||||
# stays None (= standalone); only the loaded path runs the boot-time default-on guard.
|
||||
# Injected configs keep their mode (including None), except the launching profile's
|
||||
# standalone opt-out: --config must not turn that profile into a host multiplexer.
|
||||
self.config = config if config is not None else load_gateway_config_for_runner()
|
||||
if config is not None:
|
||||
from hermes_cli.gateway_multiplex_mode import standalone_launcher_decision, log_multiplex_decision
|
||||
decision = standalone_launcher_decision(self.config)
|
||||
if decision is not None:
|
||||
log_multiplex_decision(decision)
|
||||
# Multiplexer flag flips agent.secret_scope.get_secret() to fail-closed on unscoped credential
|
||||
# reads, so a missed migration crashes loudly instead of leaking a cross-profile value.
|
||||
try:
|
||||
@@ -5361,6 +5366,15 @@ def _claim_host_gateway_role(force: bool = False) -> None:
|
||||
"""
|
||||
from gateway import host_rendezvous as hr
|
||||
|
||||
# The host lock can be free after a standalone owner exits while a coexisting
|
||||
# multiplexer remains live. Its per-home channel still governs our opt-out.
|
||||
if not force:
|
||||
from gateway.host_attach import REFUSE, standalone_attach_decision
|
||||
decision = standalone_attach_decision(get_hermes_home(), None)
|
||||
if decision is not None and decision.outcome == REFUSE:
|
||||
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
print(decision.message)
|
||||
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
|
||||
try:
|
||||
outcome, error = hr.claim_host_lock(hr.ROLE_GATEWAY)
|
||||
if outcome is hr.HostLockOutcome.ACQUIRED:
|
||||
@@ -5394,6 +5408,22 @@ def _claim_host_gateway_role(force: bool = False) -> None:
|
||||
logger.warning("--force: starting a second gateway although %s owns this host.",
|
||||
hr.describe(owner) if owner else "another process")
|
||||
return
|
||||
from gateway.host_attach import (
|
||||
ATTACH_CHANNEL_WAIT_S, START, host_gateway, standalone_attach_decision,
|
||||
)
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
if profile_is_standalone(get_hermes_home()):
|
||||
# Recheck after losing the atomic lock: the pre-lock served set may be stale.
|
||||
live_owner = host_gateway(wait_for_channel=ATTACH_CHANNEL_WAIT_S)
|
||||
if live_owner is not None:
|
||||
decision = standalone_attach_decision(get_hermes_home(), live_owner)
|
||||
if decision is not None:
|
||||
if decision.outcome == START:
|
||||
return
|
||||
from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE
|
||||
print(decision.message)
|
||||
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
|
||||
_refuse_second_host_gateway(owner)
|
||||
if _owner_is_standalone():
|
||||
# COMPOSITION with #118236: `host_attach.decide` sent us here with START precisely because
|
||||
# the owner is another profile's STANDALONE gateway and will never serve us. Refusing now
|
||||
@@ -5454,6 +5484,25 @@ def _refuse_second_host_gateway(owner) -> None:
|
||||
raise SystemExit(GATEWAY_SERVICE_RESTART_EXIT_CODE)
|
||||
|
||||
|
||||
def _log_standalone_profiles_at_boot(runner) -> None:
|
||||
"""One INFO line per standalone profile when the MULTIPLEXER takes its served set at boot.
|
||||
|
||||
The host gateway silently omits an opted-out profile from its served set; without this line an
|
||||
operator reading the boot log cannot tell "not created yet" from "excluded by config".
|
||||
"""
|
||||
try:
|
||||
if not getattr(runner.config, "multiplex_profiles", False):
|
||||
return
|
||||
from hermes_cli.profiles import profiles_to_serve, profile_is_standalone
|
||||
served = set(runner.served_profile_names())
|
||||
for name, home in profiles_to_serve(True, include_standalone=True):
|
||||
if name != "default" and name not in served and profile_is_standalone(home):
|
||||
logger.info("profile '%s' is standalone (gateway.standalone: true); not served by "
|
||||
"this gateway", name)
|
||||
except Exception:
|
||||
logger.warning("standalone-profile boot notice failed", exc_info=True)
|
||||
|
||||
|
||||
def _refresh_host_gateway_record(runner) -> None:
|
||||
"""Republish the host record with the SETTLED served set, now that the channel answers.
|
||||
|
||||
@@ -5810,6 +5859,7 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
|
||||
_control_server = await _start_gateway_start_control_socket(runner)
|
||||
# Now the attach channel answers: republish the host record with the settled served set.
|
||||
_refresh_host_gateway_record(runner)
|
||||
_log_standalone_profiles_at_boot(runner)
|
||||
|
||||
def _lifecycle_record_startup() -> None:
|
||||
# Report if the previous life died uncleanly (SIGKILL / OOM / VM death), then claim the
|
||||
|
||||
@@ -48,6 +48,7 @@ class GatewayProfileReconcileMixin:
|
||||
_served_profile_homes: Optional[Dict[str, "Path"]] = None
|
||||
_served_profile_signatures: Optional[Dict[str, tuple]] = None
|
||||
_profile_reconcile_lock: Optional[asyncio.Lock] = None
|
||||
_profile_own_gateway_warned: Optional[set[str]] = None
|
||||
|
||||
# ── state helpers ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -105,6 +106,20 @@ class GatewayProfileReconcileMixin:
|
||||
active = getattr(self, "_primary_profile_name", None) or "default"
|
||||
current = {str(name): Path(home) for name, home in _multiplex_profile_homes(self.config)}
|
||||
known = dict(self._served_profile_homes or {})
|
||||
from gateway.status import live_gateway_pid_for_home
|
||||
|
||||
blocked = set()
|
||||
warned = self._profile_own_gateway_warned or set()
|
||||
for name in list(current):
|
||||
if name == active or name in known:
|
||||
continue
|
||||
if live_gateway_pid_for_home(current[name]) is not None:
|
||||
blocked.add(name)
|
||||
if name not in warned:
|
||||
logger.warning("[MULTIPLEX] Profile '%s' still runs its own gateway; "
|
||||
"stop it before the host can serve this profile", name)
|
||||
del current[name]
|
||||
self._profile_own_gateway_warned = blocked
|
||||
sigs = self._served_profile_signatures or {}
|
||||
added = [n for n in current if n not in known and n != active]
|
||||
removed = [n for n in known if n not in current and n != active]
|
||||
|
||||
@@ -3705,6 +3705,11 @@ def _served_profile_needs_no_service() -> bool:
|
||||
# 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()
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
if profile_is_standalone(get_hermes_home()):
|
||||
from gateway.host_attach import standalone_rescan_message
|
||||
print_info(standalone_rescan_message(_current_profile_name()))
|
||||
return True
|
||||
print_success(
|
||||
f"Profile '{_current_profile_name()}' is already served by the default multiplexer."
|
||||
)
|
||||
@@ -3731,6 +3736,12 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
|
||||
try:
|
||||
suffix = _current_profile_name()
|
||||
from hermes_constants import profile_name_for_home
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
# A profile that authored gateway.standalone: true opted out of the host multiplexer: it is
|
||||
# allowed a gateway of its own without --force. Only a RUNNING host record that still lists
|
||||
# it (the host has not rescanned since the key was set) is refused with the rescan remedy.
|
||||
standalone = (profile_name_for_home(get_hermes_home()) not in (None, "default")
|
||||
and profile_is_standalone(get_hermes_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")
|
||||
@@ -3739,6 +3750,12 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
|
||||
return False
|
||||
owner = _served_by_another_host_gateway()
|
||||
served = owner is not None or named_profile_served_by_running_multiplexer()
|
||||
if standalone:
|
||||
if not served:
|
||||
return False
|
||||
from gateway.host_attach import standalone_rescan_message
|
||||
print_error(standalone_rescan_message(suffix))
|
||||
return True
|
||||
if not served and not new_standalone:
|
||||
return False
|
||||
|
||||
@@ -3769,6 +3786,11 @@ def _named_profile_refused_under_multiplexer(force: bool = False) -> bool:
|
||||
print()
|
||||
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")
|
||||
print()
|
||||
from hermes_constants import display_hermes_home
|
||||
print(" Or opt this profile out of the host gateway for good: set")
|
||||
print(f" gateway.standalone: true in {display_hermes_home(get_hermes_home())}/config.yaml.")
|
||||
print(" Wait for the host gateway to rescan (<=30s), or send its rescan-profiles control verb.")
|
||||
return True
|
||||
|
||||
|
||||
@@ -4996,10 +5018,14 @@ def _cmd_status(args):
|
||||
full = getattr(args, "full", False)
|
||||
system = getattr(args, "system", False)
|
||||
snapshot = get_gateway_runtime_snapshot(system=system)
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
from hermes_cli.profiles import get_active_profile_name, profile_is_standalone
|
||||
|
||||
active_standalone = ((get_active_profile_name() or "default") != "default"
|
||||
and profile_is_standalone(get_hermes_home()))
|
||||
if active_standalone:
|
||||
print("standalone by config (gateway.standalone: true)")
|
||||
_windows_service_installed = is_windows() and _gw_windows().is_installed()
|
||||
if not snapshot.running and named_profile_served_by_running_multiplexer():
|
||||
if not active_standalone and not snapshot.running and named_profile_served_by_running_multiplexer():
|
||||
# Satellite profile: the default multiplexer is the live inbound process for it.
|
||||
print("✓ Gateway is running via the default-profile multiplexer")
|
||||
print(" Manage it from the default profile: hermes gateway status")
|
||||
@@ -5035,6 +5061,20 @@ def _cmd_status(args):
|
||||
|
||||
_print_duplicate_credential_warnings()
|
||||
_print_other_profiles_gateway_status()
|
||||
_print_standalone_by_config()
|
||||
|
||||
|
||||
def _print_standalone_by_config() -> None:
|
||||
"""Default-profile status: name the profiles that opted out of the host multiplexer by config,
|
||||
so the served set the host gateway reports is not mistaken for the installed roster."""
|
||||
from hermes_cli.profiles import get_active_profile_name, profiles_to_serve
|
||||
if (get_active_profile_name() or "default") != "default":
|
||||
return
|
||||
roster = {name for name, _home in profiles_to_serve(True, include_standalone=True)}
|
||||
served = {name for name, _home in profiles_to_serve(True)}
|
||||
names = sorted(roster - served - {"default"})
|
||||
if names:
|
||||
print(f"standalone by config: {', '.join(names)}")
|
||||
|
||||
|
||||
def _cmd_list(args):
|
||||
|
||||
@@ -114,6 +114,9 @@ class MigrationPlan:
|
||||
interrupted: bool = False
|
||||
blockers: list[str] = field(default_factory=list)
|
||||
notices: list[str] = field(default_factory=list)
|
||||
# Profiles that authored `gateway.standalone: true`: they keep their own gateway and are neither
|
||||
# a blocker nor a fold target — the plan names them so the operator knows they were left alone.
|
||||
standalone_by_config: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def secondaries(self) -> list[ProfileGateway]:
|
||||
@@ -160,6 +163,7 @@ class MigrationPlan:
|
||||
return {
|
||||
"default_home": str(self.default_home),
|
||||
"profiles": [p.to_dict() for p in self.profiles],
|
||||
"standalone_by_config": list(self.standalone_by_config),
|
||||
"multiplex_flag_on": self.multiplex_flag_on,
|
||||
"live_served": self.live_served,
|
||||
"already_multiplexed": self.already_multiplexed,
|
||||
@@ -557,6 +561,11 @@ def build_migration_plan() -> MigrationPlan:
|
||||
live_served=recorded_served_profiles(default_home),
|
||||
manifest=_read_manifest(default_home),
|
||||
)
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
foldable = {name for name, _home in _profile_homes()}
|
||||
plan.standalone_by_config = tuple(
|
||||
name for name, _home in profiles_to_serve(True, include_standalone=True)
|
||||
if name != "default" and name not in foldable)
|
||||
plan.interrupted = plan.multiplex_flag_on and _manifest_not_yet_served(plan.manifest, plan.live_served)
|
||||
if len(plan.profiles) < 2:
|
||||
plan.notices.append("Only one profile exists: nothing to multiplex.")
|
||||
@@ -609,6 +618,9 @@ def format_plan(plan: MigrationPlan, *, dry_run: bool) -> list[str]:
|
||||
lines = [head, f" default home: {plan.default_home}", "", " profile gateway pid service"]
|
||||
for p in plan.profiles:
|
||||
lines.append(f" {p.name:<12} {str(p.pid or '-'):<13} {p.service_label()}")
|
||||
if plan.standalone_by_config:
|
||||
lines.append(f" Standalone by config (gateway.standalone: true), left alone: "
|
||||
f"{', '.join(plan.standalone_by_config)}")
|
||||
lines.append("")
|
||||
if plan.already_multiplexed:
|
||||
lines.append(" ✓ The default gateway is already multiplexing"
|
||||
|
||||
@@ -26,6 +26,7 @@ from typing import Optional
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SINGLE_PROFILE_REASON = "only one profile exists (nothing to multiplex)"
|
||||
STANDALONE_PROFILE_REASON = "this profile is standalone (gateway.standalone: true); it serves only itself"
|
||||
|
||||
#: ``gateway.multiplex_profiles: false`` is no longer an opt-out from the one-gateway-per-host
|
||||
#: topology; it parses, it is reported, and it is ignored.
|
||||
@@ -90,6 +91,22 @@ class MultiplexDecision:
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def _standalone_launcher() -> bool:
|
||||
from hermes_constants import get_hermes_home, profile_name_for_home
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
|
||||
home = get_hermes_home()
|
||||
return profile_name_for_home(home) not in (None, "default") and profile_is_standalone(home)
|
||||
|
||||
|
||||
def standalone_launcher_decision(config) -> Optional[MultiplexDecision]:
|
||||
"""The per-profile opt-out also binds callers supplying an explicit GatewayConfig."""
|
||||
if not _standalone_launcher():
|
||||
return None
|
||||
config.multiplex_profiles = False
|
||||
return MultiplexDecision(False, "guard", STANDALONE_PROFILE_REASON)
|
||||
|
||||
|
||||
def implicit_multiplex_blocker() -> Optional[str]:
|
||||
"""Why THIS process must not multiplex right now, or None when it may.
|
||||
|
||||
@@ -109,6 +126,8 @@ def implicit_multiplex_blocker() -> Optional[str]:
|
||||
verb built on "the default's multiplexer" blind to the process actually serving the host.
|
||||
"""
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
if _standalone_launcher():
|
||||
return STANDALONE_PROFILE_REASON
|
||||
# Cheap and first: a single-profile install has nothing to multiplex, and the fail-closed secret
|
||||
# scope the multiplexer arms buys it nothing. (Also keeps every embedded/test runner off the
|
||||
# service-manager probes below.) Create a second profile and restart to start serving it.
|
||||
@@ -141,6 +160,9 @@ def resolve_multiplex_mode(config) -> MultiplexDecision:
|
||||
says why, and it converges by itself once ``hermes gateway migrate --multiplex`` has run.
|
||||
"""
|
||||
current = getattr(config, "multiplex_profiles", None)
|
||||
standalone = standalone_launcher_decision(config)
|
||||
if standalone is not None:
|
||||
return standalone
|
||||
if current:
|
||||
return MultiplexDecision(True, "config")
|
||||
retired_opt_out = current is False
|
||||
@@ -177,8 +199,7 @@ def log_multiplex_decision(decision: MultiplexDecision) -> None:
|
||||
logger.info("Single-profile install: gateway.multiplex_profiles unset, serving the default profile only.")
|
||||
elif decision.source == "guard":
|
||||
logger.warning(
|
||||
"This gateway stays standalone: %s. It serves the default profile only; the host "
|
||||
"converges once that is resolved.",
|
||||
"This gateway stays standalone: %s. It serves only the launching profile.",
|
||||
decision.reason)
|
||||
elif decision.source == "default":
|
||||
logger.info("Serving every profile on this host (gateway.multiplex_profiles unset; default on).")
|
||||
|
||||
@@ -234,7 +234,7 @@ def enabled_declarations(home: Path) -> list[PythonDeclaration]:
|
||||
def dependency_homes() -> list[Path]:
|
||||
"""Every home whose plugins share this venv: the default home plus live named profiles."""
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
return [home for _name, home in profiles_to_serve(multiplex=True)]
|
||||
return [home for _name, home in profiles_to_serve(multiplex=True, include_standalone=True)]
|
||||
|
||||
|
||||
def union_specs(declarations: Iterable[PythonDeclaration]) -> list[str]:
|
||||
|
||||
@@ -944,19 +944,92 @@ def list_profiles(*, lazy_skill_count: bool = False) -> List[ProfileInfo]:
|
||||
return profiles
|
||||
|
||||
|
||||
def profiles_to_serve(multiplex: bool) -> List[Tuple[str, Path]]:
|
||||
#: One signature/result per home: the webhook and
|
||||
#: api-server callers run :func:`profiles_to_serve` per inbound request, so the reader
|
||||
#: must not re-parse a profile's config.yaml every time.
|
||||
_STANDALONE_MEMO: Dict[str, Tuple[Optional[tuple], Optional[bool]]] = {}
|
||||
_STANDALONE_WARNED = False
|
||||
|
||||
_STANDALONE_DEFAULT_WARNING = (
|
||||
"gateway.standalone is ignored on the default profile: it is the host gateway")
|
||||
|
||||
|
||||
def profile_is_standalone(home: Path) -> bool:
|
||||
"""Does ``home``'s own config.yaml opt this profile out of the host multiplexer
|
||||
(``gateway.standalone: true``)? Memoised by file signature. The DEFAULT profile is
|
||||
never standalone — it IS the host — and warns once per process if the key is set there."""
|
||||
global _STANDALONE_WARNED
|
||||
from yaml import YAMLError
|
||||
from utils import file_signature
|
||||
|
||||
home = Path(home)
|
||||
cfg_path = home / "config.yaml"
|
||||
key = str(home)
|
||||
try:
|
||||
signature = file_signature(cfg_path.stat())
|
||||
except FileNotFoundError:
|
||||
signature = None
|
||||
except OSError as exc:
|
||||
signature = ("stat-error", exc.errno)
|
||||
if _STANDALONE_MEMO.get(key) != (signature, False):
|
||||
logger.warning("Cannot read gateway.standalone from %s (%s); treating as not standalone",
|
||||
cfg_path, type(exc).__name__)
|
||||
_STANDALONE_MEMO[key] = (signature, False)
|
||||
return False
|
||||
cached = _STANDALONE_MEMO.get(key)
|
||||
if cached is not None and cached[0] == signature and cached[1] is not None:
|
||||
return cached[1]
|
||||
value = None
|
||||
if signature is not None:
|
||||
from hermes_cli.config import read_user_config_raw
|
||||
try:
|
||||
cfg = read_user_config_raw(cfg_path) or {}
|
||||
except (YAMLError, OSError, UnicodeError) as exc:
|
||||
if cached is None or cached[0] != signature:
|
||||
logger.warning("Cannot read gateway.standalone from %s (%s); treating as not standalone",
|
||||
cfg_path, type(exc).__name__)
|
||||
# Access can recover without changing the signature. None retains the
|
||||
# warning receipt without caching a transient failure as config.
|
||||
_STANDALONE_MEMO[key] = (signature, False if isinstance(exc, YAMLError) else None)
|
||||
return False
|
||||
if isinstance(cfg.get("gateway"), dict):
|
||||
value = cfg["gateway"].get("standalone")
|
||||
result = _standalone_truthy(value)
|
||||
if home == _get_default_hermes_home():
|
||||
if result and not _STANDALONE_WARNED:
|
||||
logger.warning(_STANDALONE_DEFAULT_WARNING)
|
||||
_STANDALONE_WARNED = True
|
||||
result = False
|
||||
_STANDALONE_MEMO[key] = (signature, result)
|
||||
return result
|
||||
|
||||
|
||||
def _standalone_truthy(value: object) -> bool:
|
||||
"""``gateway.standalone`` truthiness via the shared bool parser; only the
|
||||
``gateway:`` section's ``standalone`` key is read (no top-level alias)."""
|
||||
from gateway.config import _bool_token
|
||||
if isinstance(value, str):
|
||||
return _bool_token(value) is True
|
||||
return bool(value)
|
||||
|
||||
|
||||
def profiles_to_serve(multiplex: bool, *, include_standalone: bool = False) -> List[Tuple[str, Path]]:
|
||||
"""``(profile_name, hermes_home)`` pairs a gateway should serve — the single chokepoint
|
||||
for "which profiles does the inbound gateway handle".
|
||||
|
||||
``multiplex=False``: exactly one entry for the *active* profile (byte-for-byte the
|
||||
historical single-profile behavior; name is ``"default"`` or the named profile's id).
|
||||
``multiplex=True``: default plus every live named profile under ``profiles/`` (tombstoned
|
||||
profiles skipped). Pure directory read: never creates a profile dir (#94590)."""
|
||||
profiles skipped). Pure directory read: never creates a profile dir (#94590).
|
||||
|
||||
Named profiles that authored ``gateway.standalone: true`` are skipped because they opted
|
||||
out of the host multiplexer; callers enumerating INSTALLED profiles pass ``include_standalone=True``."""
|
||||
active = get_active_profile_name() or "default"
|
||||
if not multiplex:
|
||||
return [(active, get_profile_dir(active))]
|
||||
serve: List[Tuple[str, Path]] = [("default", _get_default_hermes_home())]
|
||||
serve.extend((entry.name, entry) for entry in _iter_named_profile_dirs())
|
||||
serve.extend((entry.name, entry) for entry in _iter_named_profile_dirs()
|
||||
if include_standalone or not profile_is_standalone(entry))
|
||||
return serve
|
||||
|
||||
|
||||
|
||||
@@ -990,7 +990,7 @@ def _record_attested_cold_start_profiles(token: dict, running_profiles: set) ->
|
||||
from hermes_cli.profiles import get_active_profile_name, profiles_to_serve
|
||||
active = get_active_profile_name() or "default"
|
||||
cold: dict[str, str] = {}
|
||||
for name, home in profiles_to_serve(multiplex=True):
|
||||
for name, home in profiles_to_serve(multiplex=True, include_standalone=True):
|
||||
if name in running_profiles or (name == active and token.get("cold_start_if_installed")):
|
||||
continue
|
||||
generation = gateway_windows.attested_death_generation([], home=Path(home))
|
||||
|
||||
@@ -214,7 +214,7 @@ def _profile_targets(log_label: str) -> List[Tuple[str, Path]]:
|
||||
fan-out that only needs name/path (#114041)."""
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
try:
|
||||
targets = list(profiles_mod.profiles_to_serve(multiplex=True))
|
||||
targets = list(profiles_mod.profiles_to_serve(multiplex=True, include_standalone=True))
|
||||
except Exception:
|
||||
_log.exception("%s: profile enumeration failed", log_label)
|
||||
targets = []
|
||||
|
||||
@@ -87,7 +87,7 @@ def _cron_profile_dicts() -> List[Dict[str, Any]]:
|
||||
try:
|
||||
return [
|
||||
{"name": name, "path": str(home), "is_default": name == "default"}
|
||||
for name, home in profiles_mod.profiles_to_serve(multiplex=True)]
|
||||
for name, home in profiles_mod.profiles_to_serve(multiplex=True, include_standalone=True)]
|
||||
except Exception:
|
||||
_log.exception("Failed to list profiles for cron dashboard; falling back to directory scan")
|
||||
return _fallback_profile_dicts(profiles_mod)
|
||||
|
||||
@@ -148,7 +148,7 @@ def _collect_profile_gateway_topology() -> Dict[str, Any]:
|
||||
try:
|
||||
from hermes_cli.profiles import _check_gateway_running, profiles_to_serve
|
||||
from gateway.status import read_runtime_status
|
||||
homes = profiles_to_serve(True)
|
||||
homes = profiles_to_serve(True, include_standalone=True)
|
||||
except Exception:
|
||||
_log.debug("profile/gateway topology enumeration failed", exc_info=True)
|
||||
return {"profiles": [], "gateway_mode": "unknown", "gateways": [], "profile_platforms": {}}
|
||||
@@ -539,11 +539,20 @@ def multiplexed_profile_refusal(profile: Optional[str], verb: str) -> Optional[s
|
||||
if not requested or requested.lower() in {"current", "default"}:
|
||||
return None
|
||||
served = _profile_is_multiplexed(requested)
|
||||
from hermes_cli.profiles import profile_is_standalone
|
||||
from hermes_cli.web_server_profiles import _resolve_profile_dir
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
standalone = profile_is_standalone(profile_dir)
|
||||
if standalone:
|
||||
# The profile opted out of the host multiplexer, so its own gateway is the answer now: only a
|
||||
# host record that still lists it (the host started before the key was set) is refused.
|
||||
if not served:
|
||||
return None
|
||||
from gateway.host_attach import standalone_rescan_message
|
||||
return standalone_rescan_message(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
|
||||
profile_dir = _resolve_profile_dir(requested)
|
||||
if _check_gateway_running(profile_dir):
|
||||
return None
|
||||
if served:
|
||||
|
||||
141
tests/gateway/test_host_attach_standalone.py
Normal file
141
tests/gateway/test_host_attach_standalone.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""A configured standalone profile may coexist, but never double-bind a served profile."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import host_attach, host_rendezvous as hr
|
||||
|
||||
|
||||
def test_boot_notice_only_labels_configured_standalone_profiles(standalone_home, monkeypatch, caplog):
|
||||
from gateway.run import _log_standalone_profiles_at_boot
|
||||
from hermes_cli import profiles
|
||||
|
||||
root, solo = standalone_home
|
||||
member = root / "profiles" / "member"
|
||||
member.mkdir()
|
||||
(member / "config.yaml").write_text("{}\n")
|
||||
monkeypatch.setattr(profiles, "profiles_to_serve", lambda *a, **kw: [("solo", solo), ("member", member)])
|
||||
runner = SimpleNamespace(config=SimpleNamespace(multiplex_profiles=True), served_profile_names=lambda: ["default"])
|
||||
with caplog.at_level("INFO"):
|
||||
_log_standalone_profiles_at_boot(runner)
|
||||
messages = [r.getMessage() for r in caplog.records if "not served by this gateway" in r.getMessage()]
|
||||
assert len(messages) == 1 and "'solo'" in messages[0]
|
||||
|
||||
def broken_roster(*a, **kw):
|
||||
raise OSError("unreadable roster")
|
||||
|
||||
monkeypatch.setattr(profiles, "profiles_to_serve", broken_roster)
|
||||
_log_standalone_profiles_at_boot(runner)
|
||||
assert any(r.levelname == "WARNING" and "boot notice failed" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standalone_home(tmp_path, monkeypatch):
|
||||
root = tmp_path / "hermes"
|
||||
home = root / "profiles" / "solo"
|
||||
home.mkdir(parents=True)
|
||||
(home / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_GATEWAY_LOCK_DIR", str(tmp_path / "locks"))
|
||||
from hermes_cli import profiles
|
||||
monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: root)
|
||||
monkeypatch.setattr(profiles, "_get_profiles_root", lambda: root / "profiles")
|
||||
return root, home
|
||||
|
||||
|
||||
@pytest.mark.parametrize("served,known", [(False, True), (True, True), (False, False)])
|
||||
def test_standalone_attach_requires_known_unserved_profile(standalone_home, monkeypatch, served, known):
|
||||
root, home = standalone_home
|
||||
owner = host_attach.HostGateway(os.getpid() + 1, root,
|
||||
("default", "solo") if served else ("default",),
|
||||
served_known=known)
|
||||
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
|
||||
requests = []
|
||||
monkeypatch.setattr(host_attach, "request_serve_profile", lambda *a, **kw: requests.append(a))
|
||||
decision = host_attach.decide(home)
|
||||
if known and not served:
|
||||
assert decision.outcome == host_attach.START
|
||||
else:
|
||||
assert decision.outcome == host_attach.REFUSE
|
||||
assert decision.transient
|
||||
if served:
|
||||
assert "rescan-profiles" in decision.message
|
||||
assert "30s" in decision.message
|
||||
assert requests == [], "an opted-out profile must never ask the host to serve it"
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
@pytest.mark.parametrize("served,known", [(False, True), (True, True), (False, False)])
|
||||
def test_standalone_lock_loser_requires_known_unserved_profile(
|
||||
standalone_home, monkeypatch, capsys, served, known,
|
||||
):
|
||||
import fcntl
|
||||
from gateway import run
|
||||
|
||||
root, home = standalone_home
|
||||
owner = host_attach.HostGateway(os.getpid() + 1, root,
|
||||
("default", "solo") if served else ("default",),
|
||||
served_known=known)
|
||||
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
|
||||
monkeypatch.setattr(run, "get_hermes_home", lambda: home)
|
||||
monkeypatch.setattr(host_attach, "request_serve_profile", lambda *a, **kw: None)
|
||||
hr.ensure_host_state_dir()
|
||||
with open(hr.lock_path(hr.ROLE_GATEWAY), "a+") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
if known and not served:
|
||||
run._claim_host_gateway_role()
|
||||
else:
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run._claim_host_gateway_role()
|
||||
assert exc.value.code == 75
|
||||
if served:
|
||||
assert "rescan-profiles" in capsys.readouterr().out
|
||||
|
||||
|
||||
@pytest.mark.linux_only
|
||||
@pytest.mark.parametrize("host_name", ["default", "member"])
|
||||
def test_standalone_owner_cannot_hide_a_live_multiplexer(standalone_home, monkeypatch, host_name):
|
||||
"""A starts first, host starts beside A, then B opts out before the host rescans."""
|
||||
import fcntl
|
||||
from gateway import run, status
|
||||
|
||||
root, home = standalone_home
|
||||
first = root / "profiles" / "first"
|
||||
first.mkdir()
|
||||
(first / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
host_home = root if host_name == "default" else root / "profiles" / host_name
|
||||
host_home.mkdir(exist_ok=True)
|
||||
(host_home / "config.yaml").write_text("{}\n")
|
||||
owner = host_attach.HostGateway(os.getpid() + 1, first, ("first",), standalone=True)
|
||||
host_pid = os.getpid() + 2
|
||||
monkeypatch.setattr(host_attach, "host_gateway", lambda **kw: owner)
|
||||
monkeypatch.setattr(status, "live_gateway_pid_for_home",
|
||||
lambda h: host_pid if Path(h) == host_home else None)
|
||||
identity = {"pid": host_pid, "hermes_home": str(host_home), "profile": host_name,
|
||||
"multiplex": True, "served_profiles": ["default", "solo", host_name]}
|
||||
monkeypatch.setattr(host_attach, "_identify", lambda h: identity if h == host_home else None)
|
||||
monkeypatch.setattr(run, "get_hermes_home", lambda: home)
|
||||
hr.ensure_host_state_dir()
|
||||
with open(hr.lock_path(hr.ROLE_GATEWAY), "a+") as handle:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
for answer in (identity, None, {**identity, "pid": host_pid + 1}):
|
||||
monkeypatch.setattr(host_attach, "_identify", lambda h: answer)
|
||||
decision = host_attach.decide(home)
|
||||
assert decision.outcome == host_attach.REFUSE
|
||||
assert decision.transient
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
run._claim_host_gateway_role()
|
||||
assert exc.value.code == 75
|
||||
run._claim_host_gateway_role(force=True)
|
||||
# The original lock owner may exit without stopping the multiplexer.
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(host_attach, "host_gateway", lambda **kw: None)
|
||||
assert host_attach.decide(home).outcome == host_attach.REFUSE
|
||||
# Once the live host confirms removal, both entry points permit coexistence.
|
||||
identity["served_profiles"] = [host_name]
|
||||
monkeypatch.setattr(host_attach, "_identify", lambda h: identity)
|
||||
assert host_attach.decide(home).outcome == host_attach.START
|
||||
run._claim_host_gateway_role()
|
||||
@@ -80,6 +80,37 @@ def _served_record(home):
|
||||
return json.loads((home / "gateway_state.json").read_text(encoding="utf-8")).get("served_profiles")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_opt_out_rescans_and_opt_in_waits_for_own_gateway_to_stop(tmp_path, monkeypatch, caplog):
|
||||
runner, home = _runner(tmp_path, monkeypatch)
|
||||
solo = _mkprofile(home, "solo", "DISCORD_BOT_TOKEN=solo-token\n")
|
||||
own_pids = {}
|
||||
monkeypatch.setattr("gateway.status.live_gateway_pid_for_home", lambda h: own_pids.get(h))
|
||||
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
|
||||
await runner._start_secondary_profile_adapters()
|
||||
adapter = runner._profile_adapters["solo"][Platform.DISCORD]
|
||||
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
result = await runner.reconcile_served_profiles()
|
||||
assert result["removed"] == ["solo"]
|
||||
assert adapter.disconnected
|
||||
assert _served_record(home) == ["default"]
|
||||
|
||||
own_pids[solo] = 12345
|
||||
(solo / "config.yaml").write_text("gateway:\n standalone: false\n")
|
||||
for _ in range(2):
|
||||
result = await runner.reconcile_served_profiles()
|
||||
assert result["added"] == []
|
||||
assert result["served_profiles"] == ["default"]
|
||||
assert runner._started.count("solo") == 1
|
||||
assert len([r for r in caplog.records if "still runs its own gateway" in r.message]) == 1
|
||||
|
||||
own_pids.clear()
|
||||
result = await runner.reconcile_served_profiles()
|
||||
assert result["added"] == ["solo"]
|
||||
assert _served_record(home) == ["default", "solo"]
|
||||
assert runner._started.count("solo") == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_created_then_credentialed_profile_is_served_without_restart(tmp_path, monkeypatch):
|
||||
runner, home = _runner(tmp_path, monkeypatch)
|
||||
|
||||
35
tests/gateway/test_standalone_injected_config.py
Normal file
35
tests/gateway/test_standalone_injected_config.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""The runner boundary honours a profile opt-out even for explicit --config inputs."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", [None, False, True])
|
||||
def test_injected_config_cannot_multiplex_a_standalone_home(tmp_path, monkeypatch, flag):
|
||||
from agent.secret_scope import is_multiplex_active, set_multiplex_active
|
||||
from gateway.config import GatewayConfig
|
||||
from gateway.run import GatewayRunner, _profile_runtime_scope
|
||||
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
root = tmp_path / ".hermes"
|
||||
solo = root / "profiles" / "solo"
|
||||
solo.mkdir(parents=True)
|
||||
(root / "config.yaml").write_text("{}\n")
|
||||
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
previous = is_multiplex_active()
|
||||
try:
|
||||
for home in (solo, root, solo):
|
||||
with _profile_runtime_scope(home):
|
||||
config = GatewayConfig(multiplex_profiles=flag, sessions_dir=home / "sessions")
|
||||
runner = GatewayRunner(config)
|
||||
try:
|
||||
assert runner.config.multiplex_profiles is (False if home == solo else flag)
|
||||
assert is_multiplex_active() is (False if home == solo else bool(flag))
|
||||
finally:
|
||||
if runner._session_db is not None:
|
||||
asyncio.run(runner._session_db.close())
|
||||
finally:
|
||||
set_multiplex_active(previous)
|
||||
@@ -28,7 +28,7 @@ class CronProfileEnumerationTests(unittest.TestCase):
|
||||
):
|
||||
result = _web_server_cron._cron_profile_dicts()
|
||||
|
||||
lightweight.assert_called_once_with(multiplex=True)
|
||||
lightweight.assert_called_once_with(multiplex=True, include_standalone=True)
|
||||
self.assertEqual([item["name"] for item in result], ["default", "coder-01"])
|
||||
self.assertTrue(result[0]["is_default"])
|
||||
self.assertFalse(result[1]["is_default"])
|
||||
|
||||
@@ -880,3 +880,20 @@ def test_windows_is_migratable_and_only_s6_is_refused(monkeypatch):
|
||||
reason = gm._host_supports_migration()
|
||||
assert reason is not None and "Restart the container" in reason
|
||||
assert "nothing on this host was changed" in reason
|
||||
|
||||
|
||||
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"]]
|
||||
lines = gm.format_plan(plan, dry_run=True)
|
||||
assert any("Standalone by config (gateway.standalone: true), left alone: ops" in line for line in lines)
|
||||
|
||||
@@ -94,6 +94,36 @@ def test_preflight_blocker_and_single_profile_keep_the_unset_default_standalone(
|
||||
assert decision == mode.MultiplexDecision(False, "guard", mode.SINGLE_PROFILE_REASON)
|
||||
|
||||
|
||||
def test_standalone_named_profile_leaves_nothing_to_multiplex(fleet):
|
||||
"""A sole named profile that opts itself out via `gateway.standalone: true` is not
|
||||
served, so the host still has a single profile to serve: guard, not multiplex."""
|
||||
root, _services, _pids = fleet
|
||||
shutil.rmtree(root / "profiles/ops") # exactly one named profile remains
|
||||
(root / "profiles/coder/config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
decision = mode.resolve_multiplex_mode(load_gateway_config())
|
||||
assert decision == mode.MultiplexDecision(False, "guard", mode.SINGLE_PROFILE_REASON)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["", " multiplex_profiles: true\n", " multiplex_profiles: false\n"])
|
||||
def test_standalone_launcher_never_multiplexes_other_profiles(fleet, flag):
|
||||
from gateway.run import _profile_runtime_scope
|
||||
|
||||
root, _services, _pids = fleet
|
||||
solo = root / "profiles/coder"
|
||||
(solo / "config.yaml").write_text("gateway:\n standalone: true\n" + flag)
|
||||
# Real loader + runtime scopes: A -> B -> A, without borrowing the launch home's config.
|
||||
for home in (solo, root, solo):
|
||||
with _profile_runtime_scope(home):
|
||||
cfg = load_gateway_config()
|
||||
decision = mode.resolve_multiplex_mode(cfg)
|
||||
if home == solo:
|
||||
assert decision.enabled is False
|
||||
assert cfg.multiplex_profiles is False
|
||||
assert "this profile is standalone" in decision.reason
|
||||
else:
|
||||
assert decision.enabled is True
|
||||
|
||||
|
||||
def test_explicit_true_is_never_second_guessed_and_explicit_false_is_retired(fleet, monkeypatch):
|
||||
root, _services, pids = fleet
|
||||
pids["coder"] = 4101
|
||||
|
||||
98
tests/hermes_cli/test_gateway_multiplex_refusal.py
Normal file
98
tests/hermes_cli/test_gateway_multiplex_refusal.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""``gateway.standalone`` and the multiplexer refusal: ``_named_profile_refused_under_multiplexer``.
|
||||
|
||||
A profile that authored ``gateway.standalone: true`` runs (or may run) its own gateway without
|
||||
``--force``; the only refusal left is a RUNNING host record that still lists it (the host gateway
|
||||
has not rescanned since the key was set) — and that refusal names the rescan instead of ``--force``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_constants
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def standalone_home(tmp_path, monkeypatch):
|
||||
root = tmp_path / "hermes"
|
||||
home = root / "profiles" / "coder"
|
||||
home.mkdir(parents=True)
|
||||
(root / "config.yaml").write_text("model:\n default: x\n", encoding="utf-8")
|
||||
(home / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
||||
from hermes_cli import gateway as gw
|
||||
# The probe seams live on the hermes_cli.gateway facade, like the other refusal tests.
|
||||
monkeypatch.setattr(gw, "_is_service_installed", lambda: False)
|
||||
monkeypatch.setattr(gw, "_served_by_another_host_gateway", lambda name=None: None)
|
||||
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: False)
|
||||
return gw, home
|
||||
|
||||
|
||||
def _refusal(gw) -> tuple[bool, str]:
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
refused = gw._named_profile_refused_under_multiplexer()
|
||||
return refused, buf.getvalue()
|
||||
|
||||
|
||||
def test_standalone_named_home_is_not_refused_without_force(standalone_home):
|
||||
gw, _home = standalone_home
|
||||
refused, out = _refusal(gw)
|
||||
assert refused is False
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_standalone_named_home_still_served_by_host_record_is_refused_with_rescan(standalone_home, monkeypatch):
|
||||
gw, _home = standalone_home
|
||||
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: True)
|
||||
refused, out = _refusal(gw)
|
||||
assert refused is True
|
||||
assert "still serves profile 'coder'" in out
|
||||
assert "gateway.standalone" in out
|
||||
assert "rescan-profiles" in out and "30s" in out
|
||||
assert "--force" not in out
|
||||
|
||||
|
||||
def test_non_standalone_refusal_names_the_opt_out(standalone_home):
|
||||
gw, home = standalone_home
|
||||
(home / "config.yaml").write_text("{}\n", encoding="utf-8")
|
||||
refused, out = _refusal(gw)
|
||||
assert refused is True
|
||||
assert "Or opt this profile out of the host gateway for good" in out
|
||||
assert "gateway.standalone: true" in out
|
||||
|
||||
|
||||
def test_setup_stale_host_record_names_rescan(standalone_home, monkeypatch, capsys):
|
||||
gw, _home = standalone_home
|
||||
monkeypatch.setattr(gw, "named_profile_served_by_running_multiplexer", lambda name=None: True)
|
||||
assert gw._served_profile_needs_no_service() is True
|
||||
assert "rescan-profiles" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_dashboard_standalone_refusal_resolves_once_and_preserves_invalid_profile(standalone_home, monkeypatch):
|
||||
from fastapi import HTTPException
|
||||
from hermes_cli import web_server_gateway as web
|
||||
from hermes_cli import web_server_profiles
|
||||
|
||||
_gw, home = standalone_home
|
||||
resolved = []
|
||||
|
||||
def resolve(name):
|
||||
resolved.append(name)
|
||||
if name != "coder":
|
||||
raise HTTPException(status_code=404, detail="Unknown profile")
|
||||
return home
|
||||
|
||||
monkeypatch.setattr(web_server_profiles, "_resolve_profile_dir", resolve)
|
||||
monkeypatch.setattr(web, "_profile_is_multiplexed", lambda name: True)
|
||||
assert "rescan-profiles" in web.multiplexed_profile_refusal("coder", "start")
|
||||
assert resolved == ["coder"]
|
||||
monkeypatch.setattr(web, "_profile_is_multiplexed", lambda name: False)
|
||||
assert web.multiplexed_profile_refusal("coder", "start") is None
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
web.multiplexed_profile_refusal("missing", "stop")
|
||||
assert exc.value.status_code == 404
|
||||
@@ -84,3 +84,38 @@ def test_served_named_profile_reports_running_without_default_pid_file(monkeypat
|
||||
beta = next(p for p in list_profiles() if p.name == "beta")
|
||||
assert beta.gateway_running is True
|
||||
assert _run_status().startswith("✓ Gateway is running via the default-profile multiplexer")
|
||||
|
||||
|
||||
def test_standalone_profile_status_reports_standalone_by_config(monkeypatch, tmp_path):
|
||||
"""`hermes -p X gateway status` on a standalone X says so and never claims the multiplexer."""
|
||||
_fake_multiplexer(monkeypatch, tmp_path, multiplex=True)
|
||||
(tmp_path / "profiles" / "beta" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
gw._gateway_command_inner(
|
||||
SimpleNamespace(gateway_command="status", deep=False, full=False, system=False)
|
||||
)
|
||||
out = buf.getvalue()
|
||||
assert "standalone by config (gateway.standalone: true)" in out
|
||||
assert "via the default-profile multiplexer" not in out
|
||||
|
||||
|
||||
def test_default_status_lists_standalone_profiles(monkeypatch, tmp_path):
|
||||
"""The default (host) profile's status names the profiles that opted out by config."""
|
||||
import hermes_constants
|
||||
|
||||
(tmp_path / "profiles" / "beta").mkdir(parents=True)
|
||||
(tmp_path / "profiles" / "beta" / "config.yaml").write_text("gateway:\n standalone: true\n", encoding="utf-8")
|
||||
(tmp_path / "config.yaml").write_text("gateway:\n multiplex_profiles: true\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(hermes_constants, "_default_hermes_root_memo", None)
|
||||
from hermes_cli import gateway as gw
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
gw._gateway_command_inner(
|
||||
SimpleNamespace(gateway_command="status", deep=False, full=False, system=False)
|
||||
)
|
||||
assert "standalone by config: beta" in buf.getvalue()
|
||||
|
||||
@@ -1642,6 +1642,95 @@ class TestProfilesToServe:
|
||||
assert serve["default"] == _get_default_hermes_home()
|
||||
assert serve["coder"] == get_profile_dir("coder")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# gateway.standalone: authored opt-out of the host multiplexer
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_standalone_profile_excluded_unless_included(self, profile_env):
|
||||
"""A named profile that sets `gateway.standalone: true` is not served by the
|
||||
host multiplexer, but callers that enumerate INSTALLED profiles still see it."""
|
||||
create_profile("solo", no_alias=True)
|
||||
create_profile("member", no_alias=True)
|
||||
(get_profile_dir("solo") / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
serve = dict(profiles_to_serve(multiplex=True))
|
||||
assert set(serve) == {"default", "member"}
|
||||
served_all = dict(profiles_to_serve(multiplex=True, include_standalone=True))
|
||||
assert set(served_all) == {"default", "solo", "member"}
|
||||
|
||||
def test_default_profile_with_key_still_served_with_one_warning(self, profile_env, caplog):
|
||||
"""The default profile IS the host: the key is ignored (still served, never
|
||||
standalone) with exactly one warning per process."""
|
||||
profiles._STANDALONE_WARNED = False
|
||||
default_home = _get_default_hermes_home()
|
||||
(default_home / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
caplog.clear()
|
||||
with caplog.at_level("WARNING", logger="hermes_cli.profiles"):
|
||||
serve = dict(profiles_to_serve(multiplex=True))
|
||||
assert profiles.profile_is_standalone(default_home) is False
|
||||
assert profiles.profile_is_standalone(default_home) is False
|
||||
assert list(serve) == ["default"]
|
||||
assert serve["default"] == default_home
|
||||
assert len([r for r in caplog.records if "ignored on the default profile" in r.message]) == 1
|
||||
|
||||
@pytest.mark.parametrize("content", ["gateway: [", "[]\n", "null\n", "", "gateway: false\n"])
|
||||
def test_standalone_malformed_config_does_not_break_roster(self, profile_env, caplog, content):
|
||||
create_profile("solo", no_alias=True)
|
||||
home = get_profile_dir("solo")
|
||||
(home / "config.yaml").write_text(content)
|
||||
for _ in range(2):
|
||||
assert profiles.profile_is_standalone(home) is False
|
||||
assert "solo" in dict(profiles_to_serve(True))
|
||||
warnings = [r for r in caplog.records if "Cannot read gateway.standalone" in r.message]
|
||||
assert len(warnings) == (1 if content == "gateway: [" else 0)
|
||||
|
||||
@pytest.mark.parametrize("failure_at", ["stat", "read", "decode"])
|
||||
def test_standalone_io_failure_is_bounded_and_recovers(self, profile_env, monkeypatch, caplog, failure_at):
|
||||
from hermes_cli import config
|
||||
|
||||
create_profile("solo", no_alias=True)
|
||||
home = get_profile_dir("solo")
|
||||
cfg = home / "config.yaml"
|
||||
cfg.write_text("gateway:\n standalone: true\n")
|
||||
real_stat = Path.stat
|
||||
|
||||
def denied(path, *args, **kwargs):
|
||||
if path == cfg:
|
||||
raise PermissionError("denied")
|
||||
return real_stat(path, *args, **kwargs)
|
||||
|
||||
def unreadable(*args, **kwargs):
|
||||
if failure_at == "decode":
|
||||
raise UnicodeError("decode failed")
|
||||
raise PermissionError("denied")
|
||||
|
||||
with monkeypatch.context() as m:
|
||||
if failure_at == "stat":
|
||||
m.setattr(Path, "stat", denied)
|
||||
else:
|
||||
m.setattr(config, "read_user_config_raw", unreadable)
|
||||
assert profiles.profile_is_standalone(home) is False
|
||||
assert profiles.profile_is_standalone(home) is False
|
||||
assert len([r for r in caplog.records if "Cannot read gateway.standalone" in r.message]) == 1
|
||||
# Restoring access does not change mtime/size/inode; a read failure is not config.
|
||||
assert profiles.profile_is_standalone(home) is True
|
||||
|
||||
def test_standalone_answer_is_per_home_and_memo_invalidates_on_replacement(self, profile_env):
|
||||
"""A->B->A: signatures never cross homes; atomic replacement invalidates the memo."""
|
||||
create_profile("alpha", no_alias=True)
|
||||
create_profile("beta", no_alias=True)
|
||||
alpha, beta = get_profile_dir("alpha"), get_profile_dir("beta")
|
||||
(alpha / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
assert profiles.profile_is_standalone(alpha) is True
|
||||
assert profiles.profile_is_standalone(beta) is False
|
||||
assert profiles.profile_is_standalone(alpha) is True # memo hit, still True
|
||||
cfg = alpha / "config.yaml"
|
||||
replacement = alpha / "replacement.yaml"
|
||||
replacement.write_text("gateway:\n standalone: false\n")
|
||||
replacement.replace(cfg)
|
||||
assert profiles.profile_is_standalone(alpha) is False
|
||||
assert profiles.profile_is_standalone(beta) is False
|
||||
assert profiles.profile_is_standalone(alpha) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_profile_env spelling preservation (#82581 junction follow-up)
|
||||
|
||||
@@ -30,6 +30,17 @@ def isolated_profiles(tmp_path, monkeypatch):
|
||||
return {"default": default_home, "worker_alpha": worker_home}
|
||||
|
||||
|
||||
def test_standalone_jobs_remain_discoverable_for_dashboard_management(isolated_profiles):
|
||||
home = isolated_profiles["worker_alpha"]
|
||||
(home / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
job = _web_server_cron._call_cron_for_profile(
|
||||
"worker_alpha", "create_job", prompt="local job", schedule="every 1h",
|
||||
)
|
||||
assert isinstance(job, dict)
|
||||
assert "worker_alpha" in {p["name"] for p in _web_server_cron._cron_profile_dicts()}
|
||||
assert _web_server_cron._find_cron_job_profile(job["id"]) == "worker_alpha"
|
||||
|
||||
|
||||
def _drain_queue(q):
|
||||
values = []
|
||||
while True:
|
||||
|
||||
@@ -5,6 +5,8 @@ added to ``/api/status``: profile enumeration, single vs multiplex vs multiple
|
||||
gateway detection, and per-platform port resolution.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import web_server
|
||||
@@ -57,7 +59,7 @@ def _patch_topology(monkeypatch, homes, running, runtimes):
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
import gateway.status as status_mod
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex: homes)
|
||||
monkeypatch.setattr(profiles_mod, "profiles_to_serve", lambda multiplex, **kw: homes)
|
||||
monkeypatch.setattr(
|
||||
profiles_mod, "_check_gateway_running",
|
||||
lambda home: next(n for n, h in homes if h == home) in running,
|
||||
@@ -69,6 +71,20 @@ def _patch_topology(monkeypatch, homes, running, runtimes):
|
||||
|
||||
|
||||
class TestCollectProfileGatewayTopology:
|
||||
def test_running_standalone_profile_is_in_topology(self, tmp_path, monkeypatch):
|
||||
from hermes_cli import profiles
|
||||
|
||||
root = tmp_path / ".hermes"
|
||||
solo = root / "profiles" / "solo"
|
||||
solo.mkdir(parents=True)
|
||||
(solo / "config.yaml").write_text("gateway:\n standalone: true\n")
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
monkeypatch.setattr(profiles, "_check_gateway_running", lambda home: home == solo)
|
||||
topo = _collect_profile_gateway_topology()
|
||||
assert "solo" in topo["profiles"]
|
||||
assert [g["profile"] for g in topo["gateways"]] == ["solo"]
|
||||
|
||||
def test_no_gateways_running(self, tmp_path, monkeypatch):
|
||||
homes = [("default", tmp_path / "d"), ("coder", tmp_path / "c")]
|
||||
_patch_topology(monkeypatch, homes, running=set(), runtimes={})
|
||||
|
||||
@@ -62,7 +62,7 @@ def _servable_profile_homes() -> set:
|
||||
from hermes_constants import named_profile_has_servable_identity
|
||||
from hermes_cli.profiles import profiles_to_serve
|
||||
|
||||
homes = {Path(home).resolve() for name, home in profiles_to_serve(multiplex=True)
|
||||
homes = {Path(home).resolve() for name, home in profiles_to_serve(multiplex=True, include_standalone=True)
|
||||
if name == "default" or named_profile_has_servable_identity(home)}
|
||||
homes.add(Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes").resolve())
|
||||
return homes
|
||||
|
||||
Reference in New Issue
Block a user