diff --git a/dns_sync/adapters.py b/dns_sync/adapters.py new file mode 100644 index 0000000..996c677 --- /dev/null +++ b/dns_sync/adapters.py @@ -0,0 +1,98 @@ +from collections.abc import Mapping +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from typing import Any + + +DNS_RECORDS = "dns_records" +PROXY_HOSTS = "proxy_hosts" +CERTIFICATES = "certificates" + + +class CapabilityOperation(StrEnum): + DISCOVER = "discover" + APPLY = "apply" + VERIFY = "verify" + + +class PlanOperation(StrEnum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + NOOP = "noop" + SKIP = "skip" + + +@dataclass(frozen=True, order=True) +class Capability: + object_type: str + operation: CapabilityOperation + + def __post_init__(self) -> None: + object_type = self.object_type.strip().lower() + if not object_type: + raise ValueError("Capability object_type cannot be empty") + object.__setattr__(self, "object_type", object_type) + + +@dataclass(frozen=True) +class Endpoint: + plugin: str + address: str + label: str | None = None + + def __post_init__(self) -> None: + plugin = self.plugin.strip().lower() + address = self.address.strip().rstrip("/").lower() + + if not plugin: + raise ValueError("Endpoint plugin cannot be empty") + if not address: + raise ValueError("Endpoint address cannot be empty") + + object.__setattr__(self, "plugin", plugin) + object.__setattr__(self, "address", address) + + @property + def identity(self) -> str: + return f"{self.plugin}://{self.address}" + + +@dataclass(frozen=True) +class DiscoveredObject: + object_type: str + local_key: str + payload: Mapping[str, Any] + + +@dataclass(frozen=True) +class NormalizedObject: + object_type: str + logical_key: str + payload: Mapping[str, Any] + local_key: str | None = None + + +@dataclass(frozen=True) +class Observation: + endpoint_id: str + object: NormalizedObject + observed_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc) + ) + + +@dataclass(frozen=True) +class PlanAction: + endpoint_id: str + operation: PlanOperation + desired: NormalizedObject | None = None + current: NormalizedObject | None = None + reason: str | None = None + + +@dataclass(frozen=True) +class EndpointPlan: + endpoint_id: str + actions: tuple[PlanAction, ...] diff --git a/dns_sync/interfaces.py b/dns_sync/interfaces.py index 7828da1..001c350 100644 --- a/dns_sync/interfaces.py +++ b/dns_sync/interfaces.py @@ -1,8 +1,28 @@ from typing import Protocol +from .adapters import ( + Capability, + DiscoveredObject, + Endpoint, + NormalizedObject, + PlanAction, +) from .models import DesiredRecord, ProviderCapabilities, ProviderRecord +class AdapterPlugin(Protocol): + name: str + endpoint: Endpoint + capabilities: frozenset[Capability] + + def connect(self) -> None: ... + def discover(self, object_type: str) -> list[DiscoveredObject]: ... + def apply(self, action: PlanAction) -> str | None: ... + def verify(self, object: NormalizedObject) -> bool: ... + + +# Compatibility protocols for the existing DNS-only execution path. They remain +# in place until the current plugins and CLI are migrated to AdapterPlugin. class SourcePlugin(Protocol): name: str diff --git a/dns_sync/npm_client.py b/dns_sync/npm_client.py index 3276833..2eb812f 100644 --- a/dns_sync/npm_client.py +++ b/dns_sync/npm_client.py @@ -135,13 +135,3 @@ class NpmClient: ) return data - - def certificates(self) -> list[dict[str, Any]]: - data = self.get_json("/nginx/certificates") - - if not isinstance(data, list): - raise RuntimeError( - "NPM certificate response was not a list" - ) - - return data diff --git a/tests/test_adapter_models.py b/tests/test_adapter_models.py new file mode 100644 index 0000000..61c11d1 --- /dev/null +++ b/tests/test_adapter_models.py @@ -0,0 +1,103 @@ +import unittest + +from dns_sync.adapters import ( + CERTIFICATES, + DNS_RECORDS, + PROXY_HOSTS, + Capability, + CapabilityOperation, + Endpoint, + EndpointPlan, + NormalizedObject, + Observation, + PlanAction, + PlanOperation, +) + + +class EndpointTests(unittest.TestCase): + def test_identity_uses_plugin_and_address_not_label(self): + eve = Endpoint("NPM", "192.168.1.3:81/", label="EVE") + renamed = Endpoint("npm", "192.168.1.3:81", label="anything") + + self.assertEqual(eve.identity, "npm://192.168.1.3:81") + self.assertEqual(eve.identity, renamed.identity) + + def test_empty_endpoint_fields_are_rejected(self): + with self.assertRaises(ValueError): + Endpoint("", "192.168.1.3:81") + + with self.assertRaises(ValueError): + Endpoint("npm", "") + + +class CapabilityTests(unittest.TestCase): + def test_capabilities_are_object_type_specific(self): + capabilities = frozenset( + { + Capability(PROXY_HOSTS, CapabilityOperation.DISCOVER), + Capability(PROXY_HOSTS, CapabilityOperation.APPLY), + Capability(CERTIFICATES, CapabilityOperation.DISCOVER), + Capability(CERTIFICATES, CapabilityOperation.APPLY), + Capability(DNS_RECORDS, CapabilityOperation.DISCOVER), + } + ) + + self.assertIn( + Capability(CERTIFICATES, CapabilityOperation.APPLY), + capabilities, + ) + self.assertNotIn( + Capability(DNS_RECORDS, CapabilityOperation.APPLY), + capabilities, + ) + + +class ObservationAndPlanTests(unittest.TestCase): + def test_local_objects_can_share_one_logical_identity(self): + first = NormalizedObject( + object_type=CERTIFICATES, + logical_key="chaptarr.loxthelion.com", + local_key="63", + payload={"expires_on": "2026-10-01T00:00:00+00:00"}, + ) + second = NormalizedObject( + object_type=CERTIFICATES, + logical_key="chaptarr.loxthelion.com", + local_key="67", + payload={"expires_on": "2026-11-01T00:00:00+00:00"}, + ) + + observations = [ + Observation("npm://192.168.1.3:81", first), + Observation("npm://192.168.1.3:81", second), + ] + + self.assertEqual( + observations[0].object.logical_key, + observations[1].object.logical_key, + ) + self.assertNotEqual( + observations[0].object.local_key, + observations[1].object.local_key, + ) + + def test_plan_is_scoped_to_one_endpoint(self): + desired = NormalizedObject( + object_type=DNS_RECORDS, + logical_key="example.loxthelion.com", + payload={ + "hostname": "example.loxthelion.com", + "ip": "192.168.1.101", + "record_type": "A", + }, + ) + action = PlanAction( + endpoint_id="verizon://192.168.1.1", + operation=PlanOperation.CREATE, + desired=desired, + ) + plan = EndpointPlan(action.endpoint_id, (action,)) + + self.assertEqual(plan.endpoint_id, "verizon://192.168.1.1") + self.assertEqual(plan.actions, (action,)) diff --git a/tests/test_legacy_dns_engine.py b/tests/test_legacy_dns_engine.py new file mode 100644 index 0000000..ca3a8ae --- /dev/null +++ b/tests/test_legacy_dns_engine.py @@ -0,0 +1,54 @@ +import unittest + +from dns_sync.engine import build_sync_plan +from dns_sync.models import ( + DesiredRecord, + ProviderCapabilities, + ProviderRecord, +) + + +class LegacyDnsEngineTests(unittest.TestCase): + def test_existing_dns_plan_still_adds_updates_and_preserves_matches(self): + desired = { + "add.example.com": DesiredRecord( + hostname="add.example.com", + ip="192.168.1.101", + ), + "keep.example.com": DesiredRecord( + hostname="keep.example.com", + ip="192.168.1.101", + ), + "update.example.com": DesiredRecord( + hostname="update.example.com", + ip="192.168.1.101", + ), + } + current = { + "keep.example.com": ProviderRecord( + hostname="keep.example.com", + ip="192.168.1.101", + ), + "update.example.com": ProviderRecord( + hostname="update.example.com", + ip="192.168.1.50", + ), + } + + plan = build_sync_plan( + desired, + current, + ProviderCapabilities(), + ) + + actions = { + item.desired.hostname: item.action + for item in plan + } + self.assertEqual(actions["add.example.com"], "ADD") + self.assertEqual(actions["keep.example.com"], "UNCHANGED") + self.assertEqual(actions["update.example.com"], "UPDATE") + + +if __name__ == "__main__": + unittest.main()