Files
hermes-agent/tests/hermes_cli/test_update_fleet_check_fail_closed.py
T
Teknium 93bf6f7225 fix(cli): fail closed on empty fleet probe across all pre-update liveness signals (#93406)
The #93410 guard keyed on (restarted_services or killed_pids), which never
fires on Windows: _pause_windows_gateways_for_update /
_resume_windows_gateways_after_update populate neither list, so a healthy
resumed Windows gateway still yielded zero fleet rows and exit 0.

Hoist the decision into _fleet_probe_expected_runtimes(), keyed on every
pre-update liveness signal:
- restarted_services / killed_pids (POSIX restart bookkeeping)
- _pre_restart_gateway_pids non-empty or None (unreadable pre-state,
  same fail-closed contract as _restart_phase_failure_is_incomplete, #78574)
- pre-update plan inventoried >=1 runtime
- Windows pause/resume token carries profiles or unmapped entries

Gate the 2.0s settle sleep on the same condition so a resumed Windows
gateway gets its settle window before the probe. The guard keys only on
zero-rows-despite-expected-runtimes; non-empty snapshots (including
'unknown'-state rows) are still judged solely by print_fleet_version_matrix.

Regression tests cover: empty snapshot + plan runtimes -> incomplete;
empty snapshot + genuinely idle -> success; Windows-resume token path ->
fail-closed + settle sleep wiring.

Builds on RelaxJonh's #93410. Fixes #93406
2026-08-24 03:21:18 -07:00

130 lines
5.5 KiB
Python

"""Regression for #93406 — post-update fleet version check must fail closed.
``collect_fleet_versions()`` swallows every probe failure via
``logger.debug()`` and ``print_fleet_version_matrix([])`` early-returns
``False``, so an empty fleet snapshot used to read as "healthy fleet" and
``hermes update`` exited 0 with zero rows — even when a gateway was
verifiably live before the update.
The first guard (PR #93410) keyed on ``(restarted_services or killed_pids)``,
which never fires on Windows: ``_pause_windows_gateways_for_update`` /
``_resume_windows_gateways_after_update`` populate neither list, so a healthy
resumed Windows gateway still yielded zero rows and exit 0. The fix hoists
the "should the probe have produced rows?" decision into
``_fleet_probe_expected_runtimes`` and keys it on every pre-update liveness
signal: restart-phase bookkeeping, the pre-restart PID snapshot, the
pre-update plan inventory, and the Windows pause/resume token. The same
condition gates the 2.0s settle sleep.
"""
from __future__ import annotations
import inspect
import types
from hermes_cli.main import _fleet_probe_expected_runtimes
def _plan(runtimes):
return types.SimpleNamespace(runtimes=runtimes)
class TestEmptySnapshotFailClosed:
"""Signals under which zero fleet rows means verification failure."""
def test_incomplete_when_pre_update_plan_saw_runtimes(self):
# (a) The plan inventoried a live runtime pre-update but the restart
# phase's POSIX bookkeeping is empty (e.g. Windows, or an
# externally-supervised gateway). Zero rows must fail closed.
assert (
_fleet_probe_expected_runtimes(
_plan([object()]),
[], # pre_restart_pids: probe saw nothing
None, # no Windows resume token
[], # restarted_services
set(), # killed_pids
)
is True
)
def test_incomplete_when_windows_resume_token_has_profiles(self):
# (c) The Windows pause/resume path: restarted_services and
# killed_pids stay empty by construction, so the resume token is the
# ONLY signal that a gateway was live. This is exactly the case the
# original (restarted_services or killed_pids) guard missed.
token = {"resume_needed": False, "profiles": {"default": 4321}}
assert (
_fleet_probe_expected_runtimes(None, [], token, [], set()) is True
)
def test_incomplete_when_windows_resume_token_has_unmapped(self):
# Scheduled-Task gateways land in token["unmapped"], not profiles.
token = {"resume_needed": False, "unmapped": [{"pid": 99, "argv": ["x"]}]}
assert (
_fleet_probe_expected_runtimes(None, [], token, [], set()) is True
)
def test_incomplete_when_restart_phase_touched_gateways(self):
# The original #93410 signal still counts.
assert (
_fleet_probe_expected_runtimes(None, [], None, ["hermes-gateway"], set())
is True
)
assert _fleet_probe_expected_runtimes(None, [], None, [], {4321}) is True
def test_incomplete_when_pre_restart_pids_seen(self):
assert _fleet_probe_expected_runtimes(None, [4321], None, [], set()) is True
def test_incomplete_when_pre_restart_state_unreadable(self):
# None means the pre-state could not be read — cannot prove nothing
# was running, same contract as _restart_phase_failure_is_incomplete.
assert _fleet_probe_expected_runtimes(None, None, None, [], set()) is True
class TestEmptySnapshotGenuinelyIdle:
def test_success_when_nothing_was_running_pre_update(self):
# (b) Positive control: no plan runtimes, empty PID snapshot, no
# Windows token, no restart bookkeeping — zero rows stays a success.
assert (
_fleet_probe_expected_runtimes(_plan([]), [], None, [], set()) is False
)
def test_success_with_no_plan_at_all(self):
assert _fleet_probe_expected_runtimes(None, [], None, [], set()) is False
def test_success_with_empty_windows_token(self):
# A token that paused nothing (e.g. Windows host with no gateways)
# is not a liveness signal.
token = {"resume_needed": False, "profiles": {}, "unmapped": []}
assert _fleet_probe_expected_runtimes(None, [], token, [], set()) is False
class TestCallSiteWiring:
"""The guard AND the settle sleep must both key on the shared signal.
Sabotage-proof for the wiring itself: reverting the call site to the
pre-fix ``(restarted_services or killed_pids)`` condition — while leaving
the helper in place — makes these fail.
"""
def _impl_source(self):
from hermes_cli import update_cmd
return inspect.getsource(update_cmd._cmd_update_impl)
def test_settle_sleep_gated_on_expected_runtimes(self):
src = self._impl_source()
assert "_fleet_rows_expected = _m()._fleet_probe_expected_runtimes(" in src
# The 2.0s settle window must key on the cross-platform signal, so a
# resumed Windows gateway gets its settle window too (#93406).
assert "if _fleet_rows_expected:\n" in src
assert "if restarted_services or killed_pids:\n _time.sleep" not in src
def test_zero_row_guard_gated_on_expected_runtimes(self):
src = self._impl_source()
assert "elif not _fleet_snapshot and _fleet_rows_expected:" in src
assert (
"elif not _fleet_snapshot and (restarted_services or killed_pids):"
not in src
)