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, inventory_path: str,
source_path: str, source_path: str,
params: dict | None = None, params: dict | None = None,
inventory_content: str | None = 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] = [] _run_lines[run_id] = []
db_output = "" db_output = ""
@@ -161,14 +166,21 @@ async def start_run(
lines_since_flush = 0 lines_since_flush = 0
last_flush_at = time.monotonic() last_flush_at = time.monotonic()
cmd = build_ansible_command(playbook_path, inventory_path, params)
cwd = source_path if Path(source_path).exists() else None cwd = source_path if Path(source_path).exists() else None
creds = app.config.get("ANSIBLE", {}) 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-") tmpdir = tempfile.mkdtemp(prefix="steward-ansible-")
os.chmod(tmpdir, 0o700) os.chmod(tmpdir, 0o700)
try: 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) cred_args, cred_files = build_credentials(creds, tmpdir)
for cred_path, content in cred_files: for cred_path, content in cred_files:
with open(cred_path, "w", encoding="utf-8") as cf: 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()) 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: def read_playbook(source_path: str, relative_path: str) -> str | None:
"""Return contents of a playbook file, or None if not found / path escape.""" """Return contents of a playbook file, or None if not found / path escape."""
root = Path(source_path).resolve() root = Path(source_path).resolve()
+89 -1
View File
@@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from quart import Blueprint, render_template, request, redirect, url_for, current_app, session from quart import Blueprint, render_template, request, redirect, url_for, current_app, session
from sqlalchemy import and_, case, select, func 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.auth.middleware import require_role
from steward.core.audit import log_audit from steward.core.audit import log_audit
from steward.models.ansible import AnsibleRun, AnsibleRunStatus
from steward.models.hosts import Host, ProbeType from steward.models.hosts import Host, ProbeType
from steward.models.monitors import PingResult, DnsResult, PingStatus from steward.models.monitors import PingResult, DnsResult, PingStatus
from steward.models.users import UserRole from steward.models.users import UserRole
@@ -11,6 +15,13 @@ from steward.models.users import UserRole
hosts_bp = Blueprint("hosts", __name__, url_prefix="/hosts") 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]: async def _compute_uptime(db) -> dict[str, dict]:
"""Return per-host uptime % for 24h, 7d, 30d windows. """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() host = result.scalar_one_or_none()
if host is None: if host is None:
return "Not found", 404 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("/<host_id>") @hosts_bp.post("/<host_id>")
@@ -165,6 +178,81 @@ async def update_host(host_id: str):
return redirect(url_for("hosts.list_hosts")) return redirect(url_for("hosts.list_hosts"))
@hosts_bp.post("/<host_id>/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("/<host_id>/delete") @hosts_bp.post("/<host_id>/delete")
@require_role(UserRole.admin) @require_role(UserRole.admin)
async def delete_host(host_id: str): async def delete_host(host_id: str):
+38
View File
@@ -49,5 +49,43 @@
</div> </div>
</form> </form>
</div> </div>
{% if host and ansible_sources %}
<div class="card" style="margin-top:1.5rem;">
<h3 class="section-title" style="margin-bottom:0.5rem;">Run Ansible playbook against this host</h3>
<p style="color:var(--text-muted);font-size:0.82rem;margin-bottom:1rem;">
Runs against an ephemeral one-host inventory for
<code>{{ host.name }}</code> ({{ host.address or "no address — resolves by name" }}).
Use a playbook with <code>hosts: all</code>.
</p>
<form method="post" action="/hosts/{{ host.id }}/run-playbook">
<div class="form-group">
<label>Source</label>
<select name="source_name" required>
{% for s in ansible_sources %}<option value="{{ s }}">{{ s }}</option>{% endfor %}
</select>
</div>
<div class="form-group">
<label>Playbook <span style="color:var(--text-muted);font-weight:normal;">(path within source)</span></label>
<input type="text" name="playbook_path" placeholder="playbooks/site.yml" required>
</div>
<div class="form-group">
<label>Extra vars <span style="color:var(--text-muted);font-weight:normal;">(optional, one key=value per line)</span></label>
<textarea name="extra_vars" rows="2"
style="width:100%;font-family:ui-monospace,monospace;font-size:0.85rem;"></textarea>
</div>
<div class="form-group">
<label>Tags <span style="color:var(--text-muted);font-weight:normal;">(optional)</span></label>
<input type="text" name="tags">
</div>
<div class="form-group">
<label style="display:flex;align-items:center;gap:0.5rem;font-weight:normal;">
<input type="checkbox" name="check"> Dry-run (--check --diff)
</label>
</div>
<button type="submit" class="btn">Run playbook</button>
</form>
</div>
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
@@ -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
+13
View File
@@ -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"