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>
126 lines
4.2 KiB
Python
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)
|