fastapi-gsap/gsap_broker/routing/device_router.py
Tyler J King 5adc55aff5 feat(routing): add DeviceRouter for automatic connector selection
Routes operations to Intune, PowerShell, Bascule, or Ansible
based on operation type and target device OS. API-mediated ops
always go to Intune, fleet ops to Ansible, session ops routed
by OS. Single invoke endpoint for all device operations.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 06:01:55 -04:00

159 lines
5.3 KiB
Python

# 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