Standardize Verizon plugin client boundary
This commit is contained in:
@@ -1,30 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DesiredRecord:
|
|
||||||
hostname: str
|
|
||||||
ip: str
|
|
||||||
record_type: str = "A"
|
|
||||||
wildcard: bool = False
|
|
||||||
source: str = ""
|
|
||||||
source_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProviderRecord:
|
|
||||||
hostname: str
|
|
||||||
ip: str
|
|
||||||
record_type: str = "A"
|
|
||||||
metadata: dict[str, Any] = field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ProviderCapabilities:
|
|
||||||
record_types: frozenset[str] = frozenset({"A"})
|
|
||||||
wildcard_records: bool = False
|
|
||||||
supports_create: bool = True
|
|
||||||
supports_update: bool = True
|
|
||||||
supports_delete: bool = False
|
|
||||||
max_records: int | None = None
|
|
||||||
@@ -16,8 +16,7 @@ from dns_sync.adapter_support import (
|
|||||||
require_endpoint_plugin,
|
require_endpoint_plugin,
|
||||||
)
|
)
|
||||||
from dns_sync.config import parse_endpoint_list
|
from dns_sync.config import parse_endpoint_list
|
||||||
from dns_sync.models import DesiredRecord
|
from .client import VerizonClient, VerizonRecord
|
||||||
from .provider import Plugin as VerizonProvider
|
|
||||||
|
|
||||||
|
|
||||||
def configured_endpoints(config):
|
def configured_endpoints(config):
|
||||||
@@ -53,12 +52,11 @@ class Plugin:
|
|||||||
def __init__(self, config, endpoint):
|
def __init__(self, config, endpoint):
|
||||||
require_endpoint_plugin(endpoint, self.name)
|
require_endpoint_plugin(endpoint, self.name)
|
||||||
|
|
||||||
self.config = config
|
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
self.provider = VerizonProvider(config, endpoint)
|
self.client = VerizonClient(endpoint)
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
self.provider.connect()
|
self.client.connect()
|
||||||
|
|
||||||
def discover(
|
def discover(
|
||||||
self,
|
self,
|
||||||
@@ -72,7 +70,7 @@ class Plugin:
|
|||||||
|
|
||||||
discovered: list[DiscoveredObject] = []
|
discovered: list[DiscoveredObject] = []
|
||||||
records = sorted(
|
records = sorted(
|
||||||
self.provider.list_records().values(),
|
self.client.list_records().values(),
|
||||||
key=lambda record: record.hostname.lower(),
|
key=lambda record: record.hostname.lower(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -101,7 +99,7 @@ class Plugin:
|
|||||||
def _desired_record(
|
def _desired_record(
|
||||||
self,
|
self,
|
||||||
object: NormalizedObject,
|
object: NormalizedObject,
|
||||||
) -> DesiredRecord:
|
) -> VerizonRecord:
|
||||||
if object.object_type != DNS_RECORDS:
|
if object.object_type != DNS_RECORDS:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"{self.endpoint.identity} cannot handle "
|
f"{self.endpoint.identity} cannot handle "
|
||||||
@@ -129,11 +127,10 @@ class Plugin:
|
|||||||
"Verizon DNS does not support wildcard records"
|
"Verizon DNS does not support wildcard records"
|
||||||
)
|
)
|
||||||
|
|
||||||
return DesiredRecord(
|
return VerizonRecord(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
ip=str(payload.get("ip") or ""),
|
ip=str(payload.get("ip") or ""),
|
||||||
record_type=record_type,
|
record_type=record_type,
|
||||||
wildcard=False,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def apply(self, action: PlanAction) -> ApplyResult | None:
|
def apply(self, action: PlanAction) -> ApplyResult | None:
|
||||||
@@ -156,16 +153,16 @@ class Plugin:
|
|||||||
record = self._desired_record(desired)
|
record = self._desired_record(desired)
|
||||||
|
|
||||||
if action.operation == PlanOperation.CREATE:
|
if action.operation == PlanOperation.CREATE:
|
||||||
detail = self.provider.add_record(record)
|
detail = self.client.add_record(record)
|
||||||
elif action.operation == PlanOperation.UPDATE:
|
elif action.operation == PlanOperation.UPDATE:
|
||||||
detail = self.provider.update_record(record)
|
detail = self.client.update_record(record)
|
||||||
else:
|
else:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Verizon adapter does not support "
|
f"Verizon adapter does not support "
|
||||||
f"{action.operation.value} actions"
|
f"{action.operation.value} actions"
|
||||||
)
|
)
|
||||||
|
|
||||||
records = self.provider.list_records()
|
records = self.client.list_records()
|
||||||
applied_record = records.get(record.hostname.lower())
|
applied_record = records.get(record.hostname.lower())
|
||||||
if applied_record is None:
|
if applied_record is None:
|
||||||
applied_record = records.get(record.hostname)
|
applied_record = records.get(record.hostname)
|
||||||
@@ -197,6 +194,6 @@ class Plugin:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def verify(self, object: NormalizedObject) -> bool:
|
def verify(self, object: NormalizedObject) -> bool:
|
||||||
return self.provider.verify_record(
|
return self.client.verify_record(
|
||||||
self._desired_record(object)
|
self._desired_record(object)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from dns_sync.adapters import Endpoint
|
from dns_sync.adapters import Endpoint
|
||||||
from dns_sync.models import ProviderCapabilities, ProviderRecord
|
|
||||||
from dns_sync.normalizer import normalize_hostname, normalize_ipv4
|
from dns_sync.normalizer import normalize_hostname, normalize_ipv4
|
||||||
|
|
||||||
LUCI_USERNAME = (
|
LUCI_USERNAME = (
|
||||||
@@ -17,6 +18,15 @@ LUCI_USERNAME = (
|
|||||||
CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);')
|
CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);')
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VerizonRecord:
|
||||||
|
hostname: str
|
||||||
|
ip: str
|
||||||
|
record_type: str = "A"
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
class _Slot:
|
class _Slot:
|
||||||
def __init__(self, slot):
|
def __init__(self, slot):
|
||||||
self.slot = slot
|
self.slot = slot
|
||||||
@@ -26,36 +36,19 @@ class _Slot:
|
|||||||
self.ip_token = ""
|
self.ip_token = ""
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class VerizonClient:
|
||||||
name = "verizon"
|
|
||||||
capabilities = ProviderCapabilities(
|
|
||||||
record_types=frozenset({"A"}),
|
|
||||||
wildcard_records=False,
|
|
||||||
supports_create=True,
|
|
||||||
supports_update=True,
|
|
||||||
supports_delete=True,
|
|
||||||
max_records=64,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
config,
|
endpoint: Endpoint,
|
||||||
endpoint: Endpoint | None = None,
|
|
||||||
):
|
):
|
||||||
self.config = config
|
|
||||||
self.endpoint = endpoint
|
self.endpoint = endpoint
|
||||||
|
|
||||||
if endpoint is None:
|
if endpoint.plugin != "verizon":
|
||||||
self.router_url = os.getenv(
|
raise RuntimeError(
|
||||||
"ROUTER_URL",
|
"VerizonClient requires a verizon endpoint"
|
||||||
"https://192.168.1.1",
|
)
|
||||||
).rstrip("/")
|
|
||||||
else:
|
self.router_url = f"https://{endpoint.address}"
|
||||||
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")
|
self.password = os.getenv("ROUTER_PASSWORD")
|
||||||
if not self.password:
|
if not self.password:
|
||||||
@@ -161,7 +154,7 @@ class Plugin:
|
|||||||
# The firmware UI has no wildcard support, but normalize defensively.
|
# The firmware UI has no wildcard support, but normalize defensively.
|
||||||
pass
|
pass
|
||||||
ip = normalize_ipv4(slot.ip, hostname)
|
ip = normalize_ipv4(slot.ip, hostname)
|
||||||
result[hostname.lower()] = ProviderRecord(
|
result[hostname.lower()] = VerizonRecord(
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
ip=ip,
|
ip=ip,
|
||||||
record_type="A",
|
record_type="A",
|
||||||
@@ -176,7 +169,7 @@ class Plugin:
|
|||||||
|
|
||||||
def _write_slot(self, slot, hostname, ip):
|
def _write_slot(self, slot, hostname, ip):
|
||||||
if not self.auth_token:
|
if not self.auth_token:
|
||||||
raise RuntimeError("Verizon provider is not authenticated")
|
raise RuntimeError("Verizon client is not authenticated")
|
||||||
if not slot.ip_token or not slot.name_token:
|
if not slot.ip_token or not slot.name_token:
|
||||||
raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens")
|
raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens")
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ from dns_sync.adapters import (
|
|||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
from dns_sync.config import AppConfig
|
from dns_sync.config import AppConfig
|
||||||
from dns_sync.models import ProviderRecord
|
from external_plugins.verizon.client import VerizonRecord
|
||||||
from dns_sync.registry import (
|
from dns_sync.registry import (
|
||||||
configured_adapter_endpoints,
|
configured_adapter_endpoints,
|
||||||
load_adapter,
|
load_adapter,
|
||||||
@@ -34,7 +34,7 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
):
|
):
|
||||||
return AppConfig.from_env()
|
return AppConfig.from_env()
|
||||||
|
|
||||||
def adapter(self, provider):
|
def adapter(self, client):
|
||||||
config = self.config()
|
config = self.config()
|
||||||
|
|
||||||
import dns_sync.registry as registry
|
import dns_sync.registry as registry
|
||||||
@@ -58,8 +58,8 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
|
|
||||||
with patch.object(
|
with patch.object(
|
||||||
module,
|
module,
|
||||||
"VerizonProvider",
|
"VerizonClient",
|
||||||
return_value=provider,
|
return_value=client,
|
||||||
):
|
):
|
||||||
endpoint = configured_adapter_endpoints(
|
endpoint = configured_adapter_endpoints(
|
||||||
"verizon",
|
"verizon",
|
||||||
@@ -138,7 +138,7 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_generic_adapter_uses_endpoint_not_router_url(self):
|
def test_client_uses_explicit_endpoint(self):
|
||||||
with patch.dict(
|
with patch.dict(
|
||||||
os.environ,
|
os.environ,
|
||||||
{
|
{
|
||||||
@@ -169,33 +169,33 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
"verizon://192.168.1.1:8443",
|
"verizon://192.168.1.1:8443",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
adapter.provider.router_url,
|
adapter.client.router_url,
|
||||||
"https://192.168.1.1:8443",
|
"https://192.168.1.1:8443",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_connect_delegates_to_provider(self):
|
def test_connect_delegates_to_client(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
|
|
||||||
adapter.connect()
|
adapter.connect()
|
||||||
|
|
||||||
provider.connect.assert_called_once_with()
|
client.connect.assert_called_once_with()
|
||||||
|
|
||||||
def test_discovers_dns_records_with_slot_local_keys(self):
|
def test_discovers_dns_records_with_slot_local_keys(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
provider.list_records.return_value = {
|
client.list_records.return_value = {
|
||||||
"second.example.com": ProviderRecord(
|
"second.example.com": VerizonRecord(
|
||||||
hostname="second.example.com",
|
hostname="second.example.com",
|
||||||
ip="192.168.1.101",
|
ip="192.168.1.101",
|
||||||
metadata={"slot": 42},
|
metadata={"slot": 42},
|
||||||
),
|
),
|
||||||
"first.example.com": ProviderRecord(
|
"first.example.com": VerizonRecord(
|
||||||
hostname="first.example.com",
|
hostname="first.example.com",
|
||||||
ip="192.168.1.101",
|
ip="192.168.1.101",
|
||||||
metadata={"slot": 41},
|
metadata={"slot": 41},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
|
|
||||||
discovered = adapter.discover(DNS_RECORDS)
|
discovered = adapter.discover(DNS_RECORDS)
|
||||||
|
|
||||||
@@ -213,17 +213,17 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_create_and_update_delegate_without_backend_ip(self):
|
def test_create_and_update_delegate_without_backend_ip(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
provider.add_record.return_value = "created"
|
client.add_record.return_value = "created"
|
||||||
provider.update_record.return_value = "updated"
|
client.update_record.return_value = "updated"
|
||||||
provider.list_records.return_value = {
|
client.list_records.return_value = {
|
||||||
"app.example.com": ProviderRecord(
|
"app.example.com": VerizonRecord(
|
||||||
hostname="app.example.com",
|
hostname="app.example.com",
|
||||||
ip="192.168.1.101",
|
ip="192.168.1.101",
|
||||||
metadata={"slot": 41},
|
metadata={"slot": 41},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
desired = self.dns_object(ip="192.168.1.101")
|
desired = self.dns_object(ip="192.168.1.101")
|
||||||
|
|
||||||
create_result = adapter.apply(
|
create_result = adapter.apply(
|
||||||
@@ -258,17 +258,17 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
"updated",
|
"updated",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
provider.add_record.call_args.args[0].ip,
|
client.add_record.call_args.args[0].ip,
|
||||||
"192.168.1.101",
|
"192.168.1.101",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
provider.update_record.call_args.args[0].ip,
|
client.update_record.call_args.args[0].ip,
|
||||||
"192.168.1.101",
|
"192.168.1.101",
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_noop_does_not_mutate_router(self):
|
def test_noop_does_not_mutate_router(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
|
|
||||||
result = adapter.apply(
|
result = adapter.apply(
|
||||||
PlanAction(
|
PlanAction(
|
||||||
@@ -279,24 +279,24 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertIsNone(result)
|
self.assertIsNone(result)
|
||||||
provider.add_record.assert_not_called()
|
client.add_record.assert_not_called()
|
||||||
provider.update_record.assert_not_called()
|
client.update_record.assert_not_called()
|
||||||
|
|
||||||
def test_verify_delegates_expected_dns_record(self):
|
def test_verify_delegates_expected_dns_record(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
provider.verify_record.return_value = True
|
client.verify_record.return_value = True
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
|
|
||||||
verified = adapter.verify(self.dns_object())
|
verified = adapter.verify(self.dns_object())
|
||||||
|
|
||||||
self.assertTrue(verified)
|
self.assertTrue(verified)
|
||||||
record = provider.verify_record.call_args.args[0]
|
record = client.verify_record.call_args.args[0]
|
||||||
self.assertEqual(record.hostname, "app.example.com")
|
self.assertEqual(record.hostname, "app.example.com")
|
||||||
self.assertEqual(record.ip, "192.168.1.101")
|
self.assertEqual(record.ip, "192.168.1.101")
|
||||||
|
|
||||||
def test_unsupported_objects_actions_and_wildcards_fail(self):
|
def test_unsupported_objects_actions_and_wildcards_fail(self):
|
||||||
provider = Mock()
|
client = Mock()
|
||||||
adapter = self.adapter(provider)
|
adapter = self.adapter(client)
|
||||||
|
|
||||||
with self.assertRaisesRegex(RuntimeError, "cannot discover"):
|
with self.assertRaisesRegex(RuntimeError, "cannot discover"):
|
||||||
adapter.discover("certificates")
|
adapter.discover("certificates")
|
||||||
|
|||||||
Reference in New Issue
Block a user