# steward/ansible/runner.py """Shared playbook-run launcher used by the manual route, alerts, and schedules. Centralises the resolve-inventory → create AnsibleRun → launch executor flow so manual, alert-triggered, and scheduled runs all take the exact same path. """ from __future__ import annotations import asyncio import uuid from datetime import datetime, timezone from steward.ansible import executor, guards, sources as src_module from steward.ansible.inventory_gen import ( fetch_scope_targets, generate_inventory, inventory_to_yaml, ) from steward.models.ansible import AnsibleRun, AnsibleRunStatus # A run in one of these hasn't finished: a schedule or an alert action whose # previous run is still in one skips its next firing (#5158). IN_FLIGHT = (AnsibleRunStatus.queued, AnsibleRunStatus.running) async def run_in_flight(db, run_id: str | None) -> bool: """Whether run_id names a run that hasn't finished. Runs left running by a restart are marked interrupted at startup, so this can't stick.""" if not run_id: return False run = await db.get(AnsibleRun, run_id) return run is not None and run.status in IN_FLIGHT async def trigger_run( app, *, source_name: str, playbook_path: str, inventory_scope: str = "steward:all", params: dict | None = None, triggered_by: str | None = None, inventory_content: str | None = None, connection: dict | None = None, secret_vars: dict | None = None, redact: list[str] | None = None, ): """Resolve inventory for the scope, persist an AnsibleRun, and launch it. triggered_by=None marks a system/automated run (alerts, schedules). If inventory_content is provided, it is used verbatim and scope resolution is skipped (the caller built a bespoke inventory — e.g. host_agent deploy injecting per-host tokens); inventory_scope is still recorded for display. connection is an optional per-run SSH override (user/password) for first-contact provisioning — passed to the executor but deliberately NOT stored on the AnsibleRun row (params), so the password never lands in the DB. secret_vars (sensitive run-time playbook variables) are likewise passed through to the executor but never persisted. redact names further values to mask in the run's output (a bespoke inventory's per-host tokens). Returns (run, source, error): on success error is None; on failure run is None and error is a short human-readable reason. """ sources = src_module.get_sources(app.config.get("ANSIBLE", {})) source = next((s for s in sources if s["name"] == source_name), None) if source is None: return None, None, "Source not found" # Every run path (web, MCP, alerts, schedules, plugins) arrives here, so # this is where a run is held to what its source lists (ansible/guards.py). err = guards.check_playbook(source, playbook_path) if err is None and inventory_content is None: err = guards.check_inventory_scope(source, inventory_scope) if err: return None, source, err inventory_path: str | None = None if inventory_content is not None: pass # caller-supplied inventory wins; scope kept only for display elif inventory_scope.startswith("steward:"): async with app.db_sessionmaker() as db: targets = await fetch_scope_targets(db, inventory_scope) inventory_content = inventory_to_yaml(generate_inventory(targets)) else: inventory_path = inventory_scope.split(":", 2)[2] run_id = str(uuid.uuid4()) run = AnsibleRun( id=run_id, playbook_path=playbook_path, inventory_path=inventory_path, inventory_scope=inventory_scope, source_name=source_name, triggered_by=triggered_by, status=AnsibleRunStatus.running, started_at=datetime.now(timezone.utc), params=params or None, ) async with app.db_sessionmaker() as db: async with db.begin(): db.add(run) task = asyncio.create_task( executor.start_run( app, run_id, playbook_path, inventory_path or "", source["path"], params or None, inventory_content, connection=connection, secret_vars=secret_vars, redact=redact, ) ) task.add_done_callback( lambda t: t.exception() and app.logger.error( "Ansible run %s raised: %s", run_id, t.exception() ) ) return run, source, None