from collections.abc import Iterable from dataclasses import dataclass from datetime import datetime, timezone from typing import Protocol from .adapters import ( CERTIFICATES, 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, merge_desired_with_previous, 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, ...] @dataclass(frozen=True) class _PlanningAdapterView: endpoint: Endpoint capabilities: frozenset[Capability] 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 _live_discovery_pairs( adapters: Iterable[ReconciliationAdapter], store: SQLiteStateStore, attempted_at: datetime, ) -> frozenset[tuple[str, str]]: live: set[tuple[str, str]] = set() for adapter in adapters: endpoint_id = adapter.endpoint.identity for object_type in _discovery_types(adapter): status = store.observation_status( endpoint_id, object_type, ) if ( status is not None and status.is_live and status.last_attempt_at == attempted_at ): live.add( ( endpoint_id, object_type, ) ) return frozenset(live) def _authoritative_observations( observations: Iterable[Observation], live_pairs: frozenset[tuple[str, str]], object_types: frozenset[str], ) -> list[Observation]: return [ observation for observation in observations if ( observation.object.object_type in object_types and ( observation.endpoint_id, observation.object.object_type, ) in live_pairs ) ] def _planning_adapter_views( adapters: Iterable[ReconciliationAdapter], live_pairs: frozenset[tuple[str, str]], ) -> list[_PlanningAdapterView]: views: list[_PlanningAdapterView] = [] for adapter in adapters: endpoint_id = adapter.endpoint.identity capabilities = frozenset( capability for capability in adapter.capabilities if ( capability.operation != CapabilityOperation.APPLY or ( endpoint_id, capability.object_type, ) in live_pairs ) ) views.append( _PlanningAdapterView( endpoint=adapter.endpoint, capabilities=capabilities, ) ) return views 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), ) # Cached observations remain visible, but only a successful # discovery from this exact run may establish desired state or # make an endpoint eligible for mutation. live_pairs = _live_discovery_pairs( adapter_list, store, timestamp, ) certificate_authoritative = any( object_type == CERTIFICATES for _, object_type in live_pairs ) proxy_authoritative = any( object_type == PROXY_HOSTS for _, object_type in live_pairs ) npm_observations = _authoritative_observations( observations, live_pairs, frozenset( { CERTIFICATES, PROXY_HOSTS, } ), ) npm_desired = ( reconcile_observations(npm_observations) if npm_observations else [] ) if certificate_authoritative: previous_certificates = [ row.object for row in store.load_desired_state(CERTIFICATES) ] npm_desired = merge_desired_with_previous( npm_desired, previous_certificates, ) proxy_observations = _authoritative_observations( observations, live_pairs, frozenset({PROXY_HOSTS}), ) projected_dns = ( project_proxy_hosts_to_dns( proxy_observations, target_ip, include_disabled=include_disabled, ) if proxy_authoritative else [] ) projected_dns = _filter_projected_dns( projected_dns, domain_matching=domain_matching, domain=domain, ) dns_desired = ( reconcile_observations(projected_dns) if projected_dns else [] ) # Keep the reconciliation domains separate until each has # produced portable desired state. Router DNS observations are # current target state, never a desired-state authority. desired = [ *npm_desired, *dns_desired, ] authority_by_type = { CERTIFICATES: certificate_authoritative, PROXY_HOSTS: proxy_authoritative, # DNS desired state is projected from proxy-host authority. DNS_RECORDS: proxy_authoritative, } for object_type in ( CERTIFICATES, PROXY_HOSTS, DNS_RECORDS, ): if not authority_by_type[object_type]: # No successful source discovery this run: retain the # previously selected desired snapshot for diagnostics, # but do not use it for this run's plans. continue store.replace_desired_state( object_type, [ object for object in desired if object.object_type == object_type ], timestamp, ) plans = build_endpoint_plans( _planning_adapter_views( adapter_list, live_pairs, ), desired, observations, ) return ReconciliationRun( observations=tuple(observations), desired=tuple(desired), plans=plans, failures=tuple(failures), )