"""Ansible runs are named by what ran where, say how long ago and for how long, and wear the one status badge every page uses (#5125).""" from datetime import datetime, timedelta, timezone from types import SimpleNamespace import pytest from quart import render_template, session from steward.ansible.inventory_gen import run_title from steward.ansible.origins import describe_origin from steward.ansible.routes import hosts_worst_first, run_duration, run_view from steward.models.ansible import AnsibleRunStatus from tests.test_style_tokens import hex_colours @pytest.mark.parametrize("label, title", [ ("target web-1", "update.yml on web-1"), ("group Docker Swarm", "update.yml on group Docker Swarm"), ("all inventory targets", "update.yml on all inventory targets"), ("homelab/hosts.ini", "update.yml on homelab/hosts.ini"), ("", "update.yml"), ]) def test_a_run_is_named_by_what_ran_where(label, title): assert run_title("update.yml", label) == title def _run(rid, status, started_min_ago, took_s=None, scope="steward:target:t1", check=False): now = datetime.now(timezone.utc) started = now - timedelta(minutes=started_min_ago) return SimpleNamespace( id=rid, playbook_path="update.yml", source_name="builtin", inventory_scope=scope, status=status, started_at=started, finished_at=started + timedelta(seconds=took_s) if took_s is not None else None, params={"check": True} if check else None, results=None, output="") def test_a_running_run_counts_its_duration_so_far(): now = datetime.now(timezone.utc) run = _run("r", AnsibleRunStatus.running, 3) assert run_duration(run, now) == pytest.approx(180, abs=2) assert run_duration(_run("f", AnsibleRunStatus.success, 10, took_s=95), now) == 95 async def test_the_runs_list_names_each_run_with_one_badge(app): now = datetime.now(timezone.utc) labels = {"steward:target:t1": "target web-1", "steward:all": "all inventory targets"} runs = [run_view(r, labels, now) for r in ( _run("aaaa1111", AnsibleRunStatus.running, 2), _run("bbbb2222", AnsibleRunStatus.failed, 60, took_s=125, scope="steward:all", check=True), _run("cccc3333", AnsibleRunStatus.success, 120, took_s=40), )] async with app.test_request_context("/ansible/"): session["user_role"] = "viewer" page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x") main = page[page.index("update.yml on web-1' in main assert "steward:target:t1" not in main # no raw scope, not even as a tooltip assert ">update.yml on all inventory targets" in main assert 'Running' in main assert 'Failed' in main assert 'Succeeded' in main # The list continues itself as the reader scrolls (#5211): no cap of 50. assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main assert "2m 5s" in main and "40s" in main and " so far" in main assert "dry run" in main and "ago" in main assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone assert not hex_colours(main), hex_colours(main) async def test_run_detail_titles_the_run_and_keeps_the_trail(app): now = datetime.now(timezone.utc) run = _run("dddd4444", AnsibleRunStatus.interrupted, 30, took_s=600) async with app.test_request_context("/ansible/runs/dddd4444"): session["user_role"] = "operator" page = await render_template( "ansible/run_detail.html", run=run, row=run_view(run, {"steward:target:t1": "target web-1"}, now), origin={"text": "schedule nightly", "url": "/ansible/schedules", "kind": "schedule"}, hosts=hosts_worst_first({"hosts": { "alpha": {"ok": 3, "changed": 0, "unreachable": 0, "failed": 0, "skipped": 0}, "zulu": {"ok": 1, "changed": 0, "unreachable": 0, "failed": 1, "skipped": 0}}})) assert "update.yml on web-1 — Steward" in page assert 'href="/ansible/"' in page # breadcrumb back to the runs assert 'Interrupted' in page assert "
10m
" in page assert "/cancel" not in page # an interrupted run has nothing to cancel # #5212: who started it, by name; no run id in the page; the failed host first. assert '
schedule nightly
' in page assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "") assert page.index(">zulu<") < page.index(">alpha<") main = page[page.index("Running' in page assert "update.yml on web-1" in page and 'href="/ansible/runs/eeee5555"' in page assert not hex_colours(page), hex_colours(page) def _origin_of(triggered_by=None, origin=None, origin_id=None): run = SimpleNamespace(triggered_by=triggered_by, origin=origin, origin_id=origin_id) return describe_origin(run, {"u1": "alice"}, {"s1": "nightly"}, {"r1": "nas down"}) def test_a_run_says_who_or_what_started_it(): """#5212: the runs list tells a schedule, an alert, an agent and a person apart.""" assert _origin_of("u1") == {"text": "alice", "url": None, "kind": "user"} assert _origin_of(None, "schedule", "s1")["text"] == "schedule nightly" assert _origin_of("u1", "schedule", "s1")["text"] == "schedule nightly (run now by alice)" assert _origin_of(None, "alert", "r1") == {"text": "alert rule nas down", "url": "/alerts/history?rule=r1", "kind": "alert"} assert _origin_of("u1", "mcp")["text"] == "alice via MCP" assert _origin_of(None, "schedule", "gone")["text"] == "a deleted schedule" assert _origin_of("u-gone")["text"] == "a deleted user" assert _origin_of()["text"] == "Steward" # automated, from before origins def test_failed_hosts_come_first_then_changed_then_by_name(): hosts = {"c": {"changed": 2}, "b": {"failed": 1}, "a": {}, "d": {"unreachable": 1}} assert [h for h, _ in hosts_worst_first({"hosts": hosts})] == ["b", "d", "c", "a"] assert hosts_worst_first(None) == [] and hosts_worst_first({"hosts": None}) == []