From 38f61b71c1619cef6aaaead8bf7f111f293d36ab Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 2 Jun 2026 19:26:58 -0400 Subject: [PATCH] feat(ansible): run a playbook against a single Steward host (task 547) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) -> ' ansible_host=' - 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//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) --- steward/ansible/executor.py | 18 ++++- steward/ansible/sources.py | 12 +++ steward/hosts/routes.py | 90 ++++++++++++++++++++- steward/templates/hosts/form.html | 38 +++++++++ tests/integration/test_host_targeted_run.py | 66 +++++++++++++++ tests/test_host_inventory.py | 13 +++ 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 tests/integration/test_host_targeted_run.py create mode 100644 tests/test_host_inventory.py diff --git a/steward/ansible/executor.py b/steward/ansible/executor.py index e27eeb5..d194b67 100644 --- a/steward/ansible/executor.py +++ b/steward/ansible/executor.py @@ -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: diff --git a/steward/ansible/sources.py b/steward/ansible/sources.py index f6e6135..a457d4d 100644 --- a/steward/ansible/sources.py +++ b/steward/ansible/sources.py @@ -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() diff --git a/steward/hosts/routes.py b/steward/hosts/routes.py index 33afa1a..c48e795 100644 --- a/steward/hosts/routes.py +++ b/steward/hosts/routes.py @@ -1,9 +1,13 @@ from __future__ import annotations +import asyncio +import uuid from datetime import datetime, timedelta, timezone from quart import Blueprint, render_template, request, redirect, url_for, current_app, session from sqlalchemy import and_, case, select, func +from steward.ansible import executor, sources as ansible_src from steward.auth.middleware import require_role from steward.core.audit import log_audit +from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.hosts import Host, ProbeType from steward.models.monitors import PingResult, DnsResult, PingStatus from steward.models.users import UserRole @@ -11,6 +15,13 @@ from steward.models.users import UserRole hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts") +def _ansible_source_names() -> list[str]: + try: + return [s["name"] for s in ansible_src.get_sources(current_app.config.get("ANSIBLE", {}))] + except Exception: + return [] + + async def _compute_uptime(db) -> dict[str, dict]: """Return per-host uptime % for 24h, 7d, 30d windows. @@ -141,7 +152,9 @@ async def edit_host(host_id: str): host = result.scalar_one_or_none() if host is None: return "Not found", 404 - return await render_template("hosts/form.html", host=host, probe_types=list(ProbeType)) + return await render_template( + "hosts/form.html", host=host, probe_types=list(ProbeType), + ansible_sources=_ansible_source_names()) @hosts_bp.post("/") @@ -165,6 +178,81 @@ async def update_host(host_id: str): return redirect(url_for("hosts.list_hosts")) +@hosts_bp.post("//run-playbook") +@require_role(UserRole.operator) +async def run_playbook(host_id: str): + """Run an Ansible playbook against just this host (ephemeral one-host inventory).""" + form = await request.form + async with current_app.db_sessionmaker() as db: + host = (await db.execute(select(Host).where(Host.id == host_id))).scalar_one_or_none() + if host is None: + return "Not found", 404 + + source_name = (form.get("source_name", "") or "").strip() + playbook = (form.get("playbook_path", "") or "").strip() + if not source_name or not playbook: + return "source and playbook are required", 400 + try: + srcs = ansible_src.get_sources(current_app.config.get("ANSIBLE", {})) + except Exception: + return "Ansible sources are misconfigured", 400 + source = next((s for s in srcs if s["name"] == source_name), None) + if source is None: + return "Source not found", 404 + 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 + + run_id = str(uuid.uuid4()) + inv_content = ansible_src.host_inventory_content(host) + run = AnsibleRun( + id=run_id, + playbook_path=playbook, + inventory_path=f"host: {host.name}", + source_name=source_name, + triggered_by=session.get("user_id"), + status=AnsibleRunStatus.running, + params=params_or_none, + ) + async with current_app.db_sessionmaker() as db: + async with db.begin(): + db.add(run) + + task = asyncio.create_task( + 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, + ) + ) + task.add_done_callback( + lambda t: t.exception() and current_app.logger.error( + "Host playbook run %s raised: %s", run_id, t.exception())) + + await log_audit(current_app, session.get("user_id"), session.get("username", ""), + "ansible.host_run", entity_type="host", entity_id=host.name, + detail={"playbook": playbook, "source": source_name}) + return redirect(url_for("ansible.run_detail", run_id=run_id)) + + @hosts_bp.post("//delete") @require_role(UserRole.admin) async def delete_host(host_id: str): diff --git a/steward/templates/hosts/form.html b/steward/templates/hosts/form.html index ef2cd82..3963be2 100644 --- a/steward/templates/hosts/form.html +++ b/steward/templates/hosts/form.html @@ -49,5 +49,43 @@ + + {% if host and ansible_sources %} +
+

Run Ansible playbook against this host

+

+ Runs against an ephemeral one-host inventory for + {{ host.name }} ({{ host.address or "no address — resolves by name" }}). + Use a playbook with hosts: all. +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {% endif %} {% endblock %} diff --git a/tests/integration/test_host_targeted_run.py b/tests/integration/test_host_targeted_run.py new file mode 100644 index 0000000..bdd6ad7 --- /dev/null +++ b/tests/integration/test_host_targeted_run.py @@ -0,0 +1,66 @@ +"""Integration: a host-targeted run uses the ephemeral one-host inventory (task 547). + +Runs a `hosts: all` / `connection: local` playbook through executor.start_run with +an ephemeral inventory built from a Host, and asserts the play executed against +that host's inventory_hostname — proving the inventory_content plumbing. +""" +from __future__ import annotations + +import asyncio +import os +import textwrap +import uuid + +import pytest + +pytestmark = pytest.mark.integration + +_NEEDS_DB = pytest.mark.skipif( + not os.environ.get("STEWARD_DATABASE_URL"), + reason="integration test needs a live Postgres (STEWARD_DATABASE_URL)", +) + + +@pytest.fixture +def app(): + if not os.environ.get("STEWARD_DATABASE_URL"): + pytest.skip("needs Postgres") + from steward.app import create_app + return create_app(testing=False) + + +@_NEEDS_DB +def test_ephemeral_inventory_targets_host(app, tmp_path): + from sqlalchemy import select + from steward.ansible import executor, sources + from steward.models.ansible import AnsibleRun, AnsibleRunStatus + from steward.models.hosts import Host + + (tmp_path / "play.yml").write_text(textwrap.dedent("""\ + - hosts: all + connection: local + gather_facts: false + tasks: + - debug: + msg: "HOST-{{ inventory_hostname }}" + """)) + inv_content = sources.host_inventory_content(Host(name="demo-host", address="127.0.0.1")) + + run_id = str(uuid.uuid4()) + + async def _go(): + async with app.db_sessionmaker() as s: + async with s.begin(): + s.add(AnsibleRun( + id=run_id, playbook_path="play.yml", inventory_path="host: demo-host", + source_name="t", triggered_by=None, status=AnsibleRunStatus.running, + )) + await executor.start_run( + app, run_id, "play.yml", "host: demo-host", str(tmp_path), None, inv_content) + async with app.db_sessionmaker() as s: + return (await s.execute( + select(AnsibleRun).where(AnsibleRun.id == run_id))).scalar_one() + + run = asyncio.run(_go()) + assert run.status == AnsibleRunStatus.success, run.output + assert "HOST-demo-host" in (run.output or "") # ran against the ephemeral inventory host diff --git a/tests/test_host_inventory.py b/tests/test_host_inventory.py new file mode 100644 index 0000000..db6b601 --- /dev/null +++ b/tests/test_host_inventory.py @@ -0,0 +1,13 @@ +"""Unit tests for the ephemeral one-host inventory helper (task 547).""" +from steward.ansible.sources import host_inventory_content +from steward.models.hosts import Host + + +def test_with_address_sets_ansible_host(): + inv = host_inventory_content(Host(name="web1", address="10.0.0.5")) + assert inv == "web1 ansible_host=10.0.0.5\n" + + +def test_without_address_falls_back_to_name(): + inv = host_inventory_content(Host(name="web1", address="")) + assert inv == "web1\n"