feat(ansible): run a playbook against a single Steward host (task 547)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m19s

From a host's edit page, an operator can run a playbook against just that host
via an ephemeral one-host inventory — no need for the host to exist in a source
inventory.

- ansible/sources.py: pure host_inventory_content(host) -> '<name> ansible_host=<addr>'
- executor.start_run: optional inventory_content written to the temp dir and used
  as -i (overrides inventory_path); cleaned up with the creds dir
- hosts/routes.py: POST /hosts/<id>/run-playbook (operator) — validates source +
  playbook, builds the ephemeral inventory, starts a user-triggered run, redirects
  to the live run detail; edit page gets the ansible source list
- hosts/form.html: 'Run Ansible playbook against this host' panel (shown when
  editing an existing host and sources exist) — source + playbook + extra-vars/
  tags/dry-run (no --limit; single host)
- tests: unit host_inventory_content; integration runs a hosts:all/connection:local
  playbook against the ephemeral inventory and asserts inventory_hostname

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 19:26:58 -04:00
parent 0bf007173b
commit 38f61b71c1
6 changed files with 233 additions and 4 deletions
+15 -3
View File
@@ -134,8 +134,13 @@ async def start_run(
inventory_path: str,
source_path: str,
params: dict | None = None,
inventory_content: str | None = None,
) -> None:
"""Execute ansible-playbook as a subprocess and update the DB run row."""
"""Execute ansible-playbook as a subprocess and update the DB run row.
If inventory_content is given (host-targeted runs), it's written to a temp
file and used as the inventory instead of inventory_path.
"""
_run_lines[run_id] = []
db_output = ""
@@ -161,14 +166,21 @@ async def start_run(
lines_since_flush = 0
last_flush_at = time.monotonic()
cmd = build_ansible_command(playbook_path, inventory_path, params)
cwd = source_path if Path(source_path).exists() else None
creds = app.config.get("ANSIBLE", {})
# Materialize credentials into a private temp dir; removed in finally.
# Temp dir holds the ephemeral inventory (if any) + credential files; removed in finally.
tmpdir = tempfile.mkdtemp(prefix="steward-ansible-")
os.chmod(tmpdir, 0o700)
try:
if inventory_content:
inv_target = os.path.join(tmpdir, "inventory")
with open(inv_target, "w", encoding="utf-8") as invf:
invf.write(inventory_content)
else:
inv_target = inventory_path
cmd = build_ansible_command(playbook_path, inv_target, params)
cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files:
with open(cred_path, "w", encoding="utf-8") as cf:
+12
View File
@@ -72,6 +72,18 @@ def discover_inventories(source_path: str) -> list[str]:
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()