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>
This commit is contained in:
parent
2ac5aa3b85
commit
5adc55aff5
2 changed files with 161 additions and 0 deletions
2
gsap_broker/routing/__init__.py
Normal file
2
gsap_broker/routing/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copyright 2026 Guildhouse Dev
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
159
gsap_broker/routing/device_router.py
Normal file
159
gsap_broker/routing/device_router.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
# 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
|
||||||
Loading…
Reference in a new issue