feat(ansible): dropdown playbook selection + auto-populated variable fields
Stop making operators type playbook paths and guess extra-var names. - Reusable infra: shared ansible/_playbook_vars.html (the discovered-variable fields) + ansible/_playbook_options.html; two HTMX endpoints — /ansible/playbook-options (a source's playbooks, optional ?selected for edit) and /ansible/playbook-vars (a playbook's vars:/vars_prompt: as fill-in fields). browse _run_form.html refactored to include the shared partial. - Host "Run a playbook against this host": source dropdown → playbook dropdown → variable fields, all chained via HTMX. Handler reuses _parse_run_params so var__/secret__ fields flow through extra_vars_map + the unpersisted secret_vars channel. - Schedules: playbook free-text+datalist → source-dependent dropdown; fixed the extra-vars edit pre-fill to read extra_vars_map (stale list key after the earlier JSON-extra-vars change). Scribe #895. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,35 @@ def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]:
|
||||
return (params or None), (secret_vars or None), None
|
||||
|
||||
|
||||
@ansible_bp.get("/playbook-options")
|
||||
@require_role(UserRole.operator)
|
||||
async def playbook_options():
|
||||
"""HTMX fragment: <option>s of playbooks in a source, to populate a playbook
|
||||
dropdown when a source is chosen. `selected` pre-selects one (edit forms)."""
|
||||
source_name = (request.args.get("source_name", "") or "").strip()
|
||||
selected = (request.args.get("selected", "") or "").strip()
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
playbooks = src_module.discover_playbooks(source["path"]) if source else []
|
||||
return await render_template(
|
||||
"ansible/_playbook_options.html", playbooks=playbooks, selected=selected)
|
||||
|
||||
|
||||
@ansible_bp.get("/playbook-vars")
|
||||
@require_role(UserRole.operator)
|
||||
async def playbook_vars():
|
||||
"""HTMX fragment: fill-in fields for a playbook's declared variables
|
||||
(vars/vars_prompt), loaded when a playbook is chosen from a dropdown."""
|
||||
source_name = (request.args.get("source_name", "") or "").strip()
|
||||
playbook_path = (request.args.get("playbook_path", "") or "").strip()
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
variables: list = []
|
||||
if source and playbook_path:
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is not None:
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
return await render_template("ansible/_playbook_vars.html", variables=variables)
|
||||
|
||||
|
||||
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.operator)
|
||||
async def run_form(source_name: str, playbook_path: str):
|
||||
|
||||
+7
-19
@@ -349,24 +349,12 @@ async def run_playbook(host_id: str):
|
||||
if playbook not in ansible_src.discover_playbooks(source["path"]):
|
||||
return f"Playbook '{playbook}' not found in source '{source_name}'", 404
|
||||
|
||||
# Optional params (no --limit — there's exactly one host).
|
||||
extra_vars: list[str] = []
|
||||
for line in (form.get("extra_vars", "") or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "=" not in line:
|
||||
return f"Invalid extra var (expected key=value): {line!r}", 400
|
||||
extra_vars.append(line)
|
||||
params: dict = {}
|
||||
if extra_vars:
|
||||
params["extra_vars"] = extra_vars
|
||||
tags = (form.get("tags", "") or "").strip()
|
||||
if tags:
|
||||
params["tags"] = tags
|
||||
if "check" in form:
|
||||
params["check"] = True
|
||||
params_or_none = params or None
|
||||
# Shared parser: discovered var__ fields → extra_vars_map, secret__ → secret_vars
|
||||
# (unpersisted), plus tags/check. No --limit — there's exactly one host.
|
||||
from steward.ansible.routes import _parse_run_params
|
||||
params_or_none, secret_vars, err = _parse_run_params(form)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
run_id = str(uuid.uuid4())
|
||||
inv_content = ansible_src.host_inventory_content(host)
|
||||
@@ -387,7 +375,7 @@ async def run_playbook(host_id: str):
|
||||
executor.start_run(
|
||||
current_app._get_current_object(), # type: ignore[attr-defined]
|
||||
run_id, playbook, f"host: {host.name}", source["path"],
|
||||
params_or_none, inv_content,
|
||||
params_or_none, inv_content, secret_vars=secret_vars,
|
||||
)
|
||||
)
|
||||
task.add_done_callback(
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{# <option> list for a playbook <select>, populated when a source is chosen.
|
||||
Returned by /ansible/playbook-options. `selected` pre-selects one (edit). #}
|
||||
<option value="">— choose playbook —</option>
|
||||
{% for pb in playbooks %}
|
||||
<option value="{{ pb }}" {% if pb == selected %}selected{% endif %}>{{ pb }}</option>
|
||||
{% endfor %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{# Discovered playbook variables as fill-in fields. Shared by the browse run
|
||||
form, the host run form, and the schedule form. Expects `variables` (from
|
||||
discover_playbook_variables). Rendered standalone by /ansible/playbook-vars. #}
|
||||
{% if variables %}
|
||||
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
|
||||
Playbook variables
|
||||
</div>
|
||||
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;">
|
||||
Leave a field blank to use the playbook/inventory default. Secret fields are never stored.
|
||||
</p>
|
||||
{% for v in variables %}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
{{ v.prompt or v.name }}
|
||||
<span style="color:var(--text-muted);font-weight:normal;font-size:0.76rem;">
|
||||
({{ v.name }}{% if v.required %}, required{% endif %}{% if v.secret %}, secret{% endif %})
|
||||
</span>
|
||||
</label>
|
||||
{% if v.secret %}<input type="hidden" name="secret__{{ v.name }}" value="1">{% endif %}
|
||||
<input type="{{ 'password' if v.secret else 'text' }}"
|
||||
name="var__{{ v.name }}"
|
||||
{% if v.required %}required{% endif %}
|
||||
{% if v.secret %}autocomplete="new-password"{% endif %}
|
||||
placeholder="{% if v.secret %}(hidden){% elif v.default not in (None, '') %}default: {{ v.default }}{% else %}no default{% endif %}"
|
||||
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:var(--text-dim);font-size:0.8rem;margin:0.25rem 0 0.6rem;">
|
||||
No declared variables found in this playbook.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -34,36 +34,7 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{% if variables %}
|
||||
<div style="margin:0.5rem 0 0.25rem;font-size:0.82rem;color:var(--text-muted);font-weight:600;">
|
||||
Playbook variables
|
||||
</div>
|
||||
<p style="color:var(--text-dim);font-size:0.76rem;margin:0 0 0.6rem;">
|
||||
Leave a field blank to use the playbook/inventory default. Secret fields are
|
||||
never stored.
|
||||
</p>
|
||||
{% for v in variables %}
|
||||
<div class="form-group">
|
||||
<label>
|
||||
{{ v.prompt or v.name }}
|
||||
<span style="color:var(--text-muted);font-weight:normal;font-size:0.76rem;">
|
||||
({{ v.name }}{% if v.required %}, required{% endif %}{% if v.secret %}, secret{% endif %})
|
||||
</span>
|
||||
</label>
|
||||
{% if v.secret %}<input type="hidden" name="secret__{{ v.name }}" value="1">{% endif %}
|
||||
<input type="{{ 'password' if v.secret else 'text' }}"
|
||||
name="var__{{ v.name }}"
|
||||
{% if v.required %}required{% endif %}
|
||||
{% if v.secret %}autocomplete="new-password"{% endif %}
|
||||
placeholder="{% if v.secret %}(hidden){% elif v.default not in (None, '') %}default: {{ v.default }}{% else %}no default{% endif %}"
|
||||
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;">
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p style="color:var(--text-dim);font-size:0.8rem;margin:0.25rem 0 0.6rem;">
|
||||
No declared variables found in this playbook.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% include "ansible/_playbook_vars.html" %}
|
||||
|
||||
<details style="margin:0.25rem 0 0.75rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Advanced</summary>
|
||||
|
||||
@@ -86,7 +86,9 @@
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Source</label>
|
||||
<select name="source_name" required>
|
||||
<select name="source_name" required
|
||||
hx-get="/ansible/playbook-options" hx-trigger="change"
|
||||
hx-target="#sch-playbook" hx-swap="innerHTML" hx-include="this">
|
||||
<option value="">— choose source —</option>
|
||||
{% for sd in source_data %}
|
||||
<option value="{{ sd.name }}" {% if editing and editing.source_name == sd.name %}selected{% endif %}>{{ sd.name }}</option>
|
||||
@@ -94,12 +96,13 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Playbook path</label>
|
||||
<input type="text" name="playbook_path" required list="playbooks"
|
||||
value="{{ editing.playbook_path if editing else '' }}" placeholder="maintenance/docker_prune.yml">
|
||||
<datalist id="playbooks">
|
||||
{% for pb in all_playbooks %}<option value="{{ pb }}"></option>{% endfor %}
|
||||
</datalist>
|
||||
<label>Playbook</label>
|
||||
<select name="playbook_path" id="sch-playbook" required>
|
||||
<option value="">— choose playbook —</option>
|
||||
{% for pb in all_playbooks %}
|
||||
<option value="{{ pb }}" {% if editing and editing.playbook_path == pb %}selected{% endif %}>{{ pb }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:0;">
|
||||
<label>Target</label>
|
||||
@@ -132,7 +135,8 @@
|
||||
</div>
|
||||
<div class="form-group" style="margin-top:1rem;">
|
||||
<label>Extra vars (one key=value per line)</label>
|
||||
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{{ p.get('extra_vars', []) | join('\n') }}</textarea>
|
||||
<textarea name="extra_vars" rows="3" placeholder="prune_volumes=false">{% for k, v in (p.get('extra_vars_map') or {}).items() %}{{ k }}={{ v }}
|
||||
{% endfor %}</textarea>
|
||||
</div>
|
||||
<div class="form-group" style="display:flex;align-items:center;gap:0.5rem;">
|
||||
<input type="checkbox" name="check" id="check" style="width:auto;" {% if p.get('check') %}checked{% endif %}>
|
||||
|
||||
@@ -109,23 +109,38 @@
|
||||
<form method="post" action="/hosts/{{ host.id }}/run-playbook" style="margin-top:0.75rem;">
|
||||
<div class="form-group">
|
||||
<label>Source</label>
|
||||
<select name="source_name" required>
|
||||
<select name="source_name" required
|
||||
hx-get="/ansible/playbook-options" hx-trigger="change"
|
||||
hx-target="#hp-playbook" hx-swap="innerHTML" hx-include="this">
|
||||
<option value="">— choose source —</option>
|
||||
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Playbook <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
|
||||
<input type="text" name="playbook_path" placeholder="playbooks/site.yml" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
|
||||
<textarea name="extra_vars" rows="2" style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="check"> Dry-run (--check --diff)
|
||||
</label>
|
||||
<label>Playbook</label>
|
||||
<select name="playbook_path" id="hp-playbook" required
|
||||
hx-get="/ansible/playbook-vars" hx-trigger="change"
|
||||
hx-target="#hp-vars" hx-swap="innerHTML" hx-include="closest form">
|
||||
<option value="">— choose a source first —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="hp-vars"></div>
|
||||
<details style="margin:0.25rem 0 0.75rem;">
|
||||
<summary style="cursor:pointer;font-size:0.82rem;color:var(--text-muted);">Advanced</summary>
|
||||
<div class="form-group" style="margin-top:0.6rem;">
|
||||
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(one key=value per line)</span></label>
|
||||
<textarea name="extra_vars" rows="2" style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(--tags)</span></label>
|
||||
<input type="text" name="tags" placeholder="deploy,config">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
|
||||
<input type="checkbox" name="check"> Dry-run (--check --diff)
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
<button type="submit" class="btn btn-sm">Run playbook</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user