Files
FabledSteward/steward/ansible/sources.py
T
bvandeusen a996cc6908
CI / lint (push) Successful in 9s
CI / unit (push) Successful in 14s
CI / integration (push) Successful in 2m19s
CI / publish (push) Successful in 55s
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>
2026-06-16 17:54:01 -04:00

333 lines
12 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
import re
import stat
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
INVENTORY_NAMES = {"hosts", "inventory", "inventory.yml", "inventory.ini"}
# Variable names that should be entered masked + kept out of the DB. Matched
# case-insensitively as a substring of the variable name.
_SECRET_VAR_RE = re.compile(
r"(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)", re.I
)
# Name of the always-present, read-only source of first-party playbooks shipped
# inside the app (maintenance tasks, host-agent install). Not operator-editable.
BUILTIN_SOURCE_NAME = "steward-builtin"
# Always-present, WRITABLE local source where the in-app editor saves playbooks,
# so a homelab user with no git workflow can author automation. Lives on the
# persistent /data volume; created lazily on first save.
LOCAL_SOURCE_NAME = "steward-local"
USER_PLAYBOOK_DIR = os.environ.get("STEWARD_PLAYBOOK_DIR", "/data/ansible/playbooks")
def _builtin_source() -> dict:
return {
"name": BUILTIN_SOURCE_NAME,
"type": "local",
"path": str((Path(__file__).parent / "bundled").resolve()),
"url": None,
"branch": "main",
"pull_interval_seconds": 0,
"http_token": "",
}
def _local_source() -> dict:
return {
"name": LOCAL_SOURCE_NAME,
"type": "local",
"path": USER_PLAYBOOK_DIR,
"url": None,
"branch": "main",
"pull_interval_seconds": 0,
"http_token": "",
}
def is_editable_source(source: dict) -> bool:
"""Editable in the in-app editor: local dir sources except the read-only
bundled one. Git sources are GitOps (clobbered on pull) so not editable."""
return source.get("type") == "local" and source.get("name") != BUILTIN_SOURCE_NAME
def get_sources(ansible_cfg: dict) -> list[dict]:
"""Return resolved source list from ansible config section.
Each source dict has: name, type, path (resolved local path),
url (git only), branch (git only), pull_interval_seconds (git only).
Config structure in config.yaml::
ansible:
cache_dir: /var/cache/steward/ansible
sources:
- name: my-playbooks
type: local
path: /opt/playbooks
- name: infra-repo
type: git
url: https://github.com/user/infra.git
branch: main
pull_interval_seconds: 3600
"""
sources = ansible_cfg.get("sources", [])
cache_dir = ansible_cfg.get("cache_dir", "/var/cache/steward/ansible")
# Always expose the bundled (read-only) + local (writable) first-party sources.
result = [_builtin_source(), _local_source()]
for src in sources:
src_type = src.get("type", "local")
if src_type == "git":
if not src.get("url"):
raise ValueError(f"Ansible git source {src['name']!r} is missing required 'url' field")
path = str(Path(cache_dir) / src["name"])
else:
if not src.get("path"):
raise ValueError(f"Ansible local source {src['name']!r} is missing required 'path' field")
path = src.get("path", "")
result.append({
"name": src["name"],
"type": src_type,
"path": path,
"url": src.get("url"),
"branch": src.get("branch", "main"),
"pull_interval_seconds": int(src.get("pull_interval_seconds", 3600)),
"http_token": src.get("http_token", "") if src_type == "git" else "",
})
return result
def discover_playbooks(source_path: str) -> list[str]:
"""Recursively find .yml and .yaml files in source_path. Returns relative paths."""
root = Path(source_path)
if not root.exists():
return []
playbooks = set()
for ext in ("*.yml", "*.yaml"):
for p in root.rglob(ext):
playbooks.add(str(p.relative_to(root)))
return sorted(playbooks)
def discover_inventories(source_path: str) -> list[str]:
"""Non-recursive: return inventory filenames present in root of source_path."""
root = Path(source_path)
if not root.exists():
return []
return sorted(name for name in INVENTORY_NAMES if (root / name).exists())
def host_inventory_content(host) -> str:
"""An ephemeral single-host inventory line for a Steward Host.
Targets the host's address when set (`ansible_host=`), else just the name
(resolved via DNS). Playbooks run against this should use `hosts: all` (or
the host's name). Pure — `host` only needs `.name` and `.address`.
"""
if getattr(host, "address", ""):
return f"{host.name} ansible_host={host.address}\n"
return f"{host.name}\n"
def read_playbook(source_path: str, relative_path: str) -> str | None:
"""Return contents of a playbook file, or None if not found / path escape."""
root = Path(source_path).resolve()
target = (root / relative_path).resolve()
# Guard against path traversal
try:
target.relative_to(root)
except ValueError:
return None
if not target.exists() or not target.is_file():
return None
return target.read_text(errors="replace")
def _resolve_writable(source_path: str, relative_path: str) -> tuple[Path | None, str | None]:
"""Resolve relative_path under source_path, guarding traversal + extension.
Returns (target_path, None) on success or (None, error). The root need not
exist yet (steward-local is created on first save)."""
rel = relative_path.strip().lstrip("/")
if not rel:
return None, "Filename is required"
if not rel.endswith((".yml", ".yaml")):
return None, "Playbook filename must end in .yml or .yaml"
root = Path(source_path).resolve()
target = (root / rel).resolve()
try:
target.relative_to(root)
except ValueError:
return None, "Invalid path"
return target, None
def write_playbook(source_path: str, relative_path: str, content: str) -> tuple[bool, str | None]:
"""Write a playbook into a source, creating parent dirs. Traversal-guarded."""
target, err = _resolve_writable(source_path, relative_path)
if err:
return False, err
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
except OSError as exc:
return False, f"Could not write file: {exc}"
return True, None
def delete_playbook(source_path: str, relative_path: str) -> tuple[bool, str | None]:
"""Delete a playbook from a source. Traversal-guarded; no-op if absent."""
target, err = _resolve_writable(source_path, relative_path)
if err:
return False, err
try:
if target.exists():
target.unlink()
except OSError as exc:
return False, f"Could not delete file: {exc}"
return True, None
def discover_playbook_variables(content: str) -> list[dict]:
"""Parse a playbook and list the variables an operator can set at run time.
Surfaces two sources, in this precedence (first wins on name collision):
- ``vars_prompt:`` — Ansible's explicit "ask me" mechanism. ``required``
when it has no default; ``secret`` when ``private`` (Ansible's default
is private=yes) or the name looks sensitive.
- ``vars:`` scalar entries — shown with their default as a placeholder
(NOT prefilled, so an untouched field falls through to inventory/play
defaults rather than overriding them).
Only top-level plays are inspected — role defaults and included var files are
not traversed (kept simple + predictable). Returns a list of dicts:
{name, default, secret, required, prompt}.
"""
import yaml
try:
plays = yaml.safe_load(content)
except yaml.YAMLError:
return []
if not isinstance(plays, list):
return []
out: list[dict] = []
seen: set[str] = set()
def add(name, default, secret, required, prompt=None):
if not isinstance(name, str) or name in seen:
return
seen.add(name)
out.append({
"name": name,
"default": "" if default is None else default,
"secret": secret,
"required": required,
"prompt": prompt,
})
for play in plays:
if not isinstance(play, dict):
continue
for vp in play.get("vars_prompt") or []:
if not isinstance(vp, dict) or "name" not in vp:
continue
name = vp["name"]
default = vp.get("default")
private = bool(vp.get("private", True)) # Ansible defaults private=yes
add(name, default, private or bool(_SECRET_VAR_RE.search(str(name))),
default is None, vp.get("prompt"))
play_vars = play.get("vars")
if isinstance(play_vars, dict):
for name, default in play_vars.items():
# Only scalar defaults map cleanly to a single input field.
if isinstance(default, (str, int, float, bool)) or default is None:
add(name, default, bool(_SECRET_VAR_RE.search(str(name))), False)
return out
def validate_playbook_yaml(content: str) -> tuple[bool, str | None]:
"""Cheap save-time validation: parses as YAML and is a non-empty play list."""
import yaml
try:
data = yaml.safe_load(content)
except yaml.YAMLError as exc:
return False, f"YAML error: {exc}"
if data is None:
return False, "Playbook is empty"
if not isinstance(data, list):
return False, "A playbook must be a list of plays (top-level YAML list)"
return True, None
async def git_pull(source: dict) -> None:
"""Clone the git repo if absent; pull if already present.
When source['http_token'] is set, injects credentials via a temporary
GIT_ASKPASS script so they never touch .git/config or process args.
"""
path = Path(source["path"])
token = source.get("http_token", "")
env = None
askpass_path = None
if token:
fd, askpass_path = tempfile.mkstemp(prefix="steward-askpass-", suffix=".sh")
try:
script = (
"#!/bin/sh\n"
'case "$1" in\n'
' Username*) echo "oauth2" ;;\n'
f' Password*) echo "{token}" ;;\n'
"esac\n"
)
os.write(fd, script.encode())
finally:
os.close(fd)
os.chmod(askpass_path, stat.S_IRWXU)
env = {**os.environ, "GIT_ASKPASS": askpass_path}
try:
if not (path / ".git").exists():
path.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"git", "clone",
"--branch", source["branch"],
"--single-branch",
source["url"], str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git clone failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
else:
proc = await asyncio.create_subprocess_exec(
"git", "-C", str(path), "pull",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=env,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
logger.error(
"git pull failed for %r: %s",
source["name"],
stderr.decode(errors="replace"),
)
finally:
if askpass_path and os.path.exists(askpass_path):
os.unlink(askpass_path)