Compare commits
2
Commits
3a772716ae
...
e780932673
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e780932673 | ||
|
|
a96112a0e1 |
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
+17
-10
@@ -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/<run_id>/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():
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -5,17 +5,18 @@
|
||||
{% set run = row.run %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/ansible/runs/{{ run.id }}" title="{{ run.inventory_scope }}">{{ row.title }}</a>
|
||||
<a href="/ansible/runs/{{ run.id }}">{{ row.title }}</a>
|
||||
{% if row.check %}<span class="badge badge-dim" title="Ansible check mode: nothing was changed">dry run</span>{% endif %}
|
||||
<div class="field-hint">{{ run.source_name }}</div>
|
||||
</td>
|
||||
<td>{{ status_badge(run.status) }}</td>
|
||||
<td style="font-size:0.85rem;">{% if row.origin %}{% if row.origin.url %}<a href="{{ row.origin.url }}">{{ row.origin.text }}</a>{% else %}{{ row.origin.text }}{% endif %}{% endif %}</td>
|
||||
<td title="{{ run.started_at.strftime('%Y-%m-%d %H:%M:%S UTC') }}">{{ run.started_at|ago }}</td>
|
||||
<td>{{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %}</td>
|
||||
</tr>
|
||||
{% 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 %}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<tr>
|
||||
<th>Run</th>
|
||||
<th>Status</th>
|
||||
<th>Started by</th>
|
||||
<th>Started</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
|
||||
@@ -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") %}
|
||||
<form method="post" action="/ansible/runs/{{ run.id }}/cancel" data-confirm="Cancel this run?">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm btn-danger">Cancel run</button>
|
||||
@@ -20,17 +20,16 @@
|
||||
<dt>Playbook</dt><dd>{{ run.playbook_path }}</dd>
|
||||
<dt>Scope</dt><dd title="{{ run.inventory_scope }}">{{ row.scope_label }}</dd>
|
||||
<dt>Source</dt><dd>{{ run.source_name }}</dd>
|
||||
<dt>Triggered by</dt><dd>{{ triggered_label }}</dd>
|
||||
<dt>Started by</dt><dd>{% if origin.url %}<a href="{{ origin.url }}">{{ origin.text }}</a>{% else %}{{ origin.text }}{% endif %}</dd>
|
||||
{% if run.params %}
|
||||
{% if run.params.limit %}<dt>Limit</dt><dd class="mono">{{ run.params.limit }}</dd>{% endif %}
|
||||
{% if run.params.tags %}<dt>Tags</dt><dd class="mono">{{ run.params.tags }}</dd>{% endif %}
|
||||
{% if run.params.extra_vars %}<dt>Extra vars</dt><dd class="mono">{{ run.params.extra_vars | join(", ") }}</dd>{% endif %}
|
||||
{% endif %}
|
||||
<dt>ID</dt><dd class="mono field-hint">{{ run.id }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if run.results and run.results.hosts %}
|
||||
{% if hosts %}
|
||||
<div class="card">
|
||||
<div class="section-title" style="margin-bottom:0.6rem;">Host summary</div>
|
||||
<table class="table">
|
||||
@@ -41,7 +40,7 @@
|
||||
<th style="text-align:center;">skipped</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for host, c in run.results.hosts.items() %}
|
||||
{% for host, c in hosts %}
|
||||
<tr>
|
||||
<td style="font-family:ui-monospace,monospace;">{{ host }}</td>
|
||||
<td style="text-align:center;">{{ c.ok }}</td>
|
||||
@@ -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.';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@ from steward.alerts.routes import _history_row
|
||||
from steward.core.alerts import MUTED, RELEASED
|
||||
from steward.models.alerts import AlertOperator
|
||||
|
||||
RULE = SimpleNamespace(operator=AlertOperator(">"), threshold=90.0)
|
||||
RULE = SimpleNamespace(metric_name="cpu_pct", operator=AlertOperator(">"), threshold=90.0)
|
||||
|
||||
|
||||
def _event(**kw):
|
||||
@@ -32,10 +32,14 @@ def test_notes_split_into_mute_release_and_the_rest(note, notes, muted_by, relea
|
||||
assert (row["notes"], row["muted_by"], row["released"]) == (notes, muted_by, released)
|
||||
|
||||
|
||||
def test_value_and_condition():
|
||||
assert _history_row(_event(), RULE)["value"] == "97.5"
|
||||
assert _history_row(_event(metric_value=None), RULE)["value"] == "no data"
|
||||
assert _history_row(_event(), RULE)["condition"] == "> 90"
|
||||
def test_value_and_condition_read_in_words():
|
||||
"""#5212: the metric's name and unit, not "cpu_pct = 97.5" and "> 90"."""
|
||||
assert _history_row(_event(), RULE)["value"] == "CPU 98%"
|
||||
assert _history_row(_event(metric_value=None), RULE)["value"] == "No CPU reading"
|
||||
assert _history_row(_event(), RULE)["condition"] == "CPU > 90%"
|
||||
down = SimpleNamespace(metric_name="is_up", operator=AlertOperator("<"), threshold=0.5)
|
||||
row = _history_row(_event(metric_value=0.0), down)
|
||||
assert (row["value"], row["condition"]) == ("Down", "is down")
|
||||
|
||||
|
||||
def test_only_attempted_deliveries_are_listed():
|
||||
|
||||
@@ -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("<main"):]
|
||||
assert '<a href="/ansible/runs/aaaa1111" title="steward:target:t1">update.yml on web-1</a>' in 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
|
||||
@@ -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 "<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)
|
||||
|
||||
@@ -90,3 +100,27 @@ async def test_run_started_is_on_the_palette(app):
|
||||
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}) == []
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user