Inside the official image every named profile has an s6 slot that the container's boot
registers DOWN (multiplex-only). `implicit_multiplex_blocker` still called
`_host_supports_migration`, whose s6 branch refused unconditionally ("Restart the container"),
so a Hermes Cloud host with the key UNSET booted standalone after an in-place update and every
other profile's bot went silent, while an explicit `true` bypassed the guard and worked. The
guard was vetoing a second gateway that could not exist.
- Only a named slot that is UP is a blocker; registered-down slots never veto the default.
`hermes gateway migrate --multiplex` (and the `hermes update` hook) now fold an UP slot
in-process: `s6-svc -d` + `down` file, root slot (re)started, no container restart. Boot
and migration share ONE fold rule (`gateway_multiplex_s6.fold_named_slot_intent`).
- A multi-profile host that resolves standalone on a guard prints a boxed warning at gateway
start, in the `hermes update` summary, in `hermes gateway status`, and the dashboard
`/api/status` carries `multiplex_standalone_reason` with a banner. Single-profile installs
are not warned.
- The resolved unset→on default is written as `gateway.multiplex_profiles: true` into the
default profile's config.yaml (comment-preserving writer, once, never on a guard refusal).
109 lines
4.5 KiB
Python
109 lines
4.5 KiB
Python
"""s6 leg of the one-gateway-per-host convergence, shared by container boot and in-process migration.
|
|
|
|
Inside the official image every profile has an s6 slot (``/run/service/gateway-<profile>``). The
|
|
container's boot (``hermes_cli/container_boot.py``) registers every NAMED slot down and lets the
|
|
root slot inherit their autostart intent; ``hermes gateway migrate --multiplex`` (and the hook
|
|
``hermes update`` runs) must be able to do the same thing to a slot that is UP, from inside the
|
|
running container, without a container restart. Both paths decide "which named intents fold into
|
|
the root slot" through :func:`fold_named_slot_intent` so they can never disagree.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Iterable, Optional, Sequence
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
#: Only this desired state autostarts; everything else (startup_failed, starting, stopped, missing)
|
|
#: waits for the operator — no crash-loop of a broken gateway across ``docker restart``.
|
|
AUTOSTART_STATES = frozenset({"running"})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class FoldDecision:
|
|
#: Named profiles whose autostart intent the root slot takes over.
|
|
folded: tuple[str, ...]
|
|
#: Whether the root slot must be up after the fold.
|
|
root_should_start: bool
|
|
|
|
|
|
def fold_named_slot_intent(default_prior_state: Optional[str],
|
|
named_states: Iterable[tuple[str, Optional[str]]]) -> FoldDecision:
|
|
"""The ONE rule for folding per-profile s6 slots into the root slot.
|
|
|
|
A named slot is never booted from its own intent (a started named slot IS a second gateway on
|
|
this host); its ``running`` intent moves to the root slot, which is the process that serves it.
|
|
Without the fold an image only ever driven as ``hermes -p coder gateway start`` came up with
|
|
ZERO gateways: no root state, every named slot registered down, every action "registered".
|
|
"""
|
|
folded = tuple(sorted(name for name, prior in named_states if prior in AUTOSTART_STATES))
|
|
return FoldDecision(folded=folded,
|
|
root_should_start=default_prior_state in AUTOSTART_STATES or bool(folded))
|
|
|
|
|
|
def named_slot_name(profile: str) -> str:
|
|
from hermes_cli.service_manager import S6_SERVICE_PREFIX
|
|
return f"{S6_SERVICE_PREFIX}{profile}"
|
|
|
|
|
|
def running_named_slots(profiles: Sequence[str], manager=None) -> list[str]:
|
|
"""Named profiles whose s6 slot is UP right now — the only s6 state that is a second gateway.
|
|
|
|
A registered-down slot (what boot leaves behind) is a start target for ``hermes -p X gateway
|
|
start``, not a running gateway, and must never veto the multiplex default.
|
|
"""
|
|
manager = manager or _manager()
|
|
up: list[str] = []
|
|
for profile in profiles:
|
|
if profile == "default":
|
|
continue
|
|
try:
|
|
if manager.is_running(named_slot_name(profile)):
|
|
up.append(profile)
|
|
except Exception: # an unprobeable slot is not a running gateway
|
|
logger.debug("could not probe s6 slot for %s", profile, exc_info=True)
|
|
return up
|
|
|
|
|
|
def slot_is_up(profile: str, manager=None) -> bool:
|
|
return bool(running_named_slots([profile], manager))
|
|
|
|
|
|
def park_named_slot(profile: str, manager=None) -> None:
|
|
"""Stop a named profile's slot and write its ``down`` file so a supervisor restart does not
|
|
revive it (``s6-svc -d`` alone is undone by the next ``s6-svscanctl -a``/container restart)."""
|
|
manager = manager or _manager()
|
|
slot = named_slot_name(profile)
|
|
if manager.is_running(slot):
|
|
manager.stop(slot)
|
|
_wait_down(manager.scandir / slot)
|
|
(manager.scandir / slot / "down").touch()
|
|
|
|
|
|
def _wait_down(service_dir: Path, timeout_ms: int = 15000) -> None:
|
|
from hermes_cli.service_manager import _s6_run
|
|
try:
|
|
_s6_run("s6-svwait", "-d", "-t", str(timeout_ms), str(service_dir), timeout=timeout_ms / 1000 + 5)
|
|
except Exception:
|
|
logger.debug("s6-svwait failed for %s", service_dir, exc_info=True)
|
|
|
|
|
|
def bring_root_slot_up(manager=None) -> str:
|
|
"""Start (or restart, so it re-reads its config) the root slot; returns a one-line description."""
|
|
manager = manager or _manager()
|
|
slot = named_slot_name("default")
|
|
(manager.scandir / slot / "down").unlink(missing_ok=True)
|
|
if manager.is_running(slot):
|
|
manager.restart(slot)
|
|
return "restarted the root gateway slot (s6)"
|
|
manager.start(slot)
|
|
return "started the root gateway slot (s6)"
|
|
|
|
|
|
def _manager():
|
|
from hermes_cli.service_manager import S6ServiceManager
|
|
return S6ServiceManager()
|