From 5a61b6e409b6807f32cfe8e98a69a0ec1d9fcf72 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sat, 19 Sep 2026 18:42:13 -0400 Subject: [PATCH] Add endpoint-bound Verizon DNS adapter --- dns_sync/plugins/adapters/verizon.py | 154 ++++++++++++++++ dns_sync/plugins/providers/verizon.py | 22 ++- tests/test_verizon_adapter.py | 248 ++++++++++++++++++++++++++ 3 files changed, 422 insertions(+), 2 deletions(-) create mode 100644 dns_sync/plugins/adapters/verizon.py create mode 100644 tests/test_verizon_adapter.py diff --git a/dns_sync/plugins/adapters/verizon.py b/dns_sync/plugins/adapters/verizon.py new file mode 100644 index 0000000..24e6613 --- /dev/null +++ b/dns_sync/plugins/adapters/verizon.py @@ -0,0 +1,154 @@ +from ...adapters import ( + DNS_RECORDS, + Capability, + CapabilityOperation, + DiscoveredObject, + NormalizedObject, + PlanAction, + PlanOperation, +) +from ...models import DesiredRecord +from ..providers.verizon import Plugin as VerizonProvider + + +class Plugin: + name = "verizon" + capabilities = frozenset( + { + Capability( + DNS_RECORDS, + CapabilityOperation.DISCOVER, + ), + Capability( + DNS_RECORDS, + CapabilityOperation.APPLY, + ), + Capability( + DNS_RECORDS, + CapabilityOperation.VERIFY, + ), + } + ) + + def __init__(self, config, endpoint): + if endpoint.plugin != self.name: + raise RuntimeError( + "Verizon adapter requires a verizon endpoint" + ) + + self.config = config + self.endpoint = endpoint + self.provider = VerizonProvider(config, endpoint) + + def connect(self) -> None: + self.provider.connect() + + def discover( + self, + object_type: str, + ) -> list[DiscoveredObject]: + if object_type != DNS_RECORDS: + raise RuntimeError( + f"{self.endpoint.identity} cannot discover " + f"object type {object_type!r}" + ) + + discovered: list[DiscoveredObject] = [] + records = sorted( + self.provider.list_records().values(), + key=lambda record: record.hostname.lower(), + ) + + for record in records: + slot = record.metadata.get("slot") + if isinstance(slot, bool) or not isinstance(slot, int): + raise RuntimeError( + f"Verizon DNS record {record.hostname!r} " + "has no valid endpoint-local slot" + ) + + discovered.append( + DiscoveredObject( + object_type=DNS_RECORDS, + local_key=str(slot), + payload={ + "hostname": record.hostname, + "ip": record.ip, + "record_type": record.record_type, + }, + ) + ) + + return discovered + + def _desired_record( + self, + object: NormalizedObject, + ) -> DesiredRecord: + if object.object_type != DNS_RECORDS: + raise RuntimeError( + f"{self.endpoint.identity} cannot handle " + f"object type {object.object_type!r}" + ) + + payload = object.payload + hostname = str(payload.get("hostname") or "") + if hostname != object.logical_key: + raise RuntimeError( + "DNS object hostname does not match its logical key" + ) + + record_type = str( + payload.get("record_type") or "" + ).upper() + if record_type != "A": + raise RuntimeError( + f"Verizon DNS does not support {record_type!r} records" + ) + + wildcard = payload.get("wildcard") is True + if wildcard: + raise RuntimeError( + "Verizon DNS does not support wildcard records" + ) + + return DesiredRecord( + hostname=hostname, + ip=str(payload.get("ip") or ""), + record_type=record_type, + wildcard=False, + ) + + def apply(self, action: PlanAction) -> str | None: + if action.endpoint_id != self.endpoint.identity: + raise RuntimeError( + "Plan action belongs to a different endpoint" + ) + + if action.operation in { + PlanOperation.NOOP, + PlanOperation.SKIP, + }: + return None + + if action.desired is None: + raise RuntimeError( + "Verizon apply action requires desired state" + ) + + record = self._desired_record(action.desired) + + if action.operation == PlanOperation.CREATE: + return self.provider.add_record(record) + if action.operation == PlanOperation.UPDATE: + return self.provider.update_record(record) + + raise RuntimeError( + f"Verizon adapter does not support " + f"{action.operation.value} actions" + ) + + def verify(self, object: NormalizedObject) -> bool: + return self.provider.verify_record( + self._desired_record(object) + ) diff --git a/dns_sync/plugins/providers/verizon.py b/dns_sync/plugins/providers/verizon.py index 338c4d2..da6e59b 100644 --- a/dns_sync/plugins/providers/verizon.py +++ b/dns_sync/plugins/providers/verizon.py @@ -6,6 +6,7 @@ import time import requests import urllib3 +from ...adapters import Endpoint from ...models import ProviderCapabilities, ProviderRecord from ...normalizer import normalize_hostname, normalize_ipv4 @@ -36,9 +37,26 @@ class Plugin: max_records=64, ) - def __init__(self, config): + def __init__( + self, + config, + endpoint: Endpoint | None = None, + ): self.config = config - self.router_url = os.getenv("ROUTER_URL", "https://192.168.1.1").rstrip("/") + self.endpoint = endpoint + + if endpoint is None: + self.router_url = os.getenv( + "ROUTER_URL", + "https://192.168.1.1", + ).rstrip("/") + else: + if endpoint.plugin != "verizon": + raise RuntimeError( + "Verizon provider requires a verizon endpoint" + ) + self.router_url = f"https://{endpoint.address}" + self.password = os.getenv("ROUTER_PASSWORD") if not self.password: raise RuntimeError("ROUTER_PASSWORD is required when DNS_PROVIDER=verizon") diff --git a/tests/test_verizon_adapter.py b/tests/test_verizon_adapter.py new file mode 100644 index 0000000..3ee9869 --- /dev/null +++ b/tests/test_verizon_adapter.py @@ -0,0 +1,248 @@ +import os +import unittest +from unittest.mock import Mock, patch + +from dns_sync.adapters import ( + DNS_RECORDS, + Capability, + CapabilityOperation, + NormalizedObject, + PlanAction, + PlanOperation, +) +from dns_sync.config import AppConfig +from dns_sync.models import ProviderRecord +from dns_sync.registry import load_adapter + + +class VerizonAdapterTests(unittest.TestCase): + def config(self): + with patch.dict( + os.environ, + { + "NPM_ENDPOINTS": "192.168.1.3", + "VERIZON_ENDPOINTS": "192.168.1.1", + }, + clear=True, + ): + return AppConfig.from_env() + + def adapter(self, provider): + config = self.config() + with patch( + "dns_sync.plugins.adapters.verizon.VerizonProvider", + return_value=provider, + ): + return load_adapter( + "verizon", + config, + config.verizon_endpoints[0], + ) + + @staticmethod + def dns_object( + hostname="app.example.com", + ip="192.168.1.101", + wildcard=False, + ): + return NormalizedObject( + object_type=DNS_RECORDS, + logical_key=hostname, + payload={ + "hostname": hostname, + "ip": ip, + "record_type": "A", + "wildcard": wildcard, + }, + ) + + def test_capabilities_cover_dns_discovery_apply_and_verify(self): + adapter = self.adapter(Mock()) + + self.assertEqual( + adapter.capabilities, + frozenset( + { + Capability( + DNS_RECORDS, + CapabilityOperation.DISCOVER, + ), + Capability( + DNS_RECORDS, + CapabilityOperation.APPLY, + ), + Capability( + DNS_RECORDS, + CapabilityOperation.VERIFY, + ), + } + ), + ) + + def test_generic_adapter_uses_endpoint_not_router_url(self): + with patch.dict( + os.environ, + { + "NPM_ENDPOINTS": "192.168.1.3", + "VERIZON_ENDPOINTS": "192.168.1.1:8443", + "ROUTER_URL": "https://192.168.1.254", + "ROUTER_PASSWORD": "not-a-real-password", + }, + clear=True, + ): + config = AppConfig.from_env() + adapter = load_adapter( + "verizon", + config, + config.verizon_endpoints[0], + ) + + self.assertEqual( + adapter.endpoint.identity, + "verizon://192.168.1.1:8443", + ) + self.assertEqual( + adapter.provider.router_url, + "https://192.168.1.1:8443", + ) + + def test_connect_delegates_to_provider(self): + provider = Mock() + adapter = self.adapter(provider) + + adapter.connect() + + provider.connect.assert_called_once_with() + + def test_discovers_dns_records_with_slot_local_keys(self): + provider = Mock() + provider.list_records.return_value = { + "second.example.com": ProviderRecord( + hostname="second.example.com", + ip="192.168.1.101", + metadata={"slot": 42}, + ), + "first.example.com": ProviderRecord( + hostname="first.example.com", + ip="192.168.1.101", + metadata={"slot": 41}, + ), + } + adapter = self.adapter(provider) + + discovered = adapter.discover(DNS_RECORDS) + + self.assertEqual( + tuple(item.local_key for item in discovered), + ("41", "42"), + ) + self.assertEqual( + discovered[0].payload, + { + "hostname": "first.example.com", + "ip": "192.168.1.101", + "record_type": "A", + }, + ) + + def test_create_and_update_delegate_without_backend_ip(self): + provider = Mock() + provider.add_record.return_value = "slot 41" + provider.update_record.return_value = "slot 42" + adapter = self.adapter(provider) + desired = self.dns_object(ip="192.168.1.101") + + create_result = adapter.apply( + PlanAction( + endpoint_id=adapter.endpoint.identity, + operation=PlanOperation.CREATE, + desired=desired, + ) + ) + update_result = adapter.apply( + PlanAction( + endpoint_id=adapter.endpoint.identity, + operation=PlanOperation.UPDATE, + desired=desired, + ) + ) + + self.assertEqual(create_result, "slot 41") + self.assertEqual(update_result, "slot 42") + self.assertEqual( + provider.add_record.call_args.args[0].ip, + "192.168.1.101", + ) + self.assertEqual( + provider.update_record.call_args.args[0].ip, + "192.168.1.101", + ) + + def test_noop_does_not_mutate_router(self): + provider = Mock() + adapter = self.adapter(provider) + + result = adapter.apply( + PlanAction( + endpoint_id=adapter.endpoint.identity, + operation=PlanOperation.NOOP, + desired=self.dns_object(), + ) + ) + + self.assertIsNone(result) + provider.add_record.assert_not_called() + provider.update_record.assert_not_called() + + def test_verify_delegates_expected_dns_record(self): + provider = Mock() + provider.verify_record.return_value = True + adapter = self.adapter(provider) + + verified = adapter.verify(self.dns_object()) + + self.assertTrue(verified) + record = provider.verify_record.call_args.args[0] + self.assertEqual(record.hostname, "app.example.com") + self.assertEqual(record.ip, "192.168.1.101") + + def test_unsupported_objects_actions_and_wildcards_fail(self): + provider = Mock() + adapter = self.adapter(provider) + + with self.assertRaisesRegex(RuntimeError, "cannot discover"): + adapter.discover("certificates") + + with self.assertRaisesRegex(RuntimeError, "different endpoint"): + adapter.apply( + PlanAction( + endpoint_id="verizon://different:443", + operation=PlanOperation.CREATE, + desired=self.dns_object(), + ) + ) + + with self.assertRaisesRegex(RuntimeError, "wildcard"): + adapter.apply( + PlanAction( + endpoint_id=adapter.endpoint.identity, + operation=PlanOperation.CREATE, + desired=self.dns_object( + hostname="*.example.com", + wildcard=True, + ), + ) + ) + + with self.assertRaisesRegex(RuntimeError, "delete"): + adapter.apply( + PlanAction( + endpoint_id=adapter.endpoint.identity, + operation=PlanOperation.DELETE, + desired=self.dns_object(), + ) + ) + + +if __name__ == "__main__": + unittest.main()