# steward/ansible/routes.py from __future__ import annotations from datetime import datetime, timedelta, timezone from quart import ( Blueprint, Response, current_app, redirect, render_template, request, session, url_for, ) from sqlalchemy import func, 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.ansible.recap import host_recap, run_recap 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 UserRole INTERVAL_PRESETS = [ (300, "Every 5 minutes"), (3600, "Hourly"), (21600, "Every 6 hours"), (43200, "Every 12 hours"), (86400, "Daily"), (604800, "Weekly"), ] ansible_bp = Blueprint("ansible", __name__, url_prefix="/ansible") def _get_sources() -> list[dict]: ansible_cfg = current_app.config.get("ANSIBLE", {}) return src_module.get_sources(ansible_cfg) # The runs list's filters (#5329): what each status choice and origin choice # covers. A run started on the web pages has no origin and a user. RUN_STATUSES = { "failed": ("Failed", (AnsibleRunStatus.failed, AnsibleRunStatus.interrupted)), "success": ("Succeeded", (AnsibleRunStatus.success,)), "running": ("Running", (AnsibleRunStatus.running, AnsibleRunStatus.queued)), "cancelled": ("Cancelled", (AnsibleRunStatus.cancelled,)), } RUN_ORIGINS = {"schedule": "A schedule", "alert": "An alert rule", "mcp": "An AI agent", "manual": "A person"} VERDICT_DAYS = 30 def run_filters(args) -> dict: """The runs list's filters from the query string, unknown values dropped.""" status = args.get("status", "") origin = args.get("origin", "") return {"q": (args.get("q") or "").strip()[:200], "status": status if status in RUN_STATUSES else "", "origin": origin if origin in RUN_ORIGINS else ""} def filter_runs(stmt, f: dict): """`stmt` narrowed to the runs the filters choose.""" if f["q"]: stmt = stmt.where(func.lower(AnsibleRun.playbook_path).contains(f["q"].lower(), autoescape=True)) if f["status"]: stmt = stmt.where(AnsibleRun.status.in_(RUN_STATUSES[f["status"]][1])) if f["origin"] == "manual": stmt = stmt.where(AnsibleRun.origin.is_(None), AnsibleRun.triggered_by.is_not(None)) elif f["origin"]: stmt = stmt.where(AnsibleRun.origin == f["origin"]) return stmt def next_scheduled(schedules, now: datetime) -> dict | None: """The enabled schedule due soonest: {"id", "name", "at"}, `at` None when it is due now (it has never run, or its turn has passed).""" best = None for s in schedules: if not s.enabled: continue at = s.last_run_at + timedelta(seconds=s.interval_seconds) if s.last_run_at else None if at is not None and at <= now: at = None key = at or now if best is None or key < best[0]: best = (key, {"id": s.id, "name": s.name, "at": at}) return best[1] if best else None async def runs_verdict(db, now: datetime) -> dict: """What the Runs page opens on: runs in the last VERDICT_DAYS days, how many failed and the newest of those, how many are going now, and the schedule due next.""" since = now - timedelta(days=VERDICT_DAYS) bad = RUN_STATUSES["failed"][1] going = RUN_STATUSES["running"][1] total, failed, running = (await db.execute(select( func.count(), func.count().filter(AnsibleRun.status.in_(bad)), func.count().filter(AnsibleRun.status.in_(going)), ).select_from(AnsibleRun).where(AnsibleRun.started_at >= since))).one() latest_failed = None if failed: run = (await db.execute(select(AnsibleRun).where( AnsibleRun.started_at >= since, AnsibleRun.status.in_(bad)) .order_by(AnsibleRun.started_at.desc()).limit(1))).scalar_one() labels = await scope_labels(db, [run.inventory_scope]) latest_failed = run_view(run, labels, now) schedules = (await db.execute(select(AnsibleSchedule))).scalars().all() return {"days": VERDICT_DAYS, "total": total, "failed": failed, "running": running, "latest_failed": latest_failed, "next": next_scheduled(schedules, now)} @ansible_bp.get("/") @require_role(UserRole.viewer) async def index(): """Every run, newest first, a page at a time as the reader scrolls, under a verdict line and a filter bar (#5329). ?before= is the next page's rows alone (core.paging); an HTMX request from the filter bar is the table alone.""" from urllib.parse import urlencode f = run_filters(request.args) before = request.args.get("before") now = datetime.now(timezone.utc) async with current_app.db_sessionmaker() as db: stmt = keyset(filter_runs(select(AnsibleRun), f), AnsibleRun.started_at, AnsibleRun.id, before) 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) verdict = None if before or request.headers.get("HX-Request") else await runs_verdict(db, now) kept = {k: v for k, v in f.items() if v} ctx = {"runs": [{**run_view(r, labels, now), "origin": origins[r.id]} for r in runs], "more_url": "/ansible/?" + urlencode({**kept, "before": after}) if after else None, "f": f, "filtered": bool(kept), "statuses": RUN_STATUSES, "origins": RUN_ORIGINS, "verdict": verdict} if before: return await render_template("ansible/_runs_page.html", **ctx) if request.headers.get("HX-Request"): resp = await current_app.make_response(await render_template("ansible/_runs_table.html", **ctx)) else: resp = await current_app.make_response(await render_template("ansible/index.html", **ctx)) resp.vary.add("HX-Request") return resp def run_duration(run: AnsibleRun, now: datetime) -> float | None: """Seconds a run took, or has taken so far while it is still going.""" if run.started_at is None: return None return ((run.finished_at or now) - run.started_at).total_seconds() def run_view(run: AnsibleRun, labels: dict[str, str], now: datetime) -> dict: """A run as the pages name it: its title, scope label, duration and what it did across its hosts in words (#5329).""" label = labels.get(run.inventory_scope, run.inventory_scope) check = bool((run.params or {}).get("check")) return {"run": run, "title": run_title(run.playbook_path, label), "scope_label": label, "duration_s": run_duration(run, now), "check": check, "recap": run_recap(run.results, check)} def _source(name: str) -> dict | None: return next((s for s in _get_sources() if s["name"] == name), None) 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": "", "confirm": False}) rows.append({"source": source["name"], "path": path, **meta}) async with current_app.db_sessionmaker() as db: 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/playbooks.html", rows=rows, empty_sources=empty, sources=sources, can_add=bool(_editable_sources()) and session.get("user_role") == "admin") @ansible_bp.get("/playbooks//") @require_role(UserRole.viewer) 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/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: return UserRole(session.get("user_role", "viewer")) def _run_vars(params: dict | None, secret_vars: dict | None = None) -> dict: return {**((params or {}).get("extra_vars_map") or {}), **(secret_vars or {})} def check_run_form(form, source: dict | None, playbook_path: str, params: dict | None, secret_vars: dict | None) -> str | None: """The server-side gates a web run must pass (the form's own checks are only a convenience): who may set these variables, and the playbook's steward:confirm flag, which a real (non-check) run must have ticked.""" err = guards.check_vars_for_role(_run_vars(params, secret_vars), _caller_role()) if err: return err content = src_module.read_playbook(source["path"], playbook_path) if source else None meta = src_module.discover_playbook_meta(content or "") if meta["confirm"] and not (params or {}).get("check") and "confirmed" not in form: return (f"{playbook_path} is flagged destructive by its author (steward:confirm). " "Tick the confirmation to run it, or run it in check mode.") return None def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]: """Build executor params + the unpersisted secret-vars dict from form fields. Returns (params, secret_vars, error). Discovered variable fields are named ``var__``; a sibling hidden ``secret__`` marks the sensitive ones, which go into secret_vars (never persisted) instead of params. A free-form ``extra_vars`` textarea (key=value per line) is also folded in. All run-time values flow through a JSON extra-vars file downstream, so values may contain spaces/quotes safely. """ extra_vars_map: dict[str, str] = {} secret_vars: dict[str, str] = {} for key in set(form.keys()): if not key.startswith("var__"): continue name = key[len("var__"):] val = (form.get(key) or "").strip() if not val: continue # untouched → fall through to inventory/play default if f"secret__{name}" in form: secret_vars[name] = val else: extra_vars_map[name] = val for line in (form.get("extra_vars", "") or "").splitlines(): line = line.strip() if not line: continue if "=" not in line: return None, None, f"Invalid extra var (expected key=value): {line!r}" k, v = line.split("=", 1) extra_vars_map[k.strip()] = v params: dict = {} if extra_vars_map: params["extra_vars_map"] = extra_vars_map limit = (form.get("limit", "") or "").strip() if limit: params["limit"] = limit tags = (form.get("tags", "") or "").strip() if tags: params["tags"] = tags if "check" in form: params["check"] = True return (params or None), (secret_vars or None), None @ansible_bp.get("/playbook-options") @require_role(UserRole.operator) async def playbook_options(): """HTMX fragment: