Add portable NPM reconciliation policies
This commit is contained in:
@@ -4,12 +4,15 @@ 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,
|
||||
)
|
||||
|
||||
@@ -166,12 +169,12 @@ class DnsUnionPolicyTests(unittest.TestCase):
|
||||
|
||||
def test_missing_policy_fails_explicitly(self):
|
||||
observation = Observation(
|
||||
endpoint_id="npm://node-one:81",
|
||||
endpoint_id="generic://node-one",
|
||||
object=NormalizedObject(
|
||||
object_type=CERTIFICATES,
|
||||
object_type="unknown_objects",
|
||||
logical_key="app.example.com",
|
||||
local_key="67",
|
||||
payload={"expires_on": "2026-12-15"},
|
||||
payload={},
|
||||
),
|
||||
observed_at=NOW,
|
||||
)
|
||||
@@ -182,6 +185,298 @@ class DnsUnionPolicyTests(unittest.TestCase):
|
||||
):
|
||||
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_same_expiry_conflict_fails_closed(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": "2026-12-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-01T00:00:00+00:00",
|
||||
"expires_on": "2026-12-01T00:00:00+00:00",
|
||||
},
|
||||
),
|
||||
observed_at=NOW,
|
||||
),
|
||||
]
|
||||
|
||||
with self.assertRaisesRegex(
|
||||
ReconciliationConflict,
|
||||
"newest expiry",
|
||||
):
|
||||
reconcile_observations(observations)
|
||||
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user