673 lines
19 KiB
Python
673 lines
19 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_older_certificate_is_replace_and_dependent_proxy_is_update(self):
|
|
endpoint = Endpoint(
|
|
"generic",
|
|
"node.example:443",
|
|
)
|
|
|
|
generic_adapter = adapter(
|
|
"generic",
|
|
"node.example:443",
|
|
CERTIFICATES,
|
|
PROXY_HOSTS,
|
|
)
|
|
|
|
desired_certificate = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-20T00:00:00+00:00",
|
|
"expires_on": "2026-12-20T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
desired_proxy = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "https",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 443,
|
|
"enabled": True,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
),
|
|
),
|
|
)
|
|
|
|
current_certificate = NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="67",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-08-20T00:00:00+00:00",
|
|
"expires_on": "2026-11-20T00:00:00+00:00",
|
|
},
|
|
)
|
|
|
|
current_proxy = NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload=dict(desired_proxy.payload),
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="67",
|
|
),
|
|
),
|
|
)
|
|
|
|
plans = build_endpoint_plans(
|
|
[generic_adapter],
|
|
[
|
|
desired_certificate,
|
|
desired_proxy,
|
|
],
|
|
[
|
|
observed(
|
|
endpoint.identity,
|
|
current_certificate,
|
|
),
|
|
observed(
|
|
endpoint.identity,
|
|
current_proxy,
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(len(plans), 1)
|
|
|
|
actions = plans[0].actions
|
|
|
|
self.assertEqual(
|
|
[
|
|
(
|
|
action.operation,
|
|
action.desired.object_type,
|
|
)
|
|
for action in actions
|
|
],
|
|
[
|
|
(
|
|
PlanOperation.REPLACE,
|
|
CERTIFICATES,
|
|
),
|
|
(
|
|
PlanOperation.UPDATE,
|
|
PROXY_HOSTS,
|
|
),
|
|
],
|
|
)
|
|
|
|
self.assertEqual(
|
|
actions[1].reason,
|
|
"referenced object is being replaced",
|
|
)
|
|
|
|
|
|
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()
|