Extract shared adapter validation helpers

This commit is contained in:
Lyncoln
2026-09-20 18:47:21 -04:00
parent d4ceb7e1df
commit 832928159d
6 changed files with 232 additions and 28 deletions
+41
View File
@@ -0,0 +1,41 @@
from .adapters import Endpoint, NormalizedObject, PlanAction
def require_endpoint_plugin(
endpoint: Endpoint,
plugin_name: str,
) -> None:
expected = plugin_name.strip().lower()
if not expected:
raise ValueError("Adapter plugin name cannot be empty")
if endpoint.plugin != expected:
raise RuntimeError(
f"Adapter {expected!r} cannot use endpoint "
f"{endpoint.identity!r}"
)
def require_action_endpoint(
action: PlanAction,
endpoint: Endpoint,
) -> None:
if action.endpoint_id != endpoint.identity:
raise RuntimeError(
f"Plan action for {action.endpoint_id!r} does not belong "
f"to adapter endpoint {endpoint.identity!r}"
)
def require_desired(
action: PlanAction,
*,
context: str = "Adapter apply",
) -> NormalizedObject:
if action.desired is None:
raise RuntimeError(
f"{context} requires desired state"
)
return action.desired
+12 -8
View File
@@ -13,6 +13,11 @@ from ...adapters import (
PlanOperation, PlanOperation,
RetireRequest, RetireRequest,
) )
from ...adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ...certificates.material import CertificateMaterial from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object from ...object_normalizer import normalize_discovered_object
@@ -67,6 +72,7 @@ class Plugin:
) )
def __init__(self, config, endpoint): def __init__(self, config, endpoint):
require_endpoint_plugin(endpoint, self.name)
self.config = config self.config = config
self.endpoint = endpoint self.endpoint = endpoint
self.client = NpmClient(config, endpoint) self.client = NpmClient(config, endpoint)
@@ -593,16 +599,14 @@ class Plugin:
self, self,
action: PlanAction, action: PlanAction,
) -> ApplyResult | None: ) -> ApplyResult | None:
desired = action.desired desired = require_desired(
action,
if desired is None: context="NPM apply",
raise RuntimeError(
"NPM apply requires desired state"
) )
if action.endpoint_id != self.endpoint.identity: require_action_endpoint(
raise RuntimeError( action,
"NPM apply action endpoint does not match adapter" self.endpoint,
) )
if ( if (
+17 -15
View File
@@ -8,6 +8,11 @@ from dns_sync.adapters import (
PlanAction, PlanAction,
PlanOperation, PlanOperation,
) )
from dns_sync.adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.models import DesiredRecord from dns_sync.models import DesiredRecord
from .provider import Plugin as VerizonProvider from .provider import Plugin as VerizonProvider
@@ -32,10 +37,7 @@ class Plugin:
) )
def __init__(self, config, endpoint): def __init__(self, config, endpoint):
if endpoint.plugin != self.name: require_endpoint_plugin(endpoint, self.name)
raise RuntimeError(
"Verizon adapter requires a verizon endpoint"
)
self.config = config self.config = config
self.endpoint = endpoint self.endpoint = endpoint
@@ -121,9 +123,9 @@ class Plugin:
) )
def apply(self, action: PlanAction) -> ApplyResult | None: def apply(self, action: PlanAction) -> ApplyResult | None:
if action.endpoint_id != self.endpoint.identity: require_action_endpoint(
raise RuntimeError( action,
"Plan action belongs to a different endpoint" self.endpoint,
) )
if action.operation in { if action.operation in {
@@ -132,12 +134,12 @@ class Plugin:
}: }:
return None return None
if action.desired is None: desired = require_desired(
raise RuntimeError( action,
"Verizon apply action requires desired state" context="Verizon apply action",
) )
record = self._desired_record(action.desired) record = self._desired_record(desired)
if action.operation == PlanOperation.CREATE: if action.operation == PlanOperation.CREATE:
detail = self.provider.add_record(record) detail = self.provider.add_record(record)
@@ -168,11 +170,11 @@ class Plugin:
) )
applied = NormalizedObject( applied = NormalizedObject(
object_type=action.desired.object_type, object_type=desired.object_type,
logical_key=action.desired.logical_key, logical_key=desired.logical_key,
local_key=str(slot), local_key=str(slot),
payload=dict(action.desired.payload), payload=dict(desired.payload),
references=action.desired.references, references=desired.references,
) )
return ApplyResult( return ApplyResult(
+106
View File
@@ -0,0 +1,106 @@
import unittest
from dns_sync.adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.adapters import (
Endpoint,
NormalizedObject,
PlanAction,
PlanOperation,
)
class AdapterSupportTests(unittest.TestCase):
def test_endpoint_plugin_accepts_matching_plugin(self):
endpoint = Endpoint("npm", "node.example:81")
require_endpoint_plugin(endpoint, "npm")
def test_endpoint_plugin_rejects_wrong_plugin(self):
endpoint = Endpoint("npm", "node.example:81")
with self.assertRaisesRegex(
RuntimeError,
"cannot use endpoint",
):
require_endpoint_plugin(
endpoint,
"verizon",
)
def test_endpoint_plugin_rejects_empty_expected_name(self):
endpoint = Endpoint("npm", "node.example:81")
with self.assertRaisesRegex(
ValueError,
"cannot be empty",
):
require_endpoint_plugin(endpoint, " ")
def test_action_endpoint_accepts_matching_endpoint(self):
endpoint = Endpoint("npm", "node.example:81")
action = PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.CREATE,
)
require_action_endpoint(action, endpoint)
def test_action_endpoint_rejects_other_endpoint(self):
endpoint = Endpoint("npm", "node.example:81")
action = PlanAction(
endpoint_id="npm://other.example:81",
operation=PlanOperation.CREATE,
)
with self.assertRaisesRegex(
RuntimeError,
"does not belong",
):
require_action_endpoint(action, endpoint)
def test_require_desired_returns_desired_object(self):
desired = NormalizedObject(
object_type="example",
logical_key="object",
payload={},
)
action = PlanAction(
endpoint_id="example://endpoint",
operation=PlanOperation.CREATE,
desired=desired,
)
self.assertIs(
require_desired(action),
desired,
)
def test_require_desired_fails_closed(self):
action = PlanAction(
endpoint_id="example://endpoint",
operation=PlanOperation.CREATE,
)
with self.assertRaisesRegex(
RuntimeError,
"Test mutation requires desired state",
):
require_desired(
action,
context="Test mutation",
)
if __name__ == "__main__":
unittest.main()
+24
View File
@@ -6,6 +6,7 @@ from dns_sync.adapters import (
CERTIFICATES, CERTIFICATES,
PROXY_HOSTS, PROXY_HOSTS,
Capability, Capability,
Endpoint,
CapabilityOperation, CapabilityOperation,
NormalizedObject, NormalizedObject,
ObjectReference, ObjectReference,
@@ -94,6 +95,29 @@ class NpmAdapterTests(unittest.TestCase):
self.assertIs(adapters[0].client, first_client) self.assertIs(adapters[0].client, first_client)
self.assertIs(adapters[1].client, second_client) self.assertIs(adapters[1].client, second_client)
def test_adapter_rejects_endpoint_for_other_plugin(self):
config = self.config()
endpoint = Endpoint(
"verizon",
"192.168.1.1:443",
)
with patch(
"dns_sync.plugins.adapters.npm.NpmClient"
) as client_class:
with self.assertRaisesRegex(
RuntimeError,
"cannot use endpoint",
):
load_adapter(
"npm",
config,
endpoint,
)
client_class.assert_not_called()
def test_capabilities_cover_discovery_apply_and_verify(self): def test_capabilities_cover_discovery_apply_and_verify(self):
adapter = self.adapter(Mock()) adapter = self.adapter(Mock())
+28 -1
View File
@@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
from dns_sync.adapters import ( from dns_sync.adapters import (
DNS_RECORDS, DNS_RECORDS,
Capability, Capability,
Endpoint,
CapabilityOperation, CapabilityOperation,
NormalizedObject, NormalizedObject,
PlanAction, PlanAction,
@@ -79,6 +80,32 @@ class VerizonAdapterTests(unittest.TestCase):
}, },
) )
def test_adapter_rejects_endpoint_for_other_plugin(self):
config = self.config()
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm,verizon",
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
},
clear=False,
):
with self.assertRaisesRegex(
RuntimeError,
"cannot use endpoint",
):
load_adapter(
"verizon",
config,
endpoint,
)
def test_capabilities_cover_dns_discovery_apply_and_verify(self): def test_capabilities_cover_dns_discovery_apply_and_verify(self):
adapter = self.adapter(Mock()) adapter = self.adapter(Mock())
@@ -259,7 +286,7 @@ class VerizonAdapterTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "cannot discover"): with self.assertRaisesRegex(RuntimeError, "cannot discover"):
adapter.discover("certificates") adapter.discover("certificates")
with self.assertRaisesRegex(RuntimeError, "different endpoint"): with self.assertRaisesRegex(RuntimeError, "does not belong"):
adapter.apply( adapter.apply(
PlanAction( PlanAction(
endpoint_id="verizon://different:443", endpoint_id="verizon://different:443",