fix(migrate): auto-fold a unit-less default when every secondary shares one manager

The service-domain guard compared each standalone secondary against the DEFAULT profile's
installed units. A default that never had a gateway unit (the #118097 fleet: N launchd
profiles, one macOS user, no unit on default) therefore produced one "different service
domain" blocker per secondary, and maybe_auto_migrate_after_update printed blockers instead
of folding — on the most common upgrade shape.

The reference is now the manager the plan already elects as the target
(MigrationPlan.target_service_kind: the default's own unit, else the one the secondaries
use), so the guard agrees with the apply path. Genuinely mixed topologies still refuse: a
default unit under another manager than a secondary, two managers among the secondaries
(the non-elected one differs from the target), user-vs-system systemd, >1 unit on a profile.

Test: red on the PR head (base: one finding per launchd secondary), green now; both controls
asserted in the same test.
This commit is contained in:
teknium1
2026-09-21 09:10:42 -07:00
committed by Teknium
parent 9ac5cea9e0
commit e9ac4a59c0
2 changed files with 47 additions and 6 deletions

View File

@@ -91,16 +91,25 @@ def _service_label(profile: ProfileGateway) -> str:
def _guard_service_domain(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]: def _guard_service_domain(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:
"""Different manager or scope than the default gateway (system vs user systemd, launchd vs systemd, """Different manager or scope than the one the fleet converges on (system vs user systemd, launchd vs
or any service when the default is detached: the auto path never elects a secondary's manager). systemd). The reference is the default's own unit when it has one, else the manager
Two units on one profile is an ambiguous topology the unattended path does not resolve either.""" ``target_service_kind()`` elects from the secondaries: a default that never had a gateway unit is not a
service domain of its own, and refusing every secondary against it left the common upgrade fleet (N
launchd profiles, unit-less default, #118097) printing blockers instead of folding. Two managers
among the secondaries still refuse — the ones not elected differ from the target. Two units on one
profile is an ambiguous topology the unattended path does not resolve either."""
if len(profile.services) > 1: if len(profile.services) > 1:
return (f"Profile '{profile.name}' has more than one installed service ({profile.service_label()}): " return (f"Profile '{profile.name}' has more than one installed service ({profile.service_label()}): "
f"an ambiguous service topology is not folded automatically.") f"an ambiguous service topology is not folded automatically.")
if set(profile.services) == set(plan.default.services): target = plan.target_service_kind()
reference = plan.default.services or ([target] if target is not None else [])
if set(profile.services) == set(reference):
return None return None
return (f"Profile '{profile.name}' runs under {_service_label(profile)} while the default gateway " from hermes_cli.gateway_migrate import _service_label as _kind_label
f"runs under {_service_label(plan.default)}: a different service domain is not folded automatically.") against = (f"the default gateway runs under {_service_label(plan.default)}" if plan.default.services
else f"the fleet converges on {_kind_label(target)}")
return (f"Profile '{profile.name}' runs under {_service_label(profile)} while {against}: "
f"a different service domain is not folded automatically.")
def _guard_unix_user(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]: def _guard_unix_user(plan: MigrationPlan, profile: ProfileGateway) -> Optional[str]:

View File

@@ -373,6 +373,38 @@ def test_update_hook_still_migrates_same_user_same_scope_profiles_under_the_defa
assert _config_flag(fleet.root) is True and ("ops", "uninstall") in fleet.ops assert _config_flag(fleet.root) is True and ("ops", "uninstall") in fleet.ops
def test_update_hook_folds_a_unit_less_default_when_every_secondary_shares_one_manager(fleet, capsys, monkeypatch):
"""The #118097 fleet: the default profile never had a gateway unit, every secondary runs under the
SAME manager (all launchd, one macOS user). That is one service domain, and the manager the plan
elects as the target (``target_service_kind``) is the reference the guard must agree with — not the
default's empty unit list, which turned every such fleet into "blockers" instead of a fold.
Controls: a default unit under another manager, and two managers among the secondaries, still refuse."""
from hermes_cli.gateway_migrate_guards import auto_migration_blockers
monkeypatch.setattr(gm, "_gateway_identity", lambda home, pid, service: (1000, home), raising=False)
fleet.services.update({"coder": ("launchd", False), "ops": ("launchd", False)})
assert "default" not in fleet.services
plan = gm.build_migration_plan()
assert plan.target_service_kind() == ("launchd", False)
assert auto_migration_blockers(plan) == []
gm.maybe_auto_migrate_after_update()
out = capsys.readouterr().out
assert "serves 3 profiles" in out and _config_flag(fleet.root) is True
assert ("coder", "uninstall") in fleet.ops and ("ops", "uninstall") in fleet.ops
assert ("default", "install") in fleet.ops and fleet.services == {"default": ("launchd", False)}
# Control 1: the default HAS a unit under another manager -> still a different service domain.
fleet.services.update({"default": ("systemd", False), "coder": ("launchd", False), "ops": ("launchd", False)})
fleet.pids.update({"coder": 4101, "ops": 4102})
blockers = auto_migration_blockers(gm.build_migration_plan())
assert blockers and all("different service domain" in b for b in blockers)
# Control 2: unit-less default, secondaries under two managers -> the non-elected one refuses.
fleet.services.pop("default")
fleet.services.update({"coder": ("launchd", False), "ops": ("systemd", False)})
blockers = auto_migration_blockers(gm.build_migration_plan())
assert len(blockers) == 1 and "'ops'" in blockers[0] and "different service domain" in blockers[0]
def test_auto_multiplex_migration_false_opts_out_of_the_update_hook_but_not_the_explicit_command(fleet, capsys): def test_auto_multiplex_migration_false_opts_out_of_the_update_hook_but_not_the_explicit_command(fleet, capsys):
"""``gateway.auto_multiplex_migration: false`` is a durable opt-out: an otherwise-eligible fleet is """``gateway.auto_multiplex_migration: false`` is a durable opt-out: an otherwise-eligible fleet is
left alone by ``hermes update`` (no output, no ops, no flag flip), while the operator typing left alone by ``hermes update`` (no output, no ops, no flag flip), while the operator typing