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