From 72a3261582d3122ab4222d2ed2ce48a9854aab8d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 23:49:05 -0400 Subject: [PATCH] feat(ansible): Playbooks as one list, and a page per playbook that runs it (#5331, #5330) - /ansible/playbooks lists every playbook across sources: what it says it does, its category and destructive flag, how it last ran; the whole row opens the playbook. A source with none is one quiet line. - /ansible/playbooks//: the verdict (what it does, flagged destructive, last ran), the run form in place (dry run beside the button, options folded), the schedules that use it, its recent runs, the playbook itself folded. ?from= fills the form from that run: Run again on a run's page. - /ansible/browse, its view page and the /run-form fragment are gone, not redirected (rule 122); every link points at the new pages. Co-Authored-By: Claude Opus 5.5 --- steward/ansible/routes.py | 175 ++++++++++-------- steward/static/steward.css | 11 ++ steward/templates/ansible/_macros.html | 12 ++ steward/templates/ansible/_run_form.html | 75 ++++---- steward/templates/ansible/_runs_page.html | 5 +- steward/templates/ansible/_runs_table.html | 2 +- steward/templates/ansible/_tabs.html | 2 +- steward/templates/ansible/browse.html | 98 ---------- steward/templates/ansible/index.html | 2 +- steward/templates/ansible/playbook.html | 81 ++++++++ .../templates/ansible/playbook_editor.html | 4 +- steward/templates/ansible/playbooks.html | 57 ++++++ steward/templates/ansible/run_detail.html | 5 +- .../templates/settings/_ansible_sources.html | 2 +- tests/core/test_ansible_playbooks_page.py | 128 +++++++++++++ tests/core/test_ansible_runs_page.py | 5 +- tests/integration/test_ansible_runs_list.py | 13 +- 17 files changed, 447 insertions(+), 230 deletions(-) create mode 100644 steward/templates/ansible/_macros.html delete mode 100644 steward/templates/ansible/browse.html create mode 100644 steward/templates/ansible/playbook.html create mode 100644 steward/templates/ansible/playbooks.html create mode 100644 tests/core/test_ansible_playbooks_page.py diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index ff1f32b..3661d08 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -161,66 +161,113 @@ def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict: "recap": run_recap(run.results, check)} -@ansible_bp.get("/browse") -@require_role(UserRole.viewer) -async def browse(): - from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup +def _source(name: str) -> dict | None: + return next((s for s in _get_sources() if s["name"] == name), None) - all_sources = _get_sources() - source_data = [] - for source in all_sources: - playbooks = [] - for path in src_module.discover_playbooks(source["path"]): + +async def last_runs(db, keys) -> dict[tuple[str, str], AnsibleRun]: + """{(source, playbook): its newest run} for the given playbooks.""" + keys = list(keys) + if not keys: + return {} + newest = (select(AnsibleRun.source_name, AnsibleRun.playbook_path, + func.max(AnsibleRun.started_at).label("at")) + .group_by(AnsibleRun.source_name, AnsibleRun.playbook_path).subquery()) + rows = (await db.execute(select(AnsibleRun).join(newest, ( + (AnsibleRun.source_name == newest.c.source_name) + & (AnsibleRun.playbook_path == newest.c.playbook_path) + & (AnsibleRun.started_at == newest.c.at))))).scalars().all() + wanted = set(keys) + return {(r.source_name, r.playbook_path): r for r in rows + if (r.source_name, r.playbook_path) in wanted} + + +@ansible_bp.get("/playbooks") +@require_role(UserRole.viewer) +async def playbooks(): + """The Playbooks tab (#5331): every playbook across sources in one list, + each with what it says it does and how it last ran; a source with none + is one quiet line under it.""" + rows, empty = [], [] + sources = _get_sources() + for source in sources: + paths = src_module.discover_playbooks(source["path"]) + if not paths: + empty.append({**source, "editable": src_module.is_editable_source(source)}) + for path in paths: contents = src_module.read_playbook(source["path"], path) meta = (src_module.discover_playbook_meta(contents) if contents is not None - else {"description": "", "category": ""}) - playbooks.append({ - "path": path, - "category": meta["category"], - "description": meta["description"], - }) - inventories = src_module.discover_inventories(source["path"]) - source_data.append({ - "source": source, - "playbooks": playbooks, - "inventories": inventories, - "editable": src_module.is_editable_source(source), - }) - + else {"description": "", "category": "", "confirm": False}) + rows.append({"source": source["name"], "path": path, **meta}) async with current_app.db_sessionmaker() as db: - targets = (await db.execute( - select(AnsibleTarget).order_by(AnsibleTarget.name) - )).scalars().all() - groups = (await db.execute( - select(AnsibleGroup).order_by(AnsibleGroup.name) - )).scalars().all() - + last = await last_runs(db, [(r["source"], r["path"]) for r in rows]) + for r in rows: + r["last"] = last.get((r["source"], r["path"])) return await render_template( - "ansible/browse.html", - source_data=source_data, - targets=targets, - groups=groups, - ) + "ansible/playbooks.html", rows=rows, empty_sources=empty, sources=sources, + can_add=bool(_editable_sources()) and session.get("user_role") == "admin") -@ansible_bp.get("/browse//") +@ansible_bp.get("/playbooks//") @require_role(UserRole.viewer) -async def view_playbook(source_name: str, playbook_path: str): - all_sources = _get_sources() - source = next((s for s in all_sources if s["name"] == source_name), None) +async def playbook_page(source_name: str, playbook_path: str): + """One playbook's home (#5331): what it does, the form that runs it, the + schedules that use it and its recent runs, then the playbook itself. + ?from= fills the form from that run ("Run again").""" + from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget + + source = _source(source_name) if source is None: return "Source not found", 404 contents = src_module.read_playbook(source["path"], playbook_path) if contents is None: return "Playbook not found", 404 + meta = src_module.discover_playbook_meta(contents) + variables = src_module.discover_playbook_variables(contents) + now = datetime.now(timezone.utc) + async with current_app.db_sessionmaker() as db: + runs = (await db.execute(select(AnsibleRun).where( + AnsibleRun.source_name == source_name, AnsibleRun.playbook_path == playbook_path) + .order_by(AnsibleRun.started_at.desc()).limit(RECENT_RUNS))).scalars().all() + scheds = (await db.execute(select(AnsibleSchedule).where( + AnsibleSchedule.source_name == source_name, AnsibleSchedule.playbook_path == playbook_path) + .order_by(AnsibleSchedule.name))).scalars().all() + labels = await scope_labels(db, [r.inventory_scope for r in runs] + [s.inventory_scope for s in scheds]) + origins = await run_origins(db, runs) + targets = (await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all() + groups = (await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all() + again = None + if request.args.get("from"): + again = (await db.execute(select(AnsibleRun).where( + AnsibleRun.id == request.args["from"], AnsibleRun.source_name == source_name, + AnsibleRun.playbook_path == playbook_path))).scalar_one_or_none() return await render_template( - "ansible/browse.html", - source_data=[], - view_source=source_name, - view_path=playbook_path, - view_contents=contents, - view_editable=src_module.is_editable_source(source), - ) + "ansible/playbook.html", source=source, source_name=source_name, playbook_path=playbook_path, + contents=contents, description=meta["description"], category=meta["category"], + confirm=meta["confirm"], variables=variables, editable=src_module.is_editable_source(source), + runs=[{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs], + schedules=[{"s": s, "scope_label": labels.get(s.inventory_scope, s.inventory_scope), + "next": next_scheduled([s], now)} for s in scheds], + targets=targets, groups=groups, inventories=src_module.discover_inventories(source["path"]), + prefill=run_prefill(again, {v["name"] for v in variables})) + + +RECENT_RUNS = 10 + + +def run_prefill(run, declared: set[str]) -> dict: + """The run form's starting values: blank, or those of an earlier run + ("Run again"). Declared variables fill their own fields; any other extra + var goes back into the free-form box. Secret values were never stored.""" + if run is None: + return {"scope": "", "values": {}, "extra": "", "limit": "", "tags": "", "check": False} + params = run.params or {} + extra = params.get("extra_vars_map") or {} + return {"scope": run.inventory_scope, + "values": {k: v for k, v in extra.items() if k in declared}, + "extra": "\n".join(f"{k}={v}" for k, v in extra.items() if k not in declared), + "limit": params.get("limit") or "", "tags": params.get("tags") or "", + "check": bool(params.get("check"))} def _caller_role() -> UserRole: @@ -331,38 +378,6 @@ async def playbook_vars(): confirm=meta["confirm"], schedule=schedule) -@ansible_bp.get("/run-form//") -@require_role(UserRole.operator) -async def run_form(source_name: str, playbook_path: str): - """HTMX fragment: the run form for one playbook, with a field per declared - variable (vars/vars_prompt) discovered from the playbook itself.""" - from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup - - source = next((s for s in _get_sources() if s["name"] == source_name), None) - if source is None: - return "Source not found", 404 - contents = src_module.read_playbook(source["path"], playbook_path) - if contents is None: - return "Playbook not found", 404 - variables = src_module.discover_playbook_variables(contents) - meta = src_module.discover_playbook_meta(contents) - inventories = src_module.discover_inventories(source["path"]) - - async with current_app.db_sessionmaker() as db: - targets = (await db.execute( - select(AnsibleTarget).order_by(AnsibleTarget.name))).scalars().all() - groups = (await db.execute( - select(AnsibleGroup).order_by(AnsibleGroup.name))).scalars().all() - - return await render_template( - "ansible/_run_form.html", - source_name=source_name, playbook_path=playbook_path, - variables=variables, description=meta["description"], category=meta["category"], - confirm=meta["confirm"], targets=targets, groups=groups, - inventories=inventories, - ) - - @ansible_bp.post("/runs") @require_role(UserRole.operator) async def create_run(): @@ -803,7 +818,7 @@ async def save_playbook(): "ansible_playbook", f"{source_name}:{playbook_path}", {"source": source_name, "playbook": playbook_path, "bytes": len(content)}) return redirect(url_for( - "ansible.view_playbook", source_name=source_name, playbook_path=playbook_path)) + "ansible.playbook_page", source_name=source_name, playbook_path=playbook_path)) @ansible_bp.post("/playbooks/delete") @@ -819,7 +834,7 @@ async def delete_playbook(): return err, 400 await _audit("ansible.playbook.deleted", "ansible_playbook", f"{source['name']}:{playbook_path}", {"source": source["name"], "playbook": playbook_path}) - return redirect(url_for("ansible.browse")) + return redirect(url_for("ansible.playbooks")) _NEW_PLAYBOOK_TEMPLATE = """\ diff --git a/steward/static/steward.css b/steward/static/steward.css index e267940..e2fa467 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -375,6 +375,17 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat color: var(--text-muted); max-height: 600px; overflow-y: auto; white-space: pre-wrap; } .run-failures { padding: 0.75rem; border-radius: var(--fs-radius-sm); color: var(--red); max-height: 240px; } .run-log-status { margin: 0; padding: 0.5rem 1rem; } +/* A playbook's run form (ansible/_run_form.html): options folded away, the + dry-run choice beside the button. */ +.run-more { margin: 0 0 0.75rem; } +.run-more > summary { cursor: pointer; font-size: 0.85rem; color: var(--text-muted); margin-bottom: 0.6rem; } +.field-check { display: inline-flex; align-items: center; gap: 0.5rem; font-size: 0.85rem; font-weight: normal; color: var(--text-muted); } +.form-actions .field-check { margin: 0; } +.playbook-cell { max-width: 30rem; } +/* The playbook itself, folded under its page. */ +.playbook-source > summary { cursor: pointer; margin: 0; } +.playbook-source[open] > summary { margin-bottom: 0.75rem; } +.card-flush-note { margin: 0; padding: 0.75rem 1rem; } /* Monospaced data: ids, limits, tags, paths. */ .mono { font-family: var(--fs-font-mono); font-size: 0.85em; } /* The host page's health header (_host_vitals.html). */ diff --git a/steward/templates/ansible/_macros.html b/steward/templates/ansible/_macros.html new file mode 100644 index 0000000..8f5a77a --- /dev/null +++ b/steward/templates/ansible/_macros.html @@ -0,0 +1,12 @@ +{# ansible/_macros.html — small phrasings the Ansible pages share. #} + +{# A schedule's interval in words: "daily", "hourly", "every 6h". #} +{% macro interval_words(secs) -%} +{%- set named = {300: "every 5 min", 3600: "hourly", 21600: "every 6h", 43200: "every 12h", 86400: "daily", 604800: "weekly"} -%} +{{ named.get(secs) or ("every " ~ (secs|duration)) }} +{%- endmacro %} + +{# A run's or host's recap parts (ansible.recap) as one line. #} +{% macro recap_words(parts) -%} +{% for text, tone in parts %}{% if not loop.first %} · {% endif %}{% if tone %}{{ text }}{% else %}{{ text }}{% endif %}{% endfor %} +{%- endmacro %} diff --git a/steward/templates/ansible/_run_form.html b/steward/templates/ansible/_run_form.html index d706683..c7e953a 100644 --- a/steward/templates/ansible/_run_form.html +++ b/steward/templates/ansible/_run_form.html @@ -1,67 +1,62 @@ -{# Run form for a single playbook — loaded via HTMX from /ansible/run-form/… #} -
-
-

Run {{ playbook_path }}

- Cancel -
-
+{# ansible/_run_form.html — the form that runs one playbook, on its page + (#5331): where to run it, its declared variables, more options folded + away, and the dry-run choice beside the button. `prefill` holds an earlier + run's choices ("Run again"); the run starts in place, under the form. #} +
- - {% if targets %} - + {% if groups %} - {% for grp in groups %}{% endfor %} + {% for grp in groups %}{% set v = "steward:group:" ~ grp.id %}{% endfor %} {% endif %} - {% for tgt in targets %}{% endfor %} + {% for tgt in targets %}{% set v = "steward:target:" ~ tgt.id %}{% endfor %} {% endif %} {% if inventories %} - - {% for inv in inventories %}{% endfor %} + + {% for inv in inventories %}{% set v = "repo:" ~ source_name ~ ":" ~ inv %}{% endfor %} {% endif %} {% if not targets and not inventories %} - + {% endif %}
- {% include "ansible/_playbook_vars.html" %} + {% with description="", category="", values=prefill["values"] %}{% include "ansible/_playbook_vars.html" %}{% endwith %} -
- Advanced -
- - -
+
+ More options
- - + +
-
- - -
-
- +
+
+ + +
+
+ + +
-
- +
+ +
- -
-
+ +
diff --git a/steward/templates/ansible/_runs_page.html b/steward/templates/ansible/_runs_page.html index 93460af..a3f3194 100644 --- a/steward/templates/ansible/_runs_page.html +++ b/steward/templates/ansible/_runs_page.html @@ -1,7 +1,8 @@ {# ansible/_runs_page.html — one page of runs, then the sentinel that loads the next page as the reader nears it (core.paging, pref 172). The whole row opens the run (.row-link); its title is the one real link. Under the status, - what the run did across its hosts (ansible.recap.run_recap). #} + what the run did across its hosts (ansible.recap.run_recap). `compact` (a + playbook's recent runs) ends without the list's end marker. #} {% from "_macros.html" import status_badge, scroll_more, scroll_end %} {% for row in runs %} {% set run = row.run %} @@ -23,6 +24,6 @@ {% endfor %} {% if more_url %} {{ scroll_more(more_url, cols=5) }} -{% else %} +{% elif not compact %} {{ scroll_end(cols=5) }} {% endif %} diff --git a/steward/templates/ansible/_runs_table.html b/steward/templates/ansible/_runs_table.html index 51b9f1b..fde2396 100644 --- a/steward/templates/ansible/_runs_table.html +++ b/steward/templates/ansible/_runs_table.html @@ -4,7 +4,7 @@ {% if not runs %}
{%- if filtered %}{{ empty_state("No runs match these filters.", "Show every run", "/ansible/") }} - {%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/browse") }}{% endif -%} + {%- else %}{{ empty_state("No runs yet.", "Run a playbook", "/ansible/playbooks") }}{% endif -%}
{% else %}
diff --git a/steward/templates/ansible/_tabs.html b/steward/templates/ansible/_tabs.html index eb35704..601b15c 100644 --- a/steward/templates/ansible/_tabs.html +++ b/steward/templates/ansible/_tabs.html @@ -2,7 +2,7 @@ on every Ansible page; `active` names the page or the one it sits under. #} {% from "_macros.html" import section_tabs %} {% macro ansible_tabs(active) -%} -{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/browse"), +{{ section_tabs([("runs", "Runs", "/ansible/"), ("playbooks", "Playbooks", "/ansible/playbooks"), ("schedules", "Schedules", "/ansible/schedules"), ("inventory", "Inventory", "/ansible/inventory/targets")], active, "Ansible") }} {%- endmacro %} diff --git a/steward/templates/ansible/browse.html b/steward/templates/ansible/browse.html deleted file mode 100644 index 916a59e..0000000 --- a/steward/templates/ansible/browse.html +++ /dev/null @@ -1,98 +0,0 @@ -{% extends "base.html" %} -{% from "ansible/_tabs.html" import ansible_tabs %} -{% from "_macros.html" import crumbs %} -{% block title %}Browse Playbooks — Steward{% endblock %} -{% block breadcrumb %} -{%- if view_contents is defined -%} -{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (view_path, "")]) }} -{%- else -%} -{{ crumbs([("Ansible", "/ansible/"), ("Browse", "")]) }} -{%- endif -%} -{% endblock %} -{% block content %} -{{ ansible_tabs("playbooks") }} -
-

Browse Playbooks

-
- {% if session.user_role == 'admin' %} - New playbook - {% endif %} -
-
- -{% if view_contents is defined %} -
-
-

{{ view_path }}

-
- {% if view_editable and session.user_role == 'admin' %} - Edit -
{{ csrf_field() }} - - - -
- {% endif %} -
-
-
{{ view_contents }}
-
-{% endif %} - -{% if not source_data %} -{% if view_contents is not defined %} -
-

No Ansible sources configured. Add sources in Settings → Ansible.

-
-{% endif %} -{% else %} -{% for sd in source_data %} -
-
-

{{ sd.source.name }}

- {{ sd.source.type }} - {{ sd.source.path }} -
- - {% if not sd.playbooks %} -

No playbooks found at this path.

- {% else %} - - - - - - - - - {% for pb in sd.playbooks %} - - - - - {% endfor %} - -
Playbook
-
- {% if pb.category %}{{ pb.category }}{% endif %} - {{ pb.path }} -
- {% if pb.description %}
{{ pb.description }}
{% endif %} -
- View - {% if sd.editable and session.user_role == 'admin' %} - Edit - {% endif %} - Run -
- - {# Run form (loaded on demand via HTMX from /ansible/run-form//) #} -
- {% endif %} -
-{% endfor %} -{% endif %} -{% endblock %} diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index 9177840..db45b61 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -8,7 +8,7 @@ {% block content %} {{ ansible_tabs("runs") }} {% call page_header("Ansible Runs") %} - Run a playbook + Run a playbook {% endcall %} {% set v = verdict %} diff --git a/steward/templates/ansible/playbook.html b/steward/templates/ansible/playbook.html new file mode 100644 index 0000000..b27f016 --- /dev/null +++ b/steward/templates/ansible/playbook.html @@ -0,0 +1,81 @@ +{# ansible/playbook.html — one playbook's page (#5331): what it says it does + and how it last ran, the form that runs it (operators), the schedules that + use it, its recent runs, then the playbook itself, folded. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header, status_badge %} +{% from "ansible/_tabs.html" import ansible_tabs %} +{% from "ansible/_macros.html" import interval_words %} +{% block title %}{{ playbook_path }} — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (playbook_path, "")]) }}{% endblock %} +{% block content %} +{% set can_run = session.user_role in ('operator', 'admin') %} +{{ ansible_tabs("playbooks") }} +{% call page_header(playbook_path) %} + {% if editable and session.user_role == 'admin' %} + Edit +
{{ csrf_field() }} + + + +
+ {% endif %} +{% endcall %} + +

+ {%- if category %}{{ category }} {% endif -%} + {{ description or "It says nothing about what it does." }} + {%- if confirm %} · flagged destructive by its author: a real run asks you to confirm{% endif %} + · from {{ source_name }} + {%- if runs %} · last ran {{ runs[0].run.started_at|ago }} {{ status_badge(runs[0].run.status) }}{% else %} · never run{% endif -%} +

+ +{% if can_run %} +
+

Run it

+ {% include "ansible/_run_form.html" %} +
+{% endif %} + +
+

Schedules + {%- if schedules %} {{ schedules|length }}{% endif %} + {%- if can_run %} Schedule it →{% endif %}

+ {% if schedules %} + + + {% for row in schedules %}{% set s = row.s %} + + + + + + {% endfor %} + +
{{ s.name }}on {{ row.scope_label }} · {{ interval_words(s.interval_seconds) }}{% if not s.enabled %}paused + {%- elif row.next and row.next.at %}next {{ row.next.at|ago }}{% else %}due now{% endif %}
+ {% else %} +

No schedule runs it.

+ {% endif %} +
+ +
+

Recent runs + {%- if runs %} All its runs →{% endif %}

+ {% if runs %} + + + + {% with compact=true, more_url=none %}{% include "ansible/_runs_page.html" %}{% endwith %} + +
RunResultStarted byStartedTook
+ {% else %} +

It has not been run yet.

+ {% endif %} +
+ +
+ The playbook +
{{ contents }}
+
+{% endblock %} diff --git a/steward/templates/ansible/playbook_editor.html b/steward/templates/ansible/playbook_editor.html index 505fae5..9ed8a2c 100644 --- a/steward/templates/ansible/playbook_editor.html +++ b/steward/templates/ansible/playbook_editor.html @@ -2,7 +2,7 @@ {% from "ansible/_tabs.html" import ansible_tabs %} {% from "_macros.html" import crumbs %} {% block title %}{{ "Edit" if editing else "New" }} Playbook — Steward{% endblock %} -{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Browse", "/ansible/browse"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "/ansible/playbooks"), (("Edit " ~ playbook_path) if editing else "New playbook", "")]) }}{% endblock %} {% block content %} {{ ansible_tabs("playbooks") }}
@@ -50,7 +50,7 @@
- Cancel + Cancel
diff --git a/steward/templates/ansible/playbooks.html b/steward/templates/ansible/playbooks.html new file mode 100644 index 0000000..04bad08 --- /dev/null +++ b/steward/templates/ansible/playbooks.html @@ -0,0 +1,57 @@ +{# ansible/playbooks.html — the Playbooks tab (#5331): every playbook across + its sources in one list, what each says it does and how it last ran; the + whole row opens the playbook's page, where it is run. A source with no + playbooks is one quiet line under the list. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header, status_badge, empty_state %} +{% from "ansible/_tabs.html" import ansible_tabs %} +{% block title %}Playbooks — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Ansible", "/ansible/"), ("Playbooks", "")]) }}{% endblock %} +{% block content %} +{{ ansible_tabs("playbooks") }} +{% call page_header("Playbooks") %} + {% if can_add %}New playbook{% endif %} +{% endcall %} + +{% if not sources %} +
{{ empty_state("No playbook sources are set up.", "Add one in Settings", "/settings/ansible/") }}
+{% else %} +{% set n_src = rows|map(attribute="source")|unique|list|length %} +{% set n_bad = rows|selectattr("confirm")|list|length %} +

+ {{- rows|length }} playbook{{ 's' if rows|length != 1 }}{% if n_src %} from {{ n_src }} source{{ 's' if n_src != 1 }}{% endif %} + {%- if n_bad %} · {{ n_bad }} flagged destructive by {{ 'their authors' if n_bad > 1 else 'its author' }}{% endif -%} +

+ +{% if rows %} +
+ + + + {% for r in rows %} + + + + + + {% endfor %} + +
PlaybookSourceLast run
+
+{% endif %} +{% for s in empty_sources %} +

{{ s.name }} ({{ s.path }}) has no playbooks yet. + {%- if can_add and s.editable %} Write one{% endif %}

+{% endfor %} +{% endif %} +{% endblock %} diff --git a/steward/templates/ansible/run_detail.html b/steward/templates/ansible/run_detail.html index dda79a9..ba1c9e7 100644 --- a/steward/templates/ansible/run_detail.html +++ b/steward/templates/ansible/run_detail.html @@ -17,6 +17,9 @@ {% endif %} + {% if session.user_role in ("operator", "admin") %} + Run again + {% endif %} Download log {% endcall %} @@ -31,7 +34,7 @@
-
Playbook
{{ run.playbook_path }} from {{ run.source_name }}
+
Playbook
{{ run.playbook_path }} from {{ run.source_name }}
On
{{ row.scope_label }}
{% if run.params %} {% if run.params.limit %}
Limit
{{ run.params.limit }}
{% endif %} diff --git a/steward/templates/settings/_ansible_sources.html b/steward/templates/settings/_ansible_sources.html index 1a2356a..53ed241 100644 --- a/steward/templates/settings/_ansible_sources.html +++ b/steward/templates/settings/_ansible_sources.html @@ -34,7 +34,7 @@
- Browse + Playbooks {% if src.type == 'git' %} diff --git a/tests/core/test_ansible_playbooks_page.py b/tests/core/test_ansible_playbooks_page.py new file mode 100644 index 0000000..5eb8c1e --- /dev/null +++ b/tests/core/test_ansible_playbooks_page.py @@ -0,0 +1,128 @@ +"""The Playbooks tab and a playbook's own page (#5331): one list across +sources whose row opens the playbook; the page carries the form that runs +it (filled from an earlier run for "Run again"), the schedules that use it +and its recent runs.""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from quart import render_template, session + +from steward.ansible.routes import run_prefill, run_view +from steward.models.ansible import AnsibleRunStatus +from tests.test_style_tokens import hex_colours + + +def _run(rid, status, mins_ago, params=None): + now = datetime.now(timezone.utc) + return SimpleNamespace(id=rid, playbook_path="maintenance/prune.yml", source_name="steward-builtin", + inventory_scope="steward:all", status=status, + started_at=now - timedelta(minutes=mins_ago), + finished_at=now - timedelta(minutes=mins_ago - 1), params=params, + results={"hosts": {"a": {"ok": 2, "changed": 1}}}, output="") + + +def test_playbook_urls_reach_their_own_routes(app): + """The playbook page's path converter must not swallow the editor's URLs.""" + urls = app.url_map.bind("localhost") + assert urls.match("/ansible/playbooks/steward-builtin/maintenance/prune.yml")[0] == "ansible.playbook_page" + assert urls.match("/ansible/playbooks/edit/steward-local/a/b.yml")[0] == "ansible.edit_playbook" + assert urls.match("/ansible/playbooks/new")[0] == "ansible.new_playbook" + assert urls.match("/ansible/playbooks")[0] == "ansible.playbooks" + import pytest + from werkzeug.exceptions import NotFound + with pytest.raises(NotFound): + urls.match("/ansible/browse") # moved, not redirected (rule 122) + + +async def test_the_playbooks_list_is_one_table_across_sources(app): + last = _run("r1", AnsibleRunStatus.success, 60) + rows = [{"source": "steward-builtin", "path": "maintenance/prune.yml", "description": "Reclaim disk", + "category": "maintenance", "confirm": True, "last": last}, + {"source": "steward-builtin", "path": "host_agent/update.yml", "description": "Update the agent", + "category": "", "confirm": False, "last": None}] + empty = [{"name": "steward-local", "path": "/data/ansible/playbooks", "editable": True}] + async with app.test_request_context("/ansible/playbooks"): + session["user_role"] = "admin" + page = await render_template("ansible/playbooks.html", rows=rows, empty_sources=empty, + sources=[{"name": "steward-builtin"}, {"name": "steward-local"}], + can_add=True) + main = page[page.index("') == 2 + assert 'class="row-target mono" href="/ansible/playbooks/steward-builtin/maintenance/prune.yml"' in main + assert "2 playbooks from 1 source" in main and "1 flagged destructive by its author" in main + assert ">destructive" in main and 'maintenance' in main + assert 'Succeeded' in main and "never run" in main + assert "steward-local" in main and "has no playbooks yet." in main and 'href="/ansible/playbooks/new"' in main + assert 'Playbooks' in main + assert "/run-form/" not in main # the form lives on the playbook's page + assert not hex_colours(main), hex_colours(main) + + +def _page_ctx(role_runs=True, prefill=None, schedules=None): + now = datetime.now(timezone.utc) + run = _run("r9", AnsibleRunStatus.failed, 30, params={"check": True}) + return { + "source": {"name": "steward-builtin"}, "source_name": "steward-builtin", + "playbook_path": "maintenance/prune.yml", "contents": "- hosts: all\n", + "description": "Reclaim disk", "category": "maintenance", "confirm": True, + "variables": [{"name": "prune_volumes", "prompt": "", "default": "false", "required": False, "secret": False}], + "editable": False, + "runs": [{**run_view(run, {"steward:all": "all inventory targets"}, now), + "origin": {"text": "alice", "url": None, "kind": "user"}}] if role_runs else [], + "schedules": schedules or [], "targets": [SimpleNamespace(id="t1", name="web-1")], + "groups": [SimpleNamespace(id="g1", name="Docker Swarm")], "inventories": [], + "prefill": prefill or run_prefill(None, set()), + } + + +async def test_a_playbook_page_runs_it_and_shows_its_history(app): + now = datetime.now(timezone.utc) + sched = SimpleNamespace(id="s1", name="Nightly prune", enabled=True, interval_seconds=86400, + last_run_at=now - timedelta(hours=22), inventory_scope="steward:group:g1") + async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"): + session["user_role"] = "operator" + page = await render_template("ansible/playbook.html", **_page_ctx(schedules=[ + {"s": sched, "scope_label": "group Docker Swarm", + "next": {"name": "Nightly prune", "at": now + timedelta(hours=2)}}])) + main = page[page.index("'):] + verdict = verdict[:verdict.index("

")] + assert "Reclaim disk" in verdict and "flagged destructive" in verdict and "last ran" in verdict + assert 'id="run-form"' in main and 'hx-post="/ansible/runs"' in main + assert 'name="confirmed"' in main # the destructive flag's gate + assert 'name="var__prune_volumes"' in main and 'name="check"' in main + assert "Nightly prune" in main and "on group Docker Swarm · daily" in main + assert 'href="/ansible/schedules?edit=s1#form"' in main + assert 'href="/ansible/runs/r9"' in main and "End of the list." not in main + assert 'href="/ansible/?q=maintenance/prune.yml"' in main + assert "/ansible/playbooks/edit/" not in main # a built-in playbook is read-only + assert not hex_colours(main), hex_colours(main) + + +async def test_a_viewer_reads_the_playbook_but_gets_no_form(app): + async with app.test_request_context("/ansible/playbooks/steward-builtin/maintenance/prune.yml"): + session["user_role"] = "viewer" + page = await render_template("ansible/playbook.html", **_page_ctx(role_runs=False)) + assert 'id="run-form"' not in page and "never run" in page and "No schedule runs it." in page + + +def test_run_again_fills_the_form_from_the_earlier_run(): + run = SimpleNamespace(inventory_scope="steward:group:g1", params={ + "extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "tags": "a", "check": True}) + assert run_prefill(run, {"prune_volumes"}) == { + "scope": "steward:group:g1", "values": {"prune_volumes": "true"}, "extra": "other=x", + "limit": "web*", "tags": "a", "check": True} + assert run_prefill(None, set())["scope"] == "" + + +async def test_the_form_comes_back_as_the_earlier_run_left_it(app): + prefill = run_prefill(SimpleNamespace(inventory_scope="steward:group:g1", params={ + "extra_vars_map": {"prune_volumes": "true", "other": "x"}, "limit": "web*", "check": True}), {"prune_volumes"}) + async with app.test_request_context("/ansible/playbooks/x"): + session["user_role"] = "operator" + page = await render_template("ansible/playbook.html", **_page_ctx(prefill=prefill)) + assert '' in page + assert 'name="var__prune_volumes"' in page and 'value="true"' in page + assert ">other=x" in page and 'value="web*"' in page + assert '
' in page and 'name="check" checked' in page diff --git a/tests/core/test_ansible_runs_page.py b/tests/core/test_ansible_runs_page.py index c7338f2..2c16600 100644 --- a/tests/core/test_ansible_runs_page.py +++ b/tests/core/test_ansible_runs_page.py @@ -92,7 +92,7 @@ async def test_ansible_pages_share_one_strip_of_tabs(app): page = await render_template("ansible/index.html", **_ctx([])) nav = page[page.index('")] - for href in ("/ansible/", "/ansible/browse", "/ansible/schedules", "/ansible/inventory/targets"): + for href in ("/ansible/", "/ansible/playbooks", "/ansible/schedules", "/ansible/inventory/targets"): assert f'href="{href}"' in nav, href assert nav.count('aria-current="page"') == 1 and 'Runs' in nav assert "No runs yet." in page and "No runs in the last 30 days" in page @@ -180,7 +180,8 @@ async def test_run_detail_titles_the_run_and_keeps_the_trail(app): # Each host's result in words, the failed host first. assert page.index(">zulu<") < page.index(">alpha<") assert '1 failed · 1 ok' in page and ">3 ok<" in page - assert 'href="/ansible/browse/builtin/update.yml"' in page # the playbook it ran + assert 'href="/ansible/playbooks/builtin/update.yml"' in page # the playbook it ran + assert 'href="/ansible/playbooks/builtin/update.yml?from=dddd4444#run-form">Run again' in page main = page[page.index("