Let adapter plugins provide configured endpoints

This commit is contained in:
Lyncoln
2026-09-20 19:20:26 -04:00
parent 832928159d
commit 16359dc164
6 changed files with 421 additions and 34 deletions
+15 -12
View File
@@ -14,6 +14,7 @@ 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 ( from .registry import (
configured_adapter_endpoints,
enabled_plugins, enabled_plugins,
load_adapter, load_adapter,
load_provider, load_provider,
@@ -145,20 +146,22 @@ def run_sync(args):
def _configured_adapters(config): def _configured_adapters(config):
enabled = enabled_plugins() adapters = []
endpoints = [] for plugin_name in sorted(enabled_plugins()):
for endpoint in configured_adapter_endpoints(
plugin_name,
config,
):
adapters.append(
load_adapter(
plugin_name,
config,
endpoint,
)
)
if "npm" in enabled: return adapters
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
]
def run_reconcile(args): def run_reconcile(args):
+4
View File
@@ -23,6 +23,10 @@ from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object from ...object_normalizer import normalize_discovered_object
def configured_endpoints(config):
return config.npm_endpoints
_PROXY_HOST_FIELDS = ( _PROXY_HOST_FIELDS = (
"domain_names", "domain_names",
"forward_scheme", "forward_scheme",
+99 -22
View File
@@ -89,7 +89,29 @@ def _load_external_adapter_module(name: str):
existing = sys.modules.get(qualified_name) existing = sys.modules.get(qualified_name)
if existing is not None: if existing is not None:
return existing 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( spec = importlib.util.spec_from_file_location(
qualified_name, qualified_name,
@@ -129,6 +151,72 @@ def _load_builtin(kind: str, name: str):
raise 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): def _plugin_class(module, display_path: str):
plugin_class = getattr(module, "Plugin", None) plugin_class = getattr(module, "Plugin", None)
@@ -144,12 +232,17 @@ def _load(kind: str, name: str, config, *args):
normalized = _validate_plugin_name(kind, name) normalized = _validate_plugin_name(kind, name)
if kind == "adapters": if kind == "adapters":
enabled = enabled_plugins() (
normalized,
module,
display_path,
) = _adapter_module(normalized)
if normalized not in enabled: plugin_class = _plugin_class(
raise RuntimeError( module,
f"Adapter plugin {normalized!r} is not enabled" display_path,
) )
return plugin_class(config, *args)
module = _load_builtin(kind, normalized) module = _load_builtin(kind, normalized)
@@ -160,22 +253,6 @@ def _load(kind: str, name: str, config, *args):
) )
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( raise RuntimeError(
f"Unknown {kind[:-1]} plugin: {normalized}" f"Unknown {kind[:-1]} plugin: {normalized}"
) )
+4
View File
@@ -17,6 +17,10 @@ from dns_sync.models import DesiredRecord
from .provider import Plugin as VerizonProvider from .provider import Plugin as VerizonProvider
def configured_endpoints(config):
return config.verizon_endpoints
class Plugin: class Plugin:
name = "verizon" name = "verizon"
capabilities = frozenset( capabilities = frozenset(
+12
View File
@@ -150,6 +150,15 @@ class ReconcileCliTests(unittest.TestCase):
verizon_endpoint.identity: verizon, verizon_endpoint.identity: verizon,
} }
def endpoint_loader(name, config):
if name == "npm":
return (npm_endpoint,)
if name == "verizon":
return (verizon_endpoint,)
raise AssertionError(
f"Unexpected adapter plugin: {name}"
)
def loader(name, config, endpoint): def loader(name, config, endpoint):
return adapters[endpoint.identity] return adapters[endpoint.identity]
@@ -167,6 +176,9 @@ class ReconcileCliTests(unittest.TestCase):
"ENABLED_PLUGINS": "npm,verizon", "ENABLED_PLUGINS": "npm,verizon",
}, },
clear=True, clear=True,
), patch(
"dns_sync.cli.configured_adapter_endpoints",
side_effect=endpoint_loader,
), patch( ), patch(
"dns_sync.cli.load_adapter", "dns_sync.cli.load_adapter",
side_effect=loader, side_effect=loader,
+287
View File
@@ -8,6 +8,7 @@ from unittest.mock import patch
from dns_sync.adapters import Endpoint from dns_sync.adapters import Endpoint
from dns_sync.registry import ( from dns_sync.registry import (
configured_adapter_endpoints,
enabled_plugins, enabled_plugins,
load_adapter, load_adapter,
) )
@@ -221,5 +222,291 @@ class PluginRegistryTests(unittest.TestCase):
) )
def test_builtin_adapter_supplies_configured_endpoints(self):
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
config = SimpleNamespace(
npm_endpoints=(endpoint,),
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm",
},
clear=True,
):
endpoints = configured_adapter_endpoints(
"npm",
config,
)
self.assertEqual(
endpoints,
(endpoint,),
)
def test_external_adapter_cache_respects_plugin_root(self):
with tempfile.TemporaryDirectory() as first_directory, \
tempfile.TemporaryDirectory() as second_directory:
first_root = Path(first_directory)
second_root = Path(second_directory)
first_plugin = first_root / "example_cache"
second_plugin = second_root / "example_cache"
first_plugin.mkdir()
second_plugin.mkdir()
first_plugin.joinpath("__init__.py").write_text(
textwrap.dedent(
"""
from dns_sync.adapters import Endpoint
def configured_endpoints(config):
return (
Endpoint(
"example_cache",
"first.example:443",
),
)
class Plugin:
name = "example_cache"
def __init__(
self,
config,
endpoint,
):
self.endpoint = endpoint
"""
)
)
second_plugin.joinpath("__init__.py").write_text(
textwrap.dedent(
"""
from dns_sync.adapters import Endpoint
def configured_endpoints(config):
return (
Endpoint(
"example_cache",
"second.example:443",
),
)
class Plugin:
name = "example_cache"
def __init__(
self,
config,
endpoint,
):
self.endpoint = endpoint
"""
)
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example_cache",
"DNS_SYNC_PLUGIN_PATH": first_directory,
},
clear=True,
):
first = configured_adapter_endpoints(
"example_cache",
SimpleNamespace(),
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example_cache",
"DNS_SYNC_PLUGIN_PATH": second_directory,
},
clear=True,
):
second = configured_adapter_endpoints(
"example_cache",
SimpleNamespace(),
)
self.assertEqual(
tuple(endpoint.identity for endpoint in first),
("example_cache://first.example:443",),
)
self.assertEqual(
tuple(endpoint.identity for endpoint in second),
("example_cache://second.example:443",),
)
def test_external_adapter_supplies_configured_endpoints(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin = root / "example"
plugin.mkdir()
(plugin / "__init__.py").write_text(
textwrap.dedent(
"""
import os
from dns_sync.adapters import Endpoint
def configured_endpoints(config):
address = os.environ["EXAMPLE_ENDPOINT"]
return (
Endpoint("example", address),
)
class Plugin:
name = "example"
def __init__(
self,
config,
endpoint,
):
self.config = config
self.endpoint = endpoint
"""
)
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example",
"DNS_SYNC_PLUGIN_PATH": directory,
"EXAMPLE_ENDPOINT": "example.test:9443",
},
clear=True,
):
endpoints = configured_adapter_endpoints(
"example",
SimpleNamespace(),
)
self.assertEqual(
tuple(
endpoint.identity
for endpoint in endpoints
),
("example://example.test:9443",),
)
def test_adapter_missing_endpoint_hook_fails_closed(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin = root / "example_missing_hook"
plugin.mkdir()
(plugin / "__init__.py").write_text(
textwrap.dedent(
"""
class Plugin:
name = "example_missing_hook"
def __init__(
self,
config,
endpoint,
):
self.config = config
self.endpoint = endpoint
"""
)
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example_missing_hook",
"DNS_SYNC_PLUGIN_PATH": directory,
},
clear=True,
):
with self.assertRaisesRegex(
RuntimeError,
"does not export configured_endpoints",
):
configured_adapter_endpoints(
"example_missing_hook",
SimpleNamespace(),
)
def test_adapter_endpoint_hook_cannot_cross_plugin_boundary(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin = root / "example_wrong_endpoint"
plugin.mkdir()
(plugin / "__init__.py").write_text(
textwrap.dedent(
"""
from dns_sync.adapters import Endpoint
def configured_endpoints(config):
return (
Endpoint(
"other",
"example.test:443",
),
)
class Plugin:
name = "example_wrong_endpoint"
def __init__(
self,
config,
endpoint,
):
self.config = config
self.endpoint = endpoint
"""
)
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example_wrong_endpoint",
"DNS_SYNC_PLUGIN_PATH": directory,
},
clear=True,
):
with self.assertRaisesRegex(
RuntimeError,
"returned endpoint for plugin",
):
configured_adapter_endpoints(
"example_wrong_endpoint",
SimpleNamespace(),
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()