diff --git a/dns_sync/adapter_support.py b/dns_sync/adapter_support.py new file mode 100644 index 0000000..80291d7 --- /dev/null +++ b/dns_sync/adapter_support.py @@ -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 diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index faa40e7..d17c1d7 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -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,17 +599,15 @@ class Plugin: self, action: PlanAction, ) -> ApplyResult | None: - desired = action.desired + desired = require_desired( + action, + context="NPM apply", + ) - if desired is None: - raise RuntimeError( - "NPM apply requires desired state" - ) - - if action.endpoint_id != self.endpoint.identity: - raise RuntimeError( - "NPM apply action endpoint does not match adapter" - ) + require_action_endpoint( + action, + self.endpoint, + ) if ( desired.object_type == CERTIFICATES diff --git a/external_plugins/verizon/__init__.py b/external_plugins/verizon/__init__.py index 3b33627..6e08859 100644 --- a/external_plugins/verizon/__init__.py +++ b/external_plugins/verizon/__init__.py @@ -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,10 +123,10 @@ 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 { PlanOperation.NOOP, @@ -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( diff --git a/tests/test_adapter_support.py b/tests/test_adapter_support.py new file mode 100644 index 0000000..9b18e3d --- /dev/null +++ b/tests/test_adapter_support.py @@ -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() diff --git a/tests/test_npm_adapter.py b/tests/test_npm_adapter.py index 4c3c2ee..375084f 100644 --- a/tests/test_npm_adapter.py +++ b/tests/test_npm_adapter.py @@ -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()) diff --git a/tests/test_verizon_adapter.py b/tests/test_verizon_adapter.py index 26cd31c..16dad02 100644 --- a/tests/test_verizon_adapter.py +++ b/tests/test_verizon_adapter.py @@ -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",