from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timezone from typing import Protocol from .adapters import ( DNS_RECORDS, PROXY_HOSTS, Capability, CapabilityOperation, DiscoveredObject, Endpoint, EndpointPlan, NormalizedObject, Observation, ) from .engine import hostname_matches_domain from .object_normalizer import normalize_observations from .persistence import SQLiteStateStore from .projections import project_proxy_hosts_to_dns from .reconciliation import ( build_endpoint_plans, reconcile_observations, ) class ReconciliationAdapter(Protocol): endpoint: Endpoint capabilities: frozenset[Capability] def connect(self) -> None: ... def discover( self, object_type: str, ) -> list[DiscoveredObject]: ... @dataclass(frozen=True) class DiscoveryFailure: endpoint_id: str object_type: str error: str @dataclass(frozen=True) class ReconciliationRun: observations: tuple[Observation, ...] desired: tuple[NormalizedObject, ...] plans: tuple[EndpointPlan, ...] failures: tuple[DiscoveryFailure, ...] def _discovery_types( adapter: ReconciliationAdapter, ) -> tuple[str, ...]: return tuple( sorted( capability.object_type for capability in adapter.capabilities if capability.operation == CapabilityOperation.DISCOVER ) ) def _configured_observations( store: SQLiteStateStore, endpoint_ids: set[str], ) -> list[Observation]: return [ observation for observation in store.load_observations() if observation.endpoint_id in endpoint_ids ] def _filter_projected_dns( observations: Iterable[Observation], *, domain_matching: bool, domain: str | None, ) -> list[Observation]: rows = list(observations) if not domain_matching: return rows if not domain: raise RuntimeError( "Domain filtering enabled without a configured DOMAIN" ) return [ observation for observation in rows if hostname_matches_domain( observation.object.logical_key, domain, ) ] def discover_reconcile_plan( adapters: Iterable[ReconciliationAdapter], store: SQLiteStateStore, *, target_ip: str, include_disabled: bool = False, domain_matching: bool = False, domain: str | None = None, run_at: datetime | None = None, ) -> ReconciliationRun: timestamp = run_at or datetime.now(timezone.utc) if timestamp.tzinfo is None: raise RuntimeError( "Reconciliation run timestamp must be timezone-aware" ) timestamp = timestamp.astimezone(timezone.utc) adapter_list = list(adapters) adapters_by_endpoint: dict[str, ReconciliationAdapter] = {} for adapter in adapter_list: endpoint_id = adapter.endpoint.identity if endpoint_id in adapters_by_endpoint: raise RuntimeError( f"Duplicate adapter endpoint: {endpoint_id}" ) adapters_by_endpoint[endpoint_id] = adapter failures: list[DiscoveryFailure] = [] for endpoint_id, adapter in sorted( adapters_by_endpoint.items() ): object_types = _discovery_types(adapter) if not object_types: continue try: adapter.connect() except Exception as exc: for object_type in object_types: error = str(exc) store.record_discovery_failure( endpoint_id, object_type, error, timestamp, ) failures.append( DiscoveryFailure( endpoint_id, object_type, error, ) ) continue for object_type in object_types: try: discovered = adapter.discover(object_type) observations = normalize_observations( endpoint_id, discovered, timestamp, ) store.replace_observations( endpoint_id, object_type, observations, timestamp, ) except Exception as exc: error = str(exc) store.record_discovery_failure( endpoint_id, object_type, error, timestamp, ) failures.append( DiscoveryFailure( endpoint_id, object_type, error, ) ) observations = _configured_observations( store, set(adapters_by_endpoint), ) proxy_observations = [ observation for observation in observations if observation.object.object_type == PROXY_HOSTS ] projected_dns = project_proxy_hosts_to_dns( proxy_observations, target_ip, include_disabled=include_disabled, ) projected_dns = _filter_projected_dns( projected_dns, domain_matching=domain_matching, domain=domain, ) desired = reconcile_observations(projected_dns) store.replace_desired_state( DNS_RECORDS, desired, timestamp, ) plans = build_endpoint_plans( adapter_list, desired, observations, ) return ReconciliationRun( observations=tuple(observations), desired=tuple(desired), plans=plans, failures=tuple(failures), )