feat(ansible): inventory groups CRUD routes + templates + blueprint registration
This commit is contained in:
@@ -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/<group_id>")
|
||||
@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/<group_id>")
|
||||
@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/<group_id>/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/<target_id>")
|
||||
@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/<target_id>")
|
||||
@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/<target_id>/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"))
|
||||
Reference in New Issue
Block a user