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>
133 lines
5.0 KiB
Python
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)
|