From 935cbc105c2b03995b4ed95e4f4ab915c7bfe7ba Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 5 Jun 2026 18:50:09 -0400 Subject: [PATCH] feat(ansible): inventory groups CRUD routes + templates + blueprint registration --- steward/ansible/inventory_routes.py | 256 ++++++++++++++++++ steward/app.py | 2 + .../ansible/inventory/group_detail.html | 50 ++++ .../templates/ansible/inventory/groups.html | 48 ++++ 4 files changed, 356 insertions(+) create mode 100644 steward/ansible/inventory_routes.py create mode 100644 steward/templates/ansible/inventory/group_detail.html create mode 100644 steward/templates/ansible/inventory/groups.html diff --git a/steward/ansible/inventory_routes.py b/steward/ansible/inventory_routes.py new file mode 100644 index 0000000..94d3a2c --- /dev/null +++ b/steward/ansible/inventory_routes.py @@ -0,0 +1,256 @@ +"""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")) diff --git a/steward/app.py b/steward/app.py index b1ad710..d1d3683 100644 --- a/steward/app.py +++ b/steward/app.py @@ -100,6 +100,7 @@ def create_app( from .dns.routes import dns_bp from .alerts.routes import alerts_bp from .ansible.routes import ansible_bp + from .ansible.inventory_routes import inventory_bp from .settings.routes import settings_bp from .audit.routes import audit_bp @@ -110,6 +111,7 @@ def create_app( app.register_blueprint(dns_bp) app.register_blueprint(alerts_bp) app.register_blueprint(ansible_bp) + app.register_blueprint(inventory_bp) app.register_blueprint(settings_bp) app.register_blueprint(audit_bp) diff --git a/steward/templates/ansible/inventory/group_detail.html b/steward/templates/ansible/inventory/group_detail.html new file mode 100644 index 0000000..fc35507 --- /dev/null +++ b/steward/templates/ansible/inventory/group_detail.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Group: {{ group.name }} — Steward{% endblock %} +{% block content %} +
+

Group: {{ group.name }}

+ ← Groups +
+ +
+
+

Settings

+
+ + +
+
+ + +
+
+ +
+

Members

+ {% if not all_targets %} +

No targets exist yet. + Add targets first.

+ {% else %} +
+ {% for tgt in all_targets %} + + {% endfor %} +
+ {% endif %} +
+ + +
+{% endblock %} diff --git a/steward/templates/ansible/inventory/groups.html b/steward/templates/ansible/inventory/groups.html new file mode 100644 index 0000000..bdb0ec3 --- /dev/null +++ b/steward/templates/ansible/inventory/groups.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}Inventory Groups — Steward{% endblock %} +{% block content %} +
+

Inventory Groups

+ Targets → +
+ +
+

New Group

+
+
+ + +
+ +
+
+ +{% if not groups %} +
+

No groups yet. Create one above.

+
+{% else %} +
+ + + + + + {% for grp in groups %} + + + + + + {% endfor %} + +
NameMembers
{{ grp.name }}{{ grp.targets | length }} + Edit +
+ +
+
+
+{% endif %} +{% endblock %}