fix(migrate): clear the multiplexer's served record before starting secondaries; always restart the default last
Rollback started each per-profile gateway while the still-live multiplexer's gateway_state.json listed it as served, so `hermes -p X gateway run` refused with exit 78 and RestartPreventExitStatus parked the unit for good — the exact symptom #109473 reports, one step later. Clear served_profiles (and the profile's platform entries) first; recorded [] is authoritative for the guard. A failed secondary previously skipped the default restart with the flag already off, leaving config saying standalone while the process kept multiplexing. The default now restarts regardless (it is the last operation either way, so a cgroup-kill of this process can no longer strand later steps); the manifest is kept for a re-run only when something failed. Tests: the fake service layer now runs the real served-by-multiplexer guard at each secondary start, and a failed-secondary contract test; both red on the prior stack.
This commit is contained in:
@@ -465,6 +465,7 @@ def format_rollback_plan(default_home: Path, manifest: dict, *, dry_run: bool) -
|
||||
head = "Rollback plan (dry run — nothing changed)" if dry_run else "Rollback plan"
|
||||
lines = [head, f" default home: {default_home}", "", " Steps:"]
|
||||
lines.append(" - default: restore gateway.multiplex_profiles to its pre-migration value")
|
||||
lines.append(" - default: clear multiplex-owned runtime status")
|
||||
for rec in manifest.get("secondaries", []):
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
@@ -478,7 +479,6 @@ def format_rollback_plan(default_home: Path, manifest: dict, *, dry_run: bool) -
|
||||
action = "restore its recorded standalone gateway"
|
||||
lines.append(f" - {name}: {action}")
|
||||
lines += [
|
||||
" - default: clear multiplex-owned runtime status",
|
||||
f" - remove rollback manifest {default_home / MANIFEST_NAME}",
|
||||
" - default: restart the standalone gateway last",
|
||||
]
|
||||
@@ -639,19 +639,25 @@ def rollback_migration(default_home: Optional[Path] = None) -> bool:
|
||||
"default", default_home, pid=_live_gateway_pid(default_home),
|
||||
service=(default_service["kind"], bool(default_service.get("system"))) if default_service else _installed_service(default_home),
|
||||
)
|
||||
ok = True
|
||||
secondary_names: set[str] = set()
|
||||
for rec in manifest.get("secondaries", []):
|
||||
if not isinstance(rec, dict):
|
||||
ok = False
|
||||
print(" ✗ invalid secondary record in migration manifest")
|
||||
continue
|
||||
secondaries = [rec for rec in manifest.get("secondaries", []) if isinstance(rec, dict)]
|
||||
ok = len(secondaries) == len(manifest.get("secondaries", []))
|
||||
if not ok:
|
||||
print(" ✗ invalid secondary record in migration manifest")
|
||||
# The live multiplexer's record still claims every secondary; a per-profile gateway started
|
||||
# while it does is refused (exit 78, parked by RestartPreventExitStatus) — clear it FIRST.
|
||||
try:
|
||||
_reconcile_standalone_runtime(default_home, {str(rec.get("profile") or "") for rec in secondaries})
|
||||
print(" ✓ default: cleared multiplex-owned runtime status")
|
||||
except Exception as exc:
|
||||
print(f" ✗ default: could not clear multiplex-owned runtime status ({exc})")
|
||||
print(f"⚠ Rollback incomplete; manifest kept at {_manifest_path(default_home)}.")
|
||||
return False
|
||||
for rec in secondaries:
|
||||
name = str(rec.get("profile") or "")
|
||||
try:
|
||||
home = Path(rec["home"])
|
||||
if not name:
|
||||
raise ValueError("missing profile name")
|
||||
secondary_names.add(name)
|
||||
service = rec.get("service")
|
||||
if service:
|
||||
kind, system = service["kind"], bool(service.get("system"))
|
||||
@@ -667,32 +673,27 @@ def rollback_migration(default_home: Optional[Path] = None) -> bool:
|
||||
except Exception as exc:
|
||||
ok = False
|
||||
print(f" ✗ {name or '<unknown>'}: {exc}")
|
||||
if not ok:
|
||||
print(f"⚠ Rollback incomplete; manifest kept at {_manifest_path(default_home)}.")
|
||||
return False
|
||||
|
||||
try:
|
||||
_reconcile_standalone_runtime(default_home, secondary_names)
|
||||
print(" ✓ default: cleared multiplex-owned runtime status")
|
||||
if ok:
|
||||
_manifest_path(default_home).unlink(missing_ok=True)
|
||||
except Exception as exc:
|
||||
print(f" ✗ default: could not finish rollback cleanup ({exc})")
|
||||
print(f"⚠ Rollback incomplete; manifest kept at {_manifest_path(default_home)}.")
|
||||
return False
|
||||
|
||||
# The flag is already off, so the default must come back standalone even when a secondary
|
||||
# failed (otherwise config and the live process disagree). The restart is LAST: from inside
|
||||
# the gateway's cgroup a service-manager restart kills this process, so nothing after it runs.
|
||||
if default_gw.has_gateway:
|
||||
try:
|
||||
print(f" ✓ {_restart_default(default_gw, None, default_home)}")
|
||||
except Exception as exc:
|
||||
try:
|
||||
_write_manifest(default_home, manifest)
|
||||
except Exception as manifest_exc:
|
||||
print(f" ✗ default: could not restore rollback manifest ({manifest_exc})")
|
||||
if ok:
|
||||
try:
|
||||
_write_manifest(default_home, manifest)
|
||||
except Exception as manifest_exc:
|
||||
print(f" ✗ default: could not restore rollback manifest ({manifest_exc})")
|
||||
ok = False
|
||||
print(f" ✗ default: could not restart its standalone gateway ({exc})")
|
||||
print(f"⚠ Rollback incomplete; manifest kept at {_manifest_path(default_home)}.")
|
||||
return False
|
||||
print("✓ Rolled back to per-profile gateways.")
|
||||
return True
|
||||
if ok:
|
||||
print("✓ Rolled back to per-profile gateways.")
|
||||
else:
|
||||
print(f"⚠ Rollback incomplete; manifest kept at {_manifest_path(default_home)}.")
|
||||
return ok
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- CLI + update hook
|
||||
|
||||
@@ -39,6 +39,7 @@ def fleet(tmp_path, monkeypatch):
|
||||
services={"coder": ("systemd", False), "ops": ("systemd", False)},
|
||||
pids={"coder": 4101, "ops": 4102},
|
||||
ops=[],
|
||||
refused_at_start={},
|
||||
)
|
||||
|
||||
def _name(home: Path) -> str:
|
||||
@@ -47,6 +48,11 @@ def fleet(tmp_path, monkeypatch):
|
||||
def _service_op(kind, system, verb, home):
|
||||
name = _name(home)
|
||||
state.ops.append((name, verb))
|
||||
if verb == "start" and name != "default":
|
||||
# What the real `hermes -p <name> gateway run` checks first: is a live multiplexer
|
||||
# still recorded as serving me? (exit 78 if so — the unit is then parked for good).
|
||||
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
|
||||
state.refused_at_start[name] = named_profile_served_by_running_multiplexer(name)
|
||||
if verb == "uninstall":
|
||||
state.services.pop(name, None)
|
||||
elif verb == "install":
|
||||
@@ -128,6 +134,8 @@ def test_apply_records_manifest_flips_flag_and_rollback_restores(fleet, capsys):
|
||||
assert [op for op in fleet.ops if op[0] != "default"] == [
|
||||
("coder", "install"), ("coder", "start"), ("ops", "install"), ("ops", "start")]
|
||||
assert fleet.ops[-1] == ("default", "restart")
|
||||
# Each secondary's own gateway must have been startable at the moment it was started.
|
||||
assert fleet.refused_at_start == {"coder": False, "ops": False}
|
||||
runtime = json.loads(runtime_path.read_text(encoding="utf-8"))
|
||||
assert runtime["served_profiles"] == []
|
||||
assert runtime["platforms"] == {"telegram": {"state": "connected"}}
|
||||
@@ -136,6 +144,29 @@ def test_apply_records_manifest_flips_flag_and_rollback_restores(fleet, capsys):
|
||||
assert not (fleet.root / gm.MANIFEST_NAME).exists()
|
||||
|
||||
|
||||
def test_rollback_with_failed_secondary_still_restarts_default_and_keeps_manifest(fleet, monkeypatch):
|
||||
assert gm.apply_migration(gm.build_migration_plan(), served_wait=5.0) is True
|
||||
fleet.ops.clear()
|
||||
real_op = gm._service_op
|
||||
|
||||
def _flaky(kind, system, verb, home):
|
||||
if verb == "start" and _name_of(home) == "coder":
|
||||
raise RuntimeError("systemctl start failed")
|
||||
real_op(kind, system, verb, home)
|
||||
|
||||
monkeypatch.setattr(gm, "_service_op", _flaky)
|
||||
assert gm.rollback_migration(fleet.root) is False
|
||||
# The flag is off, so the default must not be left multiplexing; the manifest stays for a re-run.
|
||||
assert _config_flag(fleet.root) is False
|
||||
assert fleet.ops[-1] == ("default", "restart")
|
||||
assert ("ops", "start") in fleet.ops
|
||||
assert (fleet.root / gm.MANIFEST_NAME).exists()
|
||||
|
||||
|
||||
def _name_of(home: Path) -> str:
|
||||
return hermes_constants.profile_name_for_home(home) or "default"
|
||||
|
||||
|
||||
def test_standalone_dry_run_prints_rollback_plan_without_mutation(fleet, capsys):
|
||||
assert gm.apply_migration(gm.build_migration_plan(), served_wait=5.0) is True
|
||||
capsys.readouterr()
|
||||
|
||||
Reference in New Issue
Block a user