mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
poll_container and the dashboard _http_probe slept a full interval after every failed probe, even when less time than that was left, so a poll could run up to one interval past the deadline its caller gave. Clamp the sleep to the time remaining. The overrun never turned a failure into a pass; it only made the deadline inexact. Pre-existing: the poll_container sleep sincec918d07b50(#51024, @ethernet8023, 2026-06-22); the _http_probe sleep sincefb51253620(#34188, @benbarclay, 2026-05-29).
122 lines
5.5 KiB
Python
122 lines
5.5 KiB
Python
"""A stalled ``docker exec`` is one failed poll attempt, never the poll's verdict.
|
|
|
|
Driven without Docker: ``docker_exec_sh`` is replaced by a scripted stand-in and the poll's clock
|
|
is fake, so the timeout branches run on every host. The module lives outside ``tests/docker/`` on
|
|
purpose: default discovery skips that directory (it needs a daemon and an image), and these
|
|
stand-in tests need neither.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from tests.docker import conftest as docker_conftest
|
|
from tests.docker import test_dashboard
|
|
|
|
|
|
class _Clock:
|
|
"""``time`` stand-in: each probe attempt and each sleep advance the fake clock."""
|
|
|
|
def __init__(self) -> None:
|
|
self.now = 0.0
|
|
|
|
def monotonic(self) -> float:
|
|
return self.now
|
|
|
|
def sleep(self, seconds: float) -> None:
|
|
self.now += seconds
|
|
|
|
|
|
def _scripted_exec(clock: _Clock, outcomes: list):
|
|
def fake(container, command, *, user="hermes", timeout=30):
|
|
clock.now += timeout if outcomes[0] == "stall" else 0.1
|
|
outcome = outcomes.pop(0) if len(outcomes) > 1 else outcomes[0]
|
|
if outcome == "stall":
|
|
raise subprocess.TimeoutExpired(["docker", "exec"], timeout)
|
|
return outcome
|
|
return fake
|
|
|
|
|
|
@pytest.mark.parametrize("module", [docker_conftest, test_dashboard], ids=["poll_container", "_http_probe"])
|
|
def test_stalled_exec_is_retried_until_the_deadline(monkeypatch, module):
|
|
clock = _Clock()
|
|
monkeypatch.setattr(module, "time", clock)
|
|
ok = SimpleNamespace(returncode=0, stdout="200\n{}", stderr="")
|
|
monkeypatch.setattr(module, "docker_exec_sh", _scripted_exec(clock, ["stall", ok]))
|
|
if module is docker_conftest:
|
|
assert module.poll_container("c", "probe", deadline_s=30.0) == (True, "200\n{}")
|
|
else:
|
|
assert module._http_probe("c", "/api/status", deadline_s=30.0) == (200, "{}")
|
|
|
|
clock.now = 0.0
|
|
monkeypatch.setattr(module, "docker_exec_sh", _scripted_exec(clock, ["stall"]))
|
|
if module is docker_conftest:
|
|
with pytest.raises(AssertionError, match="docker exec stalled"):
|
|
module.poll_container("c", "probe", deadline_s=12.0)
|
|
else:
|
|
with pytest.raises(AssertionError, match="docker exec stalled"):
|
|
module._http_probe("c", "/api/status", deadline_s=12.0)
|
|
assert clock.now <= 12.0 # each attempt gets only the time left, so the deadline holds
|
|
|
|
|
|
def test_fail_closed_dashboard_guard_needs_a_completed_probe(monkeypatch):
|
|
"""Every exec stalling proves nothing about the dashboard, so the negative guard must not pass."""
|
|
clock = _Clock()
|
|
monkeypatch.setattr(docker_conftest, "time", clock)
|
|
monkeypatch.setattr(docker_conftest, "docker_exec_sh", _scripted_exec(clock, ["stall"]))
|
|
monkeypatch.setattr(test_dashboard, "start_container", lambda *args, **kwargs: "c")
|
|
with pytest.raises(AssertionError, match="stalled"):
|
|
test_dashboard.test_dashboard_insecure_env_var_no_longer_bypasses_gate("image", "c")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("returncode", "stdout", "failure"),
|
|
[
|
|
(7, "rc=7\n", None), # connection refused: the real fail-closed signal
|
|
(0, "rc=0\n", "must NOT serve"),
|
|
(1, "", "refusal"), # docker exec into a container that already exited
|
|
(127, "rc=127\n", "refusal"), # curl missing
|
|
(126, "", "refusal"), # docker exec could not start the probe (exec user missing)
|
|
],
|
|
ids=["refused", "served", "container-gone", "curl-missing", "exec-user-missing"],
|
|
)
|
|
def test_fail_closed_dashboard_guard_needs_an_observed_refusal(monkeypatch, returncode, stdout, failure):
|
|
"""A completed probe that never reached the port proves nothing either: only a refusal passes."""
|
|
clock = _Clock()
|
|
monkeypatch.setattr(docker_conftest, "time", clock)
|
|
result = SimpleNamespace(returncode=returncode, stdout=stdout, stderr="")
|
|
monkeypatch.setattr(docker_conftest, "docker_exec_sh", _scripted_exec(clock, [result]))
|
|
monkeypatch.setattr(test_dashboard, "start_container", lambda *args, **kwargs: "c")
|
|
if failure is None:
|
|
test_dashboard.test_dashboard_insecure_env_var_no_longer_bypasses_gate("image", "c")
|
|
else:
|
|
with pytest.raises(AssertionError, match=failure):
|
|
test_dashboard.test_dashboard_insecure_env_var_no_longer_bypasses_gate("image", "c")
|
|
|
|
|
|
def test_stall_after_a_completed_probe_keeps_its_output(monkeypatch):
|
|
"""A late stall must not replace the last real probe output that positive callers print."""
|
|
clock = _Clock()
|
|
monkeypatch.setattr(docker_conftest, "time", clock)
|
|
failed = SimpleNamespace(returncode=1, stdout="HOME=/root", stderr="")
|
|
monkeypatch.setattr(docker_conftest, "docker_exec_sh", _scripted_exec(clock, [failed, "stall"]))
|
|
assert docker_conftest.poll_container("c", "probe", deadline_s=12.0) == (False, "HOME=/root")
|
|
|
|
|
|
@pytest.mark.parametrize("module", [docker_conftest, test_dashboard], ids=["poll_container", "_http_probe"])
|
|
def test_sleep_between_failed_probes_stops_at_the_deadline(monkeypatch, module):
|
|
"""The pause after a failed probe never runs past the deadline the caller gave."""
|
|
clock = _Clock()
|
|
monkeypatch.setattr(module, "time", clock)
|
|
failed = SimpleNamespace(returncode=1, stdout="", stderr="")
|
|
monkeypatch.setattr(module, "docker_exec_sh", _scripted_exec(clock, [failed]))
|
|
if module is docker_conftest:
|
|
assert module.poll_container("c", "probe", deadline_s=1.0) == (False, "")
|
|
else:
|
|
with pytest.raises(AssertionError, match="never returned HTTP"):
|
|
module._http_probe("c", "/api/status", deadline_s=1.0)
|
|
assert clock.now <= 1.0
|