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
+95 -80
View File
@@ -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/<source_name>/<path:playbook_path>")
@ansible_bp.get("/playbooks/<source_name>/<path:playbook_path>")
@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=<run id> 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/<source_name>/<path:playbook_path>")
@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 = """\