From 16359dc164fc5cc724fd0555b02c5173abf6d655 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sun, 20 Sep 2026 19:20:26 -0400 Subject: [PATCH] Let adapter plugins provide configured endpoints --- dns_sync/cli.py | 27 +-- dns_sync/plugins/adapters/npm.py | 4 + dns_sync/registry.py | 121 +++++++++-- external_plugins/verizon/__init__.py | 4 + tests/test_cli_reconcile.py | 12 ++ tests/test_registry.py | 287 +++++++++++++++++++++++++++ 6 files changed, 421 insertions(+), 34 deletions(-) diff --git a/dns_sync/cli.py b/dns_sync/cli.py index b5bc08a..26a330e 100644 --- a/dns_sync/cli.py +++ b/dns_sync/cli.py @@ -14,6 +14,7 @@ from .executor import execute_endpoint_plans from .orchestrator import discover_reconcile_plan from .persistence import SQLiteStateStore from .registry import ( + configured_adapter_endpoints, enabled_plugins, load_adapter, load_provider, @@ -145,20 +146,22 @@ def run_sync(args): def _configured_adapters(config): - enabled = enabled_plugins() + adapters = [] - endpoints = [] + for plugin_name in sorted(enabled_plugins()): + for endpoint in configured_adapter_endpoints( + plugin_name, + config, + ): + adapters.append( + load_adapter( + plugin_name, + config, + endpoint, + ) + ) - if "npm" in enabled: - endpoints.extend(config.npm_endpoints) - - if "verizon" in enabled: - endpoints.extend(config.verizon_endpoints) - - return [ - load_adapter(endpoint.plugin, config, endpoint) - for endpoint in endpoints - ] + return adapters def run_reconcile(args): diff --git a/dns_sync/plugins/adapters/npm.py b/dns_sync/plugins/adapters/npm.py index d17c1d7..29819a9 100644 --- a/dns_sync/plugins/adapters/npm.py +++ b/dns_sync/plugins/adapters/npm.py @@ -23,6 +23,10 @@ from ...npm_client import NpmClient from ...object_normalizer import normalize_discovered_object +def configured_endpoints(config): + return config.npm_endpoints + + _PROXY_HOST_FIELDS = ( "domain_names", "forward_scheme", diff --git a/dns_sync/registry.py b/dns_sync/registry.py index ed60f9a..7a788ff 100644 --- a/dns_sync/registry.py +++ b/dns_sync/registry.py @@ -89,7 +89,29 @@ def _load_external_adapter_module(name: str): existing = sys.modules.get(qualified_name) if existing is not None: - return existing + existing_file = getattr( + existing, + "__file__", + None, + ) + + if existing_file is not None: + try: + same_file = ( + Path(existing_file).resolve() + == package_path.resolve() + ) + except OSError: + same_file = False + else: + same_file = False + + if same_file: + return existing + + # The same plugin name is being resolved from a different + # external plugin root. Do not reuse stale module state. + sys.modules.pop(qualified_name, None) spec = importlib.util.spec_from_file_location( qualified_name, @@ -129,6 +151,72 @@ def _load_builtin(kind: str, name: str): raise +def _adapter_module(name: str): + normalized = _validate_plugin_name("adapter", name) + + enabled = enabled_plugins() + if normalized not in enabled: + raise RuntimeError( + f"Adapter plugin {normalized!r} is not enabled" + ) + + module = _load_builtin("adapters", normalized) + + if module is not None: + return ( + normalized, + module, + f"dns_sync.plugins.adapters.{normalized}", + ) + + module = _load_external_adapter_module(normalized) + + if module is not None: + return ( + normalized, + module, + str( + _external_plugin_root() + / normalized + / "__init__.py" + ), + ) + + raise RuntimeError( + f"Unknown adapter plugin: {normalized}" + ) + + +def configured_adapter_endpoints( + name: str, + config, +): + normalized, module, display_path = _adapter_module(name) + + loader = getattr( + module, + "configured_endpoints", + None, + ) + + if loader is None: + raise RuntimeError( + f"Adapter plugin {display_path} does not export " + "configured_endpoints" + ) + + endpoints = tuple(loader(config)) + + for endpoint in endpoints: + if endpoint.plugin != normalized: + raise RuntimeError( + f"Adapter plugin {normalized!r} returned endpoint " + f"for plugin {endpoint.plugin!r}" + ) + + return endpoints + + def _plugin_class(module, display_path: str): plugin_class = getattr(module, "Plugin", None) @@ -144,12 +232,17 @@ def _load(kind: str, name: str, config, *args): normalized = _validate_plugin_name(kind, name) if kind == "adapters": - enabled = enabled_plugins() + ( + normalized, + module, + display_path, + ) = _adapter_module(normalized) - if normalized not in enabled: - raise RuntimeError( - f"Adapter plugin {normalized!r} is not enabled" - ) + plugin_class = _plugin_class( + module, + display_path, + ) + return plugin_class(config, *args) module = _load_builtin(kind, normalized) @@ -160,22 +253,6 @@ def _load(kind: str, name: str, config, *args): ) return plugin_class(config, *args) - if kind == "adapters": - module = _load_external_adapter_module( - normalized - ) - - if module is not None: - plugin_class = _plugin_class( - module, - str( - _external_plugin_root() - / normalized - / "__init__.py" - ), - ) - return plugin_class(config, *args) - raise RuntimeError( f"Unknown {kind[:-1]} plugin: {normalized}" ) diff --git a/external_plugins/verizon/__init__.py b/external_plugins/verizon/__init__.py index 6e08859..d8952c5 100644 --- a/external_plugins/verizon/__init__.py +++ b/external_plugins/verizon/__init__.py @@ -17,6 +17,10 @@ from dns_sync.models import DesiredRecord from .provider import Plugin as VerizonProvider +def configured_endpoints(config): + return config.verizon_endpoints + + class Plugin: name = "verizon" capabilities = frozenset( diff --git a/tests/test_cli_reconcile.py b/tests/test_cli_reconcile.py index 8386217..3834589 100644 --- a/tests/test_cli_reconcile.py +++ b/tests/test_cli_reconcile.py @@ -150,6 +150,15 @@ class ReconcileCliTests(unittest.TestCase): verizon_endpoint.identity: verizon, } + def endpoint_loader(name, config): + if name == "npm": + return (npm_endpoint,) + if name == "verizon": + return (verizon_endpoint,) + raise AssertionError( + f"Unexpected adapter plugin: {name}" + ) + def loader(name, config, endpoint): return adapters[endpoint.identity] @@ -167,6 +176,9 @@ class ReconcileCliTests(unittest.TestCase): "ENABLED_PLUGINS": "npm,verizon", }, clear=True, + ), patch( + "dns_sync.cli.configured_adapter_endpoints", + side_effect=endpoint_loader, ), patch( "dns_sync.cli.load_adapter", side_effect=loader, diff --git a/tests/test_registry.py b/tests/test_registry.py index e5ca3ce..2f8988f 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -8,6 +8,7 @@ from unittest.mock import patch from dns_sync.adapters import Endpoint from dns_sync.registry import ( + configured_adapter_endpoints, enabled_plugins, load_adapter, ) @@ -221,5 +222,291 @@ class PluginRegistryTests(unittest.TestCase): ) + + def test_builtin_adapter_supplies_configured_endpoints(self): + endpoint = Endpoint( + "npm", + "192.168.1.3:81", + ) + config = SimpleNamespace( + npm_endpoints=(endpoint,), + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "npm", + }, + clear=True, + ): + endpoints = configured_adapter_endpoints( + "npm", + config, + ) + + self.assertEqual( + endpoints, + (endpoint,), + ) + + + def test_external_adapter_cache_respects_plugin_root(self): + with tempfile.TemporaryDirectory() as first_directory, \ + tempfile.TemporaryDirectory() as second_directory: + + first_root = Path(first_directory) + second_root = Path(second_directory) + + first_plugin = first_root / "example_cache" + second_plugin = second_root / "example_cache" + + first_plugin.mkdir() + second_plugin.mkdir() + + first_plugin.joinpath("__init__.py").write_text( + textwrap.dedent( + """ + from dns_sync.adapters import Endpoint + + + def configured_endpoints(config): + return ( + Endpoint( + "example_cache", + "first.example:443", + ), + ) + + + class Plugin: + name = "example_cache" + + def __init__( + self, + config, + endpoint, + ): + self.endpoint = endpoint + """ + ) + ) + + second_plugin.joinpath("__init__.py").write_text( + textwrap.dedent( + """ + from dns_sync.adapters import Endpoint + + + def configured_endpoints(config): + return ( + Endpoint( + "example_cache", + "second.example:443", + ), + ) + + + class Plugin: + name = "example_cache" + + def __init__( + self, + config, + endpoint, + ): + self.endpoint = endpoint + """ + ) + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "example_cache", + "DNS_SYNC_PLUGIN_PATH": first_directory, + }, + clear=True, + ): + first = configured_adapter_endpoints( + "example_cache", + SimpleNamespace(), + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "example_cache", + "DNS_SYNC_PLUGIN_PATH": second_directory, + }, + clear=True, + ): + second = configured_adapter_endpoints( + "example_cache", + SimpleNamespace(), + ) + + self.assertEqual( + tuple(endpoint.identity for endpoint in first), + ("example_cache://first.example:443",), + ) + self.assertEqual( + tuple(endpoint.identity for endpoint in second), + ("example_cache://second.example:443",), + ) + + + def test_external_adapter_supplies_configured_endpoints(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = root / "example" + plugin.mkdir() + + (plugin / "__init__.py").write_text( + textwrap.dedent( + """ + import os + + from dns_sync.adapters import Endpoint + + + def configured_endpoints(config): + address = os.environ["EXAMPLE_ENDPOINT"] + return ( + Endpoint("example", address), + ) + + + class Plugin: + name = "example" + + def __init__( + self, + config, + endpoint, + ): + self.config = config + self.endpoint = endpoint + """ + ) + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "example", + "DNS_SYNC_PLUGIN_PATH": directory, + "EXAMPLE_ENDPOINT": "example.test:9443", + }, + clear=True, + ): + endpoints = configured_adapter_endpoints( + "example", + SimpleNamespace(), + ) + + self.assertEqual( + tuple( + endpoint.identity + for endpoint in endpoints + ), + ("example://example.test:9443",), + ) + + + def test_adapter_missing_endpoint_hook_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = root / "example_missing_hook" + plugin.mkdir() + + (plugin / "__init__.py").write_text( + textwrap.dedent( + """ + class Plugin: + name = "example_missing_hook" + + def __init__( + self, + config, + endpoint, + ): + self.config = config + self.endpoint = endpoint + """ + ) + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "example_missing_hook", + "DNS_SYNC_PLUGIN_PATH": directory, + }, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, + "does not export configured_endpoints", + ): + configured_adapter_endpoints( + "example_missing_hook", + SimpleNamespace(), + ) + + + def test_adapter_endpoint_hook_cannot_cross_plugin_boundary(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = root / "example_wrong_endpoint" + plugin.mkdir() + + (plugin / "__init__.py").write_text( + textwrap.dedent( + """ + from dns_sync.adapters import Endpoint + + + def configured_endpoints(config): + return ( + Endpoint( + "other", + "example.test:443", + ), + ) + + + class Plugin: + name = "example_wrong_endpoint" + + def __init__( + self, + config, + endpoint, + ): + self.config = config + self.endpoint = endpoint + """ + ) + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "example_wrong_endpoint", + "DNS_SYNC_PLUGIN_PATH": directory, + }, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, + "returned endpoint for plugin", + ): + configured_adapter_endpoints( + "example_wrong_endpoint", + SimpleNamespace(), + ) + + + if __name__ == "__main__": unittest.main()