From 85afbd8d618ac27913c1cb91b4edbc1f1c752af9d4b6ee400eb047983ae9dce8 Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Tue, 14 Apr 2026 11:23:03 -0400 Subject: [PATCH] feat(ansible): add guildhouse.bastion Ansible Galaxy collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../guildhouse/bastion/galaxy.yml | 13 ++ .../plugins/callback/bastion_chronicle.py | 126 +++++++++++++ .../bastion/plugins/inventory/bastion.py | 129 ++++++++++++++ .../bastion/plugins/lookup/credential.py | 86 +++++++++ .../plugins/module_utils/bastion_client.py | 113 ++++++++++++ .../guildhouse/bastion/tests/__init__.py | 0 .../guildhouse/bastion/tests/unit/__init__.py | 0 tests/test_ansible_collection.py | 168 ++++++++++++++++++ 8 files changed, 635 insertions(+) create mode 100644 ansible_collection/guildhouse/bastion/galaxy.yml create mode 100644 ansible_collection/guildhouse/bastion/plugins/callback/bastion_chronicle.py create mode 100644 ansible_collection/guildhouse/bastion/plugins/inventory/bastion.py create mode 100644 ansible_collection/guildhouse/bastion/plugins/lookup/credential.py create mode 100644 ansible_collection/guildhouse/bastion/plugins/module_utils/bastion_client.py create mode 100644 ansible_collection/guildhouse/bastion/tests/__init__.py create mode 100644 ansible_collection/guildhouse/bastion/tests/unit/__init__.py create mode 100644 tests/test_ansible_collection.py diff --git a/ansible_collection/guildhouse/bastion/galaxy.yml b/ansible_collection/guildhouse/bastion/galaxy.yml new file mode 100644 index 0000000..97270b1 --- /dev/null +++ b/ansible_collection/guildhouse/bastion/galaxy.yml @@ -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 diff --git a/ansible_collection/guildhouse/bastion/plugins/callback/bastion_chronicle.py b/ansible_collection/guildhouse/bastion/plugins/callback/bastion_chronicle.py new file mode 100644 index 0000000..33b4dfa --- /dev/null +++ b/ansible_collection/guildhouse/bastion/plugins/callback/bastion_chronicle.py @@ -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) diff --git a/ansible_collection/guildhouse/bastion/plugins/inventory/bastion.py b/ansible_collection/guildhouse/bastion/plugins/inventory/bastion.py new file mode 100644 index 0000000..50bf9a5 --- /dev/null +++ b/ansible_collection/guildhouse/bastion/plugins/inventory/bastion.py @@ -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 diff --git a/ansible_collection/guildhouse/bastion/plugins/lookup/credential.py b/ansible_collection/guildhouse/bastion/plugins/lookup/credential.py new file mode 100644 index 0000000..33289fe --- /dev/null +++ b/ansible_collection/guildhouse/bastion/plugins/lookup/credential.py @@ -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 diff --git a/ansible_collection/guildhouse/bastion/plugins/module_utils/bastion_client.py b/ansible_collection/guildhouse/bastion/plugins/module_utils/bastion_client.py new file mode 100644 index 0000000..a771c6b --- /dev/null +++ b/ansible_collection/guildhouse/bastion/plugins/module_utils/bastion_client.py @@ -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}/") diff --git a/ansible_collection/guildhouse/bastion/tests/__init__.py b/ansible_collection/guildhouse/bastion/tests/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/ansible_collection/guildhouse/bastion/tests/unit/__init__.py b/ansible_collection/guildhouse/bastion/tests/unit/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/tests/test_ansible_collection.py b/tests/test_ansible_collection.py new file mode 100644 index 0000000..96933fd --- /dev/null +++ b/tests/test_ansible_collection.py @@ -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