#5212 "Plain labels: no raw ids, scopes, metric keys or unexplained jargon", batch 2: - A run records where it came from: AnsibleRun.origin ("schedule", "alert" or "mcp") and origin_id, set by the scheduler, Run now, alert actions and the MCP tools. Migration 0031 backfills past runs from alert events, schedules' last runs, and MCP audit entries. - The runs list has a Started by column: "schedule nightly", "alert rule nas down", "alice via MCP" or "alice", linked where there is a page. The raw scope tooltip is gone. - Run detail: no run id in the subtitle or body, Started by in words, and the host summary lists failed and unreachable hosts first. The live output only follows new lines while the reader is at the bottom, and when the run finishes the page reloads once so the badge and summary are final. - list_ansible_runs and get_ansible_run report origin and origin_id. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
6.6 KiB
Python
127 lines
6.6 KiB
Python
"""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("<main"):]
|
|
assert '<a href="/ansible/runs/aaaa1111">update.yml on web-1</a>' in main
|
|
assert "steward:target:t1" not in main # no raw scope, not even as a tooltip
|
|
assert ">update.yml on all inventory targets</a>" in main
|
|
assert '<span class="badge badge-info">Running</span>' in main
|
|
assert '<span class="badge badge-crit">Failed</span>' in main
|
|
assert '<span class="badge badge-ok">Succeeded</span>' 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 "<title>update.yml on web-1 — Steward</title>" in page
|
|
assert 'href="/ansible/"' in page # breadcrumb back to the runs
|
|
assert '<span class="badge badge-warn">Interrupted</span>' in page
|
|
assert "<dd>10m</dd>" 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 '<dd><a href="/ansible/schedules">schedule nightly</a></dd>' in page
|
|
assert "dddd4444" not in page.replace("/ansible/runs/dddd4444", "")
|
|
assert page.index(">zulu<") < page.index(">alpha<")
|
|
main = page[page.index("<main"):]
|
|
assert not hex_colours(main), hex_colours(main)
|
|
|
|
|
|
async def test_run_started_is_on_the_palette(app):
|
|
run = _run("eeee5555", AnsibleRunStatus.running, 0)
|
|
async with app.test_request_context("/ansible/runs"):
|
|
page = await render_template("ansible/run_started.html", run=run, title="update.yml on web-1")
|
|
assert '<span class="badge badge-info">Running</span>' 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}) == []
|