fix(gateway): register gateway.multiplex_profiles; explicit migrate --multiplex flips it with no standalone secondary
`hermes config set gateway.multiplex_profiles true` warned "not a recognized config key" although gateway/config.py reads it: the key (and profile_routes) were never in DEFAULT_CONFIG["gateway"]. Both are registered with their doc comment; the CLI loaders deep-merge new keys, so no _config_version bump. `hermes gateway migrate --multiplex` with two or more profiles but no secondary running its own gateway printed "nothing to migrate" and left the flag OFF. The explicit command now applies the one remaining step — flag on, default gateway (re)started, the same rollback manifest (empty secondaries) for --standalone. `hermes update`'s automatic hook keeps treating that case as a no-op: it never flips modes on an install where nothing was running.
This commit is contained in:
@@ -1946,6 +1946,19 @@ DEFAULT_CONFIG = {
|
||||
# (primary copy: state.db gateway_routing table). True for external tooling and downgrade
|
||||
# safety; False stops producing the file.
|
||||
"write_sessions_json": True,
|
||||
# One gateway for every profile on this host: the DEFAULT profile's gateway also connects
|
||||
# each named profile's bots (their own .env / config.yaml, per-profile secret scope) and
|
||||
# stamps the profile into session keys. Flip with `hermes gateway migrate --multiplex`
|
||||
# (records a rollback manifest; `--standalone` undoes it) or `hermes config set
|
||||
# gateway.multiplex_profiles true` + `hermes gateway restart`. GATEWAY_MULTIPLEX_PROFILES
|
||||
# in the environment overrides. Two profiles configuring the same bot token cannot be
|
||||
# served together — the duplicate adapter is parked; `hermes profile create --clone`
|
||||
# therefore leaves messaging channels behind unless --clone-channels is passed.
|
||||
"multiplex_profiles": False,
|
||||
# Route inbound chats of the default profile's bots to another profile
|
||||
# (gateway/profile_routing.py): [{profile, platform, chat_id|user_id|guild_id|...}].
|
||||
# Most-specific match wins; only read by the multiplexing default gateway.
|
||||
"profile_routes": [],
|
||||
# Scale-to-zero idle TIMEOUT only. When an instance is opted in via the NAS "Labs" toggle
|
||||
# (HERMES_SCALE_TO_ZERO env stamp) AND messaging is relay-only/absent AND a wakeUrl is
|
||||
# registered, the relay transport goes dormant so the platform (e.g. Fly autostop) can
|
||||
|
||||
@@ -109,7 +109,9 @@ class MigrationPlan:
|
||||
}
|
||||
|
||||
def eligible_for_migration(self) -> bool:
|
||||
""">= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers."""
|
||||
""">= 2 profiles, at least one secondary with its own gateway, multiplex off, no blockers.
|
||||
This is the AUTO-migration (``hermes update``) bar; the explicit command also proceeds with
|
||||
zero standalone secondaries (see :func:`cmd_migrate`)."""
|
||||
return (
|
||||
len(self.profiles) >= 2 and bool(self.standalone_secondaries)
|
||||
and not self.already_multiplexed and not self.blocked
|
||||
@@ -434,14 +436,22 @@ def format_plan(plan: MigrationPlan, *, dry_run: bool) -> list[str]:
|
||||
for p in plan.standalone_secondaries:
|
||||
what = " + ".join(x for x in (f"stop pid {p.pid}" if p.pid else "", f"uninstall {p.service_label()}" if p.service else "") if x)
|
||||
steps.append(f" - {p.name}: {what}")
|
||||
if len(plan.profiles) < 2: # the notice already says "only one profile exists"
|
||||
return lines + _plan_tail(plan)
|
||||
if not steps:
|
||||
lines.append(" No secondary profile runs its own gateway; nothing to migrate.")
|
||||
lines.append(" No secondary profile runs its own gateway; the only step is turning the flag on:")
|
||||
else:
|
||||
lines += [" Steps:", *steps, f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}"]
|
||||
target = plan.target_service_kind()
|
||||
lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway"
|
||||
+ (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles")
|
||||
lines.append(f" - record removed services in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)")
|
||||
lines += [" Steps:", *steps]
|
||||
lines.append(f" - default: set gateway.multiplex_profiles: true in {plan.default_home / 'config.yaml'}")
|
||||
target = plan.target_service_kind()
|
||||
lines.append(f" - default: {'restart' if plan.default.has_gateway else 'start'} the gateway"
|
||||
+ (f" via {target[0]}" if target else " (detached)") + f", verify it serves {len(plan.profiles)} profiles")
|
||||
lines.append(f" - record the previous state in {plan.default_home / MANIFEST_NAME} (rollback: hermes gateway migrate --standalone)")
|
||||
return lines + _plan_tail(plan)
|
||||
|
||||
|
||||
def _plan_tail(plan: MigrationPlan) -> list[str]:
|
||||
lines: list[str] = []
|
||||
if plan.blockers:
|
||||
lines += ["", " ✗ Blockers (fix these first, nothing will be changed):"]
|
||||
lines += [f" • {b}" for b in plan.blockers]
|
||||
@@ -635,10 +645,10 @@ def cmd_migrate(args) -> None:
|
||||
return
|
||||
if plan.already_multiplexed:
|
||||
return
|
||||
if plan.blocked:
|
||||
sys.exit(1)
|
||||
if not plan.standalone_secondaries:
|
||||
return
|
||||
if plan.blocked or len(plan.profiles) < 2:
|
||||
sys.exit(1 if plan.blocked else 0)
|
||||
# Zero standalone secondaries is still a migration when the user asks for it explicitly: the flag
|
||||
# goes on and the default gateway restarts (the update hook keeps treating that case as a no-op).
|
||||
if not getattr(args, "yes", False) and sys.stdin.isatty():
|
||||
from hermes_cli.setup import prompt_yes_no
|
||||
if not prompt_yes_no("Apply this migration now?", True):
|
||||
|
||||
@@ -1910,3 +1910,13 @@ class TestConfigCommandFailClosedSurface:
|
||||
assert excinfo.value.code == 1
|
||||
assert "not valid YAML" in capsys.readouterr().err
|
||||
assert config_path.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
def test_gateway_multiplex_keys_are_recognized_config_keys():
|
||||
"""``hermes config set gateway.multiplex_profiles true`` used to warn 'not a recognized config
|
||||
key' although gateway/config.py reads it; the key (and profile_routes) live in DEFAULT_CONFIG."""
|
||||
from hermes_cli.config import _validate_config_key
|
||||
from hermes_cli.config_defaults import DEFAULT_CONFIG
|
||||
assert DEFAULT_CONFIG["gateway"]["multiplex_profiles"] is False
|
||||
assert _validate_config_key("gateway.multiplex_profiles") == (True, None)
|
||||
assert _validate_config_key("gateway.profile_routes") == (True, None)
|
||||
|
||||
@@ -171,3 +171,31 @@ def test_update_hook_never_touches_single_profile_or_already_multiplexed(fleet,
|
||||
fleet.services.clear(); fleet.pids.clear() # secondaries exist but run no gateway of their own
|
||||
gm.maybe_auto_migrate_after_update()
|
||||
assert capsys.readouterr().out == "" and _config_flag(fleet.root) is None
|
||||
|
||||
|
||||
def test_explicit_migrate_with_no_standalone_secondaries_still_flips_flag_and_restarts_default(fleet, capsys, monkeypatch):
|
||||
"""The user typed --multiplex: 'nothing to migrate' + flag left off was a no-op the user did not ask
|
||||
for. The update hook keeps its no-op (previous test); the explicit command proceeds."""
|
||||
fleet.services.clear(); fleet.pids.clear()
|
||||
|
||||
def _detached(home): # no service manager anywhere -> detached start writes the served record
|
||||
fleet.services["default-detached"] = True
|
||||
(fleet.root / "gateway.pid").write_text(json.dumps({"pid": os.getpid(), "hermes_home": str(fleet.root)}))
|
||||
(fleet.root / "gateway_state.json").write_text(json.dumps({
|
||||
"pid": os.getpid(), "hermes_home": str(fleet.root), "gateway_state": "running",
|
||||
"served_profiles": ["default", "coder", "ops"]}))
|
||||
return True
|
||||
monkeypatch.setattr(gm, "_spawn_detached_gateway", _detached)
|
||||
assert not gm.build_migration_plan().standalone_secondaries
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
gm.cmd_migrate(SimpleNamespace(multiplex=True, standalone=False, dry_run=False, yes=True))
|
||||
assert exc.value.code == 0
|
||||
assert fleet.services.pop("default-detached") is True
|
||||
assert _config_flag(fleet.root) is True
|
||||
manifest = json.loads((fleet.root / gm.MANIFEST_NAME).read_text(encoding="utf-8"))
|
||||
assert manifest["secondaries"] == [] and manifest["flag_was"] is False
|
||||
assert ("default", "install") not in fleet.ops
|
||||
out = capsys.readouterr().out
|
||||
assert "serves 3 profiles" in out
|
||||
# The same manifest rolls it back: flag restored, nothing to reinstall.
|
||||
assert gm.rollback_migration(fleet.root) is True and _config_flag(fleet.root) is False
|
||||
|
||||
@@ -802,7 +802,23 @@ preflight:
|
||||
fix and the one-liner to run later. Nothing is changed.
|
||||
|
||||
Single-profile installs are never migrated (there is nothing to gain), and an
|
||||
install that is already multiplexing is left alone.
|
||||
install that is already multiplexing is left alone. `hermes update` also does
|
||||
nothing when no secondary profile runs its own gateway — it never flips modes
|
||||
on an install where nothing was running.
|
||||
|
||||
The explicit command is different: `hermes gateway migrate --multiplex` with
|
||||
two or more profiles and **no** standalone secondary gateway still applies the
|
||||
one remaining step — it sets `gateway.multiplex_profiles: true`, (re)starts the
|
||||
default gateway and writes the same rollback manifest (with an empty
|
||||
`secondaries` list), so `--standalone` undoes it. You asked for multiplex; you
|
||||
get multiplex.
|
||||
|
||||
:::tip Clones do not carry channels
|
||||
`hermes profile create --clone` leaves the source's bot tokens and allowlists
|
||||
behind (see [Profiles → messaging channels are never cloned](./profiles.md#messaging-channels-are-never-cloned---clone-channels-to-opt-in)),
|
||||
so a fleet of clones no longer trips the duplicate-credential blocker below.
|
||||
Older clones that still carry them are flagged by `hermes profile list`.
|
||||
:::
|
||||
|
||||
### What the migration does
|
||||
|
||||
|
||||
Reference in New Issue
Block a user