ariadne/tests/test_hermes_infra_signals.py
codex 8c65b7bd60 feat(hermes-triage): retry_transient_infra action for connectivity failures
Second entry in the action registry, proving it is a real extension point.
No cluster mutation: the action is one Jenkins rebuild.

- hermes_infra_signals: reviewable marker set across DNS/connectivity,
  image pull, upstream 5xx and agent-channel loss; Ariadne independently
  confirms a marker in the evidence before any retry, and records which
  marker justified it. "no space left on device" is deliberately excluded
  because a retry lands on the same full volume.
- decision: classification -> action registry (action_classifications),
  falling back to the previous single-classification behavior
- repair: retry_build posts to /build for unparameterized real jobs and
  buildWithParameters for the fixture demo job
- orchestrator: retry path records requested/accepted/executed and moves
  the incident to awaiting_rebuild so the existing success path resolves
  it; one action per incident still enforced, so a retry cannot loop
- events layer split out of the orchestrator to stay under the LOC cap

Motivated by real failures tonight: pip DNS resolution and a Gitea 443
connect timeout, plus live incident metis/271 (SCM checkout timeout).

30 new tests; 368 pass in the hermes suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 20:42:48 -03:00

133 lines
5.0 KiB
Python

from __future__ import annotations
import pytest
from ariadne.services import hermes_infra_signals as module
def _bundle(regions=None, tail=None, **jenkins): # type: ignore[no-untyped-def]
payload = {
"job": "titan-iac",
"console_failures": [{"marker": "ERROR:", "line_number": 10, "text": text} for text in regions or []],
"console_tail": tail,
}
payload.update(jenkins)
return {"incident_id": "titan-iac/12", "jenkins": payload, "log_evidence": {"records": []}}
@pytest.mark.parametrize("marker", module.INFRA_MARKERS)
def test_every_marker_is_detected_in_a_region(marker) -> None:
bundle = _bundle(regions=[f"+ pip install -r requirements.txt\n{marker}\nbuild step failed"])
assert module.has_transient_infra_signature(bundle) == (True, marker)
@pytest.mark.parametrize("marker", module.INFRA_MARKERS)
def test_every_marker_is_detected_in_the_tail(marker) -> None:
bundle = _bundle(tail=f"Finished: FAILURE\n{marker.upper()}")
assert module.has_transient_infra_signature(bundle) == (True, marker)
def test_real_dns_failure_from_pip_install() -> None:
text = (
"+ pip install -r requirements.txt\n"
"WARNING: Retrying (Retry(total=4)) after connection broken by "
"'NewConnectionError(...: Failed to establish a new connection: "
"[Errno -3] Temporary failure in name resolution')\n"
"ERROR: Could not install packages due to an OSError\n"
)
matched, marker = module.has_transient_infra_signature(_bundle(regions=[text]))
assert matched is True
assert marker == "failed to establish a new connection"
def test_real_scm_connect_failure_from_git_checkout() -> None:
text = (
"+ git fetch --tags --force --progress -- https://scm.bstein.dev/bstein/titan-iac.git\n"
"fatal: unable to access 'https://scm.bstein.dev/bstein/titan-iac.git/': "
"Failed to connect to scm.bstein.dev port 443 after 130626 ms: Connection timed out\n"
)
matched, marker = module.has_transient_infra_signature(_bundle(regions=[text]))
assert matched is True
assert marker == "failed to connect to"
def test_matching_is_case_insensitive() -> None:
bundle = _bundle(regions=["Kubelet reported ErrImagePull for the agent container"])
assert module.has_transient_infra_signature(bundle) == (True, "errimagepull")
def test_earliest_region_wins_over_later_regions_and_tail() -> None:
bundle = _bundle(
regions=["stage one: connection refused", "stage two: 502 bad gateway"],
tail="Finished: FAILURE (i/o timeout)",
)
assert module.has_transient_infra_signature(bundle) == (True, "connection refused")
def test_earliest_marker_within_one_region_wins() -> None:
text = "could not resolve host: registry.example\nthen later a 503 service unavailable"
bundle = _bundle(regions=[text])
assert module.has_transient_infra_signature(bundle) == (True, "could not resolve host")
def test_regions_are_searched_before_the_tail() -> None:
bundle = _bundle(regions=["nothing infrastructural here"], tail="tls handshake timeout")
assert module.has_transient_infra_signature(bundle) == (True, "tls handshake timeout")
def test_disk_exhaustion_is_deliberately_not_transient() -> None:
text = "cp: error writing '/home/jenkins/agent/workspace/x': No space left on device"
bundle = _bundle(regions=[text], tail=text)
assert "no space left on device" not in module.INFRA_MARKERS
assert module.has_transient_infra_signature(bundle) == (False, None)
def test_ordinary_test_failure_is_not_transient() -> None:
text = "FAILED tests/test_discount.py::test_bulk - AssertionError: assert 0.9 == 0.8"
bundle = _bundle(regions=[text], tail="short test summary info")
assert module.has_transient_infra_signature(bundle) == (False, None)
@pytest.mark.parametrize(
"bundle",
[
{},
{"jenkins": None},
{"jenkins": {}},
{"jenkins": {"console_failures": None, "console_tail": None}},
{"jenkins": {"console_failures": "not-a-list", "console_tail": 7}},
{"jenkins": {"console_failures": ["not-a-dict", {"text": None}], "console_tail": ""}},
{"jenkins": {"console_failures": [{}]}},
],
)
def test_empty_or_malformed_bundles_never_match(bundle) -> None:
assert module.has_transient_infra_signature(bundle) == (False, None)
def test_non_dict_bundle_never_raises() -> None:
assert module.has_transient_infra_signature(None) == (False, None) # type: ignore[arg-type]
assert module.has_transient_infra_signature("connection refused") == (False, None) # type: ignore[arg-type]
def test_unreadable_bundle_is_swallowed() -> None:
class Exploding(dict):
def get(self, *args, **kwargs): # type: ignore[no-untyped-def]
raise RuntimeError("boom")
assert module.has_transient_infra_signature(Exploding()) == (False, None)
def test_markers_are_lowercase_and_unique() -> None:
assert list(module.INFRA_MARKERS) == [marker.lower() for marker in module.INFRA_MARKERS]
assert len(set(module.INFRA_MARKERS)) == len(module.INFRA_MARKERS)