diff --git a/dns_sync/cli.py b/dns_sync/cli.py index ca6c5df..b5bc08a 100644 --- a/dns_sync/cli.py +++ b/dns_sync/cli.py @@ -13,7 +13,12 @@ from .engine import ( from .executor import execute_endpoint_plans from .orchestrator import discover_reconcile_plan from .persistence import SQLiteStateStore -from .registry import load_adapter, load_provider, load_source +from .registry import ( + enabled_plugins, + load_adapter, + load_provider, + load_source, +) from .reporting import print_endpoint_plans, print_plan @@ -140,10 +145,16 @@ def run_sync(args): def _configured_adapters(config): - endpoints = ( - *config.npm_endpoints, - *config.verizon_endpoints, - ) + enabled = enabled_plugins() + + endpoints = [] + + 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 diff --git a/dns_sync/registry.py b/dns_sync/registry.py index 10c0015..ed60f9a 100644 --- a/dns_sync/registry.py +++ b/dns_sync/registry.py @@ -1,25 +1,184 @@ import importlib +import importlib.util +import os +import sys +from pathlib import Path +from types import ModuleType -def _load(kind: str, name: str, config, *args): - if not name or not name.replace("_", "").replace("-", "").isalnum(): - raise RuntimeError(f"Invalid {kind} plugin name: {name!r}") +_EXTERNAL_NAMESPACE = "dns_sync_external_plugins" + +def _validate_plugin_name(kind: str, name: str) -> str: + normalized = name.strip().lower() + + if ( + not normalized + or not normalized.replace("_", "").replace("-", "").isalnum() + ): + raise RuntimeError( + f"Invalid {kind} plugin name: {name!r}" + ) + + return normalized + + +def enabled_plugins() -> frozenset[str]: + raw = os.getenv("ENABLED_PLUGINS", "npm") + + names = [ + item.strip().lower() + for item in raw.split(",") + ] + + if not names or any(not name for name in names): + raise RuntimeError( + "ENABLED_PLUGINS contains an empty plugin name" + ) + + validated = tuple( + _validate_plugin_name("adapter", name) + for name in names + ) + + if len(set(validated)) != len(validated): + raise RuntimeError( + "ENABLED_PLUGINS contains duplicate plugin names" + ) + + return frozenset(validated) + + +def _external_plugin_root() -> Path: + return Path( + os.getenv( + "DNS_SYNC_PLUGIN_PATH", + "/plugins", + ) + ) + + +def _ensure_external_namespace() -> None: + if _EXTERNAL_NAMESPACE in sys.modules: + return + + namespace = ModuleType(_EXTERNAL_NAMESPACE) + namespace.__package__ = _EXTERNAL_NAMESPACE + namespace.__path__ = [ + str(_external_plugin_root()) + ] + sys.modules[_EXTERNAL_NAMESPACE] = namespace + + +def _load_external_adapter_module(name: str): + module_name = name.replace("-", "_") + package_path = ( + _external_plugin_root() + / module_name + / "__init__.py" + ) + + if not package_path.is_file(): + return None + + _ensure_external_namespace() + + qualified_name = ( + f"{_EXTERNAL_NAMESPACE}.{module_name}" + ) + + existing = sys.modules.get(qualified_name) + if existing is not None: + return existing + + spec = importlib.util.spec_from_file_location( + qualified_name, + package_path, + submodule_search_locations=[ + str(package_path.parent) + ], + ) + + if spec is None or spec.loader is None: + raise RuntimeError( + f"Could not load external adapter plugin " + f"{name!r} from {package_path}" + ) + + module = importlib.util.module_from_spec(spec) + sys.modules[qualified_name] = module + + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(qualified_name, None) + raise + + return module + + +def _load_builtin(kind: str, name: str): module_name = name.replace("-", "_") path = f"dns_sync.plugins.{kind}.{module_name}" try: - module = importlib.import_module(path) + return importlib.import_module(path) except ModuleNotFoundError as exc: if exc.name == path: - raise RuntimeError(f"Unknown {kind[:-1]} plugin: {name}") from exc + return None raise - plugin_class = getattr(module, "Plugin", None) - if plugin_class is None: - raise RuntimeError(f"Plugin module {path} does not export Plugin") - return plugin_class(config, *args) +def _plugin_class(module, display_path: str): + plugin_class = getattr(module, "Plugin", None) + + if plugin_class is None: + raise RuntimeError( + f"Plugin module {display_path} does not export Plugin" + ) + + return plugin_class + + +def _load(kind: str, name: str, config, *args): + normalized = _validate_plugin_name(kind, name) + + if kind == "adapters": + enabled = enabled_plugins() + + if normalized not in enabled: + raise RuntimeError( + f"Adapter plugin {normalized!r} is not enabled" + ) + + module = _load_builtin(kind, normalized) + + if module is not None: + plugin_class = _plugin_class( + module, + f"dns_sync.plugins.{kind}.{normalized}", + ) + 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}" + ) def load_source(name: str, config): @@ -31,4 +190,9 @@ def load_provider(name: str, config): def load_adapter(name: str, config, endpoint): - return _load("adapters", name, config, endpoint) + return _load( + "adapters", + name, + config, + endpoint, + ) diff --git a/dns_sync/plugins/adapters/verizon.py b/external_plugins/verizon/__init__.py similarity index 97% rename from dns_sync/plugins/adapters/verizon.py rename to external_plugins/verizon/__init__.py index 25ebcf4..3b33627 100644 --- a/dns_sync/plugins/adapters/verizon.py +++ b/external_plugins/verizon/__init__.py @@ -1,4 +1,4 @@ -from ...adapters import ( +from dns_sync.adapters import ( DNS_RECORDS, ApplyResult, Capability, @@ -8,8 +8,8 @@ from ...adapters import ( PlanAction, PlanOperation, ) -from ...models import DesiredRecord -from ..providers.verizon import Plugin as VerizonProvider +from dns_sync.models import DesiredRecord +from .provider import Plugin as VerizonProvider class Plugin: diff --git a/dns_sync/plugins/providers/verizon.py b/external_plugins/verizon/provider.py similarity index 97% rename from dns_sync/plugins/providers/verizon.py rename to external_plugins/verizon/provider.py index da6e59b..91bfbe5 100644 --- a/dns_sync/plugins/providers/verizon.py +++ b/external_plugins/verizon/provider.py @@ -6,9 +6,9 @@ import time import requests import urllib3 -from ...adapters import Endpoint -from ...models import ProviderCapabilities, ProviderRecord -from ...normalizer import normalize_hostname, normalize_ipv4 +from dns_sync.adapters import Endpoint +from dns_sync.models import ProviderCapabilities, ProviderRecord +from dns_sync.normalizer import normalize_hostname, normalize_ipv4 LUCI_USERNAME = ( "edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761" diff --git a/tests/test_cli_reconcile.py b/tests/test_cli_reconcile.py index 87e7316..8386217 100644 --- a/tests/test_cli_reconcile.py +++ b/tests/test_cli_reconcile.py @@ -164,6 +164,7 @@ class ReconcileCliTests(unittest.TestCase): "NPM_ENDPOINTS": "192.168.1.3", "VERIZON_ENDPOINTS": "192.168.1.1", "DNS_TARGET_IP": "192.168.1.101", + "ENABLED_PLUGINS": "npm,verizon", }, clear=True, ), patch( diff --git a/tests/test_registry.py b/tests/test_registry.py new file mode 100644 index 0000000..e5ca3ce --- /dev/null +++ b/tests/test_registry.py @@ -0,0 +1,225 @@ +import os +import tempfile +import textwrap +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from dns_sync.adapters import Endpoint +from dns_sync.registry import ( + enabled_plugins, + load_adapter, +) + + +class PluginRegistryTests(unittest.TestCase): + def test_default_enabled_plugin_is_npm(self): + with patch.dict(os.environ, {}, clear=True): + self.assertEqual( + enabled_plugins(), + frozenset({"npm"}), + ) + + def test_enabled_plugins_are_explicit(self): + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": ( + "npm,verizon" + ) + }, + clear=True, + ): + self.assertEqual( + enabled_plugins(), + frozenset( + { + "npm", + "verizon", + } + ), + ) + + def test_duplicate_enabled_plugin_is_rejected(self): + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "npm,npm", + }, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, + "duplicate plugin", + ): + enabled_plugins() + + def test_disabled_adapter_is_not_loaded(self): + config = SimpleNamespace() + endpoint = Endpoint( + "verizon", + "192.168.1.1:443", + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "npm", + }, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, + "is not enabled", + ): + load_adapter( + "verizon", + config, + endpoint, + ) + + def test_external_adapter_is_loaded(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = root / "example" + plugin.mkdir() + + (plugin / "__init__.py").write_text( + textwrap.dedent( + """ + class Plugin: + name = "example" + + def __init__( + self, + config, + endpoint, + ): + self.config = config + self.endpoint = endpoint + """ + ) + ) + + config = SimpleNamespace() + endpoint = Endpoint( + "example", + "example.test:443", + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": ( + "example" + ), + "DNS_SYNC_PLUGIN_PATH": ( + directory + ), + }, + clear=True, + ): + adapter = load_adapter( + "example", + config, + endpoint, + ) + + self.assertEqual( + adapter.name, + "example", + ) + self.assertEqual( + adapter.endpoint, + endpoint, + ) + + def test_external_adapter_can_import_plugin_submodule(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + plugin = root / "example_package" + plugin.mkdir() + + (plugin / "helper.py").write_text( + "VALUE = 'loaded-from-submodule'\n" + ) + + (plugin / "__init__.py").write_text( + textwrap.dedent( + """ + from .helper import VALUE + + class Plugin: + name = "example_package" + + def __init__( + self, + config, + endpoint, + ): + self.config = config + self.endpoint = endpoint + self.value = VALUE + """ + ) + ) + + config = SimpleNamespace() + endpoint = Endpoint( + "example_package", + "example.test:443", + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": ( + "example_package" + ), + "DNS_SYNC_PLUGIN_PATH": directory, + }, + clear=True, + ): + adapter = load_adapter( + "example_package", + config, + endpoint, + ) + + self.assertEqual( + adapter.value, + "loaded-from-submodule", + ) + + def test_missing_enabled_adapter_fails_closed(self): + with tempfile.TemporaryDirectory() as directory: + config = SimpleNamespace() + endpoint = Endpoint( + "missing", + "example.test:443", + ) + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "missing", + "DNS_SYNC_PLUGIN_PATH": ( + directory + ), + }, + clear=True, + ): + with self.assertRaisesRegex( + RuntimeError, + "Unknown adapter plugin", + ): + load_adapter( + "missing", + config, + endpoint, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_verizon_adapter.py b/tests/test_verizon_adapter.py index eec94c7..26cd31c 100644 --- a/tests/test_verizon_adapter.py +++ b/tests/test_verizon_adapter.py @@ -22,6 +22,8 @@ class VerizonAdapterTests(unittest.TestCase): { "NPM_ENDPOINTS": "192.168.1.3", "VERIZON_ENDPOINTS": "192.168.1.1", + "ENABLED_PLUGINS": "npm,verizon", + "DNS_SYNC_PLUGIN_PATH": "external_plugins", }, clear=True, ): @@ -29,16 +31,37 @@ class VerizonAdapterTests(unittest.TestCase): def adapter(self, provider): config = self.config() - with patch( - "dns_sync.plugins.adapters.verizon.VerizonProvider", - return_value=provider, + + import dns_sync.registry as registry + + with patch.dict( + os.environ, + { + "ENABLED_PLUGINS": "npm,verizon", + "DNS_SYNC_PLUGIN_PATH": "external_plugins", + }, + clear=False, ): - return load_adapter( - "verizon", - config, - config.verizon_endpoints[0], + module = registry._load_external_adapter_module( + "verizon" ) + if module is None: + raise AssertionError( + "external Verizon adapter was not found" + ) + + with patch.object( + module, + "VerizonProvider", + return_value=provider, + ): + return load_adapter( + "verizon", + config, + config.verizon_endpoints[0], + ) + @staticmethod def dns_object( hostname="app.example.com", @@ -85,6 +108,8 @@ class VerizonAdapterTests(unittest.TestCase): { "NPM_ENDPOINTS": "192.168.1.3", "VERIZON_ENDPOINTS": "192.168.1.1:8443", + "ENABLED_PLUGINS": "npm,verizon", + "DNS_SYNC_PLUGIN_PATH": "external_plugins", "ROUTER_URL": "https://192.168.1.254", "ROUTER_PASSWORD": "not-a-real-password", },