# 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 select from steward.ansible import executor, guards, sources as src_module from steward.ansible.inventory_gen import run_title, scope_labels from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.ansible_schedule import AnsibleSchedule from steward.models.users import User, 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) @ansible_bp.get("/") @require_role(UserRole.viewer) async def index(): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50) ) runs = result.scalars().all() labels = await scope_labels(db, [r.inventory_scope for r in runs]) now = datetime.now(timezone.utc) return await render_template("ansible/index.html", runs=[run_view(r, labels, now) for r in runs]) 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 and duration.""" label = labels.get(run.inventory_scope, run.inventory_scope) return {"run": run, "title": run_title(run.playbook_path, label), "scope_label": label, "duration_s": run_duration(run, now), "check": bool((run.params or {}).get("check"))} @ansible_bp.get("/browse") @require_role(UserRole.viewer) async def browse(): from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup all_sources = _get_sources() source_data = [] for source in all_sources: playbooks = [] for path in src_module.discover_playbooks(source["path"]): 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), }) 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/browse.html", source_data=source_data, targets=targets, groups=groups, ) @ansible_bp.get("/browse//") @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) 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 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), ) 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: