Add portable NPM reconciliation policies
This commit is contained in:
+304
-5
@@ -1,14 +1,18 @@
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Protocol
|
||||
|
||||
from .adapters import (
|
||||
CERTIFICATES,
|
||||
PROXY_HOSTS,
|
||||
Capability,
|
||||
CapabilityOperation,
|
||||
DNS_RECORDS,
|
||||
Endpoint,
|
||||
EndpointPlan,
|
||||
NormalizedObject,
|
||||
ObjectReference,
|
||||
Observation,
|
||||
PlanAction,
|
||||
PlanOperation,
|
||||
@@ -41,6 +45,12 @@ class ReconciliationPolicy(Protocol):
|
||||
observations: Sequence[Observation],
|
||||
) -> NormalizedObject: ...
|
||||
|
||||
def equivalent(
|
||||
self,
|
||||
current: NormalizedObject,
|
||||
desired: NormalizedObject,
|
||||
) -> bool: ...
|
||||
|
||||
|
||||
def _observation_sort_key(
|
||||
observation: Observation,
|
||||
@@ -52,6 +62,94 @@ def _observation_sort_key(
|
||||
)
|
||||
|
||||
|
||||
def logicalize_observation_references(
|
||||
observations: Iterable[Observation],
|
||||
) -> tuple[Observation, ...]:
|
||||
rows = tuple(observations)
|
||||
|
||||
local_targets: dict[
|
||||
tuple[str, str, str],
|
||||
str,
|
||||
] = {}
|
||||
|
||||
for observation in rows:
|
||||
object = observation.object
|
||||
|
||||
if object.local_key is None:
|
||||
continue
|
||||
|
||||
identity = (
|
||||
observation.endpoint_id,
|
||||
object.object_type,
|
||||
object.local_key,
|
||||
)
|
||||
|
||||
existing = local_targets.get(identity)
|
||||
|
||||
if (
|
||||
existing is not None
|
||||
and existing != object.logical_key
|
||||
):
|
||||
raise ReconciliationConflict(
|
||||
"Conflicting endpoint-local object identity for "
|
||||
f"{observation.endpoint_id} "
|
||||
f"{object.object_type} "
|
||||
f"local key {object.local_key!r}"
|
||||
)
|
||||
|
||||
local_targets[identity] = object.logical_key
|
||||
|
||||
logicalized: list[Observation] = []
|
||||
|
||||
for observation in rows:
|
||||
object = observation.object
|
||||
|
||||
references = []
|
||||
|
||||
for reference in object.references:
|
||||
if reference.logical_key is not None:
|
||||
references.append(reference)
|
||||
continue
|
||||
|
||||
target = (
|
||||
observation.endpoint_id,
|
||||
reference.object_type,
|
||||
reference.local_key,
|
||||
)
|
||||
logical_key = local_targets.get(target)
|
||||
|
||||
if logical_key is None:
|
||||
raise ReconciliationConflict(
|
||||
"Unresolved endpoint-local reference from "
|
||||
f"{observation.endpoint_id} "
|
||||
f"{object.object_type} "
|
||||
f"{object.logical_key!r}: "
|
||||
f"{reference.relation} -> "
|
||||
f"{reference.object_type} "
|
||||
f"local key {reference.local_key!r}"
|
||||
)
|
||||
|
||||
references.append(
|
||||
ObjectReference(
|
||||
relation=reference.relation,
|
||||
object_type=reference.object_type,
|
||||
logical_key=logical_key,
|
||||
)
|
||||
)
|
||||
|
||||
logicalized.append(
|
||||
replace(
|
||||
observation,
|
||||
object=replace(
|
||||
object,
|
||||
references=tuple(references),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return tuple(logicalized)
|
||||
|
||||
|
||||
def group_observations(
|
||||
observations: Iterable[Observation],
|
||||
) -> dict[ObservationGroupKey, tuple[Observation, ...]]:
|
||||
@@ -116,9 +214,179 @@ class DnsUnionPolicy:
|
||||
payload=dict(first.payload),
|
||||
)
|
||||
|
||||
def equivalent(
|
||||
self,
|
||||
current: NormalizedObject,
|
||||
desired: NormalizedObject,
|
||||
) -> bool:
|
||||
return (
|
||||
current.object_type == desired.object_type
|
||||
and current.logical_key == desired.logical_key
|
||||
and current.payload == desired.payload
|
||||
and current.references == desired.references
|
||||
)
|
||||
|
||||
|
||||
class CertificateNewestPolicy:
|
||||
object_type = CERTIFICATES
|
||||
|
||||
_PORTABLE_FIELDS = (
|
||||
"domain_names",
|
||||
"valid_from",
|
||||
"expires_on",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _portable_state(
|
||||
cls,
|
||||
object: NormalizedObject,
|
||||
) -> tuple[object, ...]:
|
||||
return tuple(
|
||||
object.payload.get(field)
|
||||
for field in cls._PORTABLE_FIELDS
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _expiry_key(
|
||||
object: NormalizedObject,
|
||||
) -> tuple[int, str]:
|
||||
expires_on = object.payload.get("expires_on")
|
||||
|
||||
if expires_on is None:
|
||||
return (0, "")
|
||||
|
||||
if not isinstance(expires_on, str):
|
||||
raise ReconciliationConflict(
|
||||
"Certificate expires_on must be normalized text"
|
||||
)
|
||||
|
||||
return (1, expires_on)
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
logical_key: str,
|
||||
observations: Sequence[Observation],
|
||||
) -> NormalizedObject:
|
||||
if not observations:
|
||||
raise RuntimeError(
|
||||
"Certificate policy requires at least one observation"
|
||||
)
|
||||
|
||||
newest_key = max(
|
||||
self._expiry_key(observation.object)
|
||||
for observation in observations
|
||||
)
|
||||
|
||||
newest = [
|
||||
observation
|
||||
for observation in observations
|
||||
if self._expiry_key(observation.object) == newest_key
|
||||
]
|
||||
|
||||
baseline = self._portable_state(newest[0].object)
|
||||
|
||||
conflicting_endpoints = {
|
||||
observation.endpoint_id
|
||||
for observation in newest
|
||||
if self._portable_state(observation.object) != baseline
|
||||
}
|
||||
|
||||
if conflicting_endpoints:
|
||||
conflicting_endpoints.add(newest[0].endpoint_id)
|
||||
|
||||
raise ReconciliationConflict(
|
||||
f"Conflicting {CERTIFICATES} observations for "
|
||||
f"{logical_key!r} at newest expiry from "
|
||||
+ ", ".join(sorted(conflicting_endpoints))
|
||||
)
|
||||
|
||||
winner = newest[0].object
|
||||
|
||||
return NormalizedObject(
|
||||
object_type=CERTIFICATES,
|
||||
logical_key=logical_key,
|
||||
payload=dict(winner.payload),
|
||||
)
|
||||
|
||||
def equivalent(
|
||||
self,
|
||||
current: NormalizedObject,
|
||||
desired: NormalizedObject,
|
||||
) -> bool:
|
||||
return (
|
||||
current.object_type == CERTIFICATES
|
||||
and desired.object_type == CERTIFICATES
|
||||
and current.logical_key == desired.logical_key
|
||||
and self._portable_state(current)
|
||||
== self._portable_state(desired)
|
||||
)
|
||||
|
||||
|
||||
class ProxyHostConsensusPolicy:
|
||||
object_type = PROXY_HOSTS
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
logical_key: str,
|
||||
observations: Sequence[Observation],
|
||||
) -> NormalizedObject:
|
||||
if not observations:
|
||||
raise RuntimeError(
|
||||
"Proxy-host policy requires at least one observation"
|
||||
)
|
||||
|
||||
first = observations[0].object
|
||||
|
||||
conflicting_endpoints = {
|
||||
observation.endpoint_id
|
||||
for observation in observations
|
||||
if (
|
||||
observation.object.payload != first.payload
|
||||
or observation.object.references != first.references
|
||||
)
|
||||
}
|
||||
|
||||
if conflicting_endpoints:
|
||||
conflicting_endpoints.add(observations[0].endpoint_id)
|
||||
|
||||
raise ReconciliationConflict(
|
||||
f"Conflicting {PROXY_HOSTS} observations for "
|
||||
f"{logical_key!r} from "
|
||||
+ ", ".join(sorted(conflicting_endpoints))
|
||||
)
|
||||
|
||||
for reference in first.references:
|
||||
if reference.local_key is not None:
|
||||
raise ReconciliationConflict(
|
||||
"Proxy-host desired state cannot contain "
|
||||
"endpoint-local references"
|
||||
)
|
||||
|
||||
return NormalizedObject(
|
||||
object_type=PROXY_HOSTS,
|
||||
logical_key=logical_key,
|
||||
payload=dict(first.payload),
|
||||
references=first.references,
|
||||
)
|
||||
|
||||
def equivalent(
|
||||
self,
|
||||
current: NormalizedObject,
|
||||
desired: NormalizedObject,
|
||||
) -> bool:
|
||||
return (
|
||||
current.object_type == PROXY_HOSTS
|
||||
and desired.object_type == PROXY_HOSTS
|
||||
and current.logical_key == desired.logical_key
|
||||
and current.payload == desired.payload
|
||||
and current.references == desired.references
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_POLICIES: Mapping[str, ReconciliationPolicy] = {
|
||||
DNS_RECORDS: DnsUnionPolicy(),
|
||||
CERTIFICATES: CertificateNewestPolicy(),
|
||||
PROXY_HOSTS: ProxyHostConsensusPolicy(),
|
||||
}
|
||||
|
||||
|
||||
@@ -133,8 +401,12 @@ def reconcile_observations(
|
||||
)
|
||||
desired: list[NormalizedObject] = []
|
||||
|
||||
logicalized = logicalize_observation_references(
|
||||
observations
|
||||
)
|
||||
|
||||
for (object_type, logical_key), rows in (
|
||||
group_observations(observations).items()
|
||||
group_observations(logicalized).items()
|
||||
):
|
||||
policy = selected_policies.get(object_type)
|
||||
if policy is None:
|
||||
@@ -156,6 +428,7 @@ def build_endpoint_plans(
|
||||
adapters: Iterable[PlanningAdapter],
|
||||
desired_objects: Iterable[NormalizedObject],
|
||||
observations: Iterable[Observation],
|
||||
policies: Mapping[str, ReconciliationPolicy] | None = None,
|
||||
) -> tuple[EndpointPlan, ...]:
|
||||
desired_by_identity: dict[
|
||||
tuple[str, str],
|
||||
@@ -180,11 +453,22 @@ def build_endpoint_plans(
|
||||
)
|
||||
desired_by_identity[identity] = desired
|
||||
|
||||
selected_policies = (
|
||||
DEFAULT_POLICIES
|
||||
if policies is None
|
||||
else policies
|
||||
)
|
||||
|
||||
current_by_identity: dict[
|
||||
tuple[str, str, str],
|
||||
list[Observation],
|
||||
] = defaultdict(list)
|
||||
for observation in observations:
|
||||
|
||||
logicalized_observations = (
|
||||
logicalize_observation_references(observations)
|
||||
)
|
||||
|
||||
for observation in logicalized_observations:
|
||||
current_by_identity[
|
||||
(
|
||||
observation.endpoint_id,
|
||||
@@ -239,10 +523,25 @@ def build_endpoint_plans(
|
||||
)
|
||||
if current is None:
|
||||
operation = PlanOperation.CREATE
|
||||
elif current.payload == desired.payload:
|
||||
operation = PlanOperation.NOOP
|
||||
else:
|
||||
operation = PlanOperation.UPDATE
|
||||
policy = selected_policies.get(object_type)
|
||||
|
||||
if policy is None:
|
||||
equivalent = (
|
||||
current.payload == desired.payload
|
||||
and current.references == desired.references
|
||||
)
|
||||
else:
|
||||
equivalent = policy.equivalent(
|
||||
current,
|
||||
desired,
|
||||
)
|
||||
|
||||
operation = (
|
||||
PlanOperation.NOOP
|
||||
if equivalent
|
||||
else PlanOperation.UPDATE
|
||||
)
|
||||
|
||||
actions.append(
|
||||
PlanAction(
|
||||
|
||||
Reference in New Issue
Block a user