feat(ansible): per-variable fields in the playbook run form
CI / lint (push) Successful in 9s
CI / unit (push) Successful in 14s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 55s

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:
2026-06-16 17:54:01 -04:00
parent 0318f6423f
commit a996cc6908
7 changed files with 370 additions and 89 deletions
+67 -10
View File
@@ -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 {