Files
dns-sync/dns_sync/orchestrator.py
T

397 lines
10 KiB
Python

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,
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 []
)
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),
)