# 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, sources as src_module from steward.auth.middleware import require_role 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() return await render_template("ansible/index.html", runs=runs) @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 _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: