Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults #2

Merged
bvandeusen merged 126 commits from dev into main 2026-06-30 23:44:04 -04:00
4 changed files with 356 additions and 0 deletions
Showing only changes of commit 935cbc105c - Show all commits
+256
View File
@@ -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"))
+2
View File
@@ -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)
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block title %}Group: {{ group.name }} — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Group: {{ group.name }}</h1>
<a href="/ansible/inventory/groups" class="btn btn-ghost btn-sm">← Groups</a>
</div>
<form method="post" action="/ansible/inventory/groups/{{ group.id }}">
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Settings</h3>
<div class="form-group">
<label>Name</label>
<input type="text" name="name" value="{{ group.name }}" required>
</div>
<div class="form-group">
<label>
Group Variables
<span style="color:var(--text-muted);font-weight:normal;font-size:0.82rem;">
(YAML — applied to all members; per-target vars override these)
</span>
</label>
<textarea name="ansible_vars" rows="8"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"
placeholder="ansible_user: deploy&#10;http_port: 80">{{ ansible_vars_yaml }}</textarea>
</div>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">Members</h3>
{% if not all_targets %}
<p style="color:var(--text-muted);font-size:0.9rem;">No targets exist yet.
<a href="/ansible/inventory/targets">Add targets</a> first.</p>
{% else %}
<div style="display:grid;gap:0.4rem;margin-bottom:1rem;">
{% for tgt in all_targets %}
<label style="display:flex;align-items:center;gap:0.6rem;font-weight:normal;cursor:pointer;">
<input type="checkbox" name="target_ids" value="{{ tgt.id }}"
{% if tgt.id in member_ids %}checked{% endif %}>
<span>{{ tgt.name }}</span>
<span style="color:var(--text-muted);font-size:0.82rem;">{{ tgt.address }}</span>
</label>
{% endfor %}
</div>
{% endif %}
</div>
<button type="submit" class="btn">Save Changes</button>
</form>
{% endblock %}
@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Inventory Groups — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;">
<h1 class="page-title" style="margin-bottom:0;">Inventory Groups</h1>
<a href="/ansible/inventory/targets" class="btn btn-ghost btn-sm">Targets →</a>
</div>
<div class="card" style="margin-bottom:1.5rem;">
<h3 class="section-title">New Group</h3>
<form method="post" action="/ansible/inventory/groups" style="display:flex;gap:0.75rem;align-items:flex-end;">
<div class="form-group" style="flex:1;margin:0;">
<label>Name</label>
<input type="text" name="name" placeholder="webservers" required>
</div>
<button type="submit" class="btn">Create</button>
</form>
</div>
{% if not groups %}
<div class="card">
<p style="color:var(--text-muted);">No groups yet. Create one above.</p>
</div>
{% else %}
<div class="card-flush">
<table class="table">
<thead>
<tr><th>Name</th><th>Members</th><th></th></tr>
</thead>
<tbody>
{% for grp in groups %}
<tr>
<td><strong>{{ grp.name }}</strong></td>
<td style="color:var(--text-muted);font-size:0.9rem;">{{ grp.targets | length }}</td>
<td class="td-actions">
<a href="/ansible/inventory/groups/{{ grp.id }}" class="btn btn-sm btn-ghost">Edit</a>
<form method="post" action="/ansible/inventory/groups/{{ grp.id }}/delete" style="display:inline;">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Delete group {{ grp.name }}?')">×</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}