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,
RetireRequest,
)
from ...adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object
@@ -67,6 +72,7 @@ class Plugin:
)
def __init__(self, config, endpoint):
require_endpoint_plugin(endpoint, self.name)
self.config = config
self.endpoint = endpoint
self.client = NpmClient(config, endpoint)
@@ -593,16 +599,14 @@ class Plugin:
self,
action: PlanAction,
) -> ApplyResult | None:
desired = action.desired
if desired is None:
raise RuntimeError(
"NPM apply requires desired state"
desired = require_desired(
action,
context="NPM apply",
)
if action.endpoint_id != self.endpoint.identity:
raise RuntimeError(
"NPM apply action endpoint does not match adapter"
require_action_endpoint(
action,
self.endpoint,
)
if (
+17 -15
View File
@@ -8,6 +8,11 @@ from dns_sync.adapters import (
PlanAction,
PlanOperation,
)
from dns_sync.adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.models import DesiredRecord
from .provider import Plugin as VerizonProvider
@@ -32,10 +37,7 @@ class Plugin:
)
def __init__(self, config, endpoint):
if endpoint.plugin != self.name:
raise RuntimeError(
"Verizon adapter requires a verizon endpoint"
)
require_endpoint_plugin(endpoint, self.name)
self.config = config
self.endpoint = endpoint
@@ -121,9 +123,9 @@ class Plugin:
)
def apply(self, action: PlanAction) -> ApplyResult | None:
if action.endpoint_id != self.endpoint.identity:
raise RuntimeError(
"Plan action belongs to a different endpoint"
require_action_endpoint(
action,
self.endpoint,
)
if action.operation in {
@@ -132,12 +134,12 @@ class Plugin:
}:
return None
if action.desired is None:
raise RuntimeError(
"Verizon apply action requires desired state"
desired = require_desired(
action,
context="Verizon apply action",
)
record = self._desired_record(action.desired)
record = self._desired_record(desired)
if action.operation == PlanOperation.CREATE:
detail = self.provider.add_record(record)
@@ -168,11 +170,11 @@ class Plugin:
)
applied = NormalizedObject(
object_type=action.desired.object_type,
logical_key=action.desired.logical_key,
object_type=desired.object_type,
logical_key=desired.logical_key,
local_key=str(slot),
payload=dict(action.desired.payload),
references=action.desired.references,
payload=dict(desired.payload),
references=desired.references,
)
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,
PROXY_HOSTS,
Capability,
Endpoint,
CapabilityOperation,
NormalizedObject,
ObjectReference,
@@ -94,6 +95,29 @@ class NpmAdapterTests(unittest.TestCase):
self.assertIs(adapters[0].client, first_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):
adapter = self.adapter(Mock())
+28 -1
View File
@@ -5,6 +5,7 @@ from unittest.mock import Mock, patch
from dns_sync.adapters import (
DNS_RECORDS,
Capability,
Endpoint,
CapabilityOperation,
NormalizedObject,
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):
adapter = self.adapter(Mock())
@@ -259,7 +286,7 @@ class VerizonAdapterTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "cannot discover"):
adapter.discover("certificates")
with self.assertRaisesRegex(RuntimeError, "different endpoint"):
with self.assertRaisesRegex(RuntimeError, "does not belong"):
adapter.apply(
PlanAction(
endpoint_id="verizon://different:443",