fastapi-gsap/ansible_collection/guildhouse/bastion/plugins/callback/bastion_chronicle.py
Tyler J King 85afbd8d61 feat(ansible): add guildhouse.bastion Ansible Galaxy collection
Dynamic inventory plugin — queries Bastion for managed devices,
groups by OS and compliance state, bastion_* host vars, zero
credentials in inventory.

Credential lookup plugin — resolves short-lived credentials from
Bastion's CredentialResolver at execution time. Graceful
degradation when broker unavailable.

Chronicle callback plugin — reports playbook lifecycle events
(started, task completed, completed) to Chronicle. Optionally
triggers compliance re-evaluation after playbook completion.

Shared BastionClient for all plugins using stdlib urllib.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 11:23:03 -04:00

126 lines
4.2 KiB
Python

# 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)