Add endpoint-bound Verizon DNS adapter
This commit is contained in:
@@ -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)
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user