500 lines
16 KiB
Python
500 lines
16 KiB
Python
import unittest
|
|
from datetime import datetime, timezone
|
|
|
|
from dns_sync.adapters import (
|
|
CERTIFICATES,
|
|
DNS_RECORDS,
|
|
PROXY_HOSTS,
|
|
NormalizedObject,
|
|
ObjectReference,
|
|
Observation,
|
|
)
|
|
from dns_sync.reconciliation import (
|
|
ReconciliationConflict,
|
|
group_observations,
|
|
logicalize_observation_references,
|
|
reconcile_observations,
|
|
)
|
|
|
|
|
|
NOW = datetime(2026, 9, 19, 22, 0, tzinfo=timezone.utc)
|
|
|
|
|
|
def dns_observation(
|
|
endpoint_id: str,
|
|
hostname: str,
|
|
ip: str,
|
|
local_key: str,
|
|
) -> Observation:
|
|
return Observation(
|
|
endpoint_id=endpoint_id,
|
|
object=NormalizedObject(
|
|
object_type=DNS_RECORDS,
|
|
logical_key=hostname,
|
|
local_key=local_key,
|
|
payload={
|
|
"hostname": hostname,
|
|
"ip": ip,
|
|
"record_type": "A",
|
|
"wildcard": False,
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
)
|
|
|
|
|
|
class ObservationGroupingTests(unittest.TestCase):
|
|
def test_groups_by_type_and_logical_identity(self):
|
|
observations = [
|
|
dns_observation(
|
|
"npm://192.168.1.4:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"22",
|
|
),
|
|
dns_observation(
|
|
"npm://192.168.1.3:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"12",
|
|
),
|
|
dns_observation(
|
|
"npm://192.168.1.3:81",
|
|
"other.example.com",
|
|
"192.168.1.101",
|
|
"13",
|
|
),
|
|
]
|
|
|
|
grouped = group_observations(observations)
|
|
|
|
self.assertEqual(
|
|
tuple(grouped),
|
|
(
|
|
(DNS_RECORDS, "app.example.com"),
|
|
(DNS_RECORDS, "other.example.com"),
|
|
),
|
|
)
|
|
self.assertEqual(
|
|
tuple(
|
|
item.endpoint_id
|
|
for item in grouped[
|
|
(DNS_RECORDS, "app.example.com")
|
|
]
|
|
),
|
|
(
|
|
"npm://192.168.1.3:81",
|
|
"npm://192.168.1.4:81",
|
|
),
|
|
)
|
|
|
|
|
|
class DnsUnionPolicyTests(unittest.TestCase):
|
|
def test_union_keeps_every_unique_hostname(self):
|
|
observations = [
|
|
dns_observation(
|
|
"npm://192.168.1.3:81",
|
|
"one.example.com",
|
|
"192.168.1.101",
|
|
"12",
|
|
),
|
|
dns_observation(
|
|
"npm://192.168.1.4:81",
|
|
"two.example.com",
|
|
"192.168.1.101",
|
|
"22",
|
|
),
|
|
]
|
|
|
|
desired = reconcile_observations(observations)
|
|
|
|
self.assertEqual(
|
|
tuple(item.logical_key for item in desired),
|
|
("one.example.com", "two.example.com"),
|
|
)
|
|
self.assertTrue(
|
|
all(item.local_key is None for item in desired)
|
|
)
|
|
|
|
def test_identical_observations_converge_to_one_object(self):
|
|
observations = [
|
|
dns_observation(
|
|
"npm://192.168.1.3:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"12",
|
|
),
|
|
dns_observation(
|
|
"npm://192.168.1.4:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"22",
|
|
),
|
|
]
|
|
|
|
desired = reconcile_observations(observations)
|
|
|
|
self.assertEqual(len(desired), 1)
|
|
self.assertEqual(
|
|
desired[0].payload["ip"],
|
|
"192.168.1.101",
|
|
)
|
|
self.assertIsNone(desired[0].local_key)
|
|
|
|
def test_conflicting_values_fail_without_exposing_payload(self):
|
|
observations = [
|
|
dns_observation(
|
|
"npm://node-one:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"12",
|
|
),
|
|
dns_observation(
|
|
"npm://node-two:81",
|
|
"app.example.com",
|
|
"203.0.113.77",
|
|
"22",
|
|
),
|
|
]
|
|
|
|
with self.assertRaises(ReconciliationConflict) as raised:
|
|
reconcile_observations(observations)
|
|
|
|
message = str(raised.exception)
|
|
self.assertIn("app.example.com", message)
|
|
self.assertIn("npm://node-one:81", message)
|
|
self.assertIn("npm://node-two:81", message)
|
|
self.assertNotIn("192.168.1.101", message)
|
|
self.assertNotIn("203.0.113.77", message)
|
|
|
|
def test_missing_policy_fails_explicitly(self):
|
|
observation = Observation(
|
|
endpoint_id="generic://node-one",
|
|
object=NormalizedObject(
|
|
object_type="unknown_objects",
|
|
logical_key="app.example.com",
|
|
local_key="67",
|
|
payload={},
|
|
),
|
|
observed_at=NOW,
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
RuntimeError,
|
|
"No reconciliation policy",
|
|
):
|
|
reconcile_observations([observation])
|
|
|
|
def test_local_certificate_references_become_logical_references(self):
|
|
observations = [
|
|
Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="67",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"enabled": True,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="67",
|
|
),
|
|
),
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
]
|
|
|
|
logicalized = logicalize_observation_references(
|
|
observations
|
|
)
|
|
|
|
proxy = next(
|
|
row.object
|
|
for row in logicalized
|
|
if row.object.object_type == PROXY_HOSTS
|
|
)
|
|
|
|
self.assertEqual(
|
|
proxy.references,
|
|
(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
),
|
|
),
|
|
)
|
|
|
|
def test_unresolved_local_reference_fails_closed(self):
|
|
observation = Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key="999",
|
|
),
|
|
),
|
|
),
|
|
observed_at=NOW,
|
|
)
|
|
|
|
with self.assertRaisesRegex(
|
|
ReconciliationConflict,
|
|
"Unresolved endpoint-local reference",
|
|
):
|
|
reconcile_observations([observation])
|
|
|
|
def test_certificate_policy_selects_newest_expiry(self):
|
|
observations = [
|
|
Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="10",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "other",
|
|
"nice_name": "older",
|
|
"valid_from": "2026-06-01T00:00:00+00:00",
|
|
"expires_on": "2026-09-01T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
Observation(
|
|
endpoint_id="npm://node-two:81",
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="44",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"provider": "letsencrypt",
|
|
"nice_name": "newer",
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
]
|
|
|
|
desired = reconcile_observations(observations)
|
|
|
|
self.assertEqual(len(desired), 1)
|
|
self.assertEqual(
|
|
desired[0].payload["expires_on"],
|
|
"2026-12-01T00:00:00+00:00",
|
|
)
|
|
self.assertEqual(
|
|
desired[0].payload["provider"],
|
|
"letsencrypt",
|
|
)
|
|
self.assertIsNone(desired[0].local_key)
|
|
|
|
def test_certificate_policy_prefers_newer_valid_from_over_expiry(self):
|
|
observations = [
|
|
Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="10",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-08-01T00:00:00+00:00",
|
|
"expires_on": "2027-01-01T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
Observation(
|
|
endpoint_id="npm://node-two:81",
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key="44",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-15T00:00:00+00:00",
|
|
"expires_on": "2026-12-15T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
]
|
|
|
|
desired = reconcile_observations(observations)
|
|
|
|
self.assertEqual(len(desired), 1)
|
|
self.assertEqual(
|
|
desired[0].payload["valid_from"],
|
|
"2026-09-15T00:00:00+00:00",
|
|
)
|
|
self.assertEqual(
|
|
desired[0].payload["expires_on"],
|
|
"2026-12-15T00:00:00+00:00",
|
|
)
|
|
|
|
def test_proxy_consensus_ignores_different_endpoint_local_certificate_ids(self):
|
|
observations = []
|
|
|
|
for endpoint_id, certificate_id, proxy_id in (
|
|
("npm://node-one:81", "67", "12"),
|
|
("npm://node-two:81", "84", "31"),
|
|
):
|
|
observations.extend(
|
|
[
|
|
Observation(
|
|
endpoint_id=endpoint_id,
|
|
object=NormalizedObject(
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
local_key=certificate_id,
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"valid_from": "2026-09-01T00:00:00+00:00",
|
|
"expires_on": "2026-12-01T00:00:00+00:00",
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
Observation(
|
|
endpoint_id=endpoint_id,
|
|
object=NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key=proxy_id,
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "backend.internal",
|
|
"forward_port": 8080,
|
|
"enabled": True,
|
|
},
|
|
references=(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
local_key=certificate_id,
|
|
),
|
|
),
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
]
|
|
)
|
|
|
|
desired = reconcile_observations(observations)
|
|
|
|
proxy = next(
|
|
object
|
|
for object in desired
|
|
if object.object_type == PROXY_HOSTS
|
|
)
|
|
|
|
self.assertEqual(
|
|
proxy.references,
|
|
(
|
|
ObjectReference(
|
|
relation="certificate",
|
|
object_type=CERTIFICATES,
|
|
logical_key="app.example.com",
|
|
),
|
|
),
|
|
)
|
|
|
|
def test_proxy_consensus_rejects_backend_drift_without_payload_output(self):
|
|
observations = [
|
|
Observation(
|
|
endpoint_id="npm://node-one:81",
|
|
object=NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="12",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "one.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
Observation(
|
|
endpoint_id="npm://node-two:81",
|
|
object=NormalizedObject(
|
|
object_type=PROXY_HOSTS,
|
|
logical_key="app.example.com",
|
|
local_key="31",
|
|
payload={
|
|
"domain_names": ["app.example.com"],
|
|
"forward_scheme": "http",
|
|
"forward_host": "two.internal",
|
|
"forward_port": 8080,
|
|
},
|
|
),
|
|
observed_at=NOW,
|
|
),
|
|
]
|
|
|
|
with self.assertRaises(ReconciliationConflict) as raised:
|
|
reconcile_observations(observations)
|
|
|
|
message = str(raised.exception)
|
|
|
|
self.assertIn("app.example.com", message)
|
|
self.assertIn("npm://node-one:81", message)
|
|
self.assertIn("npm://node-two:81", message)
|
|
self.assertNotIn("one.internal", message)
|
|
self.assertNotIn("two.internal", message)
|
|
|
|
def test_explicit_empty_policy_registry_uses_no_defaults(self):
|
|
observation = dns_observation(
|
|
"npm://node-one:81",
|
|
"app.example.com",
|
|
"192.168.1.101",
|
|
"12",
|
|
)
|
|
|
|
with self.assertRaisesRegex(RuntimeError, "No reconciliation policy"):
|
|
reconcile_observations([observation], policies={})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|