# Copyright 2026 Guildhouse Dev # SPDX-License-Identifier: Apache-2.0 """DeviceRouter — automatic connector selection based on target device. The DeviceRouter inspects the target device's characteristics (OS, management channel, enrollment status) and routes operations to the appropriate connector. Routing decision logic: 1. If the operation is API-mediated (compliance check, inventory query), route to the Intune connector regardless of device OS. 2. If the device is Windows and the operation is session-based (execute, configure), route to PowerShell connector. 3. If the device is Linux/macOS and the operation is session-based, route to Bascule connector. 4. If the operation is multi-host (fleet-wide playbook), route to Ansible connector. 5. If the device is unknown, raise ``UnknownDevice``. The device inventory comes from: - Intune device cache (Windows/iOS/Android managed devices) - Bascule fleet registry (Linux governed endpoints) — future - Manual registration (for unmanaged devices) — future Rust port note: ``DeviceRouter`` maps to a struct with connector registry and device cache references. The routing logic is a match statement on ``(operation_type, device_os)``. """ from __future__ import annotations import logging from typing import Any, Optional from gsap_broker.connectors.base import ConnectorContext, ConnectorResult from gsap_broker.connectors.registry import ConnectorRegistry from gsap_broker.intune.device_cache import DeviceComplianceCache logger = logging.getLogger(__name__) # Operations that are API-mediated (go through Intune Graph API # regardless of device OS). _API_OPERATIONS = frozenset({ "get_compliance", "list_devices", "get_device", "sync_device", "retire_device", "wipe_device", }) # Operations that span multiple hosts (go through Ansible). _FLEET_OPERATIONS = frozenset({ "playbook", "adhoc", "role", "collect", }) # OS → preferred session connector mapping. _OS_CONNECTOR_MAP = { "windows": "powershell", "linux": "bascule", "macos": "bascule", "ios": "intune", # mobile — API-only "android": "intune", # mobile — API-only } class UnknownDevice(Exception): """Target device is not in any inventory.""" def __init__(self, target: str): self.target = target super().__init__(f"Unknown device: {target}") class DeviceRouter: """Routes operations to the appropriate connector based on target. Combines the device inventory (Intune cache) with the connector registry to select the best management channel for each operation. """ def __init__( self, connector_registry: ConnectorRegistry, device_cache: Optional[DeviceComplianceCache] = None, ): self._connectors = connector_registry self._devices = device_cache async def route( self, operation: str, target: str, context: ConnectorContext ) -> tuple[str, str]: """Determine which connector handles this operation. Returns: (connector_id, mapped_operation) — the connector to use and the operation name to pass to it (may differ from the input if the router translates operations). Raises: UnknownDevice: if the target is not in any inventory and cannot be routed. """ # API-mediated operations always go to Intune if operation in _API_OPERATIONS: return ("intune", operation) # Fleet operations go to Ansible if operation in _FLEET_OPERATIONS: return ("ansible", operation) # Session operations: route based on device OS device_os = await self._detect_os(target) if device_os is None: raise UnknownDevice(target) connector_id = _OS_CONNECTOR_MAP.get(device_os.lower(), "bascule") return (connector_id, operation) async def invoke( self, operation: str, target: str, parameters: dict[str, Any], context: ConnectorContext, ) -> ConnectorResult: """Route and invoke in one call.""" try: connector_id, mapped_op = await self.route(operation, target, context) except UnknownDevice as e: return ConnectorResult(success=False, error=str(e)) connector = self._connectors.get(connector_id) if connector is None: return ConnectorResult( success=False, error=f"Connector '{connector_id}' not registered", ) parameters["target"] = target return await connector.invoke(mapped_op, parameters, context) async def _detect_os(self, target: str) -> Optional[str]: """Look up the device OS from the Intune cache. Future: also check Bascule fleet registry, manual registration, DNS-based hints, etc. """ if self._devices is None: return None cached = await self._devices.get(target) if cached is not None: # ComplianceState doesn't carry OS — the cache is keyed # by device_id but the Intune connector's list_devices # response has os_type. For now, return None and let the # caller handle it. A proper DeviceInventory cache (not # just compliance) is needed — tracked for next sprint. return None return None