Add external adapter plugin boundary

This commit is contained in:
Lyncoln
2026-09-20 14:44:51 -04:00
parent 572dd9eae4
commit d6ad917b57
7 changed files with 454 additions and 28 deletions
+16 -5
View File
@@ -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
+174 -10
View File
@@ -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,
)
@@ -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"
+1
View File
@@ -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(
+225
View File
@@ -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()
+32 -7
View File
@@ -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",
},