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("