# 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, 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 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, ): """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. 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" 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)) elif inventory_scope.startswith("repo:"): parts = inventory_scope.split(":", 2) inventory_path = parts[2] if len(parts) == 3 else "" else: return None, source, "Invalid inventory_scope" 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, ) ) 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