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 .executor import execute_endpoint_plans
|
||||||
from .orchestrator import discover_reconcile_plan
|
from .orchestrator import discover_reconcile_plan
|
||||||
from .persistence import SQLiteStateStore
|
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
|
from .reporting import print_endpoint_plans, print_plan
|
||||||
|
|
||||||
|
|
||||||
@@ -140,10 +145,16 @@ def run_sync(args):
|
|||||||
|
|
||||||
|
|
||||||
def _configured_adapters(config):
|
def _configured_adapters(config):
|
||||||
endpoints = (
|
enabled = enabled_plugins()
|
||||||
*config.npm_endpoints,
|
|
||||||
*config.verizon_endpoints,
|
endpoints = []
|
||||||
)
|
|
||||||
|
if "npm" in enabled:
|
||||||
|
endpoints.extend(config.npm_endpoints)
|
||||||
|
|
||||||
|
if "verizon" in enabled:
|
||||||
|
endpoints.extend(config.verizon_endpoints)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
load_adapter(endpoint.plugin, config, endpoint)
|
load_adapter(endpoint.plugin, config, endpoint)
|
||||||
for endpoint in endpoints
|
for endpoint in endpoints
|
||||||
|
|||||||
+173
-9
@@ -1,26 +1,185 @@
|
|||||||
import importlib
|
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):
|
_EXTERNAL_NAMESPACE = "dns_sync_external_plugins"
|
||||||
if not name or not name.replace("_", "").replace("-", "").isalnum():
|
|
||||||
raise RuntimeError(f"Invalid {kind} plugin name: {name!r}")
|
|
||||||
|
|
||||||
|
|
||||||
|
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("-", "_")
|
module_name = name.replace("-", "_")
|
||||||
path = f"dns_sync.plugins.{kind}.{module_name}"
|
path = f"dns_sync.plugins.{kind}.{module_name}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(path)
|
return importlib.import_module(path)
|
||||||
except ModuleNotFoundError as exc:
|
except ModuleNotFoundError as exc:
|
||||||
if exc.name == path:
|
if exc.name == path:
|
||||||
raise RuntimeError(f"Unknown {kind[:-1]} plugin: {name}") from exc
|
return None
|
||||||
raise
|
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)
|
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):
|
def load_source(name: str, config):
|
||||||
return _load("sources", name, config)
|
return _load("sources", name, config)
|
||||||
@@ -31,4 +190,9 @@ def load_provider(name: str, config):
|
|||||||
|
|
||||||
|
|
||||||
def load_adapter(name: str, config, endpoint):
|
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,
|
DNS_RECORDS,
|
||||||
ApplyResult,
|
ApplyResult,
|
||||||
Capability,
|
Capability,
|
||||||
@@ -8,8 +8,8 @@ from ...adapters import (
|
|||||||
PlanAction,
|
PlanAction,
|
||||||
PlanOperation,
|
PlanOperation,
|
||||||
)
|
)
|
||||||
from ...models import DesiredRecord
|
from dns_sync.models import DesiredRecord
|
||||||
from ..providers.verizon import Plugin as VerizonProvider
|
from .provider import Plugin as VerizonProvider
|
||||||
|
|
||||||
|
|
||||||
class Plugin:
|
class Plugin:
|
||||||
@@ -6,9 +6,9 @@ import time
|
|||||||
import requests
|
import requests
|
||||||
import urllib3
|
import urllib3
|
||||||
|
|
||||||
from ...adapters import Endpoint
|
from dns_sync.adapters import Endpoint
|
||||||
from ...models import ProviderCapabilities, ProviderRecord
|
from dns_sync.models import ProviderCapabilities, ProviderRecord
|
||||||
from ...normalizer import normalize_hostname, normalize_ipv4
|
from dns_sync.normalizer import normalize_hostname, normalize_ipv4
|
||||||
|
|
||||||
LUCI_USERNAME = (
|
LUCI_USERNAME = (
|
||||||
"edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761"
|
"edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761"
|
||||||
@@ -164,6 +164,7 @@ class ReconcileCliTests(unittest.TestCase):
|
|||||||
"NPM_ENDPOINTS": "192.168.1.3",
|
"NPM_ENDPOINTS": "192.168.1.3",
|
||||||
"VERIZON_ENDPOINTS": "192.168.1.1",
|
"VERIZON_ENDPOINTS": "192.168.1.1",
|
||||||
"DNS_TARGET_IP": "192.168.1.101",
|
"DNS_TARGET_IP": "192.168.1.101",
|
||||||
|
"ENABLED_PLUGINS": "npm,verizon",
|
||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
), patch(
|
), 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",
|
"NPM_ENDPOINTS": "192.168.1.3",
|
||||||
"VERIZON_ENDPOINTS": "192.168.1.1",
|
"VERIZON_ENDPOINTS": "192.168.1.1",
|
||||||
|
"ENABLED_PLUGINS": "npm,verizon",
|
||||||
|
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
|
||||||
},
|
},
|
||||||
clear=True,
|
clear=True,
|
||||||
):
|
):
|
||||||
@@ -29,8 +31,29 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
|
|
||||||
def adapter(self, provider):
|
def adapter(self, provider):
|
||||||
config = self.config()
|
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_value=provider,
|
||||||
):
|
):
|
||||||
return load_adapter(
|
return load_adapter(
|
||||||
@@ -85,6 +108,8 @@ class VerizonAdapterTests(unittest.TestCase):
|
|||||||
{
|
{
|
||||||
"NPM_ENDPOINTS": "192.168.1.3",
|
"NPM_ENDPOINTS": "192.168.1.3",
|
||||||
"VERIZON_ENDPOINTS": "192.168.1.1:8443",
|
"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_URL": "https://192.168.1.254",
|
||||||
"ROUTER_PASSWORD": "not-a-real-password",
|
"ROUTER_PASSWORD": "not-a-real-password",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user