Compare commits

..
23 Commits
Author SHA1 Message Date
Lyncoln f44522d7b5 Load built image before workflow verification
Build and Publish dns-sync Image / build (push) Successful in 44s
2026-09-21 00:00:02 -04:00
Lyncoln 665956f8ee Run workflow tests in virtual environment
Build and Publish dns-sync Image / build (push) Failing after 45s
2026-09-20 23:57:00 -04:00
Lyncoln 0d0e4e02ae Add container build and publish workflow
Build and Publish dns-sync Image / build (push) Failing after 17s
2026-09-20 23:53:49 -04:00
Lyncoln 1a7538501b Document canonical plugin architecture 2026-09-20 23:46:48 -04:00
Lyncoln 0719671820 Standardize Verizon plugin client boundary 2026-09-20 23:30:15 -04:00
Lyncoln c9a3898055 Move NPM configuration and client behind plugin boundary 2026-09-20 22:50:37 -04:00
Lyncoln 332f994c26 Remove obsolete NPM certificate repository 2026-09-20 20:49:34 -04:00
Lyncoln 71d8b34793 Remove legacy source provider sync path 2026-09-20 20:36:09 -04:00
Lyncoln ed97b644e8 Move Verizon endpoint config into plugin 2026-09-20 20:29:40 -04:00
Lyncoln 16359dc164 Let adapter plugins provide configured endpoints 2026-09-20 19:20:26 -04:00
Lyncoln 832928159d Extract shared adapter validation helpers 2026-09-20 18:47:21 -04:00
Lyncoln d4ceb7e1df Run generic reconciliation in container 2026-09-20 17:34:01 -04:00
Lyncoln d6ad917b57 Add external adapter plugin boundary 2026-09-20 14:44:51 -04:00
Lyncoln 572dd9eae4 Account for deferred retirements in apply budget 2026-09-20 13:13:28 -04:00
Lyncoln a93e067bfd Exclude expired disabled NPM certificate dependencies 2026-09-20 10:53:26 -04:00
Lyncoln e4ca80d4d5 Enable safe NPM replica convergence 2026-09-20 10:37:11 -04:00
Lyncoln 291f4f492e Require live observations for convergence 2026-09-20 02:15:30 -04:00
Lyncoln 387332b5db Recover interrupted certificate replacement 2026-09-20 01:54:30 -04:00
Lyncoln 2668651a98 Add certificate replacement lifecycle 2026-09-20 01:44:56 -04:00
Lyncoln 4b094820b2 Wire NPM desired state into orchestration 2026-09-20 01:26:55 -04:00
Lyncoln 3022b76876 Add portable NPM reconciliation policies 2026-09-20 01:05:34 -04:00
Lyncoln c4cdc92865 Add NPM proxy host apply paths 2026-09-20 01:00:42 -04:00
Lyncoln 6ec4f2932e Add rollback-safe NPM certificate create 2026-09-20 00:56:25 -04:00
46 changed files with 7888 additions and 1386 deletions
+139
View File
@@ -0,0 +1,139 @@
name: Build and Publish dns-sync Image
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
# -------------------------------------------------
# Checkout (manual, no Node, no external actions)
# -------------------------------------------------
- name: Checkout repository
env:
GITEA_TOKEN: ${{ secrets.TEA_TOKEN }}
run: |
set -euo pipefail
rm -rf ./* ./.git || true
BASE="${GITHUB_SERVER_URL%/}"
REPO="${GITHUB_REPOSITORY}"
if [ -n "${GITEA_TOKEN:-}" ]; then
CLONE_URL="${BASE}/${REPO}.git"
CLONE_URL="$(echo "$CLONE_URL" | sed -E 's#^https://#https://oauth2:'"${GITEA_TOKEN}"'@#')"
git clone "$CLONE_URL" .
else
git clone "${BASE}/${REPO}.git" .
fi
git config --global --add safe.directory "$PWD"
# -------------------------------------------------
# Test source before building
# -------------------------------------------------
- name: Run test suite
run: |
set -euo pipefail
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install requests PyYAML
.venv/bin/python -m unittest discover \
-s tests \
-v
# -------------------------------------------------
# Build image
# -------------------------------------------------
- name: Build image
env:
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
SHORT_SHA="${GITHUB_SHA::7}"
FULL_IMAGE="$REGISTRY/david/dns-sync"
docker build \
-f Containerfile \
-t "$FULL_IMAGE:latest" \
-t "$FULL_IMAGE:sha-$SHORT_SHA" \
--output "type=docker,dest=$RUNNER_TEMP/dns-sync-built.tar" \
.
# -------------------------------------------------
# Verify locally before publishing
# -------------------------------------------------
- name: Load and verify image
env:
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
FULL_IMAGE="$REGISTRY/david/dns-sync"
test -s "$RUNNER_TEMP/dns-sync-built.tar"
docker load \
--input "$RUNNER_TEMP/dns-sync-built.tar"
docker image inspect "$FULL_IMAGE:latest" >/dev/null
docker run --rm \
--entrypoint python \
"$FULL_IMAGE:latest" \
-m dns_sync reconcile --help
echo
echo "Loaded image:"
docker image ls "$FULL_IMAGE"
# -------------------------------------------------
# Login to container registry
# -------------------------------------------------
- name: Docker login
env:
REGISTRY: ${{ vars.REGISTRY }}
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
REGISTRY_TOKEN: ${{ secrets.TEA_TOKEN }}
run: |
set -euo pipefail
echo "$REGISTRY_TOKEN" | docker login "$REGISTRY" \
-u "$REGISTRY_USER" \
--password-stdin
# -------------------------------------------------
# Publish
# -------------------------------------------------
- name: Publish image
env:
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
SHORT_SHA="${GITHUB_SHA::7}"
FULL_IMAGE="$REGISTRY/david/dns-sync"
docker push "$FULL_IMAGE:latest"
docker push "$FULL_IMAGE:sha-$SHORT_SHA"
- name: Report published image
env:
REGISTRY: ${{ vars.REGISTRY }}
run: |
set -euo pipefail
SHORT_SHA="${GITHUB_SHA::7}"
FULL_IMAGE="$REGISTRY/david/dns-sync"
echo "Published:"
echo " $FULL_IMAGE:latest"
echo " $FULL_IMAGE:sha-$SHORT_SHA"
+7 -2
View File
@@ -2,13 +2,18 @@ FROM docker.io/library/python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app PYTHONPATH=/app \
DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3 \
DNS_SYNC_PLUGIN_PATH=/plugins
RUN pip install --no-cache-dir requests PyYAML RUN pip install --no-cache-dir requests PyYAML
WORKDIR /app WORKDIR /app
COPY dns_sync /app/dns_sync COPY dns_sync /app/dns_sync
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
RUN mkdir -p /var/lib/dns-sync /plugins \
&& chmod 0755 /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+548 -28
View File
@@ -2,59 +2,579 @@
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
Source modules live under `dns_sync/plugins/sources/` and export a class named `Plugin` with: The reconciliation runtime is adapter-based.
- `name` Every adapter package must export:
- `discover() -> list[DesiredRecord]`
Provider modules live under `dns_sync/plugins/providers/` and export `Plugin` with: - `configured_endpoints(config)`
- `Plugin`
- `name` The registry calls:
- `capabilities`
- `connect()`
- `list_records()`
- `add_record()`
- `update_record()`
- `delete_record()`
- `verify_record()`
- `capacity()`
Plugins are convention-loaded from environment variables. No registry edit is needed: configured_endpoints(config)
- `PROXY=npm` loads `dns_sync.plugins.sources.npm.Plugin` to obtain the endpoints owned by the plugin.
- `DNS_PROVIDER=verizon` loads `dns_sync.plugins.providers.verizon.Plugin`
## Current plugins For each configured endpoint, the registry constructs:
Sources: `npm`, `file` Plugin(config, endpoint)
Providers: `verizon` 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
The generic reconciliation engine uses adapter plugins.
Built-in adapters:
- `npm` - Nginx Proxy Manager proxy hosts and certificates
External adapters are loaded from `DNS_SYNC_PLUGIN_PATH` and must also be
explicitly enabled through `ENABLED_PLUGINS`.
Example:
```text
ENABLED_PLUGINS=npm,verizon
DNS_SYNC_PLUGIN_PATH=/plugins
```
The Verizon adapter lives under `external_plugins/verizon` and is deliberately
not copied into the dns-sync container image. The external plugin directory is
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
The container periodically executes the generic reconciliation engine:
```text
python -m dns_sync reconcile
```
The default container mode is read-only.
```text
SYNC_INTERVAL=1
DNS_SYNC_APPLY=false
DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3
DNS_SYNC_PLUGIN_PATH=/plugins
ENABLED_PLUGINS=npm,verizon
```
`SYNC_INTERVAL` is expressed in minutes. The interval begins after each
reconciliation completes, so runs do not overlap.
To permit planned changes to be applied and verified:
```text
DNS_SYNC_APPLY=true
```
An optional mutation budget can be specified:
```text
DNS_SYNC_MAX_CHANGES=10
```
When `DNS_SYNC_APPLY=false`, reconciliation still performs discovery,
normalization, desired-state reconciliation, planning, and state persistence,
but no endpoint mutations are executed.
The SQLite database should be stored on persistent storage.
External plugins should be mounted read-only at `/plugins`.
## Main environment ## Main environment
```text ```text
PROXY=npm NPM_ENDPOINTS=192.168.1.3,192.168.1.4,192.168.1.5
PROXY_IP=192.168.1.101
PROXY_API_PORT=81 PROXY_API_PORT=81
PROXY_API_SCHEME=http PROXY_API_SCHEME=http
DNS_PROVIDER=verizon DNS_TARGET_IP=192.168.1.101
VERIZON_ENDPOINTS=192.168.1.1
DOMAIN_MATCHING=false DOMAIN_MATCHING=false
DOMAIN=example.com DOMAIN=example.com
SYNC_INTERVAL=1
NPM_IDENTITY=... NPM_IDENTITY=...
NPM_SECRET=... NPM_SECRET=...
ROUTER_PASSWORD=... ROUTER_PASSWORD=...
``` ```
`DNS_TARGET_IP` optionally overrides the default DNS destination, which is `PROXY_IP`.
## Domain filtering ## Domain filtering
Filtering is performed by the central engine after source discovery and before Filtering is performed centrally after adapter discovery and before
reconciliation, so all source plugins get identical behavior. reconciliation, so endpoint-specific adapters get identical behavior.
```text ```text
DOMAIN_MATCHING=true DOMAIN_MATCHING=true
+14 -4
View File
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Reverse Proxy DNS Sync Description=DNS Sync Reconciliation Service
Wants=network-online.target Wants=network-online.target
After=network-online.target After=network-online.target
@@ -7,20 +7,30 @@ After=network-online.target
Image=localhost/dns-sync:latest Image=localhost/dns-sync:latest
ContainerName=dns-sync ContainerName=dns-sync
Environment=PROXY=npm Environment=ENABLED_PLUGINS=npm,verizon
Environment=PROXY_IP=192.168.1.101 Environment=DNS_SYNC_PLUGIN_PATH=/plugins
Environment=DNS_SYNC_STATE_DB=/var/lib/dns-sync/state.sqlite3
Environment=NPM_ENDPOINTS=192.168.1.3,192.168.1.4,192.168.1.5
Environment=DNS_TARGET_IP=192.168.1.101
Environment=PROXY_API_PORT=81 Environment=PROXY_API_PORT=81
Environment=PROXY_API_SCHEME=http Environment=PROXY_API_SCHEME=http
Environment=DNS_PROVIDER=verizon
Environment=VERIZON_ENDPOINTS=192.168.1.1
Environment=DOMAIN_MATCHING=false Environment=DOMAIN_MATCHING=false
Environment=DOMAIN=loxthelion.com Environment=DOMAIN=loxthelion.com
Environment=SYNC_INTERVAL=1 Environment=SYNC_INTERVAL=1
Environment=DNS_SYNC_APPLY=false
Environment=NPM_IDENTITY=davidfolkesmd@gmail.com Environment=NPM_IDENTITY=davidfolkesmd@gmail.com
Secret=dns-sync-npm-secret,type=env,target=NPM_SECRET Secret=dns-sync-npm-secret,type=env,target=NPM_SECRET
Secret=dns-sync-router-password,type=env,target=ROUTER_PASSWORD Secret=dns-sync-router-password,type=env,target=ROUTER_PASSWORD
Volume=dns-sync-state:/var/lib/dns-sync:Z
Volume=./external_plugins:/plugins:ro,Z
[Service] [Service]
Restart=on-failure Restart=on-failure
RestartSec=30 RestartSec=30
+41
View File
@@ -0,0 +1,41 @@
from .adapters import Endpoint, NormalizedObject, PlanAction
def require_endpoint_plugin(
endpoint: Endpoint,
plugin_name: str,
) -> None:
expected = plugin_name.strip().lower()
if not expected:
raise ValueError("Adapter plugin name cannot be empty")
if endpoint.plugin != expected:
raise RuntimeError(
f"Adapter {expected!r} cannot use endpoint "
f"{endpoint.identity!r}"
)
def require_action_endpoint(
action: PlanAction,
endpoint: Endpoint,
) -> None:
if action.endpoint_id != endpoint.identity:
raise RuntimeError(
f"Plan action for {action.endpoint_id!r} does not belong "
f"to adapter endpoint {endpoint.identity!r}"
)
def require_desired(
action: PlanAction,
*,
context: str = "Adapter apply",
) -> NormalizedObject:
if action.desired is None:
raise RuntimeError(
f"{context} requires desired state"
)
return action.desired
+86
View File
@@ -19,6 +19,7 @@ class CapabilityOperation(StrEnum):
class PlanOperation(StrEnum): class PlanOperation(StrEnum):
CREATE = "create" CREATE = "create"
UPDATE = "update" UPDATE = "update"
REPLACE = "replace"
DELETE = "delete" DELETE = "delete"
NOOP = "noop" NOOP = "noop"
SKIP = "skip" SKIP = "skip"
@@ -157,16 +158,69 @@ class NormalizedObject:
) )
@dataclass(frozen=True)
class RetireRequest:
object: NormalizedObject
def __post_init__(self) -> None:
if self.object.local_key is None:
raise ValueError(
"Retired object requires an endpoint-local key"
)
@dataclass(frozen=True) @dataclass(frozen=True)
class ApplyResult: class ApplyResult:
object: NormalizedObject object: NormalizedObject
detail: str | None = None detail: str | None = None
retirements: tuple[RetireRequest, ...] = ()
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.object.local_key is None: if self.object.local_key is None:
raise ValueError( raise ValueError(
"Applied object requires an endpoint-local key" "Applied object requires an endpoint-local key"
) )
retirements = tuple(self.retirements)
if any(
not isinstance(retirement, RetireRequest)
for retirement in retirements
):
raise ValueError(
"ApplyResult retirements must be RetireRequest instances"
)
seen: set[tuple[str, str]] = set()
for retirement in retirements:
retired = retirement.object
identity = (
retired.object_type,
retired.local_key,
)
if identity in seen:
raise ValueError(
"ApplyResult retirements cannot contain duplicates"
)
seen.add(identity)
if (
retired.object_type == self.object.object_type
and retired.local_key == self.object.local_key
):
raise ValueError(
"ApplyResult cannot retire the newly applied object"
)
object.__setattr__(
self,
"retirements",
retirements,
)
if self.detail is not None: if self.detail is not None:
detail = self.detail.strip() detail = self.detail.strip()
object.__setattr__( object.__setattr__(
@@ -198,3 +252,35 @@ class PlanAction:
class EndpointPlan: class EndpointPlan:
endpoint_id: str endpoint_id: str
actions: tuple[PlanAction, ...] actions: tuple[PlanAction, ...]
retirements: tuple[RetireRequest, ...] = ()
def __post_init__(self) -> None:
retirements = tuple(self.retirements)
if any(
not isinstance(retirement, RetireRequest)
for retirement in retirements
):
raise ValueError(
"EndpointPlan retirements must be "
"RetireRequest instances"
)
identities = [
(
retirement.object.object_type,
retirement.object.local_key,
)
for retirement in retirements
]
if len(set(identities)) != len(identities):
raise ValueError(
"EndpointPlan retirements cannot contain duplicates"
)
object.__setattr__(
self,
"retirements",
retirements,
)
-19
View File
@@ -1,5 +1,4 @@
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -15,21 +14,3 @@ class CertificateIdentity:
@property @property
def key(self) -> str: def key(self) -> str:
return ",".join(self.domains) return ",".join(self.domains)
@dataclass(frozen=True)
class CertificateState:
"""
Non-secret certificate state suitable for discovery, comparison, and logs.
"""
identity: CertificateIdentity
local_id: int
provider: str
nice_name: str
valid_from: datetime | None
expires_on: datetime | None
cluster_managed: bool
acme_renew: bool
dns_challenge: bool
source: str
-79
View File
@@ -1,79 +0,0 @@
from typing import Any
from ..npm_client import NpmClient
from .models import CertificateState
from .normalizer import (
normalize_certificate_domains,
parse_npm_datetime,
)
class NpmCertificateRepository:
"""
Read-only NPM certificate repository.
This milestone intentionally discovers metadata only.
Certificate/private-key material is not requested or logged.
"""
def __init__(self, config):
self.config = config
self.client = NpmClient(config)
def _rows(self) -> list[dict[str, Any]]:
data = self.client.certificates()
if not isinstance(data, list):
raise RuntimeError(
"NPM certificate response was not a list"
)
return data
def discover(self) -> list[CertificateState]:
certificates: list[CertificateState] = []
for row in self._rows():
local_id = row.get("id")
if not isinstance(local_id, int):
raise RuntimeError(
f"NPM certificate has invalid id: {local_id!r}"
)
meta = row.get("meta")
if not isinstance(meta, dict):
meta = {}
certificates.append(
CertificateState(
identity=normalize_certificate_domains(
row.get("domain_names")
),
local_id=local_id,
provider=str(
row.get("provider") or ""
),
nice_name=str(
row.get("nice_name") or ""
),
valid_from=parse_npm_datetime(
row.get("valid_from")
),
expires_on=parse_npm_datetime(
row.get("expires_on")
),
cluster_managed=(
meta.get("cluster_managed") is True
),
acme_renew=(
meta.get("acme_renew") is True
),
dns_challenge=(
meta.get("dns_challenge") is True
),
source=self.config.proxy_url,
)
)
return certificates
+34 -97
View File
@@ -3,17 +3,14 @@ import os
import sys import sys
from .config import AppConfig from .config import AppConfig
from .engine import (
build_sync_plan,
execute_sync,
filter_desired_by_domain,
summarize_plan,
validate_desired,
)
from .executor import execute_endpoint_plans 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 load_adapter, load_provider, load_source from .registry import (
configured_adapter_endpoints,
enabled_plugins,
load_adapter,
)
from .reporting import print_endpoint_plans, print_plan from .reporting import print_endpoint_plans, print_plan
@@ -35,10 +32,6 @@ def build_parser():
parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider") parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider")
sub = parser.add_subparsers(dest="command", required=True) sub = parser.add_subparsers(dest="command", required=True)
sync = sub.add_parser("sync", help="Discover, normalize, reconcile, and apply DNS records")
sync.add_argument("--dry-run", action="store_true", help="Show the plan without modifying DNS")
sync.add_argument("--include-disabled", action="store_true", help="Include disabled source entries when supported")
reconcile = sub.add_parser( reconcile = sub.add_parser(
"reconcile", "reconcile",
help="Run the persistent generic reconciliation engine", help="Run the persistent generic reconciliation engine",
@@ -65,89 +58,29 @@ def build_parser():
"--max-changes", "--max-changes",
type=_nonnegative_int, type=_nonnegative_int,
default=None, default=None,
help="Maximum CREATE/UPDATE actions allowed with --apply", help="Maximum planned mutations allowed with --apply",
) )
sub.add_parser("plugins", help="Show the configured source/provider plugins")
return parser return parser
def run_sync(args):
config = AppConfig.from_env(include_disabled=args.include_disabled)
source = load_source(config.source_name, config)
provider = load_provider(config.provider_name, config)
discovered = source.discover()
filtered, filtered_count = filter_desired_by_domain(
discovered,
domain=config.domain,
enabled=config.domain_matching,
)
desired, warnings = validate_desired(filtered)
for warning in warnings:
print(f"WARNING: {warning}", file=sys.stderr)
provider.connect()
current = provider.list_records()
plan = build_sync_plan(desired, current, provider.capabilities)
counts = summarize_plan(plan)
used, free = provider.capacity()
print(f"Source: {source.name}:{config.proxy_url} -> {config.target_ip}")
print(f"Provider: {provider.name}")
if config.domain_matching:
print(
f"Domain matching: enabled ({config.domain} + subdomains)"
)
print(f"Filtered out: {filtered_count}")
else:
print("Domain matching: disabled")
print(f"Desired records: {len(desired)}")
if free is None:
print(f"Provider capacity: {used} used, unlimited/unknown free")
else:
print(f"Provider capacity: {used} used, {free} free")
if free is not None and counts["ADD"] > free:
raise RuntimeError(f"Sync requires {counts['ADD']} new records but provider has only {free} free slots")
print("\nReconciliation plan:")
print_plan(plan)
print()
print(
"Plan summary: "
f"{counts['ADD']} add, {counts['UPDATE']} update, "
f"{counts['UNCHANGED']} unchanged, {counts['SKIP']} skipped"
)
print("Deletion policy: disabled; provider records absent from desired state are untouched.")
if args.dry_run:
print("DRY RUN: no changes were made.")
return 0
if counts["ADD"] == 0 and counts["UPDATE"] == 0:
print("Already synchronized. No changes required.")
return 0
print("\nApplying changes...")
execute_sync(provider, plan)
print(
"\nSync complete: "
f"{counts['ADD']} added, {counts['UPDATE']} updated, "
f"{counts['UNCHANGED']} unchanged, {counts['SKIP']} skipped"
)
return 0
def _configured_adapters(config): def _configured_adapters(config):
endpoints = ( adapters = []
*config.npm_endpoints,
*config.verizon_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,
) )
return [ )
load_adapter(endpoint.plugin, config, endpoint)
for endpoint in endpoints return adapters
]
def run_reconcile(args): def run_reconcile(args):
@@ -202,11 +135,21 @@ def run_reconcile(args):
for action in plan.actions for action in plan.actions
) )
retirement_count = sum(
len(plan.retirements)
for plan in result.plans
)
mutation_count = (
change_count
+ retirement_count
)
print() print()
print( print(
"Plan summary: " "Plan summary: "
f"{create_count} create, " f"{create_count} create, "
f"{update_count} update, " f"{update_count} update, "
f"{retirement_count} retirement, "
f"{noop_count} unchanged" f"{noop_count} unchanged"
) )
@@ -221,10 +164,10 @@ def run_reconcile(args):
if ( if (
args.max_changes is not None args.max_changes is not None
and change_count > args.max_changes and mutation_count > args.max_changes
): ):
raise RuntimeError( raise RuntimeError(
f"Refusing to apply {change_count} change(s); " f"Refusing to apply {mutation_count} mutation(s); "
f"--max-changes allows {args.max_changes}" f"--max-changes allows {args.max_changes}"
) )
@@ -241,6 +184,7 @@ def run_reconcile(args):
print( print(
"APPLY COMPLETE: " "APPLY COMPLETE: "
f"{len(execution.applied)} action(s) applied and verified; " f"{len(execution.applied)} action(s) applied and verified; "
f"{len(execution.retired)} retirement(s) completed; "
f"{len(execution.skipped)} unchanged/skipped." f"{len(execution.skipped)} unchanged/skipped."
) )
return 0 return 0
@@ -250,15 +194,8 @@ def main():
parser = build_parser() parser = build_parser()
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.command == "sync":
return run_sync(args)
if args.command == "reconcile": if args.command == "reconcile":
return run_reconcile(args) return run_reconcile(args)
if args.command == "plugins":
config = AppConfig.from_env()
print(f"Source plugin: {config.source_name}")
print(f"Provider plugin: {config.provider_name}")
return 0
parser.error("unknown command") parser.error("unknown command")
except Exception as exc: except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr) print(f"ERROR: {exc}", file=sys.stderr)
+12 -80
View File
@@ -79,15 +79,6 @@ def parse_endpoint_list(
return tuple(endpoints) return tuple(endpoints)
def endpoint_host(endpoint: Endpoint) -> str:
parsed = urlsplit(f"//{endpoint.address}")
if not parsed.hostname:
raise RuntimeError(
f"Invalid endpoint address: {endpoint.address!r}"
)
return parsed.hostname
def env_bool(name: str, default: bool) -> bool: def env_bool(name: str, default: bool) -> bool:
value = os.getenv(name) value = os.getenv(name)
if value is None: if value is None:
@@ -97,83 +88,32 @@ def env_bool(name: str, default: bool) -> bool:
@dataclass(frozen=True) @dataclass(frozen=True)
class AppConfig: class AppConfig:
source_name: str
provider_name: str
npm_endpoints: tuple[Endpoint, ...]
verizon_endpoints: tuple[Endpoint, ...]
proxy_ip: str
proxy_api_port: int
proxy_api_scheme: str
target_ip: str target_ip: str
proxy_verify_tls: bool
domain_matching: bool domain_matching: bool
domain: str | None domain: str | None
include_disabled: bool = False include_disabled: bool = False
@property
def proxy_url(self) -> str:
return (
f"{self.proxy_api_scheme}://"
f"{self.npm_endpoints[0].address}"
)
@classmethod @classmethod
def from_env(cls, include_disabled: bool = False) -> "AppConfig": def from_env(cls, include_disabled: bool = False) -> "AppConfig":
source_name = os.getenv("PROXY", "npm").strip().lower()
provider_name = os.getenv("DNS_PROVIDER", "verizon").strip().lower()
verizon_endpoints = parse_endpoint_list(
"verizon",
os.getenv("VERIZON_ENDPOINTS", "192.168.1.1"),
443,
)
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
raise RuntimeError("PROXY_API_PORT must be an integer") from exc
if not 1 <= port <= 65535:
raise RuntimeError("PROXY_API_PORT must be between 1 and 65535")
scheme = os.getenv("PROXY_API_SCHEME", "http").strip().lower()
if scheme not in {"http", "https"}:
raise RuntimeError("PROXY_API_SCHEME must be http or https")
raw_proxy_ip = os.getenv("PROXY_IP")
raw_npm_endpoints = os.getenv("NPM_ENDPOINTS")
if raw_npm_endpoints is None:
if not raw_proxy_ip:
raise RuntimeError(
"NPM_ENDPOINTS or legacy PROXY_IP is required"
)
proxy_ip = normalize_ipv4(raw_proxy_ip, "PROXY_IP")
npm_endpoints = parse_endpoint_list("npm", proxy_ip, port)
else:
npm_endpoints = parse_endpoint_list(
"npm",
raw_npm_endpoints,
port,
)
if raw_proxy_ip:
proxy_ip = normalize_ipv4(raw_proxy_ip, "PROXY_IP")
else:
proxy_ip = endpoint_host(npm_endpoints[0])
raw_target_ip = os.getenv("DNS_TARGET_IP") raw_target_ip = os.getenv("DNS_TARGET_IP")
raw_proxy_ip = os.getenv("PROXY_IP")
if raw_target_ip: if raw_target_ip:
target_ip = normalize_ipv4(raw_target_ip, "DNS_TARGET_IP")
elif raw_proxy_ip:
target_ip = proxy_ip
elif len(npm_endpoints) == 1:
target_ip = normalize_ipv4( target_ip = normalize_ipv4(
endpoint_host(npm_endpoints[0]), raw_target_ip,
"DNS_TARGET_IP", "DNS_TARGET_IP",
) )
elif raw_proxy_ip:
# Deprecated compatibility fallback. Core no longer treats
# PROXY_IP as an NPM endpoint.
target_ip = normalize_ipv4(
raw_proxy_ip,
"PROXY_IP",
)
else: else:
raise RuntimeError( raise RuntimeError(
"DNS_TARGET_IP is required when multiple NPM_ENDPOINTS " "DNS_TARGET_IP is required "
"are configured without legacy PROXY_IP" "(legacy PROXY_IP is also accepted)"
) )
domain_matching = env_bool("DOMAIN_MATCHING", False) domain_matching = env_bool("DOMAIN_MATCHING", False)
@@ -192,15 +132,7 @@ class AppConfig:
) )
return cls( return cls(
source_name=source_name,
provider_name=provider_name,
npm_endpoints=npm_endpoints,
verizon_endpoints=verizon_endpoints,
proxy_ip=proxy_ip,
proxy_api_port=port,
proxy_api_scheme=scheme,
target_ip=target_ip, target_ip=target_ip,
proxy_verify_tls=env_bool("PROXY_VERIFY_TLS", True),
domain_matching=domain_matching, domain_matching=domain_matching,
domain=domain, domain=domain,
include_disabled=include_disabled, include_disabled=include_disabled,
-110
View File
@@ -1,110 +0,0 @@
from .models import DesiredRecord, ProviderRecord, SyncAction
def hostname_matches_domain(hostname: str, domain: str) -> bool:
"""Match a domain apex and any subdomain, without suffix false positives."""
candidate = hostname.lower().rstrip(".")
if candidate.startswith("*."):
candidate = candidate[2:]
base = domain.lower().rstrip(".")
return candidate == base or candidate.endswith("." + base)
def filter_desired_by_domain(
records: list[DesiredRecord],
domain: str | None,
enabled: bool,
) -> tuple[list[DesiredRecord], int]:
if not enabled:
return records, 0
if not domain:
raise RuntimeError(
"Domain filtering enabled without a configured DOMAIN"
)
matched = [
record
for record in records
if hostname_matches_domain(record.hostname, domain)
]
return matched, len(records) - len(matched)
def validate_desired(records: list[DesiredRecord]) -> tuple[dict[str, DesiredRecord], list[str]]:
desired: dict[str, DesiredRecord] = {}
warnings: list[str] = []
for record in records:
key = record.hostname.lower()
if key in desired:
warnings.append(
f"DUPLICATE {record.hostname} from {record.source}"
+ (f" id={record.source_id}" if record.source_id else "")
+ "; keeping first occurrence"
)
continue
desired[key] = record
return dict(sorted(desired.items())), warnings
def build_sync_plan(desired, current: dict[str, ProviderRecord], capabilities):
plan: list[SyncAction] = []
for hostname, record in desired.items():
if record.record_type not in capabilities.record_types:
plan.append(SyncAction("SKIP", record, reason=f"record type {record.record_type} unsupported"))
continue
if record.wildcard and not capabilities.wildcard_records:
plan.append(SyncAction("SKIP", record, reason="wildcard records unsupported"))
continue
existing = current.get(hostname.lower())
if existing is None:
if not capabilities.supports_create:
plan.append(SyncAction("SKIP", record, reason="provider cannot create records"))
else:
plan.append(SyncAction("ADD", record))
elif existing.ip == record.ip and existing.record_type == record.record_type:
plan.append(SyncAction("UNCHANGED", record, current=existing))
elif not capabilities.supports_update:
plan.append(SyncAction("SKIP", record, current=existing, reason="provider cannot update records"))
else:
plan.append(SyncAction("UPDATE", record, current=existing))
return plan
def summarize_plan(plan):
counts = {name: 0 for name in ("ADD", "UPDATE", "UNCHANGED", "SKIP")}
for item in plan:
counts[item.action] = counts.get(item.action, 0) + 1
return counts
def execute_sync(provider, plan):
counts = summarize_plan(plan)
for item in plan:
if item.action in {"UNCHANGED", "SKIP"}:
continue
if item.action == "ADD":
detail = provider.add_record(item.desired)
elif item.action == "UPDATE":
detail = provider.update_record(item.desired)
else:
raise RuntimeError(f"Unknown sync action: {item.action}")
if not provider.verify_record(item.desired):
raise RuntimeError(f"{item.desired.hostname}: {item.action.lower()} did not verify")
suffix = f" ({detail})" if detail else ""
print(f"APPLIED {item.action:<6} {item.desired.hostname} -> {item.desired.ip}{suffix}")
return counts
+134 -2
View File
@@ -13,6 +13,7 @@ from .adapters import (
ObjectReference, ObjectReference,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from .dependencies import order_plan_actions from .dependencies import order_plan_actions
from .persistence import SQLiteStateStore from .persistence import SQLiteStateStore
@@ -26,6 +27,8 @@ class ExecutionAdapter(Protocol):
def verify(self, object: NormalizedObject) -> bool: ... def verify(self, object: NormalizedObject) -> bool: ...
def retire(self, request: RetireRequest) -> None: ...
@dataclass(frozen=True) @dataclass(frozen=True)
class ExecutedAction: class ExecutedAction:
@@ -39,6 +42,7 @@ class ExecutedAction:
class ExecutionResult: class ExecutionResult:
applied: tuple[ExecutedAction, ...] applied: tuple[ExecutedAction, ...]
skipped: tuple[PlanAction, ...] skipped: tuple[PlanAction, ...]
retired: tuple[tuple[str, RetireRequest], ...]
@dataclass @dataclass
@@ -271,6 +275,30 @@ def _validated_apply_result(
"desired references" "desired references"
) )
if (
action.operation != PlanOperation.REPLACE
and result.retirements
):
raise RuntimeError(
"Only REPLACE actions may request deferred retirement"
)
for retirement in result.retirements:
retired = retirement.object
if retired.local_key is None:
raise RuntimeError(
"Deferred retirement requires endpoint-local identity"
)
if (
retired.object_type == applied.object_type
and retired.local_key == applied.local_key
):
raise RuntimeError(
"Deferred retirement cannot target applied object"
)
return result return result
@@ -287,6 +315,17 @@ def _required_capability(
) )
def _required_retirement_support(
adapter: ExecutionAdapter,
) -> None:
retire = getattr(adapter, "retire", None)
if not callable(retire):
raise RuntimeError(
f"{adapter.endpoint.identity} adapter does not support "
"deferred retirement"
)
def _preflight( def _preflight(
adapters: Iterable[ExecutionAdapter], adapters: Iterable[ExecutionAdapter],
plans: Iterable[EndpointPlan], plans: Iterable[EndpointPlan],
@@ -294,6 +333,7 @@ def _preflight(
dict[str, ExecutionAdapter], dict[str, ExecutionAdapter],
list[PlanAction], list[PlanAction],
list[PlanAction], list[PlanAction],
list[tuple[str, RetireRequest]],
]: ]:
adapters_by_endpoint: dict[str, ExecutionAdapter] = {} adapters_by_endpoint: dict[str, ExecutionAdapter] = {}
for adapter in adapters: for adapter in adapters:
@@ -306,6 +346,9 @@ def _preflight(
executable: list[PlanAction] = [] executable: list[PlanAction] = []
skipped: list[PlanAction] = [] skipped: list[PlanAction] = []
planned_retirements: list[
tuple[str, RetireRequest]
] = []
for plan in plans: for plan in plans:
adapter = adapters_by_endpoint.get(plan.endpoint_id) adapter = adapters_by_endpoint.get(plan.endpoint_id)
@@ -314,6 +357,17 @@ def _preflight(
f"No adapter for endpoint plan {plan.endpoint_id}" f"No adapter for endpoint plan {plan.endpoint_id}"
) )
if plan.retirements:
_required_retirement_support(adapter)
for retirement in plan.retirements:
planned_retirements.append(
(
plan.endpoint_id,
retirement,
)
)
for action in plan.actions: for action in plan.actions:
if action.endpoint_id != plan.endpoint_id: if action.endpoint_id != plan.endpoint_id:
raise RuntimeError( raise RuntimeError(
@@ -330,6 +384,7 @@ def _preflight(
if action.operation not in { if action.operation not in {
PlanOperation.CREATE, PlanOperation.CREATE,
PlanOperation.UPDATE, PlanOperation.UPDATE,
PlanOperation.REPLACE,
}: }:
raise RuntimeError( raise RuntimeError(
"Unsupported executable plan operation: " "Unsupported executable plan operation: "
@@ -341,6 +396,9 @@ def _preflight(
"Executable plan action requires desired state" "Executable plan action requires desired state"
) )
if action.operation == PlanOperation.REPLACE:
_required_retirement_support(adapter)
object_type = action.desired.object_type object_type = action.desired.object_type
_required_capability( _required_capability(
adapter, adapter,
@@ -354,7 +412,12 @@ def _preflight(
) )
executable.append(action) executable.append(action)
return adapters_by_endpoint, executable, skipped return (
adapters_by_endpoint,
executable,
skipped,
planned_retirements,
)
def execute_endpoint_plans( def execute_endpoint_plans(
@@ -371,11 +434,21 @@ def execute_endpoint_plans(
) )
timestamp = timestamp.astimezone(timezone.utc) timestamp = timestamp.astimezone(timezone.utc)
adapters_by_endpoint, executable, skipped = _preflight( (
adapters_by_endpoint,
executable,
skipped,
planned_retirements,
) = _preflight(
adapters, adapters,
plans, plans,
) )
applied: list[ExecutedAction] = [] applied: list[ExecutedAction] = []
pending_retirements: list[
tuple[str, RetireRequest]
] = list(planned_retirements)
local_references: dict[ local_references: dict[
str, str,
_LocalReferenceIndex, _LocalReferenceIndex,
@@ -389,6 +462,18 @@ def execute_endpoint_plans(
) )
) )
for action in skipped:
if (
action.operation == PlanOperation.NOOP
and action.current is not None
and action.current.local_key is not None
):
_remember_local_object(
local_references[action.endpoint_id],
action.current,
authoritative=True,
)
for action in order_plan_actions(executable): for action in order_plan_actions(executable):
adapter = adapters_by_endpoint[action.endpoint_id] adapter = adapters_by_endpoint[action.endpoint_id]
resolved_action = _resolve_action_references( resolved_action = _resolve_action_references(
@@ -451,6 +536,14 @@ def execute_endpoint_plans(
authoritative=True, authoritative=True,
) )
for retirement in result.retirements:
pending_retirements.append(
(
action.endpoint_id,
retirement,
)
)
applied.append( applied.append(
ExecutedAction( ExecutedAction(
endpoint_id=action.endpoint_id, endpoint_id=action.endpoint_id,
@@ -460,7 +553,46 @@ def execute_endpoint_plans(
) )
) )
retired_identities: set[
tuple[str, str, str]
] = set()
retired_requests: list[
tuple[str, RetireRequest]
] = []
for endpoint_id, retirement in pending_retirements:
retired = retirement.object
identity = (
endpoint_id,
retired.object_type,
retired.local_key,
)
if identity in retired_identities:
continue
retired_identities.add(identity)
adapter = adapters_by_endpoint[endpoint_id]
retire = getattr(adapter, "retire", None)
if retire is None:
raise RuntimeError(
f"{endpoint_id} adapter does not support "
"deferred retirement"
)
retire(retirement)
retired_requests.append(
(
endpoint_id,
retirement,
)
)
return ExecutionResult( return ExecutionResult(
applied=tuple(applied), applied=tuple(applied),
skipped=tuple(skipped), skipped=tuple(skipped),
retired=tuple(retired_requests),
) )
-22
View File
@@ -8,7 +8,6 @@ from .adapters import (
NormalizedObject, NormalizedObject,
PlanAction, PlanAction,
) )
from .models import DesiredRecord, ProviderCapabilities, ProviderRecord
class AdapterPlugin(Protocol): class AdapterPlugin(Protocol):
@@ -20,24 +19,3 @@ class AdapterPlugin(Protocol):
def discover(self, object_type: str) -> list[DiscoveredObject]: ... def discover(self, object_type: str) -> list[DiscoveredObject]: ...
def apply(self, action: PlanAction) -> ApplyResult | None: ... def apply(self, action: PlanAction) -> ApplyResult | None: ...
def verify(self, object: NormalizedObject) -> bool: ... def verify(self, object: NormalizedObject) -> bool: ...
# Compatibility protocols for the existing DNS-only execution path. They remain
# in place until the current plugins and CLI are migrated to AdapterPlugin.
class SourcePlugin(Protocol):
name: str
def discover(self) -> list[DesiredRecord]: ...
class ProviderPlugin(Protocol):
name: str
capabilities: ProviderCapabilities
def connect(self) -> None: ...
def list_records(self) -> dict[str, ProviderRecord]: ...
def add_record(self, record: DesiredRecord) -> str | None: ...
def update_record(self, record: DesiredRecord) -> str | None: ...
def delete_record(self, hostname: str) -> str | None: ...
def verify_record(self, record: DesiredRecord) -> bool: ...
def capacity(self) -> tuple[int, int | None]: ...
-38
View File
@@ -1,38 +0,0 @@
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class DesiredRecord:
hostname: str
ip: str
record_type: str = "A"
wildcard: bool = False
source: str = ""
source_id: str | None = None
@dataclass(frozen=True)
class ProviderRecord:
hostname: str
ip: str
record_type: str = "A"
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class ProviderCapabilities:
record_types: frozenset[str] = frozenset({"A"})
wildcard_records: bool = False
supports_create: bool = True
supports_update: bool = True
supports_delete: bool = False
max_records: int | None = None
@dataclass(frozen=True)
class SyncAction:
action: str
desired: DesiredRecord
current: ProviderRecord | None = None
reason: str | None = None
+10
View File
@@ -1,6 +1,16 @@
import ipaddress import ipaddress
import re import re
def hostname_matches_domain(hostname: str, domain: str) -> bool:
"""Match a domain apex and any subdomain, without suffix false positives."""
candidate = hostname.lower().rstrip(".")
if candidate.startswith("*."):
candidate = candidate[2:]
base = domain.lower().rstrip(".")
return candidate == base or candidate.endswith("." + base)
_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$") _LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
+326 -10
View File
@@ -1,9 +1,10 @@
from collections.abc import Iterable from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass, replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Protocol from typing import Protocol
from .adapters import ( from .adapters import (
CERTIFICATES,
DNS_RECORDS, DNS_RECORDS,
PROXY_HOSTS, PROXY_HOSTS,
Capability, Capability,
@@ -14,12 +15,14 @@ from .adapters import (
NormalizedObject, NormalizedObject,
Observation, Observation,
) )
from .engine import hostname_matches_domain from .normalizer import hostname_matches_domain
from .object_normalizer import normalize_observations from .object_normalizer import normalize_observations
from .persistence import SQLiteStateStore from .persistence import SQLiteStateStore
from .projections import project_proxy_hosts_to_dns from .projections import project_proxy_hosts_to_dns
from .reconciliation import ( from .reconciliation import (
ReconciliationConflict,
build_endpoint_plans, build_endpoint_plans,
merge_desired_with_previous,
reconcile_observations, reconcile_observations,
) )
@@ -51,6 +54,12 @@ class ReconciliationRun:
failures: tuple[DiscoveryFailure, ...] failures: tuple[DiscoveryFailure, ...]
@dataclass(frozen=True)
class _PlanningAdapterView:
endpoint: Endpoint
capabilities: frozenset[Capability]
def _discovery_types( def _discovery_types(
adapter: ReconciliationAdapter, adapter: ReconciliationAdapter,
) -> tuple[str, ...]: ) -> tuple[str, ...]:
@@ -74,6 +83,216 @@ def _configured_observations(
] ]
def _live_discovery_pairs(
adapters: Iterable[ReconciliationAdapter],
store: SQLiteStateStore,
attempted_at: datetime,
) -> frozenset[tuple[str, str]]:
live: set[tuple[str, str]] = set()
for adapter in adapters:
endpoint_id = adapter.endpoint.identity
for object_type in _discovery_types(adapter):
status = store.observation_status(
endpoint_id,
object_type,
)
if (
status is not None
and status.is_live
and status.last_attempt_at == attempted_at
):
live.add(
(
endpoint_id,
object_type,
)
)
return frozenset(live)
def _authoritative_observations(
observations: Iterable[Observation],
live_pairs: frozenset[tuple[str, str]],
object_types: frozenset[str],
) -> list[Observation]:
return [
observation
for observation in observations
if (
observation.object.object_type
in object_types
and (
observation.endpoint_id,
observation.object.object_type,
)
in live_pairs
)
]
def _certificate_expired(
object: NormalizedObject,
now: datetime,
) -> bool:
value = object.payload.get("expires_on")
if value is None:
return False
if not isinstance(value, str):
raise ReconciliationConflict(
"Certificate expires_on must be normalized text"
)
normalized = value.strip()
if normalized.endswith("Z"):
normalized = normalized[:-1] + "+00:00"
try:
expires_at = datetime.fromisoformat(normalized)
except ValueError as exc:
raise ReconciliationConflict(
"Certificate expires_on is not a valid timestamp"
) from exc
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
return expires_at.astimezone(timezone.utc) <= now
def _filter_expired_certificate_dependencies(
observations: Iterable[Observation],
now: datetime,
) -> list[Observation]:
rows = list(observations)
expired_local: set[tuple[str, str]] = set()
for observation in rows:
object = observation.object
if (
object.object_type != CERTIFICATES
or not _certificate_expired(object, now)
):
continue
if object.local_key is None:
raise ReconciliationConflict(
"Expired observed certificate requires "
"endpoint-local identity"
)
expired_local.add(
(
observation.endpoint_id,
object.local_key,
)
)
filtered: list[Observation] = []
for observation in rows:
object = observation.object
if (
object.object_type == CERTIFICATES
and object.local_key is not None
and (
observation.endpoint_id,
object.local_key,
)
in expired_local
):
continue
if object.object_type != PROXY_HOSTS:
filtered.append(observation)
continue
expired_references = tuple(
reference
for reference in object.references
if (
reference.object_type == CERTIFICATES
and reference.local_key is not None
and (
observation.endpoint_id,
reference.local_key,
)
in expired_local
)
)
if not expired_references:
filtered.append(observation)
continue
if object.payload.get("enabled") is True:
raise ReconciliationConflict(
"Enabled proxy host references an expired "
f"certificate: {object.logical_key!r} "
f"on {observation.endpoint_id}"
)
retained_references = tuple(
reference
for reference in object.references
if reference not in expired_references
)
filtered.append(
replace(
observation,
object=replace(
object,
references=retained_references,
),
)
)
return filtered
def _planning_adapter_views(
adapters: Iterable[ReconciliationAdapter],
live_pairs: frozenset[tuple[str, str]],
) -> list[_PlanningAdapterView]:
views: list[_PlanningAdapterView] = []
for adapter in adapters:
endpoint_id = adapter.endpoint.identity
capabilities = frozenset(
capability
for capability in adapter.capabilities
if (
capability.operation
!= CapabilityOperation.APPLY
or (
endpoint_id,
capability.object_type,
)
in live_pairs
)
)
views.append(
_PlanningAdapterView(
endpoint=adapter.endpoint,
capabilities=capabilities,
)
)
return views
def _filter_projected_dns( def _filter_projected_dns(
observations: Iterable[Observation], observations: Iterable[Observation],
*, *,
@@ -188,30 +407,127 @@ def discover_reconcile_plan(
store, store,
set(adapters_by_endpoint), set(adapters_by_endpoint),
) )
proxy_observations = [
observation # Cached observations remain visible, but only a successful
for observation in observations # discovery from this exact run may establish desired state or
if observation.object.object_type == PROXY_HOSTS # make an endpoint eligible for mutation.
live_pairs = _live_discovery_pairs(
adapter_list,
store,
timestamp,
)
certificate_authoritative = any(
object_type == CERTIFICATES
for _, object_type in live_pairs
)
proxy_authoritative = any(
object_type == PROXY_HOSTS
for _, object_type in live_pairs
)
npm_observations = _authoritative_observations(
observations,
live_pairs,
frozenset(
{
CERTIFICATES,
PROXY_HOSTS,
}
),
)
npm_observations = _filter_expired_certificate_dependencies(
npm_observations,
timestamp,
)
npm_desired = (
reconcile_observations(npm_observations)
if npm_observations
else []
)
if certificate_authoritative:
previous_certificates = [
row.object
for row in store.load_desired_state(CERTIFICATES)
] ]
projected_dns = project_proxy_hosts_to_dns(
npm_desired = merge_desired_with_previous(
npm_desired,
previous_certificates,
)
proxy_observations = _authoritative_observations(
observations,
live_pairs,
frozenset({PROXY_HOSTS}),
)
projected_dns = (
project_proxy_hosts_to_dns(
proxy_observations, proxy_observations,
target_ip, target_ip,
include_disabled=include_disabled, include_disabled=include_disabled,
) )
if proxy_authoritative
else []
)
projected_dns = _filter_projected_dns( projected_dns = _filter_projected_dns(
projected_dns, projected_dns,
domain_matching=domain_matching, domain_matching=domain_matching,
domain=domain, domain=domain,
) )
desired = reconcile_observations(projected_dns) dns_desired = (
store.replace_desired_state( reconcile_observations(projected_dns)
if projected_dns
else []
)
# Keep the reconciliation domains separate until each has
# produced portable desired state. Router DNS observations are
# current target state, never a desired-state authority.
desired = [
*npm_desired,
*dns_desired,
]
authority_by_type = {
CERTIFICATES: certificate_authoritative,
PROXY_HOSTS: proxy_authoritative,
# DNS desired state is projected from proxy-host authority.
DNS_RECORDS: proxy_authoritative,
}
for object_type in (
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS, DNS_RECORDS,
desired, ):
if not authority_by_type[object_type]:
# No successful source discovery this run: retain the
# previously selected desired snapshot for diagnostics,
# but do not use it for this run's plans.
continue
store.replace_desired_state(
object_type,
[
object
for object in desired
if object.object_type == object_type
],
timestamp, timestamp,
) )
plans = build_endpoint_plans( plans = build_endpoint_plans(
_planning_adapter_views(
adapter_list, adapter_list,
live_pairs,
),
desired, desired,
observations, observations,
) )
-357
View File
@@ -1,357 +0,0 @@
from typing import Any
from ...adapters import (
CERTIFICATES,
PROXY_HOSTS,
Capability,
CapabilityOperation,
DiscoveredObject,
NormalizedObject,
ObjectReference,
)
from ...certificates.material import CertificateMaterial
from ...npm_client import NpmClient
from ...object_normalizer import normalize_discovered_object
_PROXY_HOST_FIELDS = (
"domain_names",
"forward_scheme",
"forward_host",
"forward_port",
"certificate_id",
"ssl_forced",
"caching_enabled",
"block_exploits",
"allow_websocket_upgrade",
"http2_support",
"hsts_enabled",
"hsts_subdomains",
"enabled",
)
class Plugin:
name = "npm"
capabilities = frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.DISCOVER,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
Capability(
CERTIFICATES,
CapabilityOperation.DISCOVER,
),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
),
}
)
def __init__(self, config, endpoint):
self.config = config
self.endpoint = endpoint
self.client = NpmClient(config, endpoint)
@staticmethod
def _local_key(row: dict[str, Any], label: str) -> str:
local_id = row.get("id")
if isinstance(local_id, bool) or not isinstance(local_id, int):
raise RuntimeError(
f"NPM {label} has invalid id: {local_id!r}"
)
return str(local_id)
def connect(self) -> None:
self.client.login()
def discover(
self,
object_type: str,
) -> list[DiscoveredObject]:
if object_type == PROXY_HOSTS:
return self._discover_proxy_hosts()
if object_type == CERTIFICATES:
return self._discover_certificates()
raise RuntimeError(
f"{self.endpoint.identity} cannot discover "
f"object type {object_type!r}"
)
def _proxy_host_from_row(
self,
row: dict[str, Any],
) -> DiscoveredObject:
payload = {
field: row.get(field)
for field in _PROXY_HOST_FIELDS
}
certificate_id = row.get("certificate_id")
if certificate_id in (None, "", 0, "0"):
references = ()
else:
if isinstance(certificate_id, bool):
raise RuntimeError(
"NPM proxy host has invalid certificate_id"
)
certificate_local_key = str(
certificate_id
).strip()
if not certificate_local_key:
raise RuntimeError(
"NPM proxy host has invalid certificate_id"
)
references = (
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key=certificate_local_key,
),
)
return DiscoveredObject(
object_type=PROXY_HOSTS,
local_key=self._local_key(
row,
"proxy host",
),
payload=payload,
references=references,
)
def _certificate_from_row(
self,
row: dict[str, Any],
) -> DiscoveredObject:
meta = row.get("meta")
if not isinstance(meta, dict):
meta = {}
return DiscoveredObject(
object_type=CERTIFICATES,
local_key=self._local_key(
row,
"certificate",
),
payload={
"domain_names": list(
row.get("domain_names") or []
),
"provider": str(
row.get("provider") or ""
),
"nice_name": str(
row.get("nice_name") or ""
),
"valid_from": row.get("valid_from"),
"expires_on": row.get("expires_on"),
"cluster_managed": (
meta.get("cluster_managed") is True
),
"acme_renew": (
meta.get("acme_renew") is True
),
"dns_challenge": (
meta.get("dns_challenge") is True
),
},
)
def _discover_proxy_hosts(self) -> list[DiscoveredObject]:
return [
self._proxy_host_from_row(row)
for row in self.client.proxy_hosts()
]
def _discover_certificates(self) -> list[DiscoveredObject]:
return [
self._certificate_from_row(row)
for row in self.client.certificates()
]
@staticmethod
def _portable_certificate_matches(
actual: NormalizedObject,
desired: NormalizedObject,
) -> bool:
if actual.object_type != CERTIFICATES:
return False
if desired.object_type != CERTIFICATES:
return False
if actual.logical_key != desired.logical_key:
return False
for field in (
"domain_names",
"valid_from",
"expires_on",
):
if (
actual.payload.get(field)
!= desired.payload.get(field)
):
return False
return True
def _certificate_material_source(
self,
desired: NormalizedObject,
) -> CertificateMaterial:
if desired.object_type != CERTIFICATES:
raise RuntimeError(
"Certificate material source requires "
"a certificate object"
)
matches: list[
tuple[str, str, CertificateMaterial]
] = []
source_errors: list[str] = []
for endpoint in sorted(
self.config.npm_endpoints,
key=lambda item: item.identity,
):
if endpoint.identity == self.endpoint.identity:
continue
client = NpmClient(
self.config,
endpoint,
)
try:
rows = client.certificates()
except Exception as exc:
source_errors.append(
f"{endpoint.identity}: "
f"{type(exc).__name__}"
)
continue
for row in rows:
candidate = normalize_discovered_object(
self._certificate_from_row(row)
)
if not self._portable_certificate_matches(
candidate,
desired,
):
continue
if candidate.local_key is None:
raise RuntimeError(
"Matching NPM certificate has no "
"endpoint-local key"
)
material = client.certificate_material(
candidate.local_key
)
matches.append(
(
endpoint.identity,
candidate.local_key,
material,
)
)
if not matches:
detail = ""
if source_errors:
detail = (
"; unavailable sources: "
+ ", ".join(source_errors)
)
raise RuntimeError(
"No NPM source certificate material found for "
f"{desired.logical_key!r}"
f"{detail}"
)
baseline = matches[0][2]
for _, _, material in matches[1:]:
if material != baseline:
endpoints = ", ".join(
sorted(
{
endpoint_id
for (
endpoint_id,
_,
_,
) in matches
}
)
)
raise RuntimeError(
"Conflicting NPM certificate material "
f"sources for {desired.logical_key!r}: "
f"{endpoints}"
)
return baseline
def apply(self, action):
raise RuntimeError(
f"{self.endpoint.identity} does not support apply yet"
)
def verify(self, object: NormalizedObject) -> bool:
if object.local_key is None:
return False
if object.object_type == PROXY_HOSTS:
row = self.client.proxy_host(
object.local_key
)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
return actual == object
if object.object_type == CERTIFICATES:
row = self.client.certificate(
object.local_key
)
actual = normalize_discovered_object(
self._certificate_from_row(row)
)
if actual.local_key != object.local_key:
return False
# Provider, display name, and renewal flags describe how
# this NPM endpoint manages the certificate. They are not
# portable certificate identity. A copied Let's Encrypt
# certificate becomes provider="other" on the recipient.
return self._portable_certificate_matches(
actual,
object,
)
raise RuntimeError(
f"{self.endpoint.identity} cannot verify "
f"object type {object.object_type!r}"
)
+885
View File
@@ -0,0 +1,885 @@
import os
from typing import Any
from ....adapters import (
CERTIFICATES,
PROXY_HOSTS,
ApplyResult,
Capability,
CapabilityOperation,
DiscoveredObject,
NormalizedObject,
ObjectReference,
PlanAction,
PlanOperation,
RetireRequest,
)
from ....adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from ....certificates.material import CertificateMaterial
from ....config import parse_endpoint_list
from ....normalizer import normalize_ipv4
from .client import NpmClient
from ....object_normalizer import normalize_discovered_object
def configured_endpoints(config):
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
raise RuntimeError(
"PROXY_API_PORT must be an integer"
) from exc
if not 1 <= port <= 65535:
raise RuntimeError(
"PROXY_API_PORT must be between 1 and 65535"
)
raw_endpoints = os.getenv("NPM_ENDPOINTS")
if raw_endpoints is None:
raw_proxy_ip = os.getenv("PROXY_IP")
if not raw_proxy_ip:
raise RuntimeError(
"NPM_ENDPOINTS or legacy PROXY_IP is required"
)
raw_endpoints = normalize_ipv4(
raw_proxy_ip,
"PROXY_IP",
)
return parse_endpoint_list(
"npm",
raw_endpoints,
port,
)
_PROXY_HOST_FIELDS = (
"domain_names",
"forward_scheme",
"forward_host",
"forward_port",
"certificate_id",
"ssl_forced",
"caching_enabled",
"block_exploits",
"allow_websocket_upgrade",
"http2_support",
"hsts_enabled",
"hsts_subdomains",
"enabled",
)
class Plugin:
name = "npm"
capabilities = frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.DISCOVER,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
Capability(
CERTIFICATES,
CapabilityOperation.DISCOVER,
),
Capability(
CERTIFICATES,
CapabilityOperation.APPLY,
),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
),
}
)
def __init__(self, config, endpoint):
require_endpoint_plugin(endpoint, self.name)
self.endpoints = configured_endpoints(config)
self.endpoint = endpoint
self.client = NpmClient(endpoint)
@staticmethod
def _local_key(row: dict[str, Any], label: str) -> str:
local_id = row.get("id")
if isinstance(local_id, bool) or not isinstance(local_id, int):
raise RuntimeError(
f"NPM {label} has invalid id: {local_id!r}"
)
return str(local_id)
def connect(self) -> None:
self.client.login()
def discover(
self,
object_type: str,
) -> list[DiscoveredObject]:
if object_type == PROXY_HOSTS:
return self._discover_proxy_hosts()
if object_type == CERTIFICATES:
return self._discover_certificates()
raise RuntimeError(
f"{self.endpoint.identity} cannot discover "
f"object type {object_type!r}"
)
def _proxy_host_from_row(
self,
row: dict[str, Any],
) -> DiscoveredObject:
payload = {
field: row.get(field)
for field in _PROXY_HOST_FIELDS
}
certificate_id = row.get("certificate_id")
if certificate_id in (None, "", 0, "0"):
references = ()
else:
if isinstance(certificate_id, bool):
raise RuntimeError(
"NPM proxy host has invalid certificate_id"
)
certificate_local_key = str(
certificate_id
).strip()
if not certificate_local_key:
raise RuntimeError(
"NPM proxy host has invalid certificate_id"
)
references = (
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key=certificate_local_key,
),
)
return DiscoveredObject(
object_type=PROXY_HOSTS,
local_key=self._local_key(
row,
"proxy host",
),
payload=payload,
references=references,
)
def _certificate_from_row(
self,
row: dict[str, Any],
) -> DiscoveredObject:
meta = row.get("meta")
if not isinstance(meta, dict):
meta = {}
return DiscoveredObject(
object_type=CERTIFICATES,
local_key=self._local_key(
row,
"certificate",
),
payload={
"domain_names": list(
row.get("domain_names") or []
),
"provider": str(
row.get("provider") or ""
),
"nice_name": str(
row.get("nice_name") or ""
),
"valid_from": row.get("valid_from"),
"expires_on": row.get("expires_on"),
"cluster_managed": (
meta.get("cluster_managed") is True
),
"acme_renew": (
meta.get("acme_renew") is True
),
"dns_challenge": (
meta.get("dns_challenge") is True
),
},
)
def _discover_proxy_hosts(self) -> list[DiscoveredObject]:
return [
self._proxy_host_from_row(row)
for row in self.client.proxy_hosts()
]
def _discover_certificates(self) -> list[DiscoveredObject]:
return [
self._certificate_from_row(row)
for row in self.client.certificates()
]
@staticmethod
def _portable_certificate_matches(
actual: NormalizedObject,
desired: NormalizedObject,
) -> bool:
if actual.object_type != CERTIFICATES:
return False
if desired.object_type != CERTIFICATES:
return False
if actual.logical_key != desired.logical_key:
return False
for field in (
"domain_names",
"valid_from",
"expires_on",
):
if (
actual.payload.get(field)
!= desired.payload.get(field)
):
return False
return True
def _certificate_material_source(
self,
desired: NormalizedObject,
) -> CertificateMaterial:
if desired.object_type != CERTIFICATES:
raise RuntimeError(
"Certificate material source requires "
"a certificate object"
)
matches: list[
tuple[str, str, CertificateMaterial]
] = []
source_errors: list[str] = []
for endpoint in sorted(
self.endpoints,
key=lambda item: item.identity,
):
if endpoint.identity == self.endpoint.identity:
continue
client = NpmClient(endpoint)
try:
rows = client.certificates()
except Exception as exc:
source_errors.append(
f"{endpoint.identity}: "
f"{type(exc).__name__}"
)
continue
for row in rows:
candidate = normalize_discovered_object(
self._certificate_from_row(row)
)
if not self._portable_certificate_matches(
candidate,
desired,
):
continue
if candidate.local_key is None:
raise RuntimeError(
"Matching NPM certificate has no "
"endpoint-local key"
)
material = client.certificate_material(
candidate.local_key
)
matches.append(
(
endpoint.identity,
candidate.local_key,
material,
)
)
if not matches:
detail = ""
if source_errors:
detail = (
"; unavailable sources: "
+ ", ".join(source_errors)
)
raise RuntimeError(
"No NPM source certificate material found for "
f"{desired.logical_key!r}"
f"{detail}"
)
baseline = matches[0][2]
for _, _, material in matches[1:]:
if material != baseline:
endpoints = ", ".join(
sorted(
{
endpoint_id
for (
endpoint_id,
_,
_,
) in matches
}
)
)
raise RuntimeError(
"Conflicting NPM certificate material "
f"sources for {desired.logical_key!r}: "
f"{endpoints}"
)
return baseline
@staticmethod
def _proxy_host_matches(
actual: NormalizedObject,
desired: NormalizedObject,
*,
local_key: str | None = None,
) -> bool:
if actual.object_type != PROXY_HOSTS:
return False
if desired.object_type != PROXY_HOSTS:
return False
if local_key is not None and actual.local_key != local_key:
return False
if actual.logical_key != desired.logical_key:
return False
if actual.payload != desired.payload:
return False
if actual.references != desired.references:
return False
return True
@staticmethod
def _proxy_host_certificate_id(
desired: NormalizedObject,
) -> int:
certificate_references = []
for reference in desired.references:
if (
reference.relation != "certificate"
or reference.object_type != CERTIFICATES
):
raise RuntimeError(
"NPM proxy host has unsupported object reference"
)
certificate_references.append(reference)
if not certificate_references:
return 0
if len(certificate_references) != 1:
raise RuntimeError(
"NPM proxy host requires at most one "
"certificate reference"
)
reference = certificate_references[0]
if reference.local_key is None:
raise RuntimeError(
"NPM proxy host certificate reference "
"was not resolved to an endpoint-local key"
)
try:
certificate_id = int(reference.local_key)
except (TypeError, ValueError) as exc:
raise RuntimeError(
"NPM proxy host certificate reference "
"has invalid endpoint-local key"
) from exc
if certificate_id < 1:
raise RuntimeError(
"NPM proxy host certificate reference "
"has invalid endpoint-local key"
)
return certificate_id
def _proxy_host_api_payload(
self,
desired: NormalizedObject,
) -> dict[str, Any]:
if desired.object_type != PROXY_HOSTS:
raise RuntimeError(
"Proxy-host API payload requires proxy-host state"
)
payload = desired.payload
domains = payload.get("domain_names")
if (
not isinstance(domains, list)
or not domains
or not all(
isinstance(value, str) and value
for value in domains
)
):
raise RuntimeError(
"NPM proxy host requires domain_names"
)
forward_scheme = payload.get("forward_scheme")
if (
not isinstance(forward_scheme, str)
or not forward_scheme
):
raise RuntimeError(
"NPM proxy host requires forward_scheme"
)
forward_host = payload.get("forward_host")
if (
not isinstance(forward_host, str)
or not forward_host
):
raise RuntimeError(
"NPM proxy host requires forward_host"
)
forward_port = payload.get("forward_port")
if (
isinstance(forward_port, bool)
or not isinstance(forward_port, int)
or forward_port < 1
or forward_port > 65535
):
raise RuntimeError(
"NPM proxy host requires valid forward_port"
)
api_payload: dict[str, Any] = {
"domain_names": list(domains),
"forward_scheme": forward_scheme,
"forward_host": forward_host,
"forward_port": forward_port,
"certificate_id": (
self._proxy_host_certificate_id(desired)
),
}
for field in _PROXY_HOST_FIELDS:
if field in (
"domain_names",
"forward_scheme",
"forward_host",
"forward_port",
"certificate_id",
):
continue
if field in payload:
api_payload[field] = payload[field]
return api_payload
def _create_proxy_host(
self,
desired: NormalizedObject,
) -> ApplyResult:
payload = self._proxy_host_api_payload(
desired
)
created = self.client.create_proxy_host(
payload
)
local_key = self._local_key(
created,
"proxy host",
)
try:
row = self.client.proxy_host(local_key)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
if not self._proxy_host_matches(
actual,
desired,
local_key=local_key,
):
raise RuntimeError(
"Created NPM proxy host does not match "
"desired state"
)
except Exception:
try:
self.client.delete_proxy_host(
local_key
)
except Exception as rollback_error:
raise RuntimeError(
"NPM proxy-host creation failed and "
"rollback also failed"
) from rollback_error
raise
return ApplyResult(
object=actual,
detail="created proxy host",
)
def _update_proxy_host(
self,
action: PlanAction,
) -> ApplyResult:
desired = action.desired
current = action.current
if desired is None:
raise RuntimeError(
"NPM proxy-host update requires desired state"
)
if (
current is None
or current.object_type != PROXY_HOSTS
or current.local_key is None
):
raise RuntimeError(
"NPM proxy-host update requires "
"current endpoint-local state"
)
if current.logical_key != desired.logical_key:
raise RuntimeError(
"NPM proxy-host update identity changed"
)
local_key = current.local_key
payload = self._proxy_host_api_payload(
desired
)
self.client.update_proxy_host(
local_key,
payload,
)
row = self.client.proxy_host(local_key)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
if not self._proxy_host_matches(
actual,
desired,
local_key=local_key,
):
raise RuntimeError(
"Updated NPM proxy host does not match "
"desired state"
)
return ApplyResult(
object=actual,
detail="updated proxy host",
)
def apply(
self,
action: PlanAction,
) -> ApplyResult | None:
desired = require_desired(
action,
context="NPM apply",
)
require_action_endpoint(
action,
self.endpoint,
)
if (
desired.object_type == CERTIFICATES
and action.operation == PlanOperation.CREATE
):
return self._create_certificate(desired)
if (
desired.object_type == CERTIFICATES
and action.operation == PlanOperation.REPLACE
):
return self._replace_certificate(action)
if (
desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.CREATE
):
return self._create_proxy_host(desired)
if (
desired.object_type == PROXY_HOSTS
and action.operation == PlanOperation.UPDATE
):
return self._update_proxy_host(action)
raise RuntimeError(
f"{self.endpoint.identity} does not support "
f"{action.operation} for {desired.object_type}"
)
def _replace_certificate(
self,
action: PlanAction,
) -> ApplyResult:
desired = action.desired
current = action.current
if desired is None or desired.object_type != CERTIFICATES:
raise RuntimeError(
"NPM certificate replace requires desired certificate"
)
if current is None or current.object_type != CERTIFICATES:
raise RuntimeError(
"NPM certificate replace requires current certificate"
)
if current.local_key is None:
raise RuntimeError(
"NPM certificate replace requires current local id"
)
if current.logical_key != desired.logical_key:
raise RuntimeError(
"NPM certificate replace identity mismatch"
)
result = self._create_certificate(desired)
return ApplyResult(
object=result.object,
detail="replaced certificate",
retirements=(
RetireRequest(
object=current,
),
),
)
def retire(
self,
request: RetireRequest,
) -> None:
retired = request.object
if retired.object_type != CERTIFICATES:
raise RuntimeError(
"NPM deferred retirement only supports certificates"
)
if retired.local_key is None:
raise RuntimeError(
"NPM certificate retirement requires local id"
)
self.client.delete_certificate(
retired.local_key
)
def _create_certificate(
self,
desired: NormalizedObject,
) -> ApplyResult:
material = self._certificate_material_source(
desired
)
domains = desired.payload.get("domain_names")
if (
not isinstance(domains, list)
or not domains
or not all(
isinstance(value, str) and value
for value in domains
)
):
raise RuntimeError(
"NPM certificate create requires domain_names"
)
nice_name = desired.payload.get("nice_name")
if not isinstance(nice_name, str):
nice_name = ""
nice_name = nice_name.strip()
if not nice_name:
nice_name = (
"dns-sync: "
+ ", ".join(domains)
)
created = self.client.create_certificate(
{
# This node is receiving existing certificate
# material. It must not independently perform ACME
# issuance.
"provider": "other",
"domain_names": list(domains),
"nice_name": nice_name,
"meta": {
"cluster_managed": True,
"acme_renew": False,
"dns_challenge": False,
},
}
)
created_id = created.get("id")
if (
isinstance(created_id, bool)
or not isinstance(created_id, int)
or created_id < 1
):
raise RuntimeError(
"NPM certificate create returned invalid id"
)
local_key = str(created_id)
try:
self.client.upload_certificate(
local_key,
certificate=material.certificate,
certificate_key=material.certificate_key,
intermediate_certificate=(
material.intermediate_certificate
),
)
row = self.client.certificate(local_key)
actual = normalize_discovered_object(
self._certificate_from_row(row)
)
if actual.local_key != local_key:
raise RuntimeError(
"Created NPM certificate returned "
"unexpected local id"
)
if not self._portable_certificate_matches(
actual,
desired,
):
raise RuntimeError(
"Created NPM certificate does not match "
"desired portable certificate state"
)
except Exception:
try:
self.client.delete_certificate(local_key)
except Exception as rollback_error:
raise RuntimeError(
"NPM certificate creation failed and "
"rollback also failed"
) from rollback_error
raise
return ApplyResult(
object=actual,
detail="created certificate",
)
def verify(self, object: NormalizedObject) -> bool:
if object.local_key is None:
return False
if object.object_type == PROXY_HOSTS:
row = self.client.proxy_host(
object.local_key
)
actual = normalize_discovered_object(
self._proxy_host_from_row(row)
)
return self._proxy_host_matches(
actual,
object,
local_key=object.local_key,
)
if object.object_type == CERTIFICATES:
row = self.client.certificate(
object.local_key
)
actual = normalize_discovered_object(
self._certificate_from_row(row)
)
if actual.local_key != object.local_key:
return False
# Provider, display name, and renewal flags describe how
# this NPM endpoint manages the certificate. They are not
# portable certificate identity. A copied Let's Encrypt
# certificate becomes provider="other" on the recipient.
return self._portable_certificate_matches(
actual,
object,
)
raise RuntimeError(
f"{self.endpoint.identity} cannot verify "
f"object type {object.object_type!r}"
)
@@ -7,8 +7,9 @@ from zipfile import BadZipFile, ZipFile
import requests import requests
import urllib3 import urllib3
from .adapters import Endpoint from ....adapters import Endpoint
from .certificates.material import CertificateMaterial from ....certificates.material import CertificateMaterial
from ....config import env_bool
class NpmClient: class NpmClient:
@@ -16,11 +17,9 @@ class NpmClient:
def __init__( def __init__(
self, self,
config, endpoint: Endpoint,
endpoint: Endpoint | None = None,
): ):
self.config = config self.endpoint = endpoint
self.endpoint = endpoint or config.npm_endpoints[0]
if self.endpoint.plugin != "npm": if self.endpoint.plugin != "npm":
raise RuntimeError("NpmClient requires an npm endpoint") raise RuntimeError("NpmClient requires an npm endpoint")
@@ -34,10 +33,25 @@ class NpmClient:
if not self.secret: if not self.secret:
raise RuntimeError("NPM_SECRET is required") raise RuntimeError("NPM_SECRET is required")
self.api_scheme = os.getenv(
"PROXY_API_SCHEME",
"http",
).strip().lower()
if self.api_scheme not in {"http", "https"}:
raise RuntimeError(
"PROXY_API_SCHEME must be http or https"
)
self.verify_tls = env_bool(
"PROXY_VERIFY_TLS",
True,
)
self.session = requests.Session() self.session = requests.Session()
self.token: str | None = None self.token: str | None = None
if not config.proxy_verify_tls: if not self.verify_tls:
urllib3.disable_warnings( urllib3.disable_warnings(
urllib3.exceptions.InsecureRequestWarning urllib3.exceptions.InsecureRequestWarning
) )
@@ -45,7 +59,7 @@ class NpmClient:
@property @property
def api_url(self) -> str: def api_url(self) -> str:
return ( return (
f"{self.config.proxy_api_scheme}://" f"{self.api_scheme}://"
f"{self.endpoint.address}/api" f"{self.endpoint.address}/api"
) )
@@ -57,7 +71,7 @@ class NpmClient:
"secret": self.secret, "secret": self.secret,
}, },
timeout=15, timeout=15,
verify=self.config.proxy_verify_tls, verify=self.verify_tls,
) )
if response.status_code != 200: if response.status_code != 200:
@@ -107,7 +121,7 @@ class NpmClient:
method, method,
f"{self.api_url}{path}", f"{self.api_url}{path}",
timeout=kwargs.pop("timeout", 15), timeout=kwargs.pop("timeout", 15),
verify=self.config.proxy_verify_tls, verify=self.verify_tls,
**kwargs, **kwargs,
) )
@@ -223,6 +237,21 @@ class NpmClient:
"proxy-host update", "proxy-host update",
) )
def delete_proxy_host(
self,
local_id: str | int,
) -> None:
proxy_host_id = self._local_id(
local_id,
"proxy host",
)
self.request(
"DELETE",
f"/nginx/proxy-hosts/{proxy_host_id}",
expected_status=200,
)
def certificate( def certificate(
self, self,
local_id: str | int, local_id: str | int,
@@ -372,6 +401,21 @@ class NpmClient:
f"Unsupported NPM certificate provider: {provider!r}" f"Unsupported NPM certificate provider: {provider!r}"
) )
def delete_certificate(
self,
local_id: str | int,
) -> None:
certificate_id = self._local_id(
local_id,
"certificate",
)
self.request(
"DELETE",
f"/nginx/certificates/{certificate_id}",
expected_status=200,
)
def create_certificate( def create_certificate(
self, self,
payload: dict[str, Any], payload: dict[str, Any],
-39
View File
@@ -1,39 +0,0 @@
import os
import yaml
from ...models import DesiredRecord
from ...normalizer import normalize_hostname, normalize_ipv4
class Plugin:
name = "file"
def __init__(self, config):
self.config = config
self.path = os.getenv("DNS_SYNC_FILE")
if not self.path:
raise RuntimeError("DNS_SYNC_FILE is required when PROXY=file")
def discover(self):
try:
with open(self.path, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(f"Could not read desired-state file {self.path}: {exc}") from exc
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
raise RuntimeError("Desired-state YAML must contain a top-level records mapping")
result = []
for raw_hostname, raw_ip in data["records"].items():
hostname, wildcard = normalize_hostname(str(raw_hostname), allow_wildcard=True)
result.append(
DesiredRecord(
hostname=hostname,
ip=normalize_ipv4(str(raw_ip), hostname),
wildcard=wildcard,
source=self.name,
)
)
return result
-41
View File
@@ -1,41 +0,0 @@
from ...models import DesiredRecord
from ...normalizer import normalize_hostname
from ...npm_client import NpmClient
class Plugin:
name = "npm"
def __init__(self, config):
self.config = config
self.client = NpmClient(config)
def discover(self):
records: list[DesiredRecord] = []
for host in self.client.proxy_hosts():
enabled = bool(host.get("enabled", False))
if not enabled and not self.config.include_disabled:
continue
source_id = str(host.get("id", "")) or None
for raw_domain in host.get("domain_names", []):
hostname, wildcard = normalize_hostname(
str(raw_domain),
allow_wildcard=True,
)
records.append(
DesiredRecord(
hostname=hostname,
ip=self.config.target_ip,
record_type="A",
wildcard=wildcard,
source=self.name,
source_id=source_id,
)
)
return records
+599 -5
View File
@@ -1,17 +1,22 @@
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Iterable, Mapping, Sequence
from dataclasses import replace
from typing import Protocol from typing import Protocol
from .adapters import ( from .adapters import (
CERTIFICATES,
PROXY_HOSTS,
Capability, Capability,
CapabilityOperation, CapabilityOperation,
DNS_RECORDS, DNS_RECORDS,
Endpoint, Endpoint,
EndpointPlan, EndpointPlan,
NormalizedObject, NormalizedObject,
ObjectReference,
Observation, Observation,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from .dependencies import order_plan_actions from .dependencies import order_plan_actions
@@ -34,6 +39,7 @@ class PlanningAdapter(Protocol):
class ReconciliationPolicy(Protocol): class ReconciliationPolicy(Protocol):
object_type: str object_type: str
mutation_operation: PlanOperation
def reconcile( def reconcile(
self, self,
@@ -41,6 +47,52 @@ class ReconciliationPolicy(Protocol):
observations: Sequence[Observation], observations: Sequence[Observation],
) -> NormalizedObject: ... ) -> NormalizedObject: ...
def equivalent(
self,
current: NormalizedObject,
desired: NormalizedObject,
) -> bool: ...
def merge_desired_with_previous(
fresh: Iterable[NormalizedObject],
previous: Iterable[NormalizedObject],
policies: Mapping[str, ReconciliationPolicy] | None = None,
) -> list[NormalizedObject]:
selected_policies = (
DEFAULT_POLICIES
if policies is None
else policies
)
previous_by_identity = {
(object.object_type, object.logical_key): object
for object in previous
}
merged: list[NormalizedObject] = []
for candidate in fresh:
identity = (
candidate.object_type,
candidate.logical_key,
)
prior = previous_by_identity.get(identity)
policy = selected_policies.get(candidate.object_type)
merger = (
getattr(policy, "merge_desired", None)
if policy is not None
else None
)
if prior is not None and callable(merger):
candidate = merger(prior, candidate)
merged.append(candidate)
return merged
def _observation_sort_key( def _observation_sort_key(
observation: Observation, observation: Observation,
@@ -52,6 +104,94 @@ def _observation_sort_key(
) )
def logicalize_observation_references(
observations: Iterable[Observation],
) -> tuple[Observation, ...]:
rows = tuple(observations)
local_targets: dict[
tuple[str, str, str],
str,
] = {}
for observation in rows:
object = observation.object
if object.local_key is None:
continue
identity = (
observation.endpoint_id,
object.object_type,
object.local_key,
)
existing = local_targets.get(identity)
if (
existing is not None
and existing != object.logical_key
):
raise ReconciliationConflict(
"Conflicting endpoint-local object identity for "
f"{observation.endpoint_id} "
f"{object.object_type} "
f"local key {object.local_key!r}"
)
local_targets[identity] = object.logical_key
logicalized: list[Observation] = []
for observation in rows:
object = observation.object
references = []
for reference in object.references:
if reference.logical_key is not None:
references.append(reference)
continue
target = (
observation.endpoint_id,
reference.object_type,
reference.local_key,
)
logical_key = local_targets.get(target)
if logical_key is None:
raise ReconciliationConflict(
"Unresolved endpoint-local reference from "
f"{observation.endpoint_id} "
f"{object.object_type} "
f"{object.logical_key!r}: "
f"{reference.relation} -> "
f"{reference.object_type} "
f"local key {reference.local_key!r}"
)
references.append(
ObjectReference(
relation=reference.relation,
object_type=reference.object_type,
logical_key=logical_key,
)
)
logicalized.append(
replace(
observation,
object=replace(
object,
references=tuple(references),
),
)
)
return tuple(logicalized)
def group_observations( def group_observations(
observations: Iterable[Observation], observations: Iterable[Observation],
) -> dict[ObservationGroupKey, tuple[Observation, ...]]: ) -> dict[ObservationGroupKey, tuple[Observation, ...]]:
@@ -83,6 +223,7 @@ def group_observations(
class DnsUnionPolicy: class DnsUnionPolicy:
object_type = DNS_RECORDS object_type = DNS_RECORDS
mutation_operation = PlanOperation.UPDATE
def reconcile( def reconcile(
self, self,
@@ -116,9 +257,294 @@ class DnsUnionPolicy:
payload=dict(first.payload), payload=dict(first.payload),
) )
def equivalent(
self,
current: NormalizedObject,
desired: NormalizedObject,
) -> bool:
return (
current.object_type == desired.object_type
and current.logical_key == desired.logical_key
and current.payload == desired.payload
and current.references == desired.references
)
class CertificateNewestPolicy:
object_type = CERTIFICATES
mutation_operation = PlanOperation.REPLACE
_PORTABLE_FIELDS = (
"domain_names",
"valid_from",
"expires_on",
)
@classmethod
def _portable_state(
cls,
object: NormalizedObject,
) -> tuple[object, ...]:
return tuple(
object.payload.get(field)
for field in cls._PORTABLE_FIELDS
)
@staticmethod
def _date_key(
value: object,
field: str,
) -> tuple[int, str]:
if value is None:
return (0, "")
if not isinstance(value, str):
raise ReconciliationConflict(
f"Certificate {field} must be normalized text"
)
return (1, value)
@classmethod
def _newest_key(
cls,
object: NormalizedObject,
) -> tuple[
tuple[int, str],
tuple[int, str],
]:
return (
cls._date_key(
object.payload.get("valid_from"),
"valid_from",
),
cls._date_key(
object.payload.get("expires_on"),
"expires_on",
),
)
def reconcile(
self,
logical_key: str,
observations: Sequence[Observation],
) -> NormalizedObject:
if not observations:
raise RuntimeError(
"Certificate policy requires at least one observation"
)
newest_key = max(
self._newest_key(observation.object)
for observation in observations
)
newest = [
observation
for observation in observations
if self._newest_key(observation.object) == newest_key
]
baseline = self._portable_state(newest[0].object)
conflicting_endpoints = {
observation.endpoint_id
for observation in newest
if self._portable_state(observation.object) != baseline
}
if conflicting_endpoints:
conflicting_endpoints.add(newest[0].endpoint_id)
raise ReconciliationConflict(
f"Conflicting {CERTIFICATES} observations for "
f"{logical_key!r} at newest issuance from "
+ ", ".join(sorted(conflicting_endpoints))
)
winner = newest[0].object
return NormalizedObject(
object_type=CERTIFICATES,
logical_key=logical_key,
payload=dict(winner.payload),
)
def equivalent(
self,
current: NormalizedObject,
desired: NormalizedObject,
) -> bool:
return (
current.object_type == CERTIFICATES
and desired.object_type == CERTIFICATES
and current.logical_key == desired.logical_key
and self._portable_state(current)
== self._portable_state(desired)
)
def merge_desired(
self,
previous: NormalizedObject,
fresh: NormalizedObject,
) -> NormalizedObject:
if (
previous.object_type != CERTIFICATES
or fresh.object_type != CERTIFICATES
or previous.logical_key != fresh.logical_key
):
raise ReconciliationConflict(
"Certificate desired-state merge requires "
"matching certificate identities"
)
if self._newest_key(previous) > self._newest_key(fresh):
return previous
return fresh
@staticmethod
def _equivalent_selection_key(
object: NormalizedObject,
) -> tuple[int, str]:
# Preserve a native ACME-renewable certificate over an
# imported replica when both contain the same portable
# certificate state.
provider = object.payload.get("provider")
return (
1 if provider == "letsencrypt" else 0,
object.local_key or "",
)
def select_current(
self,
observations: Sequence[Observation],
desired: NormalizedObject,
) -> tuple[
NormalizedObject,
tuple[RetireRequest, ...],
]:
if not observations:
raise PlanningConflict(
"Certificate current-state selection requires "
"at least one observation"
)
candidates = [
observation.object
for observation in observations
]
if any(
candidate.local_key is None
for candidate in candidates
):
raise PlanningConflict(
"Observed certificate requires endpoint-local identity"
)
matching = [
candidate
for candidate in candidates
if self.equivalent(
candidate,
desired,
)
]
if matching:
selected = max(
matching,
key=self._equivalent_selection_key,
)
else:
# No local copy is already desired. Replace the newest
# local certificate and retire every other stale copy
# after the replacement and its dependents verify.
selected = max(
candidates,
key=lambda candidate: (
self._newest_key(candidate),
candidate.local_key or "",
),
)
retirements = tuple(
RetireRequest(object=candidate)
for candidate in candidates
if candidate.local_key != selected.local_key
)
return selected, retirements
class ProxyHostConsensusPolicy:
object_type = PROXY_HOSTS
mutation_operation = PlanOperation.UPDATE
def reconcile(
self,
logical_key: str,
observations: Sequence[Observation],
) -> NormalizedObject:
if not observations:
raise RuntimeError(
"Proxy-host policy requires at least one observation"
)
first = observations[0].object
conflicting_endpoints = {
observation.endpoint_id
for observation in observations
if (
observation.object.payload != first.payload
or observation.object.references != first.references
)
}
if conflicting_endpoints:
conflicting_endpoints.add(observations[0].endpoint_id)
raise ReconciliationConflict(
f"Conflicting {PROXY_HOSTS} observations for "
f"{logical_key!r} from "
+ ", ".join(sorted(conflicting_endpoints))
)
for reference in first.references:
if reference.local_key is not None:
raise ReconciliationConflict(
"Proxy-host desired state cannot contain "
"endpoint-local references"
)
return NormalizedObject(
object_type=PROXY_HOSTS,
logical_key=logical_key,
payload=dict(first.payload),
references=first.references,
)
def equivalent(
self,
current: NormalizedObject,
desired: NormalizedObject,
) -> bool:
return (
current.object_type == PROXY_HOSTS
and desired.object_type == PROXY_HOSTS
and current.logical_key == desired.logical_key
and current.payload == desired.payload
and current.references == desired.references
)
DEFAULT_POLICIES: Mapping[str, ReconciliationPolicy] = { DEFAULT_POLICIES: Mapping[str, ReconciliationPolicy] = {
DNS_RECORDS: DnsUnionPolicy(), DNS_RECORDS: DnsUnionPolicy(),
CERTIFICATES: CertificateNewestPolicy(),
PROXY_HOSTS: ProxyHostConsensusPolicy(),
} }
@@ -133,8 +559,12 @@ def reconcile_observations(
) )
desired: list[NormalizedObject] = [] desired: list[NormalizedObject] = []
logicalized = logicalize_observation_references(
observations
)
for (object_type, logical_key), rows in ( for (object_type, logical_key), rows in (
group_observations(observations).items() group_observations(logicalized).items()
): ):
policy = selected_policies.get(object_type) policy = selected_policies.get(object_type)
if policy is None: if policy is None:
@@ -156,6 +586,7 @@ def build_endpoint_plans(
adapters: Iterable[PlanningAdapter], adapters: Iterable[PlanningAdapter],
desired_objects: Iterable[NormalizedObject], desired_objects: Iterable[NormalizedObject],
observations: Iterable[Observation], observations: Iterable[Observation],
policies: Mapping[str, ReconciliationPolicy] | None = None,
) -> tuple[EndpointPlan, ...]: ) -> tuple[EndpointPlan, ...]:
desired_by_identity: dict[ desired_by_identity: dict[
tuple[str, str], tuple[str, str],
@@ -180,11 +611,40 @@ def build_endpoint_plans(
) )
desired_by_identity[identity] = desired desired_by_identity[identity] = desired
selected_policies = (
DEFAULT_POLICIES
if policies is None
else policies
)
raw_observations = tuple(observations)
current_by_identity: dict[ current_by_identity: dict[
tuple[str, str, str], tuple[str, str, str],
list[Observation], list[Observation],
] = defaultdict(list) ] = defaultdict(list)
for observation in observations:
raw_current_by_identity: dict[
tuple[str, str, str],
list[Observation],
] = defaultdict(list)
for observation in raw_observations:
raw_current_by_identity[
(
observation.endpoint_id,
observation.object.object_type,
observation.object.logical_key,
)
].append(observation)
logicalized_observations = (
logicalize_observation_references(
raw_observations
)
)
for observation in logicalized_observations:
current_by_identity[ current_by_identity[
( (
observation.endpoint_id, observation.endpoint_id,
@@ -215,6 +675,10 @@ def build_endpoint_plans(
continue continue
actions: list[PlanAction] = [] actions: list[PlanAction] = []
planned_retirements: list[
RetireRequest
] = []
for identity, desired in sorted( for identity, desired in sorted(
desired_by_identity.items() desired_by_identity.items()
): ):
@@ -226,12 +690,34 @@ def build_endpoint_plans(
(endpoint_id, object_type, logical_key), (endpoint_id, object_type, logical_key),
[], [],
) )
policy = selected_policies.get(object_type)
if len(current_rows) > 1: if len(current_rows) > 1:
selector = (
getattr(
policy,
"select_current",
None,
)
if policy is not None
else None
)
if selector is None:
raise PlanningConflict( raise PlanningConflict(
f"Multiple endpoint objects for {endpoint_id} " f"Multiple endpoint objects for {endpoint_id} "
f"{object_type} {logical_key!r}" f"{object_type} {logical_key!r}"
) )
current, retirements = selector(
tuple(current_rows),
desired,
)
planned_retirements.extend(
retirements
)
else:
current = ( current = (
current_rows[0].object current_rows[0].object
if current_rows if current_rows
@@ -239,10 +725,27 @@ def build_endpoint_plans(
) )
if current is None: if current is None:
operation = PlanOperation.CREATE operation = PlanOperation.CREATE
elif current.payload == desired.payload:
operation = PlanOperation.NOOP
else: else:
operation = PlanOperation.UPDATE if policy is None:
equivalent = (
current.payload == desired.payload
and current.references == desired.references
)
else:
equivalent = policy.equivalent(
current,
desired,
)
operation = (
PlanOperation.NOOP
if equivalent
else (
policy.mutation_operation
if policy is not None
else PlanOperation.UPDATE
)
)
actions.append( actions.append(
PlanAction( PlanAction(
@@ -253,10 +756,101 @@ def build_endpoint_plans(
) )
) )
replaced = {
(
action.desired.object_type,
action.desired.logical_key,
)
for action in actions
if (
action.operation == PlanOperation.REPLACE
and action.desired is not None
)
}
retiring_local = {
(
retirement.object.object_type,
retirement.object.local_key,
)
for retirement in planned_retirements
}
if replaced or retiring_local:
promoted: list[PlanAction] = []
for action in actions:
desired = action.desired
logical_dependency_replaced = (
desired is not None
and any(
(
reference.object_type,
reference.logical_key,
)
in replaced
for reference in desired.references
if reference.logical_key is not None
)
)
raw_rows = (
raw_current_by_identity.get(
(
endpoint_id,
desired.object_type,
desired.logical_key,
),
[],
)
if desired is not None
else []
)
local_dependency_retiring = any(
(
reference.object_type,
reference.local_key,
)
in retiring_local
for row in raw_rows
for reference in row.object.references
if reference.local_key is not None
)
if (
action.operation == PlanOperation.NOOP
and (
logical_dependency_replaced
or local_dependency_retiring
)
):
reason = (
"referenced object is being replaced"
if logical_dependency_replaced
else (
"referenced local object is being retired"
)
)
action = replace(
action,
operation=PlanOperation.UPDATE,
reason=reason,
)
promoted.append(action)
actions = promoted
plans.append( plans.append(
EndpointPlan( EndpointPlan(
endpoint_id=endpoint_id, endpoint_id=endpoint_id,
actions=order_plan_actions(actions), actions=order_plan_actions(actions),
retirements=tuple(
planned_retirements
),
) )
) )
+247 -14
View File
@@ -1,34 +1,267 @@
import importlib 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): _EXTERNAL_NAMESPACE = "dns_sync_external_plugins"
if not name or not name.replace("_", "").replace("-", "").isalnum():
raise RuntimeError(f"Invalid {kind} plugin name: {name!r}")
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:
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(
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("-", "_") module_name = name.replace("-", "_")
path = f"dns_sync.plugins.{kind}.{module_name}" path = f"dns_sync.plugins.{kind}.{module_name}"
try: try:
module = importlib.import_module(path) return importlib.import_module(path)
except ModuleNotFoundError as exc: except ModuleNotFoundError as exc:
if exc.name == path: if exc.name == path:
raise RuntimeError(f"Unknown {kind[:-1]} plugin: {name}") from exc return None
raise raise
plugin_class = getattr(module, "Plugin", None)
if plugin_class is None:
raise RuntimeError(f"Plugin module {path} does not export Plugin")
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):
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":
(
normalized,
module,
display_path,
) = _adapter_module(normalized)
plugin_class = _plugin_class(
module,
display_path,
)
return plugin_class(config, *args) return plugin_class(config, *args)
module = _load_builtin(kind, normalized)
def load_source(name: str, config): if module is not None:
return _load("sources", name, config) plugin_class = _plugin_class(
module,
f"dns_sync.plugins.{kind}.{normalized}",
)
return plugin_class(config, *args)
raise RuntimeError(
def load_provider(name: str, config): f"Unknown {kind[:-1]} plugin: {normalized}"
return _load("providers", name, config) )
def load_adapter(name: str, config, endpoint): def load_adapter(name: str, config, endpoint):
return _load("adapters", name, config, endpoint) return _load(
"adapters",
name,
config,
endpoint,
)
+56 -6
View File
@@ -2,17 +2,44 @@
set -u set -u
INTERVAL_MINUTES="${SYNC_INTERVAL:-1}" INTERVAL_MINUTES="${SYNC_INTERVAL:-1}"
APPLY_MODE="${DNS_SYNC_APPLY:-false}"
STATE_DB="${DNS_SYNC_STATE_DB:-/var/lib/dns-sync/state.sqlite3}"
MAX_CHANGES="${DNS_SYNC_MAX_CHANGES:-}"
case "$INTERVAL_MINUTES" in case "$INTERVAL_MINUTES" in
''|*[!0-9]*) ''|*[!0-9]*)
echo "ERROR: SYNC_INTERVAL must be an integer number of minutes" >&2 echo "ERROR: SYNC_INTERVAL must be an integer number of minutes" >&2
exit 1 exit 1
;; ;;
esac esac
if [ "$INTERVAL_MINUTES" -lt 1 ]; then if [ "$INTERVAL_MINUTES" -lt 1 ]; then
echo "ERROR: SYNC_INTERVAL must be at least 1 minute" >&2 echo "ERROR: SYNC_INTERVAL must be at least 1 minute" >&2
exit 1 exit 1
fi fi
case "$(printf '%s' "$APPLY_MODE" | tr '[:upper:]' '[:lower:]')" in
1|true|yes|on)
APPLY_MODE=true
;;
0|false|no|off)
APPLY_MODE=false
;;
*)
echo "ERROR: DNS_SYNC_APPLY must be true or false" >&2
exit 1
;;
esac
if [ -n "$MAX_CHANGES" ]; then
case "$MAX_CHANGES" in
*[!0-9]*)
echo "ERROR: DNS_SYNC_MAX_CHANGES must be a non-negative integer" >&2
exit 1
;;
esac
fi
INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60)) INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60))
sleep_pid="" sleep_pid=""
@@ -21,22 +48,45 @@ shutdown() {
[ -z "$sleep_pid" ] || kill "$sleep_pid" 2>/dev/null || true [ -z "$sleep_pid" ] || kill "$sleep_pid" 2>/dev/null || true
exit 0 exit 0
} }
trap shutdown INT TERM trap shutdown INT TERM
run_reconcile() {
if [ "$APPLY_MODE" = true ]; then
if [ -n "$MAX_CHANGES" ]; then
python -m dns_sync reconcile \
--state-db "$STATE_DB" \
--apply \
--max-changes "$MAX_CHANGES"
else
python -m dns_sync reconcile \
--state-db "$STATE_DB" \
--apply
fi
else
python -m dns_sync reconcile \
--state-db "$STATE_DB"
fi
}
echo "DNS sync service started" echo "DNS sync service started"
echo "Sync interval: ${INTERVAL_MINUTES} minute(s)" echo "Sync interval: ${INTERVAL_MINUTES} minute(s)"
echo "Source plugin: ${PROXY:-npm}" echo "Reconciliation mode: $([ "$APPLY_MODE" = true ] && echo APPLY || echo READ-ONLY)"
echo "DNS provider: ${DNS_PROVIDER:-verizon}" echo "State database: ${STATE_DB}"
echo "Enabled plugins: ${ENABLED_PLUGINS:-npm}"
echo "External plugin path: ${DNS_SYNC_PLUGIN_PATH:-/plugins}"
while :; do while :; do
echo echo
echo "=== DNS sync: $(date -Iseconds) ===" echo "=== DNS reconciliation: $(date -Iseconds) ==="
if python -m dns_sync sync; then
echo "Sync run completed successfully" if run_reconcile; then
echo "Reconciliation run completed successfully"
else else
rc=$? rc=$?
echo "ERROR: Sync run failed with exit code ${rc}" >&2 echo "ERROR: Reconciliation run failed with exit code ${rc}" >&2
fi fi
sleep "$INTERVAL_SECONDS" & sleep "$INTERVAL_SECONDS" &
sleep_pid=$! sleep_pid=$!
wait "$sleep_pid" || true wait "$sleep_pid" || true
+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
@@ -1,4 +1,6 @@
from ...adapters import ( import os
from dns_sync.adapters import (
DNS_RECORDS, DNS_RECORDS,
ApplyResult, ApplyResult,
Capability, Capability,
@@ -8,8 +10,24 @@ from ...adapters import (
PlanAction, PlanAction,
PlanOperation, PlanOperation,
) )
from ...models import DesiredRecord from dns_sync.adapter_support import (
from ..providers.verizon import Plugin as VerizonProvider require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.config import parse_endpoint_list
from .client import VerizonClient, VerizonRecord
def configured_endpoints(config):
return parse_endpoint_list(
"verizon",
os.getenv(
"VERIZON_ENDPOINTS",
"192.168.1.1",
),
443,
)
class Plugin: class Plugin:
@@ -32,17 +50,13 @@ class Plugin:
) )
def __init__(self, config, endpoint): def __init__(self, config, endpoint):
if endpoint.plugin != self.name: require_endpoint_plugin(endpoint, self.name)
raise RuntimeError(
"Verizon adapter requires a verizon endpoint"
)
self.config = config
self.endpoint = endpoint self.endpoint = endpoint
self.provider = VerizonProvider(config, endpoint) self.client = VerizonClient(endpoint)
def connect(self) -> None: def connect(self) -> None:
self.provider.connect() self.client.connect()
def discover( def discover(
self, self,
@@ -56,7 +70,7 @@ class Plugin:
discovered: list[DiscoveredObject] = [] discovered: list[DiscoveredObject] = []
records = sorted( records = sorted(
self.provider.list_records().values(), self.client.list_records().values(),
key=lambda record: record.hostname.lower(), key=lambda record: record.hostname.lower(),
) )
@@ -85,7 +99,7 @@ class Plugin:
def _desired_record( def _desired_record(
self, self,
object: NormalizedObject, object: NormalizedObject,
) -> DesiredRecord: ) -> VerizonRecord:
if object.object_type != DNS_RECORDS: if object.object_type != DNS_RECORDS:
raise RuntimeError( raise RuntimeError(
f"{self.endpoint.identity} cannot handle " f"{self.endpoint.identity} cannot handle "
@@ -113,17 +127,16 @@ class Plugin:
"Verizon DNS does not support wildcard records" "Verizon DNS does not support wildcard records"
) )
return DesiredRecord( return VerizonRecord(
hostname=hostname, hostname=hostname,
ip=str(payload.get("ip") or ""), ip=str(payload.get("ip") or ""),
record_type=record_type, record_type=record_type,
wildcard=False,
) )
def apply(self, action: PlanAction) -> ApplyResult | None: def apply(self, action: PlanAction) -> ApplyResult | None:
if action.endpoint_id != self.endpoint.identity: require_action_endpoint(
raise RuntimeError( action,
"Plan action belongs to a different endpoint" self.endpoint,
) )
if action.operation in { if action.operation in {
@@ -132,24 +145,24 @@ class Plugin:
}: }:
return None return None
if action.desired is None: desired = require_desired(
raise RuntimeError( action,
"Verizon apply action requires desired state" context="Verizon apply action",
) )
record = self._desired_record(action.desired) record = self._desired_record(desired)
if action.operation == PlanOperation.CREATE: if action.operation == PlanOperation.CREATE:
detail = self.provider.add_record(record) detail = self.client.add_record(record)
elif action.operation == PlanOperation.UPDATE: elif action.operation == PlanOperation.UPDATE:
detail = self.provider.update_record(record) detail = self.client.update_record(record)
else: else:
raise RuntimeError( raise RuntimeError(
f"Verizon adapter does not support " f"Verizon adapter does not support "
f"{action.operation.value} actions" f"{action.operation.value} actions"
) )
records = self.provider.list_records() records = self.client.list_records()
applied_record = records.get(record.hostname.lower()) applied_record = records.get(record.hostname.lower())
if applied_record is None: if applied_record is None:
applied_record = records.get(record.hostname) applied_record = records.get(record.hostname)
@@ -168,11 +181,11 @@ class Plugin:
) )
applied = NormalizedObject( applied = NormalizedObject(
object_type=action.desired.object_type, object_type=desired.object_type,
logical_key=action.desired.logical_key, logical_key=desired.logical_key,
local_key=str(slot), local_key=str(slot),
payload=dict(action.desired.payload), payload=dict(desired.payload),
references=action.desired.references, references=desired.references,
) )
return ApplyResult( return ApplyResult(
@@ -181,6 +194,6 @@ class Plugin:
) )
def verify(self, object: NormalizedObject) -> bool: def verify(self, object: NormalizedObject) -> bool:
return self.provider.verify_record( return self.client.verify_record(
self._desired_record(object) self._desired_record(object)
) )
@@ -1,14 +1,15 @@
import hashlib import hashlib
from dataclasses import dataclass, field
import os import os
import re import re
import time import time
from typing import Any
import requests import requests
import urllib3 import urllib3
from ...adapters import Endpoint from dns_sync.adapters import Endpoint
from ...models import ProviderCapabilities, ProviderRecord from dns_sync.normalizer import normalize_hostname, normalize_ipv4
from ...normalizer import normalize_hostname, normalize_ipv4
LUCI_USERNAME = ( LUCI_USERNAME = (
"edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761" "edbd881f1ee2f76ba0bd70fd184f87711be991a0401fd07ccd4b199665f00761"
@@ -17,6 +18,15 @@ LUCI_USERNAME = (
CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);') CFG_RE = re.compile(r'addCfg\("dns_(ip|name)(\d+)",\s*"([^"]*)",\s*"([^"]*)"\);')
@dataclass(frozen=True)
class VerizonRecord:
hostname: str
ip: str
record_type: str = "A"
metadata: dict[str, Any] = field(default_factory=dict)
class _Slot: class _Slot:
def __init__(self, slot): def __init__(self, slot):
self.slot = slot self.slot = slot
@@ -26,40 +36,23 @@ class _Slot:
self.ip_token = "" self.ip_token = ""
class Plugin: class VerizonClient:
name = "verizon"
capabilities = ProviderCapabilities(
record_types=frozenset({"A"}),
wildcard_records=False,
supports_create=True,
supports_update=True,
supports_delete=True,
max_records=64,
)
def __init__( def __init__(
self, self,
config, endpoint: Endpoint,
endpoint: Endpoint | None = None,
): ):
self.config = config
self.endpoint = endpoint self.endpoint = endpoint
if endpoint is None:
self.router_url = os.getenv(
"ROUTER_URL",
"https://192.168.1.1",
).rstrip("/")
else:
if endpoint.plugin != "verizon": if endpoint.plugin != "verizon":
raise RuntimeError( raise RuntimeError(
"Verizon provider requires a verizon endpoint" "VerizonClient requires a verizon endpoint"
) )
self.router_url = f"https://{endpoint.address}" self.router_url = f"https://{endpoint.address}"
self.password = os.getenv("ROUTER_PASSWORD") self.password = os.getenv("ROUTER_PASSWORD")
if not self.password: if not self.password:
raise RuntimeError("ROUTER_PASSWORD is required when DNS_PROVIDER=verizon") raise RuntimeError("ROUTER_PASSWORD is required by the Verizon adapter")
self.timeout = int(os.getenv("ROUTER_TIMEOUT", "10")) self.timeout = int(os.getenv("ROUTER_TIMEOUT", "10"))
self.session = requests.Session() self.session = requests.Session()
@@ -161,7 +154,7 @@ class Plugin:
# The firmware UI has no wildcard support, but normalize defensively. # The firmware UI has no wildcard support, but normalize defensively.
pass pass
ip = normalize_ipv4(slot.ip, hostname) ip = normalize_ipv4(slot.ip, hostname)
result[hostname.lower()] = ProviderRecord( result[hostname.lower()] = VerizonRecord(
hostname=hostname, hostname=hostname,
ip=ip, ip=ip,
record_type="A", record_type="A",
@@ -176,7 +169,7 @@ class Plugin:
def _write_slot(self, slot, hostname, ip): def _write_slot(self, slot, hostname, ip):
if not self.auth_token: if not self.auth_token:
raise RuntimeError("Verizon provider is not authenticated") raise RuntimeError("Verizon client is not authenticated")
if not slot.ip_token or not slot.name_token: if not slot.ip_token or not slot.name_token:
raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens") raise RuntimeError(f"Slot {slot.slot} is missing dynamic field tokens")
+106
View File
@@ -0,0 +1,106 @@
import unittest
from dns_sync.adapter_support import (
require_action_endpoint,
require_desired,
require_endpoint_plugin,
)
from dns_sync.adapters import (
Endpoint,
NormalizedObject,
PlanAction,
PlanOperation,
)
class AdapterSupportTests(unittest.TestCase):
def test_endpoint_plugin_accepts_matching_plugin(self):
endpoint = Endpoint("npm", "node.example:81")
require_endpoint_plugin(endpoint, "npm")
def test_endpoint_plugin_rejects_wrong_plugin(self):
endpoint = Endpoint("npm", "node.example:81")
with self.assertRaisesRegex(
RuntimeError,
"cannot use endpoint",
):
require_endpoint_plugin(
endpoint,
"verizon",
)
def test_endpoint_plugin_rejects_empty_expected_name(self):
endpoint = Endpoint("npm", "node.example:81")
with self.assertRaisesRegex(
ValueError,
"cannot be empty",
):
require_endpoint_plugin(endpoint, " ")
def test_action_endpoint_accepts_matching_endpoint(self):
endpoint = Endpoint("npm", "node.example:81")
action = PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.CREATE,
)
require_action_endpoint(action, endpoint)
def test_action_endpoint_rejects_other_endpoint(self):
endpoint = Endpoint("npm", "node.example:81")
action = PlanAction(
endpoint_id="npm://other.example:81",
operation=PlanOperation.CREATE,
)
with self.assertRaisesRegex(
RuntimeError,
"does not belong",
):
require_action_endpoint(action, endpoint)
def test_require_desired_returns_desired_object(self):
desired = NormalizedObject(
object_type="example",
logical_key="object",
payload={},
)
action = PlanAction(
endpoint_id="example://endpoint",
operation=PlanOperation.CREATE,
desired=desired,
)
self.assertIs(
require_desired(action),
desired,
)
def test_require_desired_fails_closed(self):
action = PlanAction(
endpoint_id="example://endpoint",
operation=PlanOperation.CREATE,
)
with self.assertRaisesRegex(
RuntimeError,
"Test mutation requires desired state",
):
require_desired(
action,
context="Test mutation",
)
if __name__ == "__main__":
unittest.main()
+88 -6
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]
@@ -164,8 +173,12 @@ class ReconcileCliTests(unittest.TestCase):
"NPM_ENDPOINTS": "192.168.1.3", "NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": "192.168.1.1", "VERIZON_ENDPOINTS": "192.168.1.1",
"DNS_TARGET_IP": "192.168.1.101", "DNS_TARGET_IP": "192.168.1.101",
"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,
@@ -220,6 +233,7 @@ class ReconcileCliTests(unittest.TestCase):
execution = SimpleNamespace( execution = SimpleNamespace(
applied=(), applied=(),
skipped=(), skipped=(),
retired=(),
) )
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -255,11 +269,14 @@ class ReconcileCliTests(unittest.TestCase):
stdout.getvalue(), stdout.getvalue(),
) )
self.assertIn( self.assertIn(
"APPLY COMPLETE: 0 action(s) applied and verified", "APPLY COMPLETE: 0 action(s) applied and verified; "
"0 retirement(s) completed",
stdout.getvalue(), stdout.getvalue(),
) )
def test_apply_budget_blocks_executor_before_mutation(self): def test_apply_budget_counts_planned_retirement_before_mutation(
self,
):
config = SimpleNamespace( config = SimpleNamespace(
target_ip="192.168.1.101", target_ip="192.168.1.101",
include_disabled=False, include_disabled=False,
@@ -267,10 +284,18 @@ class ReconcileCliTests(unittest.TestCase):
domain=None, domain=None,
) )
adapter = object() adapter = object()
action = SimpleNamespace(
operation=SimpleNamespace(value="create"), retirement = SimpleNamespace(
object=SimpleNamespace(
object_type="certificates",
logical_key="app.example.com",
local_key="63",
)
)
plan = SimpleNamespace(
actions=(),
retirements=(retirement,),
) )
plan = SimpleNamespace(actions=(action,))
reconciliation = SimpleNamespace( reconciliation = SimpleNamespace(
observations=(), observations=(),
desired=(), desired=(),
@@ -298,7 +323,64 @@ class ReconcileCliTests(unittest.TestCase):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
( (
r"Refusing to apply 1 change\(s\); " r"Refusing to apply 1 mutation\(s\); "
r"--max-changes allows 0"
),
):
run_reconcile(
SimpleNamespace(
state_db=str(state_path),
include_disabled=False,
apply=True,
max_changes=0,
)
)
execute.assert_not_called()
def test_apply_budget_blocks_executor_before_mutation(self):
config = SimpleNamespace(
target_ip="192.168.1.101",
include_disabled=False,
domain_matching=False,
domain=None,
)
adapter = object()
action = SimpleNamespace(
operation=SimpleNamespace(value="create"),
)
plan = SimpleNamespace(
actions=(action,),
retirements=(),
)
reconciliation = SimpleNamespace(
observations=(),
desired=(),
plans=(plan,),
failures=(),
)
with tempfile.TemporaryDirectory() as directory:
state_path = Path(directory) / "state.sqlite3"
with patch(
"dns_sync.cli.AppConfig.from_env",
return_value=config,
), patch(
"dns_sync.cli._configured_adapters",
return_value=[adapter],
), patch(
"dns_sync.cli.discover_reconcile_plan",
return_value=reconciliation,
), patch(
"dns_sync.cli.print_endpoint_plans",
), patch(
"dns_sync.cli.execute_endpoint_plans",
) as execute, redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(
RuntimeError,
(
r"Refusing to apply 1 mutation\(s\); "
r"--max-changes allows 0" r"--max-changes allows 0"
), ),
): ):
+13 -78
View File
@@ -47,29 +47,27 @@ class EndpointParsingTests(unittest.TestCase):
) )
class AppConfigEndpointTests(unittest.TestCase): class AppConfigPolicyTests(unittest.TestCase):
def test_legacy_proxy_ip_remains_supported(self): def test_explicit_dns_target_is_used(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{"PROXY_IP": "192.168.1.101"}, {
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
},
clear=True, clear=True,
): ):
config = AppConfig.from_env() config = AppConfig.from_env()
self.assertEqual( self.assertEqual(
tuple(endpoint.identity for endpoint in config.npm_endpoints), config.target_ip,
("npm://192.168.1.101:81",), "192.168.1.101",
) )
self.assertEqual(config.proxy_url, "http://192.168.1.101:81")
self.assertEqual(config.target_ip, "192.168.1.101")
def test_endpoint_list_controls_api_and_proxy_ip_remains_target(self): def test_legacy_proxy_ip_remains_target_fallback(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"NPM_ENDPOINTS": (
"192.168.1.3,192.168.1.4:8081,192.168.1.5"
),
"PROXY_IP": "192.168.1.101", "PROXY_IP": "192.168.1.101",
}, },
clear=True, clear=True,
@@ -77,35 +75,15 @@ class AppConfigEndpointTests(unittest.TestCase):
config = AppConfig.from_env() config = AppConfig.from_env()
self.assertEqual( self.assertEqual(
tuple(endpoint.identity for endpoint in config.npm_endpoints), config.target_ip,
( "192.168.1.101",
"npm://192.168.1.3:81",
"npm://192.168.1.4:8081",
"npm://192.168.1.5:81",
),
) )
self.assertEqual(config.proxy_url, "http://192.168.1.3:81")
self.assertEqual(config.target_ip, "192.168.1.101")
def test_explicit_dns_target_supports_multiple_endpoints(self): def test_dns_target_is_required_without_legacy_fallback(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4", "NPM_ENDPOINTS": "192.168.1.3",
"DNS_TARGET_IP": "192.168.1.101",
},
clear=True,
):
config = AppConfig.from_env()
self.assertEqual(config.proxy_url, "http://192.168.1.3:81")
self.assertEqual(config.target_ip, "192.168.1.101")
def test_multiple_endpoints_require_target_without_proxy_ip(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4",
}, },
clear=True, clear=True,
): ):
@@ -115,49 +93,6 @@ class AppConfigEndpointTests(unittest.TestCase):
): ):
AppConfig.from_env() AppConfig.from_env()
def test_verizon_endpoint_list_is_plugin_specific(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": (
"192.168.1.1,192.168.1.254:8443"
),
},
clear=True,
):
config = AppConfig.from_env()
self.assertEqual(
tuple(
endpoint.identity
for endpoint in config.verizon_endpoints
),
(
"verizon://192.168.1.1:443",
"verizon://192.168.1.254:8443",
),
)
def test_router_url_does_not_configure_generic_endpoint(self):
with patch.dict(
os.environ,
{
"NPM_ENDPOINTS": "192.168.1.3",
"ROUTER_URL": "https://192.168.1.254",
},
clear=True,
):
config = AppConfig.from_env()
self.assertEqual(
tuple(
endpoint.identity
for endpoint in config.verizon_endpoints
),
("verizon://192.168.1.1:443",),
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+528
View File
@@ -16,6 +16,7 @@ from dns_sync.adapters import (
Observation, Observation,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
RetireRequest,
) )
from dns_sync.executor import execute_endpoint_plans from dns_sync.executor import execute_endpoint_plans
from dns_sync.persistence import SQLiteStateStore from dns_sync.persistence import SQLiteStateStore
@@ -117,6 +118,9 @@ class FakeAdapter:
events: list[tuple[str, str, str]] = field( events: list[tuple[str, str, str]] = field(
default_factory=list default_factory=list
) )
retired: list[RetireRequest] = field(
default_factory=list
)
def apply(self, plan_action): def apply(self, plan_action):
desired = plan_action.desired desired = plan_action.desired
@@ -163,6 +167,16 @@ class FakeAdapter:
raise self.verify_error raise self.verify_error
return self.verify_result return self.verify_result
def retire(self, request):
self.events.append(
(
"retire",
request.object.object_type,
request.object.logical_key,
)
)
self.retired.append(request)
class ExecutionTests(unittest.TestCase): class ExecutionTests(unittest.TestCase):
def test_successful_actions_are_applied_verified_and_persisted(self): def test_successful_actions_are_applied_verified_and_persisted(self):
@@ -216,6 +230,378 @@ class ExecutionTests(unittest.TestCase):
("apply", "verify", "apply", "verify"), ("apply", "verify", "apply", "verify"),
) )
def test_replace_rebinds_dependent_before_retiring_old_object(self):
endpoint = Endpoint(
"npm",
"192.168.1.4:81",
)
class ReplacementAdapter(FakeAdapter):
def apply(self, plan_action):
result = super().apply(plan_action)
if (
plan_action.operation
== PlanOperation.REPLACE
):
return ApplyResult(
object=result.object,
detail="replacement",
retirements=(
RetireRequest(
object=plan_action.current,
),
),
)
return result
adapter = ReplacementAdapter(
endpoint,
capabilities(
CERTIFICATES,
PROXY_HOSTS,
),
apply_local_keys={
(
CERTIFICATES,
"app.example.com",
): "84",
(
PROXY_HOSTS,
"app.example.com",
): "12",
},
)
old_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-01T00:00:00+00:00",
"expires_on": "2026-11-01T00:00:00+00:00",
},
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
old_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload=dict(old_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
plan = EndpointPlan(
endpoint.identity,
(
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.REPLACE,
desired=desired_certificate,
current=old_certificate,
),
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.UPDATE,
desired=desired_proxy,
current=old_proxy,
),
),
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
endpoint.identity,
CERTIFICATES,
[
Observation(
endpoint.identity,
old_certificate,
NOW,
)
],
NOW,
)
store.replace_observations(
endpoint.identity,
PROXY_HOSTS,
[
Observation(
endpoint.identity,
old_proxy,
NOW,
)
],
NOW,
)
execute_endpoint_plans(
[adapter],
[plan],
store,
executed_at=NOW,
)
self.assertEqual(
tuple(event[0] for event in adapter.events),
(
"apply",
"verify",
"apply",
"verify",
"retire",
),
)
proxy_apply = adapter.applied_actions[1]
self.assertEqual(
proxy_apply.desired.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
)
self.assertEqual(
adapter.retired,
[
RetireRequest(
object=old_certificate,
)
],
)
def test_restart_recovery_rebinds_before_planned_retirement(self):
endpoint = Endpoint(
"npm",
"192.168.1.4:81",
)
adapter = FakeAdapter(
endpoint,
capabilities(
CERTIFICATES,
PROXY_HOSTS,
),
apply_local_keys={
(
PROXY_HOSTS,
"app.example.com",
): "12",
},
)
old_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-01T00:00:00+00:00",
"expires_on": "2026-11-01T00:00:00+00:00",
},
)
new_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
old_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload=dict(new_certificate.payload),
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload=dict(old_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
plan = EndpointPlan(
endpoint.identity,
(
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.NOOP,
desired=desired_certificate,
current=new_certificate,
),
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.UPDATE,
desired=desired_proxy,
current=old_proxy,
reason=(
"referenced local object is being retired"
),
),
),
retirements=(
RetireRequest(
object=old_certificate,
),
),
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
endpoint.identity,
CERTIFICATES,
[
Observation(
endpoint.identity,
old_certificate,
NOW,
),
Observation(
endpoint.identity,
new_certificate,
NOW,
),
],
NOW,
)
store.replace_observations(
endpoint.identity,
PROXY_HOSTS,
[
Observation(
endpoint.identity,
old_proxy,
NOW,
)
],
NOW,
)
result = execute_endpoint_plans(
[adapter],
[plan],
store,
executed_at=NOW,
)
self.assertEqual(
tuple(event[0] for event in adapter.events),
(
"apply",
"verify",
"retire",
),
)
proxy_action = adapter.applied_actions[0]
self.assertEqual(
proxy_action.desired.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
)
self.assertEqual(
adapter.retired,
[
RetireRequest(
object=old_certificate,
)
],
)
self.assertEqual(
result.retired,
(
(
endpoint.identity,
RetireRequest(
object=old_certificate,
),
),
),
)
def test_failed_verification_is_persisted_and_raises(self): def test_failed_verification_is_persisted_and_raises(self):
endpoint = Endpoint("verizon", "192.168.1.1:443") endpoint = Endpoint("verizon", "192.168.1.1:443")
adapter = FakeAdapter( adapter = FakeAdapter(
@@ -280,6 +666,148 @@ class ExecutionTests(unittest.TestCase):
) )
self.assertEqual(store.load_applied_state(), []) self.assertEqual(store.load_applied_state(), [])
def test_preflight_requires_retire_for_planned_retirement_before_mutation(
self,
):
endpoint = Endpoint("npm", "192.168.1.4:81")
inner = FakeAdapter(
endpoint,
capabilities(CERTIFICATES),
)
class NoRetireAdapter:
def __init__(self, wrapped):
self.endpoint = wrapped.endpoint
self.capabilities = wrapped.capabilities
self.wrapped = wrapped
def apply(self, plan_action):
return self.wrapped.apply(plan_action)
def verify(self, object):
return self.wrapped.verify(object)
adapter = NoRetireAdapter(inner)
old_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
},
)
plan = EndpointPlan(
endpoint.identity,
(
action(
endpoint.identity,
PlanOperation.CREATE,
object_type=CERTIFICATES,
logical_key="other.example.com",
),
),
retirements=(
RetireRequest(
object=old_certificate,
),
),
)
with SQLiteStateStore(":memory:") as store:
with self.assertRaisesRegex(
RuntimeError,
"does not support deferred retirement",
):
execute_endpoint_plans(
[adapter],
[plan],
store,
executed_at=NOW,
)
self.assertEqual(
store.load_applied_state(),
[],
)
self.assertEqual(inner.events, [])
self.assertEqual(inner.applied_actions, [])
def test_preflight_requires_retire_for_replace_before_mutation(
self,
):
endpoint = Endpoint("npm", "192.168.1.4:81")
inner = FakeAdapter(
endpoint,
capabilities(CERTIFICATES),
)
class NoRetireAdapter:
def __init__(self, wrapped):
self.endpoint = wrapped.endpoint
self.capabilities = wrapped.capabilities
self.wrapped = wrapped
def apply(self, plan_action):
return self.wrapped.apply(plan_action)
def verify(self, object):
return self.wrapped.verify(object)
adapter = NoRetireAdapter(inner)
current = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"expires_on": "2026-11-01T00:00:00+00:00",
},
)
desired = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
plan = EndpointPlan(
endpoint.identity,
(
PlanAction(
endpoint_id=endpoint.identity,
operation=PlanOperation.REPLACE,
desired=desired,
current=current,
),
),
)
with SQLiteStateStore(":memory:") as store:
with self.assertRaisesRegex(
RuntimeError,
"does not support deferred retirement",
):
execute_endpoint_plans(
[adapter],
[plan],
store,
executed_at=NOW,
)
self.assertEqual(
store.load_applied_state(),
[],
)
self.assertEqual(inner.events, [])
self.assertEqual(inner.applied_actions, [])
def test_preflight_requires_verify_before_any_mutation(self): def test_preflight_requires_verify_before_any_mutation(self):
endpoint = Endpoint("verizon", "192.168.1.1:443") endpoint = Endpoint("verizon", "192.168.1.1:443")
adapter = FakeAdapter( adapter = FakeAdapter(
-54
View File
@@ -1,54 +0,0 @@
import unittest
from dns_sync.engine import build_sync_plan
from dns_sync.models import (
DesiredRecord,
ProviderCapabilities,
ProviderRecord,
)
class LegacyDnsEngineTests(unittest.TestCase):
def test_existing_dns_plan_still_adds_updates_and_preserves_matches(self):
desired = {
"add.example.com": DesiredRecord(
hostname="add.example.com",
ip="192.168.1.101",
),
"keep.example.com": DesiredRecord(
hostname="keep.example.com",
ip="192.168.1.101",
),
"update.example.com": DesiredRecord(
hostname="update.example.com",
ip="192.168.1.101",
),
}
current = {
"keep.example.com": ProviderRecord(
hostname="keep.example.com",
ip="192.168.1.101",
),
"update.example.com": ProviderRecord(
hostname="update.example.com",
ip="192.168.1.50",
),
}
plan = build_sync_plan(
desired,
current,
ProviderCapabilities(),
)
actions = {
item.desired.hostname: item.action
for item in plan
}
self.assertEqual(actions["add.example.com"], "ADD")
self.assertEqual(actions["keep.example.com"], "UNCHANGED")
self.assertEqual(actions["update.example.com"], "UPDATE")
if __name__ == "__main__":
unittest.main()
+958 -83
View File
File diff suppressed because it is too large Load Diff
+100 -27
View File
@@ -5,48 +5,49 @@ from zipfile import ZipFile
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from dns_sync.adapters import Endpoint from dns_sync.adapters import Endpoint
from dns_sync.config import AppConfig
from dns_sync.certificates.material import CertificateMaterial from dns_sync.certificates.material import CertificateMaterial
from dns_sync.npm_client import NpmClient from dns_sync.plugins.adapters.npm.client import NpmClient
class NpmClientEndpointTests(unittest.TestCase): class NpmClientEndpointTests(unittest.TestCase):
def config(self): def test_client_uses_explicit_endpoint(self):
return AppConfig.from_env()
def test_default_client_uses_first_configured_endpoint(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4:8081",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com", "NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret", "NPM_SECRET": "not-a-real-secret",
}, },
clear=True, clear=True,
): ):
config = self.config() endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
with patch( with patch(
"dns_sync.npm_client.requests.Session.request" "dns_sync.plugins.adapters.npm.client.requests.Session.request"
) as request: ) as request:
client = NpmClient(config) client = NpmClient(endpoint)
request.assert_not_called() request.assert_not_called()
self.assertEqual(client.api_url, "http://192.168.1.3:81/api") self.assertEqual(
client.api_url,
"http://192.168.1.3:81/api",
)
def test_client_can_select_specific_configured_endpoint(self): def test_client_can_select_specific_endpoint(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"NPM_ENDPOINTS": "192.168.1.3,192.168.1.4:8081",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com", "NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret", "NPM_SECRET": "not-a-real-secret",
}, },
clear=True, clear=True,
): ):
config = self.config() endpoint = Endpoint(
client = NpmClient(config, config.npm_endpoints[1]) "npm",
"192.168.1.4:8081",
)
client = NpmClient(endpoint)
self.assertEqual( self.assertEqual(
client.api_url, client.api_url,
@@ -61,19 +62,66 @@ class NpmClientEndpointTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"PROXY_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com", "NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret", "NPM_SECRET": "not-a-real-secret",
}, },
clear=True, clear=True,
): ):
config = self.config() endpoint = Endpoint(
endpoint = Endpoint("verizon", "192.168.1.1") "verizon",
"192.168.1.1:443",
)
with self.assertRaisesRegex(RuntimeError, "npm endpoint"): with self.assertRaisesRegex(
NpmClient(config, endpoint) RuntimeError,
"npm endpoint",
):
NpmClient(endpoint)
def test_client_rejects_invalid_api_scheme(self):
with patch.dict(
os.environ,
{
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
"PROXY_API_SCHEME": "ftp",
},
clear=True,
):
with self.assertRaisesRegex(
RuntimeError,
"PROXY_API_SCHEME",
):
NpmClient(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
def test_client_owns_tls_verification_setting(self):
with patch.dict(
os.environ,
{
"NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret",
"PROXY_API_SCHEME": "https",
"PROXY_VERIFY_TLS": "false",
},
clear=True,
):
client = NpmClient(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
self.assertEqual(
client.api_url,
"https://192.168.1.3:81/api",
)
self.assertFalse(client.verify_tls)
class NpmClientMutationTests(unittest.TestCase): class NpmClientMutationTests(unittest.TestCase):
@@ -81,17 +129,16 @@ class NpmClientMutationTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"NPM_ENDPOINTS": "192.168.1.3",
"DNS_TARGET_IP": "192.168.1.101",
"NPM_IDENTITY": "test@example.com", "NPM_IDENTITY": "test@example.com",
"NPM_SECRET": "not-a-real-secret", "NPM_SECRET": "not-a-real-secret",
}, },
clear=True, clear=True,
): ):
config = AppConfig.from_env()
return NpmClient( return NpmClient(
config, Endpoint(
config.npm_endpoints[0], "npm",
"192.168.1.3:81",
)
) )
@staticmethod @staticmethod
@@ -186,6 +233,19 @@ class NpmClientMutationTests(unittest.TestCase):
) )
self.assertEqual(result["id"], 12) self.assertEqual(result["id"], 12)
def test_deletes_proxy_host_by_endpoint_local_id(self):
client = self.client()
client.request = Mock()
result = client.delete_proxy_host("12")
self.assertIsNone(result)
client.request.assert_called_once_with(
"DELETE",
"/nginx/proxy-hosts/12",
expected_status=200,
)
def test_gets_one_certificate_by_endpoint_local_id(self): def test_gets_one_certificate_by_endpoint_local_id(self):
client = self.client() client = self.client()
client.get_json = Mock( client.get_json = Mock(
@@ -321,6 +381,19 @@ class NpmClientMutationTests(unittest.TestCase):
): ):
client.certificate_material("99") client.certificate_material("99")
def test_deletes_certificate_by_endpoint_local_id(self):
client = self.client()
client.request = Mock()
result = client.delete_certificate("84")
self.assertIsNone(result)
client.request.assert_called_once_with(
"DELETE",
"/nginx/certificates/84",
expected_status=200,
)
def test_creates_certificate_metadata_with_json_payload(self): def test_creates_certificate_metadata_with_json_payload(self):
client = self.client() client = self.client()
payload = { payload = {
+34 -8
View File
@@ -147,9 +147,19 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
DNS_RECORDS DNS_RECORDS
) )
self.assertEqual(len(result.desired), 1) self.assertEqual(len(
[
item
for item in result.desired
if item.object_type == DNS_RECORDS
]
), 1)
self.assertEqual( self.assertEqual(
result.desired[0].payload["ip"], next(
item
for item in result.desired
if item.object_type == DNS_RECORDS
).payload["ip"],
"192.168.1.101", "192.168.1.101",
) )
self.assertEqual(len(result.plans), 1) self.assertEqual(len(result.plans), 1)
@@ -220,12 +230,20 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
("cached.example.com",), item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
(),
) )
self.assertEqual( self.assertEqual(
result.plans[0].actions[0].operation, len(result.plans),
PlanOperation.CREATE, 1,
)
self.assertEqual(
result.plans[0].actions,
(),
) )
self.assertFalse(status.is_live) self.assertFalse(status.is_live)
self.assertEqual(status.last_success_at, T1) self.assertEqual(status.last_success_at, T1)
@@ -304,7 +322,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
("app.example.com",), ("app.example.com",),
) )
@@ -344,7 +366,11 @@ class ReadOnlyOrchestrationTests(unittest.TestCase):
) )
self.assertEqual( self.assertEqual(
tuple(item.logical_key for item in result.desired), tuple(
item.logical_key
for item in result.desired
if item.object_type == DNS_RECORDS
),
("active.example.com",), ("active.example.com",),
) )
+581
View File
@@ -0,0 +1,581 @@
import unittest
from dataclasses import dataclass, field
from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
CapabilityOperation,
DiscoveredObject,
Endpoint,
NormalizedObject,
ObjectReference,
Observation,
)
from dns_sync.orchestrator import discover_reconcile_plan
from dns_sync.reconciliation import ReconciliationConflict
from dns_sync.persistence import SQLiteStateStore
T1 = datetime(
2026, 9, 20, 8, 0, tzinfo=timezone.utc
)
T2 = datetime(
2026, 9, 20, 9, 0, tzinfo=timezone.utc
)
@dataclass
class FakeAdapter:
endpoint: Endpoint
capabilities: frozenset[Capability]
discovered: dict[str, list[DiscoveredObject]] = field(
default_factory=dict
)
failures: dict[str, str] = field(
default_factory=dict
)
def connect(self) -> None:
return None
def discover(
self,
object_type: str,
) -> list[DiscoveredObject]:
if object_type in self.failures:
raise RuntimeError(
self.failures[object_type]
)
return list(
self.discovered.get(
object_type,
[],
)
)
def capability_set(
*object_types: str,
apply: bool = False,
) -> frozenset[Capability]:
rows = {
Capability(
object_type,
CapabilityOperation.DISCOVER,
)
for object_type in object_types
}
if apply:
rows.update(
Capability(
object_type,
CapabilityOperation.APPLY,
)
for object_type in object_types
)
return frozenset(rows)
def normalized_certificate(
local_key: str,
*,
valid_from: str,
expires_on: str,
) -> NormalizedObject:
return NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key=local_key,
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": valid_from,
"expires_on": expires_on,
},
)
def discovered_certificate(
local_key: str,
*,
valid_from: str,
expires_on: str,
) -> DiscoveredObject:
return DiscoveredObject(
object_type=CERTIFICATES,
local_key=local_key,
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": valid_from,
"expires_on": expires_on,
},
)
def normalized_proxy(
local_key: str,
) -> NormalizedObject:
return NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key=local_key,
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"enabled": True,
},
)
class ObservationAuthorityTests(unittest.TestCase):
def test_failed_newer_certificate_cache_cannot_override_live_certificate(self):
live_endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
failed_endpoint = Endpoint(
"npm",
"192.168.1.4:81",
)
live = FakeAdapter(
endpoint=live_endpoint,
capabilities=capability_set(
CERTIFICATES,
),
discovered={
CERTIFICATES: [
discovered_certificate(
"20",
valid_from=(
"2026-09-15T00:00:00+00:00"
),
expires_on=(
"2026-12-15T00:00:00+00:00"
),
)
]
},
)
failed = FakeAdapter(
endpoint=failed_endpoint,
capabilities=capability_set(
CERTIFICATES,
apply=True,
),
failures={
CERTIFICATES: "offline"
},
)
cached_newer = normalized_certificate(
"99",
valid_from=(
"2026-09-19T00:00:00+00:00"
),
expires_on=(
"2026-12-19T00:00:00+00:00"
),
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
failed_endpoint.identity,
CERTIFICATES,
[
Observation(
failed_endpoint.identity,
cached_newer,
T1,
)
],
T1,
)
result = discover_reconcile_plan(
[
live,
failed,
],
store,
target_ip="192.168.1.101",
run_at=T2,
)
certificates = [
object
for object in result.desired
if object.object_type == CERTIFICATES
]
self.assertEqual(
len(certificates),
1,
)
self.assertEqual(
certificates[0].payload["valid_from"],
"2026-09-15T00:00:00+00:00",
)
# The failed target advertised APPLY, but it must not
# receive a certificate mutation plan this run.
self.assertEqual(
result.plans,
(),
)
self.assertTrue(
any(
observation.endpoint_id
== failed_endpoint.identity
and observation.object.local_key == "99"
for observation in result.observations
)
)
def test_persisted_newer_certificate_cannot_regress_to_older_live_certificate(
self,
):
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
live = FakeAdapter(
endpoint=endpoint,
capabilities=capability_set(
CERTIFICATES,
apply=True,
),
discovered={
CERTIFICATES: [
discovered_certificate(
"20",
valid_from=(
"2026-09-15T00:00:00+00:00"
),
expires_on=(
"2026-12-15T00:00:00+00:00"
),
)
]
},
)
persisted = normalized_certificate(
"99",
valid_from=(
"2026-09-19T00:00:00+00:00"
),
expires_on=(
"2026-12-19T00:00:00+00:00"
),
)
persisted = NormalizedObject(
object_type=persisted.object_type,
logical_key=persisted.logical_key,
payload=dict(persisted.payload),
)
with SQLiteStateStore(":memory:") as store:
store.replace_desired_state(
CERTIFICATES,
[persisted],
T1,
)
result = discover_reconcile_plan(
[live],
store,
target_ip="192.168.1.101",
run_at=T2,
)
stored = store.load_desired_state(
CERTIFICATES
)
certificates = [
object
for object in result.desired
if object.object_type == CERTIFICATES
]
self.assertEqual(len(certificates), 1)
self.assertEqual(
certificates[0].payload["valid_from"],
"2026-09-19T00:00:00+00:00",
)
self.assertEqual(
certificates[0].payload["expires_on"],
"2026-12-19T00:00:00+00:00",
)
self.assertEqual(len(stored), 1)
self.assertEqual(
stored[0].object,
persisted,
)
self.assertEqual(len(result.plans), 1)
self.assertEqual(
result.plans[0].endpoint_id,
endpoint.identity,
)
self.assertEqual(
len(result.plans[0].actions),
1,
)
self.assertEqual(
result.plans[0].actions[0].operation.value,
"replace",
)
self.assertEqual(
result.plans[0].actions[0].desired,
persisted,
)
def test_expired_certificate_on_disabled_proxy_is_removed_from_desired(
self,
):
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
adapter = FakeAdapter(
endpoint=endpoint,
capabilities=capability_set(
CERTIFICATES,
PROXY_HOSTS,
),
discovered={
CERTIFICATES: [
DiscoveredObject(
object_type=CERTIFICATES,
local_key="56",
payload={
"domain_names": [
"admin.trunavigate.org"
],
"provider": "letsencrypt",
"valid_from": None,
"expires_on": (
"2026-06-22T03:40:57.000Z"
),
},
)
],
PROXY_HOSTS: [
DiscoveredObject(
object_type=PROXY_HOSTS,
local_key="29",
payload={
"domain_names": [
"admin.trunavigate.org"
],
"forward_scheme": "http",
"forward_host": "192.168.1.4",
"forward_port": 3283,
"enabled": False,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="56",
),
),
)
],
},
)
with SQLiteStateStore(":memory:") as store:
result = discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=T2,
)
certificates = [
object
for object in result.desired
if object.object_type == CERTIFICATES
]
proxies = [
object
for object in result.desired
if object.object_type == PROXY_HOSTS
]
self.assertEqual(certificates, [])
self.assertEqual(len(proxies), 1)
self.assertEqual(
proxies[0].logical_key,
"admin.trunavigate.org",
)
self.assertEqual(
proxies[0].references,
(),
)
def test_expired_certificate_on_enabled_proxy_fails_closed(
self,
):
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
adapter = FakeAdapter(
endpoint=endpoint,
capabilities=capability_set(
CERTIFICATES,
PROXY_HOSTS,
),
discovered={
CERTIFICATES: [
DiscoveredObject(
object_type=CERTIFICATES,
local_key="56",
payload={
"domain_names": [
"active.example.com"
],
"provider": "letsencrypt",
"valid_from": None,
"expires_on": (
"2026-06-22T03:40:57.000Z"
),
},
)
],
PROXY_HOSTS: [
DiscoveredObject(
object_type=PROXY_HOSTS,
local_key="29",
payload={
"domain_names": [
"active.example.com"
],
"forward_scheme": "http",
"forward_host": "192.168.1.4",
"forward_port": 3283,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="56",
),
),
)
],
},
)
with SQLiteStateStore(":memory:") as store:
with self.assertRaisesRegex(
ReconciliationConflict,
"Enabled proxy host references an expired certificate",
):
discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=T2,
)
def test_all_failed_sources_preserve_persisted_desired_without_planning(self):
npm_endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
npm = FakeAdapter(
endpoint=npm_endpoint,
capabilities=capability_set(
PROXY_HOSTS,
apply=True,
),
failures={
PROXY_HOSTS: "offline"
},
)
cached_proxy = normalized_proxy("12")
previous_dns = NormalizedObject(
object_type=DNS_RECORDS,
logical_key="app.example.com",
payload={
"hostname": "app.example.com",
"ip_address": "192.168.1.101",
},
)
with SQLiteStateStore(":memory:") as store:
store.replace_observations(
npm_endpoint.identity,
PROXY_HOSTS,
[
Observation(
npm_endpoint.identity,
cached_proxy,
T1,
)
],
T1,
)
store.replace_desired_state(
DNS_RECORDS,
[previous_dns],
T1,
)
result = discover_reconcile_plan(
[npm],
store,
target_ip="192.168.1.101",
run_at=T2,
)
persisted_dns = store.load_desired_state(
DNS_RECORDS
)
self.assertEqual(
result.desired,
(),
)
self.assertEqual(
result.plans,
(),
)
self.assertEqual(
len(persisted_dns),
1,
)
self.assertEqual(
persisted_dns[0].object,
previous_dns,
)
if __name__ == "__main__":
unittest.main()
+318
View File
@@ -0,0 +1,318 @@
import unittest
from dataclasses import dataclass
from datetime import datetime, timezone
from dns_sync.adapters import (
CERTIFICATES,
DNS_RECORDS,
PROXY_HOSTS,
Capability,
CapabilityOperation,
DiscoveredObject,
Endpoint,
ObjectReference,
)
from dns_sync.orchestrator import discover_reconcile_plan
from dns_sync.persistence import SQLiteStateStore
NOW = datetime(
2026,
9,
20,
9,
30,
tzinfo=timezone.utc,
)
@dataclass
class FakeNpmAdapter:
endpoint: Endpoint
@property
def capabilities(self):
return frozenset(
{
Capability(
CERTIFICATES,
CapabilityOperation.DISCOVER,
),
Capability(
CERTIFICATES,
CapabilityOperation.VERIFY,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.DISCOVER,
),
Capability(
PROXY_HOSTS,
CapabilityOperation.VERIFY,
),
}
)
def connect(self):
return None
def discover(self, object_type):
if object_type == CERTIFICATES:
return [
DiscoveredObject(
object_type=CERTIFICATES,
local_key="67",
payload={
"domain_names": [
"app.example.com",
],
"provider": "letsencrypt",
"nice_name": "app",
"valid_from": (
"2026-09-01T00:00:00Z"
),
"expires_on": (
"2026-12-01T00:00:00Z"
),
"cluster_managed": True,
"acme_renew": True,
"dns_challenge": False,
},
),
]
if object_type == PROXY_HOSTS:
return [
DiscoveredObject(
object_type=PROXY_HOSTS,
local_key="12",
payload={
"domain_names": [
"app.example.com",
],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"ssl_forced": True,
"caching_enabled": False,
"block_exploits": True,
"allow_websocket_upgrade": True,
"http2_support": True,
"hsts_enabled": True,
"hsts_subdomains": False,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
),
]
raise RuntimeError(
f"unsupported discovery type: {object_type}"
)
def apply(self, action):
raise AssertionError(
"NPM APPLY must remain unavailable"
)
def verify(self, object):
return True
class NpmDesiredStateOrchestrationTests(
unittest.TestCase
):
def test_orchestrator_builds_and_persists_all_three_desired_types(
self,
):
adapter = FakeNpmAdapter(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
with SQLiteStateStore(":memory:") as store:
run = discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=NOW,
)
desired_by_type = {
object.object_type: object
for object in run.desired
}
self.assertEqual(
set(desired_by_type),
{
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS,
},
)
certificate = desired_by_type[
CERTIFICATES
]
proxy = desired_by_type[PROXY_HOSTS]
dns = desired_by_type[DNS_RECORDS]
self.assertIsNone(
certificate.local_key
)
self.assertIsNone(
proxy.local_key
)
self.assertIsNone(
dns.local_key
)
self.assertEqual(
proxy.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
self.assertEqual(
dns.payload["hostname"],
"app.example.com",
)
self.assertEqual(
dns.payload["ip"],
"192.168.1.101",
)
for object_type in (
CERTIFICATES,
PROXY_HOSTS,
DNS_RECORDS,
):
persisted = (
store.load_desired_state(
object_type
)
)
self.assertEqual(
len(persisted),
1,
)
self.assertEqual(
persisted[0].object,
desired_by_type[
object_type
],
)
# The NPM adapter intentionally does not advertise
# APPLY, so this read-only orchestration must not
# produce an NPM endpoint plan.
self.assertEqual(
run.plans,
(),
)
def test_empty_npm_snapshot_clears_stale_npm_desired_state(
self,
):
adapter = FakeNpmAdapter(
Endpoint(
"npm",
"192.168.1.3:81",
)
)
with SQLiteStateStore(":memory:") as store:
first = discover_reconcile_plan(
[adapter],
store,
target_ip="192.168.1.101",
run_at=NOW,
)
self.assertTrue(first.desired)
self.assertEqual(
len(
store.load_desired_state(
CERTIFICATES
)
),
1,
)
self.assertEqual(
len(
store.load_desired_state(
PROXY_HOSTS
)
),
1,
)
class EmptyNpmAdapter(
FakeNpmAdapter
):
def discover(
self,
object_type,
):
if object_type in {
CERTIFICATES,
PROXY_HOSTS,
}:
return []
return super().discover(
object_type
)
empty = EmptyNpmAdapter(
adapter.endpoint
)
second = discover_reconcile_plan(
[empty],
store,
target_ip="192.168.1.101",
run_at=NOW.replace(minute=31),
)
self.assertEqual(
second.desired,
(),
)
self.assertEqual(
store.load_desired_state(
CERTIFICATES
),
[],
)
self.assertEqual(
store.load_desired_state(
PROXY_HOSTS
),
[],
)
self.assertEqual(
store.load_desired_state(
DNS_RECORDS
),
[],
)
if __name__ == "__main__":
unittest.main()
+537
View File
@@ -289,6 +289,543 @@ class EndpointPlanningTests(unittest.TestCase):
) )
def test_certificate_provider_difference_is_noop_when_portable_state_matches(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
CERTIFICATES,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"nice_name": "source",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
)
current = Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"nice_name": "imported copy",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
),
observed_at=NOW,
)
plans = build_endpoint_plans(
[npm_adapter],
[desired],
[current],
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.NOOP,
)
def test_proxy_reference_change_is_update_even_when_payload_matches(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="new.example.com",
),
),
)
observations = [
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="old.example.com",
local_key="67",
payload={
"domain_names": ["old.example.com"],
},
),
observed_at=NOW,
),
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
),
observed_at=NOW,
),
]
plans = build_endpoint_plans(
[npm_adapter],
[desired],
observations,
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.UPDATE,
)
def test_planner_logicalizes_matching_local_reference_to_noop(self):
endpoint = Endpoint("npm", "node-two:81")
npm_adapter = FakeAdapter(
endpoint=endpoint,
capabilities=frozenset(
{
Capability(
PROXY_HOSTS,
CapabilityOperation.APPLY,
),
}
),
)
desired = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
observations = [
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
},
),
observed_at=NOW,
),
Observation(
endpoint_id=endpoint.identity,
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="84",
),
),
),
observed_at=NOW,
),
]
plans = build_endpoint_plans(
[npm_adapter],
[desired],
observations,
)
self.assertEqual(
plans[0].actions[0].operation,
PlanOperation.NOOP,
)
self.assertEqual(
plans[0].actions[0].current.references,
desired.references,
)
def test_older_certificate_is_replace_and_dependent_proxy_is_update(self):
endpoint = Endpoint(
"generic",
"node.example:443",
)
generic_adapter = adapter(
"generic",
"node.example:443",
CERTIFICATES,
PROXY_HOSTS,
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
current_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-20T00:00:00+00:00",
"expires_on": "2026-11-20T00:00:00+00:00",
},
)
current_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
plans = build_endpoint_plans(
[generic_adapter],
[
desired_certificate,
desired_proxy,
],
[
observed(
endpoint.identity,
current_certificate,
),
observed(
endpoint.identity,
current_proxy,
),
],
)
self.assertEqual(len(plans), 1)
actions = plans[0].actions
self.assertEqual(
[
(
action.operation,
action.desired.object_type,
)
for action in actions
],
[
(
PlanOperation.REPLACE,
CERTIFICATES,
),
(
PlanOperation.UPDATE,
PROXY_HOSTS,
),
],
)
self.assertEqual(
actions[1].reason,
"referenced object is being replaced",
)
def test_restart_duplicate_certificate_uses_desired_copy_and_rebinds_proxy(self):
endpoint = Endpoint(
"generic",
"node.example:443",
)
generic_adapter = adapter(
"generic",
"node.example:443",
CERTIFICATES,
PROXY_HOSTS,
)
desired_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
desired_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "https",
"forward_host": "backend.internal",
"forward_port": 443,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
old_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-08-20T00:00:00+00:00",
"expires_on": "2026-11-20T00:00:00+00:00",
},
)
new_certificate = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="84",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
current_proxy = NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload=dict(desired_proxy.payload),
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
)
plans = build_endpoint_plans(
[generic_adapter],
[
desired_certificate,
desired_proxy,
],
[
observed(
endpoint.identity,
old_certificate,
),
observed(
endpoint.identity,
new_certificate,
),
observed(
endpoint.identity,
current_proxy,
),
],
)
self.assertEqual(len(plans), 1)
plan = plans[0]
by_type = {
action.desired.object_type: action
for action in plan.actions
}
self.assertEqual(
by_type[CERTIFICATES].operation,
PlanOperation.NOOP,
)
self.assertEqual(
by_type[CERTIFICATES].current.local_key,
"84",
)
self.assertEqual(
by_type[PROXY_HOSTS].operation,
PlanOperation.UPDATE,
)
self.assertEqual(
by_type[PROXY_HOSTS].reason,
"referenced local object is being retired",
)
self.assertEqual(
tuple(
retirement.object.local_key
for retirement in plan.retirements
),
("67",),
)
def test_equivalent_duplicate_preserves_letsencrypt_copy(self):
endpoint = Endpoint(
"generic",
"node.example:443",
)
generic_adapter = adapter(
"generic",
"node.example:443",
CERTIFICATES,
)
desired = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
imported = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="90",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
native = NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"valid_from": "2026-09-20T00:00:00+00:00",
"expires_on": "2026-12-20T00:00:00+00:00",
},
)
plans = build_endpoint_plans(
[generic_adapter],
[desired],
[
observed(endpoint.identity, imported),
observed(endpoint.identity, native),
],
)
action = plans[0].actions[0]
self.assertEqual(
action.operation,
PlanOperation.NOOP,
)
self.assertEqual(
action.current.local_key,
"12",
)
self.assertEqual(
tuple(
retirement.object.local_key
for retirement in plans[0].retirements
),
("90",),
)
def test_duplicate_endpoint_objects_fail_without_payload_output(self): def test_duplicate_endpoint_objects_fail_without_payload_output(self):
desired = [ desired = [
dns_object("app.example.com", "192.168.1.101") dns_object("app.example.com", "192.168.1.101")
+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()
+304 -3
View File
@@ -4,12 +4,15 @@ from datetime import datetime, timezone
from dns_sync.adapters import ( from dns_sync.adapters import (
CERTIFICATES, CERTIFICATES,
DNS_RECORDS, DNS_RECORDS,
PROXY_HOSTS,
NormalizedObject, NormalizedObject,
ObjectReference,
Observation, Observation,
) )
from dns_sync.reconciliation import ( from dns_sync.reconciliation import (
ReconciliationConflict, ReconciliationConflict,
group_observations, group_observations,
logicalize_observation_references,
reconcile_observations, reconcile_observations,
) )
@@ -166,12 +169,12 @@ class DnsUnionPolicyTests(unittest.TestCase):
def test_missing_policy_fails_explicitly(self): def test_missing_policy_fails_explicitly(self):
observation = Observation( observation = Observation(
endpoint_id="npm://node-one:81", endpoint_id="generic://node-one",
object=NormalizedObject( object=NormalizedObject(
object_type=CERTIFICATES, object_type="unknown_objects",
logical_key="app.example.com", logical_key="app.example.com",
local_key="67", local_key="67",
payload={"expires_on": "2026-12-15"}, payload={},
), ),
observed_at=NOW, observed_at=NOW,
) )
@@ -182,6 +185,304 @@ class DnsUnionPolicyTests(unittest.TestCase):
): ):
reconcile_observations([observation]) reconcile_observations([observation])
def test_local_certificate_references_become_logical_references(self):
observations = [
Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="67",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
),
observed_at=NOW,
),
Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="67",
),
),
),
observed_at=NOW,
),
]
logicalized = logicalize_observation_references(
observations
)
proxy = next(
row.object
for row in logicalized
if row.object.object_type == PROXY_HOSTS
)
self.assertEqual(
proxy.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
def test_unresolved_local_reference_fails_closed(self):
observation = Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key="999",
),
),
),
observed_at=NOW,
)
with self.assertRaisesRegex(
ReconciliationConflict,
"Unresolved endpoint-local reference",
):
reconcile_observations([observation])
def test_certificate_policy_selects_newest_expiry(self):
observations = [
Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="10",
payload={
"domain_names": ["app.example.com"],
"provider": "other",
"nice_name": "older",
"valid_from": "2026-06-01T00:00:00+00:00",
"expires_on": "2026-09-01T00:00:00+00:00",
},
),
observed_at=NOW,
),
Observation(
endpoint_id="npm://node-two:81",
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="44",
payload={
"domain_names": ["app.example.com"],
"provider": "letsencrypt",
"nice_name": "newer",
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
),
observed_at=NOW,
),
]
desired = reconcile_observations(observations)
self.assertEqual(len(desired), 1)
self.assertEqual(
desired[0].payload["expires_on"],
"2026-12-01T00:00:00+00:00",
)
self.assertEqual(
desired[0].payload["provider"],
"letsencrypt",
)
self.assertIsNone(desired[0].local_key)
def test_certificate_policy_prefers_newer_valid_from_over_expiry(self):
observations = [
Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="10",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-08-01T00:00:00+00:00",
"expires_on": "2027-01-01T00:00:00+00:00",
},
),
observed_at=NOW,
),
Observation(
endpoint_id="npm://node-two:81",
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key="44",
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-15T00:00:00+00:00",
"expires_on": "2026-12-15T00:00:00+00:00",
},
),
observed_at=NOW,
),
]
desired = reconcile_observations(observations)
self.assertEqual(len(desired), 1)
self.assertEqual(
desired[0].payload["valid_from"],
"2026-09-15T00:00:00+00:00",
)
self.assertEqual(
desired[0].payload["expires_on"],
"2026-12-15T00:00:00+00:00",
)
def test_proxy_consensus_ignores_different_endpoint_local_certificate_ids(self):
observations = []
for endpoint_id, certificate_id, proxy_id in (
("npm://node-one:81", "67", "12"),
("npm://node-two:81", "84", "31"),
):
observations.extend(
[
Observation(
endpoint_id=endpoint_id,
object=NormalizedObject(
object_type=CERTIFICATES,
logical_key="app.example.com",
local_key=certificate_id,
payload={
"domain_names": ["app.example.com"],
"valid_from": "2026-09-01T00:00:00+00:00",
"expires_on": "2026-12-01T00:00:00+00:00",
},
),
observed_at=NOW,
),
Observation(
endpoint_id=endpoint_id,
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key=proxy_id,
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "backend.internal",
"forward_port": 8080,
"enabled": True,
},
references=(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
local_key=certificate_id,
),
),
),
observed_at=NOW,
),
]
)
desired = reconcile_observations(observations)
proxy = next(
object
for object in desired
if object.object_type == PROXY_HOSTS
)
self.assertEqual(
proxy.references,
(
ObjectReference(
relation="certificate",
object_type=CERTIFICATES,
logical_key="app.example.com",
),
),
)
def test_proxy_consensus_rejects_backend_drift_without_payload_output(self):
observations = [
Observation(
endpoint_id="npm://node-one:81",
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="12",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "one.internal",
"forward_port": 8080,
},
),
observed_at=NOW,
),
Observation(
endpoint_id="npm://node-two:81",
object=NormalizedObject(
object_type=PROXY_HOSTS,
logical_key="app.example.com",
local_key="31",
payload={
"domain_names": ["app.example.com"],
"forward_scheme": "http",
"forward_host": "two.internal",
"forward_port": 8080,
},
),
observed_at=NOW,
),
]
with self.assertRaises(ReconciliationConflict) as raised:
reconcile_observations(observations)
message = str(raised.exception)
self.assertIn("app.example.com", message)
self.assertIn("npm://node-one:81", message)
self.assertIn("npm://node-two:81", message)
self.assertNotIn("one.internal", message)
self.assertNotIn("two.internal", message)
def test_explicit_empty_policy_registry_uses_no_defaults(self): def test_explicit_empty_policy_registry_uses_no_defaults(self):
observation = dns_observation( observation = dns_observation(
"npm://node-one:81", "npm://node-one:81",
+511
View File
@@ -0,0 +1,511 @@
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 (
configured_adapter_endpoints,
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,
)
def test_builtin_adapter_supplies_configured_endpoints(self):
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
config = SimpleNamespace()
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm",
"NPM_ENDPOINTS": "192.168.1.3",
},
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__":
unittest.main()
+105 -38
View File
@@ -5,14 +5,18 @@ from unittest.mock import Mock, patch
from dns_sync.adapters import ( from dns_sync.adapters import (
DNS_RECORDS, DNS_RECORDS,
Capability, Capability,
Endpoint,
CapabilityOperation, CapabilityOperation,
NormalizedObject, NormalizedObject,
PlanAction, PlanAction,
PlanOperation, PlanOperation,
) )
from dns_sync.config import AppConfig from dns_sync.config import AppConfig
from dns_sync.models import ProviderRecord from external_plugins.verizon.client import VerizonRecord
from dns_sync.registry import load_adapter from dns_sync.registry import (
configured_adapter_endpoints,
load_adapter,
)
class VerizonAdapterTests(unittest.TestCase): class VerizonAdapterTests(unittest.TestCase):
@@ -20,23 +24,52 @@ class VerizonAdapterTests(unittest.TestCase):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3", "NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": "192.168.1.1", "VERIZON_ENDPOINTS": "192.168.1.1",
"ENABLED_PLUGINS": "npm,verizon",
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
}, },
clear=True, clear=True,
): ):
return AppConfig.from_env() return AppConfig.from_env()
def adapter(self, provider): def adapter(self, client):
config = self.config() config = self.config()
with patch(
"dns_sync.plugins.adapters.verizon.VerizonProvider", import dns_sync.registry as registry
return_value=provider,
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm,verizon",
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
},
clear=False,
): ):
module = registry._load_external_adapter_module(
"verizon"
)
if module is None:
raise AssertionError(
"external Verizon adapter was not found"
)
with patch.object(
module,
"VerizonClient",
return_value=client,
):
endpoint = configured_adapter_endpoints(
"verizon",
config,
)[0]
return load_adapter( return load_adapter(
"verizon", "verizon",
config, config,
config.verizon_endpoints[0], endpoint,
) )
@staticmethod @staticmethod
@@ -56,6 +89,32 @@ class VerizonAdapterTests(unittest.TestCase):
}, },
) )
def test_adapter_rejects_endpoint_for_other_plugin(self):
config = self.config()
endpoint = Endpoint(
"npm",
"192.168.1.3:81",
)
with patch.dict(
os.environ,
{
"ENABLED_PLUGINS": "npm,verizon",
"DNS_SYNC_PLUGIN_PATH": "external_plugins",
},
clear=False,
):
with self.assertRaisesRegex(
RuntimeError,
"cannot use endpoint",
):
load_adapter(
"verizon",
config,
endpoint,
)
def test_capabilities_cover_dns_discovery_apply_and_verify(self): def test_capabilities_cover_dns_discovery_apply_and_verify(self):
adapter = self.adapter(Mock()) adapter = self.adapter(Mock())
@@ -79,22 +138,30 @@ class VerizonAdapterTests(unittest.TestCase):
), ),
) )
def test_generic_adapter_uses_endpoint_not_router_url(self): def test_client_uses_explicit_endpoint(self):
with patch.dict( with patch.dict(
os.environ, os.environ,
{ {
"DNS_TARGET_IP": "192.168.1.101",
"NPM_ENDPOINTS": "192.168.1.3", "NPM_ENDPOINTS": "192.168.1.3",
"VERIZON_ENDPOINTS": "192.168.1.1:8443", "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_URL": "https://192.168.1.254",
"ROUTER_PASSWORD": "not-a-real-password", "ROUTER_PASSWORD": "not-a-real-password",
}, },
clear=True, clear=True,
): ):
config = AppConfig.from_env() config = AppConfig.from_env()
endpoint = configured_adapter_endpoints(
"verizon",
config,
)[0]
adapter = load_adapter( adapter = load_adapter(
"verizon", "verizon",
config, config,
config.verizon_endpoints[0], endpoint,
) )
self.assertEqual( self.assertEqual(
@@ -102,33 +169,33 @@ class VerizonAdapterTests(unittest.TestCase):
"verizon://192.168.1.1:8443", "verizon://192.168.1.1:8443",
) )
self.assertEqual( self.assertEqual(
adapter.provider.router_url, adapter.client.router_url,
"https://192.168.1.1:8443", "https://192.168.1.1:8443",
) )
def test_connect_delegates_to_provider(self): def test_connect_delegates_to_client(self):
provider = Mock() client = Mock()
adapter = self.adapter(provider) adapter = self.adapter(client)
adapter.connect() adapter.connect()
provider.connect.assert_called_once_with() client.connect.assert_called_once_with()
def test_discovers_dns_records_with_slot_local_keys(self): def test_discovers_dns_records_with_slot_local_keys(self):
provider = Mock() client = Mock()
provider.list_records.return_value = { client.list_records.return_value = {
"second.example.com": ProviderRecord( "second.example.com": VerizonRecord(
hostname="second.example.com", hostname="second.example.com",
ip="192.168.1.101", ip="192.168.1.101",
metadata={"slot": 42}, metadata={"slot": 42},
), ),
"first.example.com": ProviderRecord( "first.example.com": VerizonRecord(
hostname="first.example.com", hostname="first.example.com",
ip="192.168.1.101", ip="192.168.1.101",
metadata={"slot": 41}, metadata={"slot": 41},
), ),
} }
adapter = self.adapter(provider) adapter = self.adapter(client)
discovered = adapter.discover(DNS_RECORDS) discovered = adapter.discover(DNS_RECORDS)
@@ -146,17 +213,17 @@ class VerizonAdapterTests(unittest.TestCase):
) )
def test_create_and_update_delegate_without_backend_ip(self): def test_create_and_update_delegate_without_backend_ip(self):
provider = Mock() client = Mock()
provider.add_record.return_value = "created" client.add_record.return_value = "created"
provider.update_record.return_value = "updated" client.update_record.return_value = "updated"
provider.list_records.return_value = { client.list_records.return_value = {
"app.example.com": ProviderRecord( "app.example.com": VerizonRecord(
hostname="app.example.com", hostname="app.example.com",
ip="192.168.1.101", ip="192.168.1.101",
metadata={"slot": 41}, metadata={"slot": 41},
), ),
} }
adapter = self.adapter(provider) adapter = self.adapter(client)
desired = self.dns_object(ip="192.168.1.101") desired = self.dns_object(ip="192.168.1.101")
create_result = adapter.apply( create_result = adapter.apply(
@@ -191,17 +258,17 @@ class VerizonAdapterTests(unittest.TestCase):
"updated", "updated",
) )
self.assertEqual( self.assertEqual(
provider.add_record.call_args.args[0].ip, client.add_record.call_args.args[0].ip,
"192.168.1.101", "192.168.1.101",
) )
self.assertEqual( self.assertEqual(
provider.update_record.call_args.args[0].ip, client.update_record.call_args.args[0].ip,
"192.168.1.101", "192.168.1.101",
) )
def test_noop_does_not_mutate_router(self): def test_noop_does_not_mutate_router(self):
provider = Mock() client = Mock()
adapter = self.adapter(provider) adapter = self.adapter(client)
result = adapter.apply( result = adapter.apply(
PlanAction( PlanAction(
@@ -212,29 +279,29 @@ class VerizonAdapterTests(unittest.TestCase):
) )
self.assertIsNone(result) self.assertIsNone(result)
provider.add_record.assert_not_called() client.add_record.assert_not_called()
provider.update_record.assert_not_called() client.update_record.assert_not_called()
def test_verify_delegates_expected_dns_record(self): def test_verify_delegates_expected_dns_record(self):
provider = Mock() client = Mock()
provider.verify_record.return_value = True client.verify_record.return_value = True
adapter = self.adapter(provider) adapter = self.adapter(client)
verified = adapter.verify(self.dns_object()) verified = adapter.verify(self.dns_object())
self.assertTrue(verified) self.assertTrue(verified)
record = provider.verify_record.call_args.args[0] record = client.verify_record.call_args.args[0]
self.assertEqual(record.hostname, "app.example.com") self.assertEqual(record.hostname, "app.example.com")
self.assertEqual(record.ip, "192.168.1.101") self.assertEqual(record.ip, "192.168.1.101")
def test_unsupported_objects_actions_and_wildcards_fail(self): def test_unsupported_objects_actions_and_wildcards_fail(self):
provider = Mock() client = Mock()
adapter = self.adapter(provider) adapter = self.adapter(client)
with self.assertRaisesRegex(RuntimeError, "cannot discover"): with self.assertRaisesRegex(RuntimeError, "cannot discover"):
adapter.discover("certificates") adapter.discover("certificates")
with self.assertRaisesRegex(RuntimeError, "different endpoint"): with self.assertRaisesRegex(RuntimeError, "does not belong"):
adapter.apply( adapter.apply(
PlanAction( PlanAction(
endpoint_id="verizon://different:443", endpoint_id="verizon://different:443",