541 lines
14 KiB
Python
541 lines
14 KiB
Python
from collections.abc import Iterable
|
|
from dataclasses import dataclass, replace
|
|
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 .normalizer 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 (
|
|
ReconciliationConflict,
|
|
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 _certificate_expired(
|
|
object: NormalizedObject,
|
|
now: datetime,
|
|
) -> bool:
|
|
value = object.payload.get("expires_on")
|
|
|
|
if value is None:
|
|
return False
|
|
|
|
if not isinstance(value, str):
|
|
raise ReconciliationConflict(
|
|
"Certificate expires_on must be normalized text"
|
|
)
|
|
|
|
normalized = value.strip()
|
|
|
|
if normalized.endswith("Z"):
|
|
normalized = normalized[:-1] + "+00:00"
|
|
|
|
try:
|
|
expires_at = datetime.fromisoformat(normalized)
|
|
except ValueError as exc:
|
|
raise ReconciliationConflict(
|
|
"Certificate expires_on is not a valid timestamp"
|
|
) from exc
|
|
|
|
if expires_at.tzinfo is None:
|
|
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
|
|
|
return expires_at.astimezone(timezone.utc) <= now
|
|
|
|
|
|
def _filter_expired_certificate_dependencies(
|
|
observations: Iterable[Observation],
|
|
now: datetime,
|
|
) -> list[Observation]:
|
|
rows = list(observations)
|
|
|
|
expired_local: set[tuple[str, str]] = set()
|
|
|
|
for observation in rows:
|
|
object = observation.object
|
|
|
|
if (
|
|
object.object_type != CERTIFICATES
|
|
or not _certificate_expired(object, now)
|
|
):
|
|
continue
|
|
|
|
if object.local_key is None:
|
|
raise ReconciliationConflict(
|
|
"Expired observed certificate requires "
|
|
"endpoint-local identity"
|
|
)
|
|
|
|
expired_local.add(
|
|
(
|
|
observation.endpoint_id,
|
|
object.local_key,
|
|
)
|
|
)
|
|
|
|
filtered: list[Observation] = []
|
|
|
|
for observation in rows:
|
|
object = observation.object
|
|
|
|
if (
|
|
object.object_type == CERTIFICATES
|
|
and object.local_key is not None
|
|
and (
|
|
observation.endpoint_id,
|
|
object.local_key,
|
|
)
|
|
in expired_local
|
|
):
|
|
continue
|
|
|
|
if object.object_type != PROXY_HOSTS:
|
|
filtered.append(observation)
|
|
continue
|
|
|
|
expired_references = tuple(
|
|
reference
|
|
for reference in object.references
|
|
if (
|
|
reference.object_type == CERTIFICATES
|
|
and reference.local_key is not None
|
|
and (
|
|
observation.endpoint_id,
|
|
reference.local_key,
|
|
)
|
|
in expired_local
|
|
)
|
|
)
|
|
|
|
if not expired_references:
|
|
filtered.append(observation)
|
|
continue
|
|
|
|
if object.payload.get("enabled") is True:
|
|
raise ReconciliationConflict(
|
|
"Enabled proxy host references an expired "
|
|
f"certificate: {object.logical_key!r} "
|
|
f"on {observation.endpoint_id}"
|
|
)
|
|
|
|
retained_references = tuple(
|
|
reference
|
|
for reference in object.references
|
|
if reference not in expired_references
|
|
)
|
|
|
|
filtered.append(
|
|
replace(
|
|
observation,
|
|
object=replace(
|
|
object,
|
|
references=retained_references,
|
|
),
|
|
)
|
|
)
|
|
|
|
return filtered
|
|
|
|
|
|
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_observations = _filter_expired_certificate_dependencies(
|
|
npm_observations,
|
|
timestamp,
|
|
)
|
|
|
|
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),
|
|
)
|