Let adapter plugins provide configured endpoints
This commit is contained in:
+15
-12
@@ -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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+97
-20
@@ -89,8 +89,30 @@ 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:
|
||||||
|
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
|
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,
|
||||||
package_path,
|
package_path,
|
||||||
@@ -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}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user