diff --git a/dns_sync/orchestrator.py b/dns_sync/orchestrator.py index e3684d9..e24418c 100644 --- a/dns_sync/orchestrator.py +++ b/dns_sync/orchestrator.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from typing import Protocol from .adapters import ( + CERTIFICATES, DNS_RECORDS, PROXY_HOSTS, Capability, @@ -188,11 +189,28 @@ def discover_reconcile_plan( store, set(adapters_by_endpoint), ) + npm_observations = [ + observation + for observation in observations + if observation.object.object_type + in { + CERTIFICATES, + PROXY_HOSTS, + } + ] + + npm_desired = ( + reconcile_observations(npm_observations) + if npm_observations + else [] + ) + proxy_observations = [ observation for observation in observations if observation.object.object_type == PROXY_HOSTS ] + projected_dns = project_proxy_hosts_to_dns( proxy_observations, target_ip, @@ -204,12 +222,35 @@ def discover_reconcile_plan( domain=domain, ) - desired = reconcile_observations(projected_dns) - store.replace_desired_state( - DNS_RECORDS, - desired, - timestamp, + dns_desired = ( + reconcile_observations(projected_dns) + if projected_dns + else [] ) + + # Keep the reconciliation domains separate until each has + # produced portable desired state. In particular, router DNS + # observations must never participate in the proxy->DNS union. + desired = [ + *npm_desired, + *dns_desired, + ] + + for object_type in ( + CERTIFICATES, + PROXY_HOSTS, + DNS_RECORDS, + ): + store.replace_desired_state( + object_type, + [ + object + for object in desired + if object.object_type == object_type + ], + timestamp, + ) + plans = build_endpoint_plans( adapter_list, desired, diff --git a/tests/test_orchestrator.py b/tests/test_orchestrator.py index 19efcaf..6479356 100644 --- a/tests/test_orchestrator.py +++ b/tests/test_orchestrator.py @@ -147,9 +147,19 @@ class ReadOnlyOrchestrationTests(unittest.TestCase): DNS_RECORDS ) - self.assertEqual(len(result.desired), 1) + self.assertEqual(len( + [ + item + for item in result.desired + if item.object_type == DNS_RECORDS + ] + ), 1) self.assertEqual( - result.desired[0].payload["ip"], + next( + item + for item in result.desired + if item.object_type == DNS_RECORDS + ).payload["ip"], "192.168.1.101", ) self.assertEqual(len(result.plans), 1) @@ -220,7 +230,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase): ) self.assertEqual( - tuple(item.logical_key for item in result.desired), + tuple( + item.logical_key + for item in result.desired + if item.object_type == DNS_RECORDS + ), ("cached.example.com",), ) self.assertEqual( @@ -304,7 +318,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase): ) self.assertEqual( - tuple(item.logical_key for item in result.desired), + tuple( + item.logical_key + for item in result.desired + if item.object_type == DNS_RECORDS + ), ("app.example.com",), ) @@ -344,7 +362,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase): ) self.assertEqual( - tuple(item.logical_key for item in result.desired), + tuple( + item.logical_key + for item in result.desired + if item.object_type == DNS_RECORDS + ), ("active.example.com",), ) diff --git a/tests/test_orchestrator_npm_desired.py b/tests/test_orchestrator_npm_desired.py new file mode 100644 index 0000000..127fec6 --- /dev/null +++ b/tests/test_orchestrator_npm_desired.py @@ -0,0 +1,318 @@ +import unittest +from dataclasses import dataclass +from datetime import datetime, timezone + +from dns_sync.adapters import ( + CERTIFICATES, + DNS_RECORDS, + PROXY_HOSTS, + Capability, + CapabilityOperation, + DiscoveredObject, + Endpoint, + ObjectReference, +) +from dns_sync.orchestrator import discover_reconcile_plan +from dns_sync.persistence import SQLiteStateStore + + +NOW = datetime( + 2026, + 9, + 20, + 9, + 30, + tzinfo=timezone.utc, +) + + +@dataclass +class FakeNpmAdapter: + endpoint: Endpoint + + @property + def capabilities(self): + return frozenset( + { + Capability( + CERTIFICATES, + CapabilityOperation.DISCOVER, + ), + Capability( + CERTIFICATES, + CapabilityOperation.VERIFY, + ), + Capability( + PROXY_HOSTS, + CapabilityOperation.DISCOVER, + ), + Capability( + PROXY_HOSTS, + CapabilityOperation.VERIFY, + ), + } + ) + + def connect(self): + return None + + def discover(self, object_type): + if object_type == CERTIFICATES: + return [ + DiscoveredObject( + object_type=CERTIFICATES, + local_key="67", + payload={ + "domain_names": [ + "app.example.com", + ], + "provider": "letsencrypt", + "nice_name": "app", + "valid_from": ( + "2026-09-01T00:00:00Z" + ), + "expires_on": ( + "2026-12-01T00:00:00Z" + ), + "cluster_managed": True, + "acme_renew": True, + "dns_challenge": False, + }, + ), + ] + + if object_type == PROXY_HOSTS: + return [ + DiscoveredObject( + object_type=PROXY_HOSTS, + local_key="12", + payload={ + "domain_names": [ + "app.example.com", + ], + "forward_scheme": "http", + "forward_host": "backend.internal", + "forward_port": 8080, + "ssl_forced": True, + "caching_enabled": False, + "block_exploits": True, + "allow_websocket_upgrade": True, + "http2_support": True, + "hsts_enabled": True, + "hsts_subdomains": False, + "enabled": True, + }, + references=( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + local_key="67", + ), + ), + ), + ] + + raise RuntimeError( + f"unsupported discovery type: {object_type}" + ) + + def apply(self, action): + raise AssertionError( + "NPM APPLY must remain unavailable" + ) + + def verify(self, object): + return True + + +class NpmDesiredStateOrchestrationTests( + unittest.TestCase +): + def test_orchestrator_builds_and_persists_all_three_desired_types( + self, + ): + adapter = FakeNpmAdapter( + Endpoint( + "npm", + "192.168.1.3:81", + ) + ) + + with SQLiteStateStore(":memory:") as store: + run = discover_reconcile_plan( + [adapter], + store, + target_ip="192.168.1.101", + run_at=NOW, + ) + + desired_by_type = { + object.object_type: object + for object in run.desired + } + + self.assertEqual( + set(desired_by_type), + { + CERTIFICATES, + PROXY_HOSTS, + DNS_RECORDS, + }, + ) + + certificate = desired_by_type[ + CERTIFICATES + ] + proxy = desired_by_type[PROXY_HOSTS] + dns = desired_by_type[DNS_RECORDS] + + self.assertIsNone( + certificate.local_key + ) + self.assertIsNone( + proxy.local_key + ) + self.assertIsNone( + dns.local_key + ) + + self.assertEqual( + proxy.references, + ( + ObjectReference( + relation="certificate", + object_type=CERTIFICATES, + logical_key="app.example.com", + ), + ), + ) + + self.assertEqual( + dns.payload["hostname"], + "app.example.com", + ) + self.assertEqual( + dns.payload["ip"], + "192.168.1.101", + ) + + for object_type in ( + CERTIFICATES, + PROXY_HOSTS, + DNS_RECORDS, + ): + persisted = ( + store.load_desired_state( + object_type + ) + ) + + self.assertEqual( + len(persisted), + 1, + ) + self.assertEqual( + persisted[0].object, + desired_by_type[ + object_type + ], + ) + + # The NPM adapter intentionally does not advertise + # APPLY, so this read-only orchestration must not + # produce an NPM endpoint plan. + self.assertEqual( + run.plans, + (), + ) + + def test_empty_npm_snapshot_clears_stale_npm_desired_state( + self, + ): + adapter = FakeNpmAdapter( + Endpoint( + "npm", + "192.168.1.3:81", + ) + ) + + with SQLiteStateStore(":memory:") as store: + first = discover_reconcile_plan( + [adapter], + store, + target_ip="192.168.1.101", + run_at=NOW, + ) + + self.assertTrue(first.desired) + self.assertEqual( + len( + store.load_desired_state( + CERTIFICATES + ) + ), + 1, + ) + self.assertEqual( + len( + store.load_desired_state( + PROXY_HOSTS + ) + ), + 1, + ) + + class EmptyNpmAdapter( + FakeNpmAdapter + ): + def discover( + self, + object_type, + ): + if object_type in { + CERTIFICATES, + PROXY_HOSTS, + }: + return [] + + return super().discover( + object_type + ) + + empty = EmptyNpmAdapter( + adapter.endpoint + ) + + second = discover_reconcile_plan( + [empty], + store, + target_ip="192.168.1.101", + run_at=NOW.replace(minute=31), + ) + + self.assertEqual( + second.desired, + (), + ) + self.assertEqual( + store.load_desired_state( + CERTIFICATES + ), + [], + ) + self.assertEqual( + store.load_desired_state( + PROXY_HOSTS + ), + [], + ) + self.assertEqual( + store.load_desired_state( + DNS_RECORDS + ), + [], + ) + + +if __name__ == "__main__": + unittest.main()