Files
dns-sync/tests/test_planning.py
T

558 lines
15 KiB
Python

import unittest
from dataclasses import dataclass
from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
CapabilityOperation,
Endpoint,
NormalizedObject,
ObjectReference,
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_plan_orders_referenced_object_before_dependent(self):
endpoint = Endpoint("generic", "node.example:443")
generic_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
CERTIFICATES,
CapabilityOperation.APPLY,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
}
),
)
certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
},
)
proxy_host = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "192.168.1.50",
"forward_port": 8080,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
plans = build_endpoint_plans(
[generic_adapter],
[
proxy_host,
certificate,
],
[],
)
self.assertEqual(len(plans), 1)
self.assertEqual(
tuple(
action.desired.object_type
for action in plans[0].actions
),
(
CERTIFICATES,
PROXY_HOSTS,
),
)
def test_plan_dependency_cycle_fails_before_plan_is_returned(self):
endpoint = Endpoint("generic", "node.example:443")
object_a = "object_a"
object_b = "object_b"
generic_adapter = adapter(
"generic",
"node.example:443",
object_a,
object_b,
)
first = NormalizedObject(
object_type=object_a,
logical_key="a",
payload={},
references=(
ObjectReference(
relation="b",
object_type=object_b,
logical_key="b",
),
),
)
second = NormalizedObject(
object_type=object_b,
logical_key="b",
payload={},
references=(
ObjectReference(
relation="a",
object_type=object_a,
logical_key="a",
),
),
)
with self.assertRaisesRegex(
RuntimeError,
"dependency cycle",
):
build_endpoint_plans(
[generic_adapter],
[first, second],
[],
)
def test_certificate_provider_difference_is_noop_when_portable_state_matches(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
CERTIFICATES,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"nice_name": "source",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
current = Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"nice_name": "imported copy",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
),
observed_at=NOW,
)
plans = build_endpoint_plans(
[npm_adapter],
[desired],
[current],
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.NOOP,
)
def test_proxy_reference_change_is_update_even_when_payload_matches(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="new.example.com",
),
),
)
observations = [
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="old.example.com",
local_key="67",
payload={
"domain_names": ["old.example.com"],
},
),
observed_at=NOW,
),
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
),
observed_at=NOW,
),
]
plans = build_endpoint_plans(
[npm_adapter],
[desired],
observations,
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.UPDATE,
)
def test_planner_logicalizes_matching_local_reference_to_noop(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
observations = [
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
},
),
observed_at=NOW,
),
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
),
observed_at=NOW,
),
]
plans = build_endpoint_plans(
[npm_adapter],
[desired],
observations,
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.NOOP,
)
self.assertEqual(
plans[0].actions[0].current.references,
desired.references,
)
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()