# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """Bastion Chronicle callback plugin for Ansible. Reports playbook execution events to Chronicle via the Bastion broker. Every playbook run, task result, and final status becomes a governed audit event. Events emitted: ANSIBLE_PLAYBOOK_STARTED -- playbook execution begins ANSIBLE_TASK_COMPLETED -- individual task result (per host) ANSIBLE_PLAYBOOK_COMPLETED -- final playbook status + summary """ from __future__ import annotations import logging import os from datetime import datetime, UTC logger = logging.getLogger(__name__) DOCUMENTATION = """ name: guildhouse.bastion.bastion_chronicle type: notification short_description: Bastion Chronicle audit callback description: - Reports playbook lifecycle events to Chronicle - Optionally triggers compliance re-evaluation after playbook """ def _get_client(): """Lazily construct BastionClient.""" try: from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient return BastionClient() except ImportError: return None class ChronicleCallback: """Bastion Chronicle callback for Ansible playbook auditing. This is a standalone class (not subclassing Ansible's CallbackBase) so it can be tested without Ansible installed. The actual Ansible callback module wraps this class. """ CALLBACK_VERSION = 2.0 CALLBACK_TYPE = "notification" CALLBACK_NAME = "guildhouse.bastion.bastion_chronicle" def __init__(self): self._client = _get_client() self._ac_id = os.environ.get("BASTION_AC_ID", "") self._recheck = os.environ.get("BASTION_RECHECK_COMPLIANCE", "").lower() == "true" self._affected_hosts: set[str] = set() self._events: list[dict] = [] def on_playbook_start(self, playbook_name: str, hosts: list[str] | None = None): """Emit ANSIBLE_PLAYBOOK_STARTED Chronicle event.""" event = { "kind": "ANSIBLE_PLAYBOOK_STARTED", "playbook": playbook_name, "hosts": hosts or [], "ac_id": self._ac_id, "timestamp": datetime.now(UTC).isoformat(), } self._events.append(event) self._emit(event) def on_task_completed(self, host: str, task_name: str, status: str, changed: bool = False): """Emit ANSIBLE_TASK_COMPLETED for a single host result.""" self._affected_hosts.add(host) event = { "kind": "ANSIBLE_TASK_COMPLETED", "host": host, "task": task_name, "status": status, "changed": changed, "ac_id": self._ac_id, "timestamp": datetime.now(UTC).isoformat(), } self._events.append(event) self._emit(event) def on_playbook_completed(self, stats: dict): """Emit ANSIBLE_PLAYBOOK_COMPLETED with summary. Optionally triggers compliance re-evaluation for affected hosts. """ event = { "kind": "ANSIBLE_PLAYBOOK_COMPLETED", "stats": stats, "affected_hosts": sorted(self._affected_hosts), "ac_id": self._ac_id, "timestamp": datetime.now(UTC).isoformat(), } self._events.append(event) self._emit(event) if self._recheck and self._affected_hosts: self._trigger_compliance_recheck() def _emit(self, event: dict): """Send event to Chronicle via Bastion client.""" if self._client is None: logger.debug("Chronicle callback: no Bastion client available") return try: self._client.emit_chronicle(event["kind"], event) except Exception as e: logger.warning("Chronicle emit failed: %s", e) def _trigger_compliance_recheck(self): """Trigger compliance re-evaluation for affected hosts.""" if self._client is None: return for host in self._affected_hosts: try: self._client.get_device_compliance(host) logger.info("Compliance re-check triggered for %s", host) except Exception as e: logger.warning("Compliance re-check failed for %s: %s", host, e)