"""CRUD routes for Ansible inventory: groups and targets.""" from __future__ import annotations import uuid import yaml from quart import Blueprint, current_app, redirect, render_template, request, session, url_for from sqlalchemy import select from sqlalchemy.orm import selectinload from steward.ansible import guards from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit from steward.models.ansible_inventory import AnsibleGroup, AnsibleTarget from steward.models.users import UserRole inventory_bp = Blueprint("ansible_inventory", __name__, url_prefix="/ansible/inventory") def _parse_ansible_vars(raw: str) -> dict: """Parse a YAML/JSON string into a dict. Raises ValueError on invalid input.""" raw = raw.strip() if not raw: return {} try: parsed = yaml.safe_load(raw) except yaml.YAMLError as exc: raise ValueError(f"Invalid YAML: {exc}") from exc if parsed is None: return {} if not isinstance(parsed, dict): raise ValueError("ansible_vars must be a YAML mapping (key: value pairs)") return parsed def _vars_refusal(new: dict, old: dict | None = None) -> str | None: """Why this editor may not make this vars change, or None (ansible/guards.py).""" return guards.check_vars_for_role(guards.changed_vars(new, old), UserRole(session.get("user_role", "viewer"))) # --------------------------------------------------------------------------- # Groups # --------------------------------------------------------------------------- @inventory_bp.get("/groups") @require_role(UserRole.viewer) async def groups_list(): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleGroup) .options(selectinload(AnsibleGroup.targets)) .order_by(AnsibleGroup.name) ) groups = result.scalars().all() return await render_template("ansible/inventory/groups.html", groups=groups) @inventory_bp.post("/groups") @require_role(UserRole.operator) async def groups_create(): form = await request.form name = form.get("name", "").strip() if not name: return "name is required", 400 try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 err = _vars_refusal(ansible_vars) if err: return err, 400 group = AnsibleGroup(id=str(uuid.uuid4()), name=name, ansible_vars=ansible_vars) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(group) await _audit("ansible.group.created", "ansible_group", group.id, {"name": name, "vars": sorted(ansible_vars)}) return redirect(url_for("ansible_inventory.groups_list")) @inventory_bp.get("/groups/") @require_role(UserRole.viewer) async def group_detail(group_id: str): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleGroup) .where(AnsibleGroup.id == group_id) .options(selectinload(AnsibleGroup.targets)) ) group = result.scalar_one_or_none() if group is None: return "Group not found", 404 all_targets = ( await db.execute(select(AnsibleTarget).order_by(AnsibleTarget.name)) ).scalars().all() member_ids = {t.id for t in group.targets} ansible_vars_yaml = yaml.dump(group.ansible_vars) if group.ansible_vars else "" return await render_template( "ansible/inventory/group_detail.html", group=group, all_targets=all_targets, member_ids=member_ids, ansible_vars_yaml=ansible_vars_yaml, ) @inventory_bp.post("/groups/") @require_role(UserRole.operator) async def group_update(group_id: str): form = await request.form try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleGroup) .where(AnsibleGroup.id == group_id) .options(selectinload(AnsibleGroup.targets)) ) group = result.scalar_one_or_none() if group is None: return "Group not found", 404 err = _vars_refusal(ansible_vars, group.ansible_vars) if err: return err, 400 name = form.get("name", group.name).strip() if name: group.name = name group.ansible_vars = ansible_vars new_ids = set(form.getlist("target_ids")) all_targets = ( await db.execute(select(AnsibleTarget)) ).scalars().all() target_map = {t.id: t for t in all_targets} group.targets = [target_map[tid] for tid in new_ids if tid in target_map] detail = {"name": group.name, "vars": sorted(ansible_vars), "targets": sorted(t.name for t in group.targets)} await _audit("ansible.group.updated", "ansible_group", group_id, detail) return redirect(url_for("ansible_inventory.group_detail", group_id=group_id)) @inventory_bp.post("/groups//delete") @require_role(UserRole.operator) async def group_delete(group_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleGroup).where(AnsibleGroup.id == group_id) ) group = result.scalar_one_or_none() if group is None: return redirect(url_for("ansible_inventory.groups_list")) name = group.name await db.delete(group) await _audit("ansible.group.deleted", "ansible_group", group_id, {"name": name}) return redirect(url_for("ansible_inventory.groups_list")) # --------------------------------------------------------------------------- # Targets # --------------------------------------------------------------------------- @inventory_bp.get("/targets") @require_role(UserRole.viewer) async def targets_list(): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleTarget) .options(selectinload(AnsibleTarget.groups)) .order_by(AnsibleTarget.name) ) targets = result.scalars().all() return await render_template("ansible/inventory/targets.html", targets=targets) @inventory_bp.post("/targets") @require_role(UserRole.operator) async def targets_create(): form = await request.form name = form.get("name", "").strip() address = form.get("address", "").strip() if not name or not address: return "name and address are required", 400 try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 err = _vars_refusal(ansible_vars) if err: return err, 400 target = AnsibleTarget( id=str(uuid.uuid4()), name=name, address=address, ansible_vars=ansible_vars ) async with current_app.db_sessionmaker() as db: async with db.begin(): db.add(target) await _audit("ansible.target.created", "ansible_target", target.id, {"name": name, "address": address, "vars": sorted(ansible_vars)}) return redirect(url_for("ansible_inventory.targets_list")) @inventory_bp.get("/targets/") @require_role(UserRole.viewer) async def target_detail(target_id: str): async with current_app.db_sessionmaker() as db: result = await db.execute( select(AnsibleTarget) .where(AnsibleTarget.id == target_id) .options(selectinload(AnsibleTarget.groups)) ) target = result.scalar_one_or_none() if target is None: return "Target not found", 404 all_groups = ( await db.execute(select(AnsibleGroup).order_by(AnsibleGroup.name)) ).scalars().all() member_group_ids = {g.id for g in target.groups} ansible_vars_yaml = yaml.dump(target.ansible_vars) if target.ansible_vars else "" return await render_template( "ansible/inventory/target_detail.html", target=target, all_groups=all_groups, member_group_ids=member_group_ids, ansible_vars_yaml=ansible_vars_yaml, ) @inventory_bp.post("/targets/") @require_role(UserRole.operator) async def target_update(target_id: str): form = await request.form try: ansible_vars = _parse_ansible_vars(form.get("ansible_vars", "")) except ValueError as exc: return str(exc), 400 async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleTarget) .where(AnsibleTarget.id == target_id) .options(selectinload(AnsibleTarget.groups)) ) target = result.scalar_one_or_none() if target is None: return "Target not found", 404 err = _vars_refusal(ansible_vars, target.ansible_vars) if err: return err, 400 name = form.get("name", target.name).strip() address = form.get("address", target.address).strip() if name: target.name = name if address: target.address = address target.ansible_vars = ansible_vars new_group_ids = set(form.getlist("group_ids")) all_groups_result = await db.execute(select(AnsibleGroup)) group_map = {g.id: g for g in all_groups_result.scalars().all()} target.groups = [group_map[gid] for gid in new_group_ids if gid in group_map] detail = {"name": target.name, "address": target.address, "vars": sorted(ansible_vars), "groups": sorted(g.name for g in target.groups)} await _audit("ansible.target.updated", "ansible_target", target_id, detail) return redirect(url_for("ansible_inventory.target_detail", target_id=target_id)) @inventory_bp.post("/targets//delete") @require_role(UserRole.operator) async def target_delete(target_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): result = await db.execute( select(AnsibleTarget).where(AnsibleTarget.id == target_id) ) target = result.scalar_one_or_none() if target is None: return redirect(url_for("ansible_inventory.targets_list")) name = target.name await db.delete(target) await _audit("ansible.target.deleted", "ansible_target", target_id, {"name": name}) return redirect(url_for("ansible_inventory.targets_list"))