feat(ansible): Playbooks as one list, and a page per playbook that runs it (#5331, #5330)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m20s
CI / publish (push) Skipped

- /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/<source>/<path>: 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=<run> 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 23:49:05 -04:00
co-authored by Claude Opus 5.5
parent a02f224f22
commit 72a3261582
17 changed files with 447 additions and 230 deletions
+128
View File
@@ -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("<main"):]
assert main.count('<tr class="row-link">') == 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</span>" in main and '<span class="chip">maintenance</span>' in main
assert '<span class="badge badge-ok">Succeeded</span>' 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 '<a href="/ansible/playbooks" aria-current="page">Playbooks</a>' 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("<main"):]
assert 'href="/ansible/playbooks"' in page # crumbs back to the list
verdict = main[main.index('<p class="page-verdict">'):]
verdict = verdict[:verdict.index("</p>")]
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 '<option value="steward:group:g1" selected>Group: Docker Swarm</option>' in page
assert 'name="var__prune_volumes"' in page and 'value="true"' in page
assert ">other=x</textarea>" in page and 'value="web*"' in page
assert '<details class="run-more" open>' in page and 'name="check" checked' in page
+3 -2
View File
@@ -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('<nav class="section-tabs" aria-label="Ansible">'):]
nav = nav[:nav.index("</nav>")]
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 '<a href="/ansible/" aria-current="page">Runs</a>' 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 '<span class="tone-crit">1 failed</span> · 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</a>' in page
main = page[page.index("<main"):]
assert 'style="' not in main[:main.index('id="run-output"')] # no hand-set colours
assert not hex_colours(main), hex_colours(main)
+12 -1
View File
@@ -60,7 +60,12 @@ def test_the_runs_tab_counts_and_filters(app):
out = {"page": await get(f"/ansible/?q=it{tag}"),
"failed": await get(f"/ansible/?q=it{tag}&status=failed", headers={"HX-Request": "true"}),
"sched": await get(f"/ansible/?q=it{tag}&origin=schedule", headers={"HX-Request": "true"}),
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"})}
"person": await get(f"/ansible/?q=it{tag}&origin=manual", headers={"HX-Request": "true"}),
# The Playbooks tab and a built-in playbook's page (#5331): their
# newest-run and schedule queries against Postgres.
"playbooks": await get("/ansible/playbooks"),
"playbook": await get("/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"),
"missing": await get("/ansible/playbooks/steward-builtin/nope.yml")}
async with app.db_sessionmaker() as s:
async with s.begin():
from sqlalchemy import delete
@@ -83,3 +88,9 @@ def test_the_runs_tab_counts_and_filters(app):
assert f"it{tag}/prune.yml" in sched and f"it{tag}/update.yml" not in sched
person = out["person"][1]
assert f"it{tag}/update.yml" in person and f"it{tag}/prune.yml" not in person
status, playbooks = out["playbooks"]
assert status == 200 and 'href="/ansible/playbooks/steward-builtin/maintenance/docker_prune.yml"' in playbooks
status, playbook = out["playbook"]
assert status == 200 and "The playbook" in playbook and 'id="run-form"' not in playbook # a viewer
assert out["missing"][0] == 404