168 lines
6.9 KiB
Python
168 lines
6.9 KiB
Python
"""Regression: Windows gateway pause/resume must feed the #91277 Phase 2
|
|
plan-vs-execution reconciliation, not report a correctly-relaunched Windows
|
|
gateway as "unaccounted".
|
|
|
|
``_pause_windows_gateways_for_update`` / ``_resume_windows_gateways_after_update``
|
|
are Windows's own gateway restart mechanism — separate from the
|
|
systemd/launchd restart phase in ``_cmd_update_impl`` that populates
|
|
``restarted_services`` / ``relaunched_profiles`` / ``killed_pids`` /
|
|
``externally_supervised_profiles``. Before this fix, a Windows gateway that
|
|
was correctly paused and relaunched left no trace in that bookkeeping, so
|
|
``match_runtime_outcomes`` classified it "unaccounted" — the plan saw it and
|
|
NO bookkeeping mentions it — and ``report_unaccounted_runtimes`` escalated
|
|
that into ``sys.exit(1)`` even though the update (and the restart) succeeded.
|
|
|
|
``_resume_windows_gateways_after_update`` now writes the profiles it
|
|
successfully relaunched onto ``token["relaunched_profiles"]``; the update
|
|
command merges that into the shared ``relaunched_profiles`` list before
|
|
reconciliation runs (mirrored here directly, since driving the full
|
|
``_cmd_update_impl`` end to end is impractical).
|
|
"""
|
|
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
import hermes_cli.gateway as gateway
|
|
import hermes_cli.gateway_windows as gateway_windows
|
|
import hermes_cli.main as hm
|
|
from hermes_cli.update_cmd import _resume_windows_gateways_after_update
|
|
from hermes_cli.update_inventory import (
|
|
RuntimeRecord,
|
|
UpdatePlan,
|
|
match_runtime_outcomes,
|
|
report_unaccounted_runtimes,
|
|
)
|
|
|
|
|
|
def _token(profiles: dict) -> dict:
|
|
return {
|
|
"resume_needed": True,
|
|
"profiles": profiles,
|
|
"unmapped_pids": [],
|
|
"unmapped": [],
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _stub_post_relaunch_liveness(monkeypatch):
|
|
"""The resume path now verifies a stable gateway process actually exists
|
|
before vouching for the relaunch (#48820 3rd/4th repro — a parent Job
|
|
Object killing the respawned gateway made '✓ Restarting' a lie). These
|
|
reconciliation tests exercise the token bookkeeping, not the liveness
|
|
poll, so stub it as 'gateway came up'."""
|
|
monkeypatch.setattr(
|
|
gateway_windows, "_wait_for_gateway_ready", lambda **_kw: [4242]
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway_windows, "_write_start_attestation", lambda *_a, **_kw: None
|
|
)
|
|
|
|
|
|
def test_resume_records_successfully_relaunched_profiles_on_the_token(monkeypatch):
|
|
monkeypatch.setattr(hm, "_is_windows", lambda: True)
|
|
monkeypatch.setattr(hm, "_refresh_windows_gateway_launchers", lambda: None)
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_profile_gateway_restart", lambda *_a: True
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_gateway_restart_by_cmdline", lambda *_a: True
|
|
)
|
|
|
|
token = _token({"default": 1111, "work": 2222})
|
|
with patch("builtins.print"):
|
|
_resume_windows_gateways_after_update(token)
|
|
|
|
assert sorted(token["relaunched_profiles"]) == ["default", "work"]
|
|
|
|
|
|
def test_resume_omits_profiles_whose_relaunch_failed(monkeypatch):
|
|
"""A profile whose relaunch genuinely fails must NOT be marked
|
|
'relaunched' — it needs to keep surfacing as unaccounted so the user is
|
|
told to restart it manually (Windows has no watcher to recover it)."""
|
|
monkeypatch.setattr(hm, "_is_windows", lambda: True)
|
|
monkeypatch.setattr(hm, "_refresh_windows_gateway_launchers", lambda: None)
|
|
|
|
def _relaunch(profile, _old_pid):
|
|
return profile == "default" # "work" fails to relaunch
|
|
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_profile_gateway_restart", _relaunch
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_gateway_restart_by_cmdline", lambda *_a: True
|
|
)
|
|
|
|
token = _token({"default": 1111, "work": 2222})
|
|
with patch("builtins.print"):
|
|
# Fail-closed contract: a profile whose relaunch failed
|
|
# raises so the update is marked incomplete (the caller catches,
|
|
# records the phase error, and exits 1 in gateway mode).
|
|
with pytest.raises(RuntimeError, match="Could not restart every paused"):
|
|
_resume_windows_gateways_after_update(token)
|
|
|
|
assert token["relaunched_profiles"] == ["default"]
|
|
|
|
|
|
def test_merged_windows_relaunch_resolves_as_restarted_not_unaccounted(monkeypatch):
|
|
"""End-to-end shape of the actual fix: the token's relaunched_profiles,
|
|
merged into the shared list _cmd_update_impl passes to
|
|
match_runtime_outcomes, must turn a Windows gateway's plan row from
|
|
'unaccounted' (loud warning + exit 1) into 'restarted' (clean)."""
|
|
monkeypatch.setattr(hm, "_is_windows", lambda: True)
|
|
monkeypatch.setattr(hm, "_refresh_windows_gateway_launchers", lambda: None)
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_profile_gateway_restart", lambda *_a: True
|
|
)
|
|
monkeypatch.setattr(
|
|
gateway, "launch_detached_gateway_restart_by_cmdline", lambda *_a: True
|
|
)
|
|
|
|
old_pid = 4242
|
|
token = _token({"default": old_pid})
|
|
with patch("builtins.print"):
|
|
_resume_windows_gateways_after_update(token)
|
|
|
|
# collect_runtime_inventory() would have recorded the pre-update PID —
|
|
# the plan is built BEFORE the pause/relaunch, so it still names the old
|
|
# pid even though a fresh process now owns the port.
|
|
plan = UpdatePlan()
|
|
plan.runtimes = [
|
|
RuntimeRecord(
|
|
kind="gateway", profile="default", pid=old_pid, supervisor="manual",
|
|
restart_via="manual",
|
|
)
|
|
]
|
|
|
|
# Without the merge (the pre-fix state): unaccounted, escalates.
|
|
pre_fix_outcomes = match_runtime_outcomes(
|
|
plan, restarted_services=[], relaunched_profiles=[],
|
|
externally_supervised_profiles=[], killed_pids=set(), failed_units=[],
|
|
)
|
|
assert pre_fix_outcomes[0]["outcome"] == "unaccounted"
|
|
assert report_unaccounted_runtimes(pre_fix_outcomes) is True
|
|
|
|
# With the merge _cmd_update_impl now performs: restarted, clean.
|
|
relaunched_profiles: list = []
|
|
for profile in token.get("relaunched_profiles") or []:
|
|
if profile not in relaunched_profiles:
|
|
relaunched_profiles.append(profile)
|
|
|
|
post_fix_outcomes = match_runtime_outcomes(
|
|
plan, restarted_services=[], relaunched_profiles=relaunched_profiles,
|
|
externally_supervised_profiles=[], killed_pids=set(), failed_units=[],
|
|
)
|
|
assert post_fix_outcomes[0]["outcome"] == "restarted"
|
|
assert report_unaccounted_runtimes(post_fix_outcomes) is False
|
|
|
|
|
|
def test_resume_with_no_relaunched_profiles_key_does_not_crash_the_merge():
|
|
"""A token from an early-return path (e.g. cold-start, no profiles) may
|
|
never gain a 'relaunched_profiles' key at all — the merge in
|
|
_cmd_update_impl must tolerate that (.get(...) or [])."""
|
|
token = {"resume_needed": False}
|
|
relaunched_profiles: list = []
|
|
for profile in token.get("relaunched_profiles") or []:
|
|
relaunched_profiles.append(profile)
|
|
assert relaunched_profiles == []
|