import importlib import importlib.util import os import sys from pathlib import Path from types import ModuleType _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: 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, 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: return importlib.import_module(path) except ModuleNotFoundError as exc: if exc.name == path: return None 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) 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": ( normalized, module, display_path, ) = _adapter_module(normalized) plugin_class = _plugin_class( module, display_path, ) return plugin_class(config, *args) 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) raise RuntimeError( f"Unknown {kind[:-1]} plugin: {normalized}" ) def load_source(name: str, config): return _load("sources", name, config) def load_provider(name: str, config): return _load("providers", name, config) def load_adapter(name: str, config, endpoint): return _load( "adapters", name, config, endpoint, )