Require live observations for convergence

This commit is contained in:
Lyncoln
2026-09-20 02:15:30 -04:00
parent 387332b5db
commit 291f4f492e
3 changed files with 482 additions and 25 deletions
+153 -22
View File
@@ -52,6 +52,12 @@ class ReconciliationRun:
failures: tuple[DiscoveryFailure, ...]
@dataclass(frozen=True)
class _PlanningAdapterView:
endpoint: Endpoint
capabilities: frozenset[Capability]
def _discovery_types(
adapter: ReconciliationAdapter,
) -> tuple[str, ...]:
@@ -75,6 +81,90 @@ def _configured_observations(
]
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],
*,
@@ -189,15 +279,35 @@ def discover_reconcile_plan(
store,
set(adapters_by_endpoint),
)
npm_observations = [
observation
for observation in observations
if observation.object.object_type
in {
CERTIFICATES,
PROXY_HOSTS,
}
]
# 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)
@@ -205,17 +315,22 @@ def discover_reconcile_plan(
else []
)
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,
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,
@@ -229,18 +344,31 @@ def discover_reconcile_plan(
)
# Keep the reconciliation domains separate until each has
# produced portable desired state. In particular, router DNS
# observations must never participate in the proxy->DNS union.
# 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,
[
@@ -252,7 +380,10 @@ def discover_reconcile_plan(
)
plans = build_endpoint_plans(
adapter_list,
_planning_adapter_views(
adapter_list,
live_pairs,
),
desired,
observations,
)