Ansible schedule form: structured playbook-variable fields + first-item dropdown defaults #2
@@ -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"))
|
||||
@@ -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 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 %}
|
||||
Reference in New Issue
Block a user