feat(ansible): scope picker in run form + create_run() DB inventory generation

This commit is contained in:
2026-06-05 18:49:02 -04:00
parent d906232eb2
commit 2c991eabd2
3 changed files with 70 additions and 20 deletions
+39 -5
View File
@@ -37,6 +37,8 @@ async def index():
@ansible_bp.get("/browse") @ansible_bp.get("/browse")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def browse(): async def browse():
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
all_sources = _get_sources() all_sources = _get_sources()
source_data = [] source_data = []
for source in all_sources: for source in all_sources:
@@ -47,7 +49,21 @@ async def browse():
"playbooks": playbooks, "playbooks": playbooks,
"inventories": inventories, "inventories": inventories,
}) })
return await render_template("ansible/browse.html", source_data=source_data)
async with current_app.db_sessionmaker() as db:
targets = (await db.execute(
select(AnsibleTarget).order_by(AnsibleTarget.name)
)).scalars().all()
groups = (await db.execute(
select(AnsibleGroup).order_by(AnsibleGroup.name)
)).scalars().all()
return await render_template(
"ansible/browse.html",
source_data=source_data,
targets=targets,
groups=groups,
)
@ansible_bp.get("/browse/<source_name>/<path:playbook_path>") @ansible_bp.get("/browse/<source_name>/<path:playbook_path>")
@@ -72,19 +88,35 @@ async def view_playbook(source_name: str, playbook_path: str):
@ansible_bp.post("/runs") @ansible_bp.post("/runs")
@require_role(UserRole.operator) @require_role(UserRole.operator)
async def create_run(): async def create_run():
import json as _json
from steward.ansible.inventory_gen import fetch_scope_targets, generate_inventory
form = await request.form form = await request.form
playbook_path = form.get("playbook_path", "").strip() playbook_path = form.get("playbook_path", "").strip()
source_name = form.get("source_name", "").strip() source_name = form.get("source_name", "").strip()
inventory_path = form.get("inventory_path", "").strip() inventory_scope = form.get("inventory_scope", "steward:all").strip()
if not playbook_path or not inventory_path: if not playbook_path:
return "playbook_path and inventory_path are required", 400 return "playbook_path is required", 400
all_sources = _get_sources() all_sources = _get_sources()
source = next((s for s in all_sources if s["name"] == source_name), None) source = next((s for s in all_sources if s["name"] == source_name), None)
if source is None: if source is None:
return "Source not found", 404 return "Source not found", 404
# Resolve inventory for this scope.
inventory_content: str | None = None
inventory_path: str | None = None
if inventory_scope.startswith("steward:"):
async with current_app.db_sessionmaker() as db:
targets = await fetch_scope_targets(db, inventory_scope)
inventory_content = _json.dumps(generate_inventory(targets))
elif inventory_scope.startswith("repo:"):
parts = inventory_scope.split(":", 2)
inventory_path = parts[2] if len(parts) == 3 else ""
else:
return "Invalid inventory_scope", 400
# Optional run parameters (all passed as argv by the executor — no shell). # Optional run parameters (all passed as argv by the executor — no shell).
extra_vars: list[str] = [] extra_vars: list[str] = []
for line in (form.get("extra_vars", "") or "").splitlines(): for line in (form.get("extra_vars", "") or "").splitlines():
@@ -116,6 +148,7 @@ async def create_run():
id=run_id, id=run_id,
playbook_path=playbook_path, playbook_path=playbook_path,
inventory_path=inventory_path, inventory_path=inventory_path,
inventory_scope=inventory_scope,
source_name=source_name, source_name=source_name,
triggered_by=session["user_id"], triggered_by=session["user_id"],
status=AnsibleRunStatus.running, status=AnsibleRunStatus.running,
@@ -131,9 +164,10 @@ async def create_run():
current_app._get_current_object(), # type: ignore[attr-defined] current_app._get_current_object(), # type: ignore[attr-defined]
run_id, run_id,
playbook_path, playbook_path,
inventory_path, inventory_path or "",
source["path"], source["path"],
params_or_none, params_or_none,
inventory_content,
) )
) )
task.add_done_callback( task.add_done_callback(
+30 -14
View File
@@ -71,18 +71,37 @@
readonly style="color:var(--text-muted);" value=""> readonly style="color:var(--text-muted);" value="">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Inventory</label> <label>Run Against</label>
<select name="inventory_path"> <select name="inventory_scope">
{% for inv in sd.inventories %} {% if targets %}
<option value="{{ inv }}">{{ inv }}</option> <optgroup label="All Targets">
{% endfor %} <option value="steward:all">All targets ({{ targets | length }})</option>
<option value="">— Enter manually below —</option> </optgroup>
{% if groups %}
<optgroup label="Group">
{% for grp in groups %}
<option value="steward:group:{{ grp.id }}">Group: {{ grp.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
<optgroup label="Single Target">
{% for tgt in targets %}
<option value="steward:target:{{ tgt.id }}">{{ tgt.name }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if sd.inventories %}
<optgroup label="Repo Inventory File">
{% for inv in sd.inventories %}
<option value="repo:{{ sd.source.name }}:{{ inv }}">{{ sd.source.name }}/{{ inv }}</option>
{% endfor %}
</optgroup>
{% endif %}
{% if not targets and not sd.inventories %}
<option value="" disabled>No targets or inventory files — add targets at /ansible/inventory/targets</option>
{% endif %}
</select> </select>
</div> </div>
<div class="form-group">
<label>Or enter inventory path manually</label>
<input type="text" id="manual-inv-{{ sd.source.name }}" placeholder="inventories/production/hosts">
</div>
<div class="form-group"> <div class="form-group">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label> <label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label>
<textarea name="extra_vars" rows="2" placeholder="version=1.2.3&#10;restart=true" <textarea name="extra_vars" rows="2" placeholder="version=1.2.3&#10;restart=true"
@@ -103,10 +122,7 @@
</label> </label>
</div> </div>
<div style="display:flex;gap:0.75rem;align-items:center;"> <div style="display:flex;gap:0.75rem;align-items:center;">
<button type="button" class="btn" <button type="submit" class="btn">Execute</button>
onclick="var manual=document.getElementById('manual-inv-{{ sd.source.name }}').value.trim();
if(manual){this.closest('form').querySelector('[name=inventory_path]').value=manual;}
htmx.trigger(this.closest('form'),'submit');">Execute</button>
<a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;" <a href="#" onclick="document.getElementById('run-form-{{ sd.source.name }}').style.display='none';return false;"
class="btn btn-ghost btn-sm">Cancel</a> class="btn btn-ghost btn-sm">Cancel</a>
</div> </div>
+1 -1
View File
@@ -11,7 +11,7 @@
<div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;"> <div style="display:grid;grid-template-columns:auto 1fr;gap:0.4rem 1rem;font-size:0.9rem;margin-bottom:1rem;">
<span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span> <span style="color:var(--text-muted);">ID</span><span style="color:var(--text-dim);font-family:monospace;">{{ run.id }}</span>
<span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span> <span style="color:var(--text-muted);">Playbook</span><span>{{ run.playbook_path }}</span>
<span style="color:var(--text-muted);">Inventory</span><span>{{ run.inventory_path }}</span> <span style="color:var(--text-muted);">Scope</span><span style="font-family:ui-monospace,monospace;font-size:0.85rem;">{{ run.inventory_scope }}</span>
<span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span> <span style="color:var(--text-muted);">Source</span><span>{{ run.source_name }}</span>
<span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span> <span style="color:var(--text-muted);">Triggered by</span><span>{{ triggered_label }}</span>
<span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span> <span style="color:var(--text-muted);">Status</span><span style="color:{{ status_color }};font-weight:bold;">{{ run.status.value.upper() }}</span>