Document canonical plugin architecture
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user