diff --git a/plugins/docker/mcp_tools.py b/plugins/docker/mcp_tools.py index df2533b..5ac7858 100644 --- a/plugins/docker/mcp_tools.py +++ b/plugins/docker/mcp_tools.py @@ -626,7 +626,7 @@ async def prune_docker(host: str, what: str, check: bool = True) -> dict: mcp_app(), source_name=BUILTIN_SOURCE_NAME, playbook_path=PRUNE_BUILTIN_PLAYBOOK, inventory_scope=f"steward:target:{target.id}", params={"extra_vars_map": docker_svc.prune_extra_vars(what)}, - triggered_by=caller.user_id) + triggered_by=caller.user_id, origin="mcp") if err: raise ValueError(f"the prune could not start: {err}.") await audit_mcp("docker.prune.started", "host", h.name, diff --git a/steward/ansible/origins.py b/steward/ansible/origins.py new file mode 100644 index 0000000..02c701c --- /dev/null +++ b/steward/ansible/origins.py @@ -0,0 +1,52 @@ +"""Who or what started each run, as the runs list and run detail say it. + +A run records the user who started it (triggered_by) and where it came from +(origin / origin_id, migration 0031): a schedule, an alert rule, or an agent +over MCP. This turns those into "schedule nightly", "alert rule nas down", +"alice via MCP" or "alice", in three queries for a whole page of runs. +""" +from __future__ import annotations + +from sqlalchemy import select + +from steward.models.alerts import AlertRule +from steward.models.ansible_schedule import AnsibleSchedule +from steward.models.users import User + + +def describe_origin(run, users: dict, schedules: dict, rules: dict) -> dict: + """{"text", "url", "kind"} for one run, from id → name maps. A schedule or + rule deleted since is named as such; a run with neither a user nor an + origin predates origins and was started by Steward itself.""" + who = users.get(run.triggered_by, "a deleted user") if run.triggered_by else None + if run.origin == "schedule": + name = schedules.get(run.origin_id) + text = f"schedule {name}" if name else "a deleted schedule" + if who: + text += f" (run now by {who})" + return {"text": text, "url": "/ansible/schedules" if name else None, "kind": "schedule"} + if run.origin == "alert": + name = rules.get(run.origin_id) + return {"text": f"alert rule {name}" if name else "a deleted alert rule", + "url": f"/alerts/history?rule={run.origin_id}" if name else None, "kind": "alert"} + if run.origin == "mcp": + return {"text": f"{who or 'an agent'} via MCP", "url": None, "kind": "mcp"} + if who: + return {"text": who, "url": None, "kind": "user"} + return {"text": "Steward", "url": None, "kind": "system"} + + +async def run_origins(db, runs) -> dict[str, dict]: + """{run id: describe_origin(run)} for a page of runs.""" + runs = list(runs) + user_ids = {r.triggered_by for r in runs if r.triggered_by} + sched_ids = {r.origin_id for r in runs if r.origin == "schedule" and r.origin_id} + rule_ids = {r.origin_id for r in runs if r.origin == "alert" and r.origin_id} + users = dict((await db.execute( + select(User.id, User.username).where(User.id.in_(user_ids)))).all()) if user_ids else {} + schedules = dict((await db.execute( + select(AnsibleSchedule.id, AnsibleSchedule.name).where(AnsibleSchedule.id.in_(sched_ids)))).all()) \ + if sched_ids else {} + rules = dict((await db.execute( + select(AlertRule.id, AlertRule.name).where(AlertRule.id.in_(rule_ids)))).all()) if rule_ids else {} + return {r.id: describe_origin(r, users, schedules, rules) for r in runs} diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 583f7ee..c9badea 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -10,12 +10,13 @@ from sqlalchemy import select from steward.ansible import executor, guards, sources as src_module from steward.ansible.inventory_gen import run_title, scope_labels +from steward.ansible.origins import run_origins from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.core.paging import keyset, next_page from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.ansible_schedule import AnsibleSchedule -from steward.models.users import User, UserRole +from steward.models.users import UserRole INTERVAL_PRESETS = [ (300, "Every 5 minutes"), @@ -45,8 +46,10 @@ async def index(): runs, after = next_page((await db.execute(stmt)).scalars().all(), lambda r: (r.started_at, r.id)) labels = await scope_labels(db, [r.inventory_scope for r in runs]) + origins = await run_origins(db, runs) now = datetime.now(timezone.utc) - ctx = {"runs": [run_view(r, labels, now) for r in runs], "after": after} + ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs], + "after": after} if before: return await render_template("ansible/_runs_page.html", **ctx) return await render_template("ansible/index.html", **ctx) @@ -359,18 +362,22 @@ async def run_detail(run_id: str): run = result.scalar_one_or_none() if run is None: return "Not found", 404 - # NULL triggered_by = an automated (system) run; otherwise resolve the user. - triggered_label = "system" - if run.triggered_by: - res = await db.execute( - select(User.username).where(User.id == run.triggered_by)) - triggered_label = res.scalar_one_or_none() or "(deleted user)" + origin = (await run_origins(db, [run]))[run.id] labels = await scope_labels(db, [run.inventory_scope]) return await render_template( - "ansible/run_detail.html", run=run, triggered_label=triggered_label, + "ansible/run_detail.html", run=run, origin=origin, hosts=hosts_worst_first(run.results), row=run_view(run, labels, datetime.now(timezone.utc))) +def hosts_worst_first(results: dict | None) -> list[tuple[str, dict]]: + """A run's per-host recap, failed or unreachable hosts first, then those + that changed something, then the rest, each by name.""" + hosts = (results or {}).get("hosts") or {} + return sorted(hosts.items(), key=lambda kv: ( + -((kv[1].get("failed") or 0) + (kv[1].get("unreachable") or 0) > 0), + -((kv[1].get("changed") or 0) > 0), kv[0])) + + @ansible_bp.post("/runs//cancel") @require_role(UserRole.operator) async def cancel_run(run_id: str): @@ -614,7 +621,7 @@ async def run_schedule_now(schedule_id: str): current_app._get_current_object(), # type: ignore[attr-defined] source_name=source_name, playbook_path=playbook_path, inventory_scope=inventory_scope, params=params, - triggered_by=session["user_id"], + triggered_by=session["user_id"], origin="schedule", origin_id=schedule_id, ) async with current_app.db_sessionmaker() as db: async with db.begin(): diff --git a/steward/ansible/runner.py b/steward/ansible/runner.py index 8f7ca6a..235b053 100644 --- a/steward/ansible/runner.py +++ b/steward/ansible/runner.py @@ -38,6 +38,8 @@ async def trigger_run( inventory_scope: str = "steward:all", params: dict | None = None, triggered_by: str | None = None, + origin: str | None = None, + origin_id: str | None = None, inventory_content: str | None = None, connection: dict | None = None, secret_vars: dict | None = None, @@ -46,6 +48,8 @@ async def trigger_run( """Resolve inventory for the scope, persist an AnsibleRun, and launch it. triggered_by=None marks a system/automated run (alerts, schedules). + origin / origin_id say where it came from (AnsibleRun.origin), so the runs + list can say "schedule nightly" or "alert rule nas down". If inventory_content is provided, it is used verbatim and scope resolution is skipped (the caller built a bespoke inventory — e.g. host_agent deploy injecting per-host tokens); inventory_scope is still recorded for display. @@ -88,6 +92,8 @@ async def trigger_run( inventory_scope=inventory_scope, source_name=source_name, triggered_by=triggered_by, + origin=origin, + origin_id=origin_id, status=AnsibleRunStatus.running, started_at=datetime.now(timezone.utc), params=params or None, diff --git a/steward/ansible/scheduler.py b/steward/ansible/scheduler.py index 17a67c1..c458847 100644 --- a/steward/ansible/scheduler.py +++ b/steward/ansible/scheduler.py @@ -64,6 +64,8 @@ async def run_due_schedules(app) -> int: inventory_scope=s.inventory_scope, params=s.params, triggered_by=None, + origin="schedule", + origin_id=s.id, ) except Exception: logger.exception("Schedule %s (%s) failed to launch", s.id, s.name) diff --git a/steward/core/alerts.py b/steward/core/alerts.py index db0c452..87801a1 100644 --- a/steward/core/alerts.py +++ b/steward/core/alerts.py @@ -590,7 +590,8 @@ async def _start_ansible_action(app, rule, value, event_id, trigger_run) -> None try: run, _source, err = await trigger_run( app, source_name=source_name, playbook_path=playbook, - inventory_scope=scope, params=params, triggered_by=None) + inventory_scope=scope, params=params, triggered_by=None, + origin="alert", origin_id=rule.id) except Exception as exc: logger.exception("Ansible action for rule %r failed to start", rule.name) run, err = None, str(exc) diff --git a/steward/mcp/tools/automation.py b/steward/mcp/tools/automation.py index 6883af2..797b51a 100644 --- a/steward/mcp/tools/automation.py +++ b/steward/mcp/tools/automation.py @@ -289,6 +289,9 @@ def run_row(r: AnsibleRun, users: dict, labels: dict) -> dict: "status": r.status.value, "check": bool((r.params or {}).get("check")), "triggered_by": users.get(r.triggered_by, "(deleted user)") if r.triggered_by else "system", + # "schedule" / "alert" (origin_id = that schedule or rule), "mcp", or null for the web pages. + "origin": r.origin, + "origin_id": r.origin_id, "started_at": iso(r.started_at), "age_s": age_s(r.started_at), "finished_at": iso(end), @@ -459,7 +462,7 @@ async def run_playbook(source: str, playbook: str, scope: str = "all", run, _src, err = await trigger_run( mcp_app(), source_name=s["name"], playbook_path=playbook.strip(), inventory_scope=stored_scope, params=params or None, - secret_vars=secret or None, triggered_by=caller.user_id) + secret_vars=secret or None, triggered_by=caller.user_id, origin="mcp") if err: raise ValueError(f"the run could not start: {err}.") await audit_mcp("ansible.run.started", "ansible_run", run.id, { diff --git a/steward/migrations/versions/0031_ansible_run_origin.py b/steward/migrations/versions/0031_ansible_run_origin.py new file mode 100644 index 0000000..67615fc --- /dev/null +++ b/steward/migrations/versions/0031_ansible_run_origin.py @@ -0,0 +1,41 @@ +"""Where a run came from: a schedule, an alert rule, or an agent over MCP (#5212) + +Revision ID: 0031_ansible_run_origin +Revises: 0030_plugin_metric_latest +Create Date: 2026-10-07 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0031_ansible_run_origin" +down_revision: Union[str, None] = "0030_plugin_metric_latest" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("ansible_runs", sa.Column("origin", sa.String(16), nullable=True)) + op.add_column("ansible_runs", sa.Column("origin_id", sa.String(36), nullable=True)) + # Recover what the record already says about past runs. These tables are + # small (runs, alert events that started one, audit entries), unlike the + # metrics seed 0030 left to the background. + op.execute(""" + UPDATE ansible_runs r SET origin = 'alert', origin_id = e.rule_id + FROM alert_events e WHERE e.ansible_run_id = r.id + """) + op.execute(""" + UPDATE ansible_runs r SET origin = 'schedule', origin_id = s.id + FROM ansible_schedules s WHERE s.last_run_id = r.id AND r.origin IS NULL + """) + op.execute(""" + UPDATE ansible_runs r SET origin = 'mcp' + FROM audit_events a + WHERE a.action = 'ansible.run.started' AND a.entity_id = r.id + AND a.username LIKE '% via MCP' AND r.origin IS NULL + """) + + +def downgrade() -> None: + op.drop_column("ansible_runs", "origin_id") + op.drop_column("ansible_runs", "origin") diff --git a/steward/models/ansible.py b/steward/models/ansible.py index f0ace14..8580772 100644 --- a/steward/models/ansible.py +++ b/steward/models/ansible.py @@ -31,6 +31,11 @@ class AnsibleRun(Base): triggered_by: Mapped[str | None] = mapped_column( String(36), ForeignKey("users.id", ondelete="RESTRICT"), nullable=True ) + # Where the run came from, beyond who started it: "schedule" or "alert" + # (origin_id is that schedule's or rule's id), or "mcp" for an agent on a + # user's token. NULL is a person on the web pages. + origin: Mapped[str | None] = mapped_column(String(16), nullable=True) + origin_id: Mapped[str | None] = mapped_column(String(36), nullable=True) status: Mapped[AnsibleRunStatus] = mapped_column( Enum(AnsibleRunStatus), nullable=False, default=AnsibleRunStatus.running ) diff --git a/steward/templates/ansible/_runs_page.html b/steward/templates/ansible/_runs_page.html index 0fe55ff..c97ce1e 100644 --- a/steward/templates/ansible/_runs_page.html +++ b/steward/templates/ansible/_runs_page.html @@ -5,17 +5,18 @@ {% set run = row.run %} - {{ row.title }} + {{ row.title }} {% if row.check %}dry run{% endif %}
{{ run.source_name }}
{{ status_badge(run.status) }} + {% if row.origin %}{% if row.origin.url %}{{ row.origin.text }}{% else %}{{ row.origin.text }}{% endif %}{% endif %} {{ run.started_at|ago }} {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} {% endfor %} {% if after %} -{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=4) }} +{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=5) }} {% else %} -{{ scroll_end(cols=4) }} +{{ scroll_end(cols=5) }} {% endif %} diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index 5ad9d35..416b150 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -16,6 +16,7 @@ Run Status + Started by Started Duration diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index af73437..fed683c 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -3,7 +3,7 @@ {% block title %}{{ row.title }} — Steward{% endblock %} {% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), (row.title, "")]) }}{% endblock %} {% block content %} -{% call page_header(row.title, "Run " ~ run.id[:8] ~ " from " ~ run.source_name) %} +{% call page_header(row.title, "From the " ~ run.source_name ~ " playbook source") %} {% if run.status.value in ("running", "queued") and session.user_role in ("operator", "admin") %}
{{ csrf_field() }} @@ -20,17 +20,16 @@
Playbook
{{ run.playbook_path }}
Scope
{{ row.scope_label }}
Source
{{ run.source_name }}
-
Triggered by
{{ triggered_label }}
+
Started by
{% if origin.url %}{{ origin.text }}{% else %}{{ origin.text }}{% endif %}
{% if run.params %} {% if run.params.limit %}
Limit
{{ run.params.limit }}
{% endif %} {% if run.params.tags %}
Tags
{{ run.params.tags }}
{% endif %} {% if run.params.extra_vars %}
Extra vars
{{ run.params.extra_vars | join(", ") }}
{% endif %} {% endif %} -
ID
{{ run.id }}
-{% if run.results and run.results.hosts %} +{% if hosts %}
Host summary
@@ -41,7 +40,7 @@ - {% for host, c in run.results.hosts.items() %} + {% for host, c in hosts %} @@ -75,13 +74,25 @@ var es = new EventSource('/ansible/runs/{{ run.id }}/stream'); var pre = document.getElementById('live-output'); var status = document.getElementById('run-done-status'); + // Follow new lines only while the reader is at the bottom; scrolled + // up to read something, they keep their place. es.addEventListener('output', function(e) { + var atBottom = pre.scrollHeight - pre.scrollTop - pre.clientHeight < 24; pre.textContent += e.data + '\n'; - pre.scrollTop = pre.scrollHeight; + if (atBottom) pre.scrollTop = pre.scrollHeight; }); + // Finished: load the page again, so the status, duration and host + // summary are the run's final ones rather than "Running". The + // executor saves the final status before it says done; should that + // save have failed, ?finished stops a second reload going round. es.addEventListener('done', function(e) { es.close(); - status.textContent = 'Finished: ' + e.data; + if (window.location.search.indexOf('finished=1') === -1) { + status.textContent = 'Finished. Loading the summary…'; + window.location.replace(window.location.pathname + '?finished=1'); + } else { + status.textContent = 'Finished (' + e.data + '), but the summary could not be saved.'; + } }); })(); diff --git a/tests/core/test_ansible_runs_page.py b/tests/core/test_ansible_runs_page.py index 8ef2762..03d3be1 100644 --- a/tests/core/test_ansible_runs_page.py +++ b/tests/core/test_ansible_runs_page.py @@ -7,7 +7,8 @@ import pytest from quart import render_template, session from steward.ansible.inventory_gen import run_title -from steward.ansible.routes import run_duration, run_view +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 @@ -53,7 +54,8 @@ async def test_the_runs_list_names_each_run_with_one_badge(app): 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 '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 @@ -71,14 +73,22 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app): 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, triggered_label="system", - row=run_view(run, {"steward:target:t1": "target web-1"}, now)) + 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}) == [] diff --git a/tests/integration/test_alert_ansible_action.py b/tests/integration/test_alert_ansible_action.py index 0658414..756ccd9 100644 --- a/tests/integration/test_alert_ansible_action.py +++ b/tests/integration/test_alert_ansible_action.py @@ -111,6 +111,7 @@ def test_firing_triggers_ansible_action(app, tmp_path): run, event = asyncio.run(_go()) assert run is not None, "no AnsibleRun was created by the firing rule" assert run.triggered_by is None # system-triggered + assert run.origin == "alert" and run.origin_id == event.rule_id # the runs list names the rule (#5212) assert run.status == AnsibleRunStatus.success, run.output assert "FIRED-cpu_pct" in (run.output or "") # injected context var reached the play assert event.ansible_run_id == run.id # firing event linked to the run
skipped
{{ host }} {{ c.ok }}