Enable safe NPM replica convergence

This commit is contained in:
Lyncoln
2026-09-20 10:37:11 -04:00
parent 291f4f492e
commit e4ca80d4d5
7 changed files with 351 additions and 16 deletions
+17
View File
@@ -314,6 +314,17 @@ def _required_capability(
)
def _required_retirement_support(
adapter: ExecutionAdapter,
) -> None:
retire = getattr(adapter, "retire", None)
if not callable(retire):
raise RuntimeError(
f"{adapter.endpoint.identity} adapter does not support "
"deferred retirement"
)
def _preflight(
adapters: Iterable[ExecutionAdapter],
plans: Iterable[EndpointPlan],
@@ -345,6 +356,9 @@ def _preflight(
f"No adapter for endpoint plan {plan.endpoint_id}"
)
if plan.retirements:
_required_retirement_support(adapter)
for retirement in plan.retirements:
planned_retirements.append(
(
@@ -381,6 +395,9 @@ def _preflight(
"Executable plan action requires desired state"
)
if action.operation == PlanOperation.REPLACE:
_required_retirement_support(adapter)
object_type = action.desired.object_type
_required_capability(
adapter,
+12
View File
@@ -21,6 +21,7 @@ from .persistence import SQLiteStateStore
from .projections import project_proxy_hosts_to_dns
from .reconciliation import (
build_endpoint_plans,
merge_desired_with_previous,
reconcile_observations,
)
@@ -315,6 +316,17 @@ def discover_reconcile_plan(
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,
+8
View File
@@ -43,6 +43,10 @@ class Plugin:
PROXY_HOSTS,
CapabilityOperation.DISCOVER,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
@@ -51,6 +55,10 @@ class Plugin:
CERTIFICATES,
CapabilityOperation.DISCOVER,
),
Capability(
CERTIFICATES,
CapabilityOperation.APPLY,
),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
+60
View File
@@ -54,6 +54,46 @@ class ReconciliationPolicy(Protocol):
) -> bool: ...
def merge_desired_with_previous(
fresh: Iterable[NormalizedObject],
previous: Iterable[NormalizedObject],
policies: Mapping[str, ReconciliationPolicy] | None = None,
) -> list[NormalizedObject]:
selected_policies = (
DEFAULT_POLICIES
if policies is None
else policies
)
previous_by_identity = {
(object.object_type, object.logical_key): object
for object in previous
}
merged: list[NormalizedObject] = []
for candidate in fresh:
identity = (
candidate.object_type,
candidate.logical_key,
)
prior = previous_by_identity.get(identity)
policy = selected_policies.get(candidate.object_type)
merger = (
getattr(policy, "merge_desired", None)
if policy is not None
else None
)
if prior is not None and callable(merger):
candidate = merger(prior, candidate)
merged.append(candidate)
return merged
def _observation_sort_key(
observation: Observation,
) -> tuple[str, str, str]:
@@ -343,6 +383,26 @@ class CertificateNewestPolicy:
== self._portable_state(desired)
)
def merge_desired(
self,
previous: NormalizedObject,
fresh: NormalizedObject,
) -> NormalizedObject:
if (
previous.object_type != CERTIFICATES
or fresh.object_type != CERTIFICATES
or previous.logical_key != fresh.logical_key
):
raise ReconciliationConflict(
"Certificate desired-state merge requires "
"matching certificate identities"
)
if self._newest_key(previous) > self._newest_key(fresh):
return previous
return fresh
@staticmethod
def _equivalent_selection_key(
object: NormalizedObject,