feat(ansible): per-variable fields in the playbook run form
When you click Run, Steward now parses the playbook and renders a field for each declared variable instead of a blank extra-vars textarea. The form loads on demand via HTMX (/ansible/run-form/<source>/<playbook>). - sources.discover_playbook_variables: parse vars: defaults + vars_prompt: (vars_prompt wins on name collision; non-scalar vars skipped; role/include vars not traversed). Flags secret-looking names + vars_prompt private. - Run-time values flow through a JSON extra-vars file (-e @file), which is space/quote-safe — fixes a latent shlex-split bug in the old -e key=value textarea path. executor.build_extra_vars_file (pure) + start_run merge. - Secret-flagged fields are masked AND routed through an unpersisted secret_vars channel (runner.trigger_run → start_run), so passwords entered at run time never land in the DB / run history. - Defaults shown as placeholders (not prefilled): an untouched field falls through to the inventory/play default instead of overriding it. - routes: run_form HTMX endpoint; _parse_run_params now returns (params, secret_vars, err) and reads var__/secret__ fields. Schedules drop secret vars (can't prompt unattended). - templates: ansible/_run_form.html fragment; browse.html rewired to HTMX, static JS run-form removed. Advanced section keeps limit/tags/check + a free-form extra-vars escape hatch. - tests: test_playbook_variables.py (discovery + extra-vars file). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+67
-10
@@ -95,19 +95,43 @@ async def view_playbook(source_name: str, playbook_path: str):
|
||||
)
|
||||
|
||||
|
||||
def _parse_run_params(form) -> tuple[dict | None, str | None]:
|
||||
"""Build the executor params dict from form fields. Returns (params, error)."""
|
||||
extra_vars: list[str] = []
|
||||
def _parse_run_params(form) -> tuple[dict | None, dict | None, str | None]:
|
||||
"""Build executor params + the unpersisted secret-vars dict from form fields.
|
||||
|
||||
Returns (params, secret_vars, error). Discovered variable fields are named
|
||||
``var__<name>``; a sibling hidden ``secret__<name>`` marks the sensitive
|
||||
ones, which go into secret_vars (never persisted) instead of params. A
|
||||
free-form ``extra_vars`` textarea (key=value per line) is also folded in.
|
||||
All run-time values flow through a JSON extra-vars file downstream, so
|
||||
values may contain spaces/quotes safely.
|
||||
"""
|
||||
extra_vars_map: dict[str, str] = {}
|
||||
secret_vars: dict[str, str] = {}
|
||||
|
||||
for key in set(form.keys()):
|
||||
if not key.startswith("var__"):
|
||||
continue
|
||||
name = key[len("var__"):]
|
||||
val = (form.get(key) or "").strip()
|
||||
if not val:
|
||||
continue # untouched → fall through to inventory/play default
|
||||
if f"secret__{name}" in form:
|
||||
secret_vars[name] = val
|
||||
else:
|
||||
extra_vars_map[name] = val
|
||||
|
||||
for line in (form.get("extra_vars", "") or "").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "=" not in line:
|
||||
return None, f"Invalid extra var (expected key=value): {line!r}"
|
||||
extra_vars.append(line)
|
||||
return None, None, f"Invalid extra var (expected key=value): {line!r}"
|
||||
k, v = line.split("=", 1)
|
||||
extra_vars_map[k.strip()] = v
|
||||
|
||||
params: dict = {}
|
||||
if extra_vars:
|
||||
params["extra_vars"] = extra_vars
|
||||
if extra_vars_map:
|
||||
params["extra_vars_map"] = extra_vars_map
|
||||
limit = (form.get("limit", "") or "").strip()
|
||||
if limit:
|
||||
params["limit"] = limit
|
||||
@@ -116,7 +140,37 @@ def _parse_run_params(form) -> tuple[dict | None, str | None]:
|
||||
params["tags"] = tags
|
||||
if "check" in form:
|
||||
params["check"] = True
|
||||
return (params or None), None
|
||||
return (params or None), (secret_vars or None), None
|
||||
|
||||
|
||||
@ansible_bp.get("/run-form/<source_name>/<path:playbook_path>")
|
||||
@require_role(UserRole.operator)
|
||||
async def run_form(source_name: str, playbook_path: str):
|
||||
"""HTMX fragment: the run form for one playbook, with a field per declared
|
||||
variable (vars/vars_prompt) discovered from the playbook itself."""
|
||||
from steward.models.ansible_inventory import AnsibleTarget, AnsibleGroup
|
||||
|
||||
source = next((s for s in _get_sources() if s["name"] == source_name), None)
|
||||
if source is None:
|
||||
return "Source not found", 404
|
||||
contents = src_module.read_playbook(source["path"], playbook_path)
|
||||
if contents is None:
|
||||
return "Playbook not found", 404
|
||||
variables = src_module.discover_playbook_variables(contents)
|
||||
inventories = src_module.discover_inventories(source["path"])
|
||||
|
||||
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/_run_form.html",
|
||||
source_name=source_name, playbook_path=playbook_path,
|
||||
variables=variables, targets=targets, groups=groups,
|
||||
inventories=inventories,
|
||||
)
|
||||
|
||||
|
||||
@ansible_bp.post("/runs")
|
||||
@@ -132,7 +186,7 @@ async def create_run():
|
||||
if not playbook_path:
|
||||
return "playbook_path is required", 400
|
||||
|
||||
params_or_none, err = _parse_run_params(form)
|
||||
params_or_none, secret_vars, err = _parse_run_params(form)
|
||||
if err:
|
||||
return err, 400
|
||||
|
||||
@@ -142,6 +196,7 @@ async def create_run():
|
||||
playbook_path=playbook_path,
|
||||
inventory_scope=inventory_scope,
|
||||
params=params_or_none,
|
||||
secret_vars=secret_vars,
|
||||
triggered_by=session["user_id"],
|
||||
)
|
||||
if err == "Source not found":
|
||||
@@ -304,7 +359,9 @@ def _schedule_form_fields(form) -> tuple[dict | None, str | None]:
|
||||
interval = 0
|
||||
if not name or not source_name or not playbook_path or interval <= 0:
|
||||
return None, "name, source, playbook, and a positive interval are required"
|
||||
params, err = _parse_run_params(form)
|
||||
# Scheduled runs can't prompt, so secret vars are intentionally dropped —
|
||||
# automation should rely on global creds / inventory vars, not run-time secrets.
|
||||
params, _secret_vars, err = _parse_run_params(form)
|
||||
if err:
|
||||
return None, err
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user