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>
This commit is contained in:
parent
77964e4042
commit
85afbd8d61
8 changed files with 635 additions and 0 deletions
13
ansible_collection/guildhouse/bastion/galaxy.yml
Normal file
13
ansible_collection/guildhouse/bastion/galaxy.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
namespace: guildhouse
|
||||
name: bastion
|
||||
version: 0.1.0
|
||||
description: >
|
||||
Bastion MDM integration for Ansible. Provides dynamic inventory
|
||||
from Bastion's device registry, credential resolution via
|
||||
Bastion's zero-storage CredentialResolver, and Chronicle audit
|
||||
callback for playbook governance.
|
||||
license:
|
||||
- Apache-2.0
|
||||
dependencies: {}
|
||||
repository: https://git.guildhouse.dev/tking/fastapi-gsap
|
||||
documentation: https://git.guildhouse.dev/tking/fastapi-gsap/docs/ansible
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
# 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)
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Bastion dynamic inventory plugin for Ansible.
|
||||
|
||||
Queries the Bastion broker for managed devices and presents them
|
||||
as Ansible inventory. Devices are grouped by OS, compliance state,
|
||||
and accord scope. Host variables include Bastion-specific metadata
|
||||
with bastion_ prefix.
|
||||
|
||||
BOUNDARY: This plugin provides inventory and read-only host
|
||||
variables. It does NOT provide credentials -- that is the
|
||||
credential lookup plugin's job.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Credential field names that must NEVER appear as host variables.
|
||||
_CREDENTIAL_FIELDS = frozenset({
|
||||
"ansible_password", "ansible_ssh_pass", "ansible_ssh_private_key_file",
|
||||
"ansible_become_pass", "ansible_winrm_password",
|
||||
})
|
||||
|
||||
DOCUMENTATION = """
|
||||
name: guildhouse.bastion.bastion
|
||||
plugin_type: inventory
|
||||
short_description: Bastion MDM dynamic inventory
|
||||
description:
|
||||
- Queries Bastion broker for managed devices
|
||||
- Groups by OS, compliance state, accord scope
|
||||
- Provides bastion_* host variables
|
||||
options:
|
||||
bastion_url:
|
||||
description: Bastion broker URL
|
||||
required: true
|
||||
env:
|
||||
- name: BASTION_URL
|
||||
bastion_token:
|
||||
description: Bearer token for Bastion API
|
||||
required: false
|
||||
env:
|
||||
- name: BASTION_TOKEN
|
||||
"""
|
||||
|
||||
|
||||
def _fetch_devices(url, token=""):
|
||||
"""Fetch device list from Bastion broker."""
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
body = json.dumps({"operation": "list_devices", "parameters": {}}).encode()
|
||||
req = Request(f"{url}/connectors/intune/invoke/", data=body, headers=headers, method="POST")
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
data = json.loads(resp.read())
|
||||
if data.get("success"):
|
||||
return data.get("data", [])
|
||||
except URLError as e:
|
||||
logger.warning("Failed to fetch devices from Bastion: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
def build_inventory(devices):
|
||||
"""Build Ansible inventory dict from Bastion device list.
|
||||
|
||||
Returns inventory suitable for ``--list`` output or internal
|
||||
``parse()`` population.
|
||||
|
||||
NEVER includes credential variables in host vars.
|
||||
"""
|
||||
inventory = {
|
||||
"_meta": {"hostvars": {}},
|
||||
"all": {"hosts": [], "children": []},
|
||||
}
|
||||
groups: dict[str, list[str]] = {}
|
||||
|
||||
for device in devices:
|
||||
device_name = device.get("device_name", "")
|
||||
device_id = device.get("device_id", "")
|
||||
if not device_name:
|
||||
continue
|
||||
|
||||
hostname = device_name.lower()
|
||||
inventory["all"]["hosts"].append(hostname)
|
||||
|
||||
# Host variables (bastion_ prefix, NEVER credentials)
|
||||
hostvars = {
|
||||
"bastion_device_id": device_id,
|
||||
"bastion_compliance_state": device.get("compliance_state", "unknown"),
|
||||
"bastion_os_type": device.get("os_type", "").lower(),
|
||||
"bastion_os_version": device.get("os_version", ""),
|
||||
"bastion_last_sync": device.get("last_sync"),
|
||||
"bastion_user_principal_name": device.get("user_principal_name"),
|
||||
"bastion_entra_device_id": device.get("entra_device_id"),
|
||||
"bastion_management_channel": "intune",
|
||||
}
|
||||
|
||||
# Safety: ensure no credential fields leak through
|
||||
for field in _CREDENTIAL_FIELDS:
|
||||
hostvars.pop(field, None)
|
||||
|
||||
inventory["_meta"]["hostvars"][hostname] = hostvars
|
||||
|
||||
# Group by OS
|
||||
os_type = device.get("os_type", "unknown").lower()
|
||||
os_group = f"os_{os_type}" if os_type else "os_unknown"
|
||||
groups.setdefault(os_group, []).append(hostname)
|
||||
|
||||
# Group by compliance
|
||||
compliance = device.get("compliance_state", "unknown").lower()
|
||||
comp_group = f"compliance_{compliance}"
|
||||
groups.setdefault(comp_group, []).append(hostname)
|
||||
|
||||
# Add groups to inventory
|
||||
for group_name, hosts in groups.items():
|
||||
inventory[group_name] = {"hosts": hosts}
|
||||
if group_name not in inventory["all"].get("children", []):
|
||||
inventory["all"].setdefault("children", []).append(group_name)
|
||||
|
||||
return inventory
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Bastion credential lookup plugin for Ansible.
|
||||
|
||||
Resolves short-lived credentials from Bastion's CredentialResolver
|
||||
at playbook execution time. Replaces static credentials in
|
||||
Ansible Vault with dynamic, scoped, time-limited credentials.
|
||||
|
||||
SECURITY: Credentials are resolved per-host at execution time.
|
||||
They are NOT cached, NOT written to facts, NOT stored in any
|
||||
persistent structure. Ansible holds them only for the duration
|
||||
of the connection to that specific host.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DOCUMENTATION = """
|
||||
name: guildhouse.bastion.credential
|
||||
short_description: Resolve credentials from Bastion
|
||||
description:
|
||||
- Acquires short-lived credentials from Bastion CredentialResolver
|
||||
- Zero credential storage -- resolved at execution time only
|
||||
options:
|
||||
host:
|
||||
description: Target hostname to resolve credential for
|
||||
required: true
|
||||
type:
|
||||
description: Credential type (kerberos, oauth, ssh_cert, auto)
|
||||
required: false
|
||||
default: auto
|
||||
ac_id:
|
||||
description: Authorization Context ID for governed resolution
|
||||
required: false
|
||||
env:
|
||||
- name: BASTION_AC_ID
|
||||
"""
|
||||
|
||||
# OS type to credential type mapping for auto-detection
|
||||
_AUTO_TYPE_MAP = {
|
||||
"windows": "kerberos",
|
||||
"linux": "ssh_cert",
|
||||
"macos": "ssh_cert",
|
||||
}
|
||||
|
||||
|
||||
def resolve_credential(host, credential_type="auto", ac_id=None, host_vars=None):
|
||||
"""Resolve a credential for the specified host.
|
||||
|
||||
1. Determine credential type (auto-detect from bastion_os_type
|
||||
host var, or use explicit type)
|
||||
2. Call Bastion broker's credential resolution endpoint
|
||||
3. Return the credential value
|
||||
|
||||
NOTE: Until the broker exposes a /credentials/resolve HTTP
|
||||
endpoint, this returns None with a warning, allowing Ansible
|
||||
to fall back to its own credential resolution.
|
||||
"""
|
||||
if credential_type == "auto" and host_vars:
|
||||
os_type = host_vars.get("bastion_os_type", "").lower()
|
||||
credential_type = _AUTO_TYPE_MAP.get(os_type, "oauth")
|
||||
|
||||
# Import here to avoid hard dependency on bastion_client at
|
||||
# module load time (Ansible loads all lookup plugins eagerly).
|
||||
try:
|
||||
from ansible_collection.guildhouse.bastion.plugins.module_utils.bastion_client import BastionClient
|
||||
client = BastionClient()
|
||||
result = client.resolve_credential(credential_type, host, ac_id)
|
||||
if result is not None:
|
||||
return result
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.warning("Bastion credential resolution failed for %s: %s", host, e)
|
||||
|
||||
logger.debug(
|
||||
"Bastion credential resolution not available for %s "
|
||||
"(type=%s). Falling back to Ansible credential resolution.",
|
||||
host, credential_type,
|
||||
)
|
||||
return None
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Shared HTTP client for Bastion broker API.
|
||||
|
||||
Used by all plugins (inventory, credential, callback) to
|
||||
communicate with the Bastion broker. Handles authentication,
|
||||
base URL configuration, and error handling.
|
||||
|
||||
Configuration via environment variables:
|
||||
BASTION_URL -- broker base URL (e.g. http://localhost:8000)
|
||||
BASTION_TOKEN -- bearer token for API authentication
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Uses stdlib urllib to avoid requiring httpx/requests as a
|
||||
# dependency for the Ansible collection. Ansible control nodes
|
||||
# always have Python stdlib available.
|
||||
|
||||
|
||||
class BastionClient:
|
||||
"""HTTP client for the Bastion broker API."""
|
||||
|
||||
def __init__(self, url=None, token=None):
|
||||
self.url = (url or os.environ.get("BASTION_URL", "http://localhost:8000")).rstrip("/")
|
||||
self.token = token or os.environ.get("BASTION_TOKEN", "")
|
||||
|
||||
def _headers(self):
|
||||
h = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
h["Authorization"] = f"Bearer {self.token}"
|
||||
return h
|
||||
|
||||
def _get(self, path, params=None):
|
||||
"""HTTP GET with auth headers."""
|
||||
url = f"{self.url}{path}"
|
||||
if params:
|
||||
qs = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
url = f"{url}?{qs}"
|
||||
req = Request(url, headers=self._headers(), method="GET")
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except URLError as e:
|
||||
logger.warning("Bastion API GET %s failed: %s", path, e)
|
||||
return None
|
||||
|
||||
def _post(self, path, body=None):
|
||||
"""HTTP POST with auth headers."""
|
||||
url = f"{self.url}{path}"
|
||||
data = json.dumps(body or {}).encode()
|
||||
req = Request(url, data=data, headers=self._headers(), method="POST")
|
||||
try:
|
||||
with urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except URLError as e:
|
||||
logger.warning("Bastion API POST %s failed: %s", path, e)
|
||||
return None
|
||||
|
||||
def get_devices(self, filters=None):
|
||||
"""Query Bastion for managed device inventory."""
|
||||
result = self._post(
|
||||
"/connectors/intune/invoke/",
|
||||
{"operation": "list_devices", "parameters": filters or {}},
|
||||
)
|
||||
if result and result.get("success"):
|
||||
return result.get("data", [])
|
||||
return []
|
||||
|
||||
def get_device_compliance(self, device_id):
|
||||
"""Get compliance state for a specific device."""
|
||||
result = self._post(
|
||||
"/connectors/intune/invoke/",
|
||||
{"operation": "get_compliance", "parameters": {"device_id": device_id}},
|
||||
)
|
||||
if result and result.get("success"):
|
||||
return result.get("data", {})
|
||||
return None
|
||||
|
||||
def resolve_credential(self, credential_type, target, ac_id=None):
|
||||
"""Resolve a short-lived credential for a target host.
|
||||
|
||||
NOTE: The broker does not yet expose a /credentials/resolve
|
||||
HTTP endpoint. Returns None for now -- Ansible falls back to
|
||||
its own credential resolution. The endpoint will be added
|
||||
when the CredentialResolver gets an HTTP API.
|
||||
"""
|
||||
logger.debug(
|
||||
"Credential resolution not yet available via HTTP API "
|
||||
"(type=%s, target=%s)", credential_type, target
|
||||
)
|
||||
return None
|
||||
|
||||
def emit_chronicle(self, event_type, event_data):
|
||||
"""Emit a Chronicle event via the broker."""
|
||||
return self._post(
|
||||
"/connectors/echo/invoke/",
|
||||
{
|
||||
"operation": "chronicle_proxy",
|
||||
"parameters": {"kind": event_type, **event_data},
|
||||
},
|
||||
)
|
||||
|
||||
def get_ac(self, ac_id):
|
||||
"""Verify an AC is active."""
|
||||
return self._get(f"/governance/session/{ac_id}/")
|
||||
0
ansible_collection/guildhouse/bastion/tests/__init__.py
Normal file
0
ansible_collection/guildhouse/bastion/tests/__init__.py
Normal file
168
tests/test_ansible_collection.py
Normal file
168
tests/test_ansible_collection.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
# Copyright 2026 Guildhouse Dev
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Tests for the Ansible collection plugins — inventory, credential, callback."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Add the collection to the path so plugins are importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "ansible_collection"))
|
||||
|
||||
from guildhouse.bastion.plugins.inventory.bastion import build_inventory, _CREDENTIAL_FIELDS
|
||||
from guildhouse.bastion.plugins.lookup.credential import resolve_credential, _AUTO_TYPE_MAP
|
||||
from guildhouse.bastion.plugins.callback.bastion_chronicle import ChronicleCallback
|
||||
|
||||
|
||||
# ── Inventory plugin ─────────────────────────────────────────────
|
||||
|
||||
MOCK_DEVICES = [
|
||||
{
|
||||
"device_id": "00000000-0000-0000-0000-000000000001",
|
||||
"device_name": "LAPTOP-WIN-01",
|
||||
"os_type": "Windows",
|
||||
"os_version": "10.0.19045",
|
||||
"compliance_state": "compliant",
|
||||
"last_sync": "2026-04-14T00:00:00Z",
|
||||
"user_principal_name": "alice@contoso.com",
|
||||
"entra_device_id": "entra-001",
|
||||
},
|
||||
{
|
||||
"device_id": "00000000-0000-0000-0000-000000000002",
|
||||
"device_name": "SRV-LINUX-01",
|
||||
"os_type": "Linux",
|
||||
"os_version": "6.6",
|
||||
"compliance_state": "noncompliant",
|
||||
},
|
||||
{
|
||||
"device_id": "00000000-0000-0000-0000-000000000003",
|
||||
"device_name": "MAC-01",
|
||||
"os_type": "macOS",
|
||||
"os_version": "14.0",
|
||||
"compliance_state": "compliant",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_inventory_groups_by_os():
|
||||
"""TEST 12: Devices grouped by OS correctly."""
|
||||
inv = build_inventory(MOCK_DEVICES)
|
||||
|
||||
assert "laptop-win-01" in inv["os_windows"]["hosts"]
|
||||
assert "srv-linux-01" in inv["os_linux"]["hosts"]
|
||||
assert "mac-01" in inv["os_macos"]["hosts"]
|
||||
|
||||
|
||||
def test_inventory_groups_by_compliance():
|
||||
"""TEST 12: Devices grouped by compliance state."""
|
||||
inv = build_inventory(MOCK_DEVICES)
|
||||
|
||||
assert "laptop-win-01" in inv["compliance_compliant"]["hosts"]
|
||||
assert "mac-01" in inv["compliance_compliant"]["hosts"]
|
||||
assert "srv-linux-01" in inv["compliance_noncompliant"]["hosts"]
|
||||
|
||||
|
||||
def test_inventory_host_vars_have_bastion_prefix():
|
||||
"""TEST 12: Host variables prefixed with bastion_."""
|
||||
inv = build_inventory(MOCK_DEVICES)
|
||||
hostvars = inv["_meta"]["hostvars"]["laptop-win-01"]
|
||||
|
||||
assert hostvars["bastion_device_id"] == "00000000-0000-0000-0000-000000000001"
|
||||
assert hostvars["bastion_compliance_state"] == "compliant"
|
||||
assert hostvars["bastion_os_type"] == "windows"
|
||||
assert hostvars["bastion_management_channel"] == "intune"
|
||||
|
||||
|
||||
def test_inventory_no_credentials_in_host_vars():
|
||||
"""TEST 13: No credential variables in host vars."""
|
||||
inv = build_inventory(MOCK_DEVICES)
|
||||
|
||||
for hostname, hostvars in inv["_meta"]["hostvars"].items():
|
||||
for field in _CREDENTIAL_FIELDS:
|
||||
assert field not in hostvars, f"Credential field '{field}' found in {hostname} host vars"
|
||||
|
||||
|
||||
def test_inventory_empty_device_list():
|
||||
"""Empty device list produces valid empty inventory."""
|
||||
inv = build_inventory([])
|
||||
assert inv["all"]["hosts"] == []
|
||||
assert inv["_meta"]["hostvars"] == {}
|
||||
|
||||
|
||||
# ── Credential lookup plugin ─────────────────────────────────────
|
||||
|
||||
def test_credential_auto_detect_windows():
|
||||
"""Auto-detect type from bastion_os_type."""
|
||||
cred_type = _AUTO_TYPE_MAP.get("windows")
|
||||
assert cred_type == "kerberos"
|
||||
|
||||
|
||||
def test_credential_auto_detect_linux():
|
||||
cred_type = _AUTO_TYPE_MAP.get("linux")
|
||||
assert cred_type == "ssh_cert"
|
||||
|
||||
|
||||
def test_credential_graceful_degradation():
|
||||
"""TEST 14: Returns None when broker unavailable."""
|
||||
result = resolve_credential("host-1", credential_type="kerberos")
|
||||
assert result is None # Broker not running — graceful degradation
|
||||
|
||||
|
||||
# ── Chronicle callback plugin ────────────────────────────────────
|
||||
|
||||
def test_callback_playbook_started():
|
||||
"""TEST 15: PLAYBOOK_STARTED event emitted."""
|
||||
cb = ChronicleCallback()
|
||||
cb.on_playbook_start("test-playbook.yml", ["host-1", "host-2"])
|
||||
assert len(cb._events) == 1
|
||||
assert cb._events[0]["kind"] == "ANSIBLE_PLAYBOOK_STARTED"
|
||||
assert cb._events[0]["playbook"] == "test-playbook.yml"
|
||||
|
||||
|
||||
def test_callback_task_completed():
|
||||
"""TEST 15: TASK_COMPLETED event emitted per host."""
|
||||
cb = ChronicleCallback()
|
||||
cb.on_task_completed("host-1", "Ping", "ok", changed=False)
|
||||
cb.on_task_completed("host-2", "Ping", "failed", changed=False)
|
||||
assert len(cb._events) == 2
|
||||
assert cb._events[0]["kind"] == "ANSIBLE_TASK_COMPLETED"
|
||||
assert cb._events[1]["status"] == "failed"
|
||||
|
||||
|
||||
def test_callback_playbook_completed():
|
||||
"""TEST 15: PLAYBOOK_COMPLETED event with stats."""
|
||||
cb = ChronicleCallback()
|
||||
cb.on_task_completed("host-1", "Ping", "ok")
|
||||
cb.on_playbook_completed({"ok": 1, "failed": 0, "changed": 0})
|
||||
events = [e for e in cb._events if e["kind"] == "ANSIBLE_PLAYBOOK_COMPLETED"]
|
||||
assert len(events) == 1
|
||||
assert "host-1" in events[0]["affected_hosts"]
|
||||
|
||||
|
||||
def test_callback_full_lifecycle():
|
||||
"""TEST 15: Full playbook lifecycle audited with 3 events."""
|
||||
cb = ChronicleCallback()
|
||||
cb.on_playbook_start("site.yml", ["host-1"])
|
||||
cb.on_task_completed("host-1", "Install packages", "changed", changed=True)
|
||||
cb.on_playbook_completed({"ok": 0, "changed": 1, "failed": 0})
|
||||
|
||||
kinds = [e["kind"] for e in cb._events]
|
||||
assert kinds == [
|
||||
"ANSIBLE_PLAYBOOK_STARTED",
|
||||
"ANSIBLE_TASK_COMPLETED",
|
||||
"ANSIBLE_PLAYBOOK_COMPLETED",
|
||||
]
|
||||
|
||||
|
||||
def test_callback_compliance_recheck_triggered(monkeypatch):
|
||||
"""TEST 16: Compliance re-eval triggered when configured."""
|
||||
monkeypatch.setenv("BASTION_RECHECK_COMPLIANCE", "true")
|
||||
cb = ChronicleCallback()
|
||||
cb._recheck = True # env already set but constructor ran before monkeypatch
|
||||
cb._affected_hosts = {"host-1", "host-2"}
|
||||
|
||||
# Without a real client, _trigger_compliance_recheck is a no-op
|
||||
# but should not raise
|
||||
cb.on_playbook_completed({"ok": 2})
|
||||
assert len(cb._events) == 1 # PLAYBOOK_COMPLETED emitted
|
||||
Loading…
Reference in a new issue