"""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, url_for from sqlalchemy import select from sqlalchemy.orm import selectinload from steward.auth.middleware import require_role 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 # --------------------------------------------------------------------------- # 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 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) 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 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] 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 not None: await db.delete(group) 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 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) 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 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] 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 not None: await db.delete(target) return redirect(url_for("ansible_inventory.targets_list"))