Build capability-filtered endpoint plans
This commit is contained in:
@@ -3,9 +3,15 @@ from collections.abc import Iterable, Mapping, Sequence
|
|||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from .adapters import (
|
from .adapters import (
|
||||||
|
Capability,
|
||||||
|
CapabilityOperation,
|
||||||
DNS_RECORDS,
|
DNS_RECORDS,
|
||||||
|
Endpoint,
|
||||||
|
EndpointPlan,
|
||||||
NormalizedObject,
|
NormalizedObject,
|
||||||
Observation,
|
Observation,
|
||||||
|
PlanAction,
|
||||||
|
PlanOperation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -16,6 +22,15 @@ class ReconciliationConflict(RuntimeError):
|
|||||||
"""Raised when observations cannot be reconciled safely."""
|
"""Raised when observations cannot be reconciled safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningConflict(RuntimeError):
|
||||||
|
"""Raised when an endpoint cannot be planned safely."""
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningAdapter(Protocol):
|
||||||
|
endpoint: Endpoint
|
||||||
|
capabilities: frozenset[Capability]
|
||||||
|
|
||||||
|
|
||||||
class ReconciliationPolicy(Protocol):
|
class ReconciliationPolicy(Protocol):
|
||||||
object_type: str
|
object_type: str
|
||||||
|
|
||||||
@@ -134,3 +149,114 @@ def reconcile_observations(
|
|||||||
desired.append(policy.reconcile(logical_key, rows))
|
desired.append(policy.reconcile(logical_key, rows))
|
||||||
|
|
||||||
return desired
|
return desired
|
||||||
|
|
||||||
|
|
||||||
|
def build_endpoint_plans(
|
||||||
|
adapters: Iterable[PlanningAdapter],
|
||||||
|
desired_objects: Iterable[NormalizedObject],
|
||||||
|
observations: Iterable[Observation],
|
||||||
|
) -> tuple[EndpointPlan, ...]:
|
||||||
|
desired_by_identity: dict[
|
||||||
|
tuple[str, str],
|
||||||
|
NormalizedObject,
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
for desired in desired_objects:
|
||||||
|
if desired.local_key is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Desired objects in plans cannot contain "
|
||||||
|
"an endpoint-local key"
|
||||||
|
)
|
||||||
|
|
||||||
|
identity = (
|
||||||
|
desired.object_type,
|
||||||
|
desired.logical_key,
|
||||||
|
)
|
||||||
|
if identity in desired_by_identity:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Duplicate desired object identity: "
|
||||||
|
f"{desired.object_type} {desired.logical_key}"
|
||||||
|
)
|
||||||
|
desired_by_identity[identity] = desired
|
||||||
|
|
||||||
|
current_by_identity: dict[
|
||||||
|
tuple[str, str, str],
|
||||||
|
list[Observation],
|
||||||
|
] = defaultdict(list)
|
||||||
|
for observation in observations:
|
||||||
|
current_by_identity[
|
||||||
|
(
|
||||||
|
observation.endpoint_id,
|
||||||
|
observation.object.object_type,
|
||||||
|
observation.object.logical_key,
|
||||||
|
)
|
||||||
|
].append(observation)
|
||||||
|
|
||||||
|
adapters_by_endpoint: dict[str, PlanningAdapter] = {}
|
||||||
|
for adapter in adapters:
|
||||||
|
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
|
||||||
|
|
||||||
|
plans: list[EndpointPlan] = []
|
||||||
|
for endpoint_id, adapter in sorted(
|
||||||
|
adapters_by_endpoint.items()
|
||||||
|
):
|
||||||
|
apply_types = {
|
||||||
|
capability.object_type
|
||||||
|
for capability in adapter.capabilities
|
||||||
|
if capability.operation == CapabilityOperation.APPLY
|
||||||
|
}
|
||||||
|
if not apply_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
actions: list[PlanAction] = []
|
||||||
|
for identity, desired in sorted(
|
||||||
|
desired_by_identity.items()
|
||||||
|
):
|
||||||
|
object_type, logical_key = identity
|
||||||
|
if object_type not in apply_types:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_rows = current_by_identity.get(
|
||||||
|
(endpoint_id, object_type, logical_key),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
if len(current_rows) > 1:
|
||||||
|
raise PlanningConflict(
|
||||||
|
f"Multiple endpoint objects for {endpoint_id} "
|
||||||
|
f"{object_type} {logical_key!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
current = (
|
||||||
|
current_rows[0].object
|
||||||
|
if current_rows
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if current is None:
|
||||||
|
operation = PlanOperation.CREATE
|
||||||
|
elif current.payload == desired.payload:
|
||||||
|
operation = PlanOperation.NOOP
|
||||||
|
else:
|
||||||
|
operation = PlanOperation.UPDATE
|
||||||
|
|
||||||
|
actions.append(
|
||||||
|
PlanAction(
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
operation=operation,
|
||||||
|
desired=desired,
|
||||||
|
current=current,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
plans.append(
|
||||||
|
EndpointPlan(
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
actions=tuple(actions),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(plans)
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import unittest
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dns_sync.adapters import (
|
||||||
|
CERTIFICATES,
|
||||||
|
DNS_RECORDS,
|
||||||
|
Capability,
|
||||||
|
CapabilityOperation,
|
||||||
|
Endpoint,
|
||||||
|
NormalizedObject,
|
||||||
|
Observation,
|
||||||
|
PlanOperation,
|
||||||
|
)
|
||||||
|
from dns_sync.reconciliation import (
|
||||||
|
PlanningConflict,
|
||||||
|
build_endpoint_plans,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 19, 22, 30, tzinfo=timezone.utc)
|
||||||
|
FIOS = "verizon://192.168.1.1"
|
||||||
|
SECONDARY = "test-dns://192.168.1.2"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakeAdapter:
|
||||||
|
endpoint: Endpoint
|
||||||
|
capabilities: frozenset[Capability]
|
||||||
|
|
||||||
|
|
||||||
|
def adapter(
|
||||||
|
plugin: str,
|
||||||
|
address: str,
|
||||||
|
*apply_types: str,
|
||||||
|
) -> FakeAdapter:
|
||||||
|
return FakeAdapter(
|
||||||
|
endpoint=Endpoint(plugin, address),
|
||||||
|
capabilities=frozenset(
|
||||||
|
Capability(
|
||||||
|
object_type,
|
||||||
|
CapabilityOperation.APPLY,
|
||||||
|
)
|
||||||
|
for object_type in apply_types
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def dns_object(
|
||||||
|
hostname: str,
|
||||||
|
ip: str,
|
||||||
|
local_key: str | None = None,
|
||||||
|
) -> NormalizedObject:
|
||||||
|
return NormalizedObject(
|
||||||
|
object_type=DNS_RECORDS,
|
||||||
|
logical_key=hostname,
|
||||||
|
local_key=local_key,
|
||||||
|
payload={
|
||||||
|
"hostname": hostname,
|
||||||
|
"ip": ip,
|
||||||
|
"record_type": "A",
|
||||||
|
"wildcard": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def observed(
|
||||||
|
endpoint_id: str,
|
||||||
|
object: NormalizedObject,
|
||||||
|
) -> Observation:
|
||||||
|
return Observation(
|
||||||
|
endpoint_id=endpoint_id,
|
||||||
|
object=object,
|
||||||
|
observed_at=NOW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EndpointPlanningTests(unittest.TestCase):
|
||||||
|
def test_builds_separate_capability_filtered_plans(self):
|
||||||
|
desired = [
|
||||||
|
dns_object("add.example.com", "192.168.1.101"),
|
||||||
|
dns_object("keep.example.com", "192.168.1.101"),
|
||||||
|
dns_object("update.example.com", "192.168.1.101"),
|
||||||
|
NormalizedObject(
|
||||||
|
object_type=CERTIFICATES,
|
||||||
|
logical_key="app.example.com",
|
||||||
|
payload={
|
||||||
|
"domain_names": ["app.example.com"],
|
||||||
|
"expires_on": "2026-12-15",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
current = [
|
||||||
|
observed(
|
||||||
|
FIOS,
|
||||||
|
dns_object(
|
||||||
|
"keep.example.com",
|
||||||
|
"192.168.1.101",
|
||||||
|
local_key="41",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observed(
|
||||||
|
FIOS,
|
||||||
|
dns_object(
|
||||||
|
"update.example.com",
|
||||||
|
"192.168.1.50",
|
||||||
|
local_key="42",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observed(
|
||||||
|
FIOS,
|
||||||
|
dns_object(
|
||||||
|
"unmanaged.example.com",
|
||||||
|
"192.168.1.77",
|
||||||
|
local_key="43",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
adapters = [
|
||||||
|
adapter("verizon", "192.168.1.1", DNS_RECORDS),
|
||||||
|
adapter("test-dns", "192.168.1.2", DNS_RECORDS),
|
||||||
|
FakeAdapter(
|
||||||
|
endpoint=Endpoint("npm", "192.168.1.3:81"),
|
||||||
|
capabilities=frozenset(
|
||||||
|
{
|
||||||
|
Capability(
|
||||||
|
CERTIFICATES,
|
||||||
|
CapabilityOperation.DISCOVER,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
plans = build_endpoint_plans(
|
||||||
|
adapters,
|
||||||
|
desired,
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
tuple(plan.endpoint_id for plan in plans),
|
||||||
|
(SECONDARY, FIOS),
|
||||||
|
)
|
||||||
|
|
||||||
|
secondary_actions = {
|
||||||
|
action.desired.logical_key: action.operation
|
||||||
|
for action in plans[0].actions
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
set(secondary_actions.values()),
|
||||||
|
{PlanOperation.CREATE},
|
||||||
|
)
|
||||||
|
|
||||||
|
fios_actions = {
|
||||||
|
action.desired.logical_key: action.operation
|
||||||
|
for action in plans[1].actions
|
||||||
|
}
|
||||||
|
self.assertEqual(
|
||||||
|
fios_actions,
|
||||||
|
{
|
||||||
|
"add.example.com": PlanOperation.CREATE,
|
||||||
|
"keep.example.com": PlanOperation.NOOP,
|
||||||
|
"update.example.com": PlanOperation.UPDATE,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
planned_keys = {
|
||||||
|
action.desired.logical_key
|
||||||
|
for plan in plans
|
||||||
|
for action in plan.actions
|
||||||
|
}
|
||||||
|
self.assertNotIn("app.example.com", planned_keys)
|
||||||
|
self.assertNotIn("unmanaged.example.com", planned_keys)
|
||||||
|
|
||||||
|
def test_duplicate_endpoint_objects_fail_without_payload_output(self):
|
||||||
|
desired = [
|
||||||
|
dns_object("app.example.com", "192.168.1.101")
|
||||||
|
]
|
||||||
|
current = [
|
||||||
|
observed(
|
||||||
|
FIOS,
|
||||||
|
dns_object(
|
||||||
|
"app.example.com",
|
||||||
|
"192.168.1.50",
|
||||||
|
local_key="41",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
observed(
|
||||||
|
FIOS,
|
||||||
|
dns_object(
|
||||||
|
"app.example.com",
|
||||||
|
"203.0.113.77",
|
||||||
|
local_key="42",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with self.assertRaises(PlanningConflict) as raised:
|
||||||
|
build_endpoint_plans(
|
||||||
|
[adapter("verizon", "192.168.1.1", DNS_RECORDS)],
|
||||||
|
desired,
|
||||||
|
current,
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(raised.exception)
|
||||||
|
self.assertIn(FIOS, message)
|
||||||
|
self.assertIn("app.example.com", message)
|
||||||
|
self.assertNotIn("192.168.1.50", message)
|
||||||
|
self.assertNotIn("203.0.113.77", message)
|
||||||
|
|
||||||
|
def test_duplicate_desired_identity_is_rejected(self):
|
||||||
|
duplicate = dns_object(
|
||||||
|
"app.example.com",
|
||||||
|
"192.168.1.101",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
RuntimeError,
|
||||||
|
"Duplicate desired object identity",
|
||||||
|
):
|
||||||
|
build_endpoint_plans(
|
||||||
|
[adapter("verizon", "192.168.1.1", DNS_RECORDS)],
|
||||||
|
[duplicate, duplicate],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user