Add external adapter plugin boundary
This commit is contained in:
+16
-5
@@ -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
|
||||
|
||||
+173
-9
@@ -1,26 +1,185 @@
|
||||
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")
|
||||
|
||||
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):
|
||||
return _load("sources", name, 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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
@@ -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,8 +31,29 @@ class VerizonAdapterTests(unittest.TestCase):
|
||||
|
||||
def adapter(self, provider):
|
||||
config = self.config()
|
||||
with patch(
|
||||
"dns_sync.plugins.adapters.verizon.VerizonProvider",
|
||||
|
||||
import dns_sync.registry as registry
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"ENABLED_PLUGINS": "npm,verizon",
|
||||
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
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(
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user