Document canonical plugin architecture

This commit is contained in:
Lyncoln
2026-09-20 23:46:48 -04:00
parent 0719671820
commit 1a7538501b
5 changed files with 861 additions and 6 deletions
+480 -6
View File
@@ -2,19 +2,374 @@
A plugin-based DNS reconciliation service. A plugin-based DNS reconciliation service.
## Architecture
dns-sync separates generic reconciliation from application-specific endpoint
behavior.
native endpoint
|
v
<App>Client
|
| native objects
v
Plugin
|
| DiscoveredObject
v
dns-sync core
|
| normalization
| observations
| desired state
| persistence
| reconciliation
| planning
v
PlanAction
|
v
Plugin
|
| native translation
v
<App>Client
|
v
native endpoint
The core owns orchestration truth.
Plugins own application semantics.
Clients own endpoint-native communication.
## Plugin contract ## Plugin contract
The reconciliation runtime is adapter-based. The reconciliation runtime is adapter-based.
Adapter plugins expose: Every adapter package must export:
- `Plugin`
- `configured_endpoints(config)` - `configured_endpoints(config)`
- `Plugin`
Each adapter declares object-specific discovery, apply, and verification The registry calls:
capabilities. Built-in adapters live under `dns_sync/plugins/adapters/`.
External adapters are loaded from `DNS_SYNC_PLUGIN_PATH` and must be configured_endpoints(config)
explicitly enabled through `ENABLED_PLUGINS`.
to obtain the endpoints owned by the plugin.
For each configured endpoint, the registry constructs:
Plugin(config, endpoint)
Every endpoint returned by `configured_endpoints(config)` must identify the
plugin that returned it. The registry rejects cross-plugin endpoint ownership.
Plugins declare object-specific capabilities using the generic
`Capability` model from `dns_sync.adapters`.
Capabilities currently describe:
- discovery
- apply
- verification
for a particular generic object type.
Built-in adapters live under:
dns_sync/plugins/adapters/
External adapters are loaded from:
DNS_SYNC_PLUGIN_PATH
and must be explicitly enabled through:
ENABLED_PLUGINS
## Canonical plugin structure
Application integrations should use this package structure:
<plugin_name>/
├── __init__.py
└── client.py
The repository contains a copyable example under:
examples/plugin_template/
The example is documentation only and is not loaded from that location.
### Adapter boundary: __init__.py
`__init__.py` is the dns-sync-facing plugin boundary.
It owns:
- endpoint configuration through `configured_endpoints(config)`
- generic capability declarations
- validation of the supplied endpoint
- native-to-generic discovery translation
- generic-to-native apply translation
- verification
- optional retirement behavior
- application-specific semantic validation
The adapter communicates with the core using generic types such as:
- `Endpoint`
- `DiscoveredObject`
- `NormalizedObject`
- `ObjectReference`
- `PlanAction`
- `ApplyResult`
- `RetireRequest`
### Native boundary: client.py
`client.py` is private implementation machinery for that application.
It owns:
- authentication
- HTTP sessions, sockets, commands, or other transport
- application-native request and response handling
- application-native models
- endpoint-local mutation primitives
- endpoint-specific verification mechanics
A client represents one explicit `Endpoint`.
The client should not independently rediscover which endpoint it is supposed
to contact from another global endpoint setting.
There is deliberately no generic dns-sync `Client` base class. Different
applications may communicate through completely different mechanisms. The
shared contract is the adapter plugin boundary, not the transport layer.
Application-specific models belong inside the plugin package rather than the
dns-sync core unless they represent a genuinely application-independent core
concept.
## Discovery
`Plugin.discover(object_type)` reads native endpoint state through the private
client and returns `DiscoveredObject` instances.
A discovered object carries:
object_type
local_key
payload
references
`local_key` is endpoint-local identity. It is not assumed to be portable to
another endpoint.
The plugin owns translation from the native application's representation into
this generic discovery representation.
Unsupported object types should fail explicitly rather than silently returning
an empty or misleading result.
## Normalized state
The core converts discoveries into `NormalizedObject` instances and owns the
reconciliation state built from them.
A normalized object carries:
object_type
logical_key
payload
local_key
references
`logical_key` represents portable identity.
`local_key` represents endpoint-local identity.
Plugins do not persist their own reconciliation truth. The core owns:
- observations
- observation status
- desired state
- applied and verified state
- reconciliation
- planning
- dependency resolution
## Object references
Dependencies between objects use `ObjectReference`.
A reference identifies another object by exactly one of:
local_key
or:
logical_key
but never both.
Endpoint discovery may expose local references because those are the identities
native applications actually use.
Central reconciliation can convert those into portable logical references.
During execution, dns-sync resolves portable references back to the correct
endpoint-local identity before invoking the target adapter.
Plugins should never treat an endpoint-local ID from one endpoint as portable
desired state for another endpoint.
## Apply
`Plugin.apply(action)` receives a generic `PlanAction`.
Before mutation, the adapter should validate that the action belongs to its
endpoint.
Shared validation helpers are available in:
dns_sync.adapter_support
including:
require_endpoint_plugin(...)
require_action_endpoint(...)
require_desired(...)
`NOOP` and `SKIP` actions must not mutate the endpoint.
For a mutating operation, the adapter should:
1. validate the requested operation;
2. validate the desired normalized object;
3. translate desired state into an application-native object;
4. invoke the private client;
5. re-fetch or rediscover the actual resulting native object;
6. obtain its real endpoint-local identity;
7. return an `ApplyResult`.
Do not guess or predict endpoint-local IDs when the endpoint assigns them.
Unsupported operations should fail explicitly.
## Apply results
A successful mutation returns an `ApplyResult` whose normalized object contains
the actual endpoint-local key.
Conceptually:
ApplyResult(
object=normalized_applied_object,
detail=...,
)
The returned object's `local_key` is required.
An apply result may also include deferred `RetireRequest` objects when a
replacement workflow requires the old object to remain temporarily while
dependencies are rebound.
## Verification
`Plugin.verify(object)` checks that the actual endpoint state matches the
normalized object dns-sync expects to exist.
Verification is intentionally separate from mutation.
A successful API response is not sufficient evidence that endpoint state
converged.
The executor requires supported mutations to pass verification before the
result is treated as successfully applied.
## Retirement
`retire()` is optional.
A plugin should implement retirement only when it can safely remove or retire
endpoint-local objects.
Retirement is used by deletion and replacement workflows.
The executor performs preflight checks before mutation. If execution requires
retirement and the adapter does not provide it, the operation fails before the
unsafe mutation begins.
Retirement should use the endpoint-local identity supplied by dns-sync and fail
closed when the object cannot be identified safely.
## Fail-closed behavior
Plugins should reject ambiguous or unsupported behavior.
In particular:
- reject endpoints owned by another plugin;
- reject actions intended for another endpoint;
- reject unsupported object types;
- reject unsupported plan operations;
- reject malformed native state;
- reject ambiguous endpoint-local identities;
- never guess missing IDs;
- never report successful application until the resulting object can be
identified;
- never treat mutation success alone as verification.
The generic executor adds additional preflight and dependency safety checks.
## Configuration ownership
Configuration should live at the lowest layer that actually understands it.
Use this rule:
Does generic reconciliation need to understand this value?
yes -> core configuration may be appropriate
no -> plugin/client configuration
Examples of generic reconciliation policy include:
DNS_TARGET_IP
DOMAIN_MATCHING
DOMAIN
Examples of plugin/client configuration include:
NPM_ENDPOINTS
PROXY_API_PORT
PROXY_API_SCHEME
NPM_IDENTITY
NPM_SECRET
VERIZON_ENDPOINTS
ROUTER_PASSWORD
ROUTER_TIMEOUT
`configured_endpoints(config)` owns endpoint discovery for the plugin.
The client receives the resulting explicit `Endpoint`; it should not implement
a second independent endpoint-selection mechanism.
## Secret handling
Secret material should remain below the normalized and persistence boundary
unless the core has an explicit safe representation for it.
Plugins must not place credentials, authentication tokens, private keys, or
other secret material into generic persisted payloads.
Runtime credentials should normally be provided through environment variables,
container secrets, or another external secret-management mechanism.
## Plugins ## Plugins
@@ -38,6 +393,125 @@ The Verizon adapter lives under `external_plugins/verizon` and is deliberately
not copied into the dns-sync container image. The external plugin directory is not copied into the dns-sync container image. The external plugin directory is
mounted read-only at runtime. mounted read-only at runtime.
## Plugin loading
Only explicitly enabled plugins are loadable.
The registry first checks for a built-in adapter under:
dns_sync/plugins/adapters/<plugin_name>/
If no built-in adapter exists, it looks for an external package at:
$DNS_SYNC_PLUGIN_PATH/<plugin_name>/__init__.py
External plugins are loaded as packages, so they may use relative imports for
private modules such as:
from .client import ExampleClient
Imports from dns-sync itself should remain absolute, for example:
from dns_sync.adapters import Endpoint
from dns_sync.adapter_support import require_endpoint_plugin
The external plugin directory does not need to be added globally to
`PYTHONPATH`.
## Adding a new plugin
Start from:
examples/plugin_template/
For an external plugin:
1. copy the template into the configured external plugin root;
2. rename the directory to the plugin name;
3. rename `ExampleClient` and `ExampleRecord`;
4. implement `configured_endpoints(config)`;
5. implement native authentication and transport in `client.py`;
6. declare only the generic capabilities actually supported;
7. implement native-to-`DiscoveredObject` translation;
8. implement `NormalizedObject`-to-native translation;
9. implement `apply()` and `verify()`;
10. implement `retire()` only when safe retirement is supported;
11. add focused client and adapter tests;
12. enable the plugin through `ENABLED_PLUGINS`;
13. run read-only reconciliation against the real endpoint before permitting
mutations.
Do not begin a new integration by adding application-specific models or
transport behavior to the core.
## Reference implementations
Use both existing plugins when designing a new integration.
### NPM
The NPM plugin demonstrates:
- a built-in adapter;
- multiple configured endpoints;
- proxy-host and certificate object types;
- endpoint-local object IDs;
- cross-object certificate references;
- portable logical references;
- replacement workflows;
- deferred retirement;
- certificate material handling.
Its package lives at:
dns_sync/plugins/adapters/npm/
### Verizon
The Verizon plugin demonstrates:
- an external adapter;
- plugin-owned endpoint configuration;
- a private native record model;
- a private endpoint client;
- DNS record discovery;
- create/update operations;
- verification;
- a read-only mounted external plugin package.
Its package lives at:
external_plugins/verizon/
Together they demonstrate the preferred boundary:
dns-sync core
|
+-- generic state and orchestration
|
+-- Plugin
|
+-- native / normalized translation
|
+-- <App>Client
|
+-- application-native implementation
## Plugin development validation
Before committing a plugin change, run at least:
git diff --check
python3 -m compileall -q dns_sync external_plugins tests examples
python3 -m unittest discover -s tests -v
Changes involving plugin loading, native clients, endpoint configuration, or
mutation behavior should also be validated with a real read-only container
reconciliation against the intended endpoints.
A clean read-only plan does not by itself authorize mutation. Review unexpected
planned changes before enabling apply mode.
## Container runtime ## Container runtime
The container periodically executes the generic reconciliation engine: The container periodically executes the generic reconciliation engine:
+44
View File
@@ -0,0 +1,44 @@
# dns-sync plugin template
This directory is a documentation/example package. dns-sync does not load it
from this location.
Copy it to one of these locations and rename it for the application:
dns_sync/plugins/adapters/<plugin_name>/
for a built-in plugin, or:
$DNS_SYNC_PLUGIN_PATH/<plugin_name>/
for an external plugin.
The canonical package layout is:
<plugin_name>/
├── __init__.py
└── client.py
`__init__.py` is the dns-sync adapter boundary. It owns endpoint discovery,
generic capabilities, native-to-normalized translation, apply, verify, and
optional retirement behavior.
`client.py` is private to the plugin. It owns authentication, transport,
application-specific models, native request/response handling, and endpoint
operations.
## Before enabling a copied plugin
1. Rename `example`, `ExampleClient`, and `ExampleRecord`.
2. Implement `configured_endpoints(config)`.
3. Implement the native client methods.
4. Declare only capabilities the plugin really supports.
5. Implement discovery for those object types.
6. Translate normalized desired state into native state in `apply()`.
7. Re-discover mutations and return the real endpoint-local key.
8. Implement `verify()`.
9. Add `retire()` only when retirement/deletion is safely supported.
10. Add focused adapter/client tests.
11. Validate using read-only reconciliation before enabling mutations.
See the root `README.md` for the complete plugin contract and architecture.
+208
View File
@@ -0,0 +1,208 @@
"""Canonical dns-sync adapter plugin template."""
import os
from dns_sync.adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.adapters import (
DNS_RECORDS,
ApplyResult,
Capability,
CapabilityOperation,
DiscoveredObject,
NormalizedObject,
PlanAction,
PlanOperation,
)
from dns_sync.config import parse_endpoint_list
from .client import ExampleClient, ExampleRecord
PLUGIN_NAME = "example"
def configured_endpoints(config):
"""Return every endpoint owned by this plugin."""
return parse_endpoint_list(
PLUGIN_NAME,
os.getenv(
"EXAMPLE_ENDPOINTS",
"example.test",
),
443,
)
class Plugin:
"""dns-sync adapter for one configured endpoint."""
name = PLUGIN_NAME
capabilities = frozenset(
{
Capability(
DNS_RECORDS,
CapabilityOperation.DISCOVER,
),
Capability(
DNS_RECORDS,
CapabilityOperation.APPLY,
),
Capability(
DNS_RECORDS,
CapabilityOperation.VERIFY,
),
}
)
def __init__(self, config, endpoint):
require_endpoint_plugin(
endpoint,
self.name,
)
self.endpoint = endpoint
self.client = ExampleClient(endpoint)
def connect(self) -> None:
self.client.connect()
def discover(
self,
object_type: str,
) -> list[DiscoveredObject]:
if object_type != DNS_RECORDS:
raise RuntimeError(
f"{self.endpoint.identity} cannot discover "
f"object type {object_type!r}"
)
return [
DiscoveredObject(
object_type=DNS_RECORDS,
local_key=record.local_key,
payload={
"hostname": record.hostname,
"ip": record.ip,
"record_type": record.record_type,
},
)
for record in self.client.list_records()
]
def _native_record(
self,
object: NormalizedObject,
) -> ExampleRecord:
if object.object_type != DNS_RECORDS:
raise RuntimeError(
f"{self.endpoint.identity} cannot handle "
f"object type {object.object_type!r}"
)
payload = object.payload
hostname = str(payload.get("hostname") or "")
if hostname != object.logical_key:
raise RuntimeError(
"DNS object hostname does not match its logical key"
)
record_type = str(
payload.get("record_type") or ""
).upper()
if record_type != "A":
raise RuntimeError(
f"Example plugin does not support "
f"{record_type!r} records"
)
if payload.get("wildcard") is True:
raise RuntimeError(
"Example plugin does not support wildcard records"
)
return ExampleRecord(
local_key=object.local_key or "",
hostname=hostname,
ip=str(payload.get("ip") or ""),
record_type=record_type,
)
def apply(
self,
action: PlanAction,
) -> ApplyResult | None:
require_action_endpoint(
action,
self.endpoint,
)
if action.operation in {
PlanOperation.NOOP,
PlanOperation.SKIP,
}:
return None
desired = require_desired(
action,
context="Example apply action",
)
record = self._native_record(desired)
if action.operation == PlanOperation.CREATE:
detail = self.client.add_record(record)
elif action.operation == PlanOperation.UPDATE:
detail = self.client.update_record(record)
else:
raise RuntimeError(
f"Example adapter does not support "
f"{action.operation.value} actions"
)
records = self.client.list_records()
applied = next(
(
candidate
for candidate in records
if candidate.hostname.lower()
== record.hostname.lower()
),
None,
)
if applied is None:
raise RuntimeError(
"Example mutation succeeded but the object "
"could not be rediscovered"
)
normalized = NormalizedObject(
object_type=desired.object_type,
logical_key=desired.logical_key,
local_key=applied.local_key,
payload=dict(desired.payload),
references=desired.references,
)
return ApplyResult(
object=normalized,
detail=detail,
)
def verify(
self,
object: NormalizedObject,
) -> bool:
return self.client.verify_record(
self._native_record(object)
)
# Add retire(self, request) only when this plugin supports safe
# deletion/replacement retirement.
+56
View File
@@ -0,0 +1,56 @@
"""Private native client for the example dns-sync plugin."""
from dataclasses import dataclass
from dns_sync.adapters import Endpoint
@dataclass(frozen=True)
class ExampleRecord:
"""Endpoint-native record used only by this plugin."""
local_key: str
hostname: str
ip: str
record_type: str = "A"
class ExampleClient:
"""Client for one explicit example endpoint."""
def __init__(self, endpoint: Endpoint):
if endpoint.plugin != "example":
raise RuntimeError(
"ExampleClient requires an example endpoint"
)
self.endpoint = endpoint
def connect(self) -> None:
"""Authenticate or initialize the native connection."""
raise NotImplementedError
def list_records(self) -> list[ExampleRecord]:
"""Return endpoint-native records."""
raise NotImplementedError
def add_record(
self,
record: ExampleRecord,
) -> str | None:
"""Create one native record."""
raise NotImplementedError
def update_record(
self,
record: ExampleRecord,
) -> str | None:
"""Update one native record."""
raise NotImplementedError
def verify_record(
self,
record: ExampleRecord,
) -> bool:
"""Verify endpoint-native state."""
raise NotImplementedError
+73
View File
@@ -0,0 +1,73 @@
import os
import shutil
import tempfile
import unittest
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from dns_sync.registry import (
configured_adapter_endpoints,
load_adapter,
)
class PluginTemplateTests(unittest.TestCase):
def test_template_loads_as_external_plugin(self):
source = Path("examples/plugin_template")
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
plugin = root / "example"
shutil.copytree(
source,
plugin,
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "example",
"DNS_SYNC_PLUGIN_PATH": directory,
"EXAMPLE_ENDPOINTS": "example.test",
},
clear=True,
):
config = SimpleNamespace()
endpoints = configured_adapter_endpoints(
"example",
config,
)
self.assertEqual(
tuple(
endpoint.identity
for endpoint in endpoints
),
("example://example.test:443",),
)
adapter = load_adapter(
"example",
config,
endpoints[0],
)
self.assertEqual(
adapter.name,
"example",
)
self.assertEqual(
adapter.endpoint,
endpoints[0],
)
self.assertEqual(
adapter.client.endpoint,
endpoints[0],
)
if __name__ == "__main__":
unittest.main()