Compare commits
23
Commits
44489db3ff
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f44522d7b5 | ||
|
|
665956f8ee | ||
|
|
0d0e4e02ae | ||
|
|
1a7538501b | ||
|
|
0719671820 | ||
|
|
c9a3898055 | ||
|
|
332f994c26 | ||
|
|
71d8b34793 | ||
|
|
ed97b644e8 | ||
|
|
16359dc164 | ||
|
|
832928159d | ||
|
|
d4ceb7e1df | ||
|
|
d6ad917b57 | ||
|
|
572dd9eae4 | ||
|
|
a93e067bfd | ||
|
|
e4ca80d4d5 | ||
|
|
291f4f492e | ||
|
|
387332b5db | ||
|
|
2668651a98 | ||
|
|
4b094820b2 | ||
|
|
3022b76876 | ||
|
|
c4cdc92865 | ||
|
|
6ec4f2932e |
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
+35
-98
@@ -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(
|
||||||
return [
|
plugin_name,
|
||||||
load_adapter(endpoint.plugin, config, endpoint)
|
config,
|
||||||
for endpoint in endpoints
|
):
|
||||||
]
|
adapters.append(
|
||||||
|
load_adapter(
|
||||||
|
plugin_name,
|
||||||
|
config,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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]: ...
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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])?$")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+333
-17
@@ -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(
|
||||||
projected_dns = project_proxy_hosts_to_dns(
|
adapter_list,
|
||||||
proxy_observations,
|
store,
|
||||||
target_ip,
|
timestamp,
|
||||||
include_disabled=include_disabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
]
|
||||||
|
|
||||||
|
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,
|
||||||
|
target_ip,
|
||||||
|
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)
|
||||||
DNS_RECORDS,
|
if projected_dns
|
||||||
desired,
|
else []
|
||||||
timestamp,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
plans = build_endpoint_plans(
|
plans = build_endpoint_plans(
|
||||||
adapter_list,
|
_planning_adapter_views(
|
||||||
|
adapter_list,
|
||||||
|
live_pairs,
|
||||||
|
),
|
||||||
desired,
|
desired,
|
||||||
observations,
|
observations,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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}"
|
|
||||||
)
|
|
||||||
@@ -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],
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
+607
-13
@@ -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,23 +690,62 @@ 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:
|
||||||
raise PlanningConflict(
|
selector = (
|
||||||
f"Multiple endpoint objects for {endpoint_id} "
|
getattr(
|
||||||
f"{object_type} {logical_key!r}"
|
policy,
|
||||||
|
"select_current",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if policy is not None
|
||||||
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
current = (
|
if selector is None:
|
||||||
current_rows[0].object
|
raise PlanningConflict(
|
||||||
if current_rows
|
f"Multiple endpoint objects for {endpoint_id} "
|
||||||
else None
|
f"{object_type} {logical_key!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
current, retirements = selector(
|
||||||
|
tuple(current_rows),
|
||||||
|
desired,
|
||||||
|
)
|
||||||
|
|
||||||
|
planned_retirements.extend(
|
||||||
|
retirements
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
current = (
|
||||||
|
current_rows[0].object
|
||||||
|
if current_rows
|
||||||
|
else None
|
||||||
|
)
|
||||||
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
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+245
-12
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
plugin_class = getattr(module, "Plugin", None)
|
||||||
|
|
||||||
if plugin_class is None:
|
if plugin_class is None:
|
||||||
raise RuntimeError(f"Plugin module {path} does not export Plugin")
|
raise RuntimeError(
|
||||||
|
f"Plugin module {display_path} does not export Plugin"
|
||||||
|
)
|
||||||
|
|
||||||
return plugin_class(config, *args)
|
return plugin_class
|
||||||
|
|
||||||
|
|
||||||
def load_source(name: str, config):
|
def _load(kind: str, name: str, config, *args):
|
||||||
return _load("sources", name, config)
|
normalized = _validate_plugin_name(kind, name)
|
||||||
|
|
||||||
|
if kind == "adapters":
|
||||||
|
(
|
||||||
|
normalized,
|
||||||
|
module,
|
||||||
|
display_path,
|
||||||
|
) = _adapter_module(normalized)
|
||||||
|
|
||||||
def load_provider(name: str, config):
|
plugin_class = _plugin_class(
|
||||||
return _load("providers", name, config)
|
module,
|
||||||
|
display_path,
|
||||||
|
)
|
||||||
|
return plugin_class(config, *args)
|
||||||
|
|
||||||
|
module = _load_builtin(kind, normalized)
|
||||||
|
|
||||||
|
if module is not None:
|
||||||
|
plugin_class = _plugin_class(
|
||||||
|
module,
|
||||||
|
f"dns_sync.plugins.{kind}.{normalized}",
|
||||||
|
)
|
||||||
|
return plugin_class(config, *args)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unknown {kind[:-1]} plugin: {normalized}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# dns-sync plugin template
|
||||||
|
|
||||||
|
This directory is a documentation/example package. dns-sync does not load it
|
||||||
|
from this location.
|
||||||
|
|
||||||
|
Copy it to one of these locations and rename it for the application:
|
||||||
|
|
||||||
|
dns_sync/plugins/adapters/<plugin_name>/
|
||||||
|
|
||||||
|
for a built-in plugin, or:
|
||||||
|
|
||||||
|
$DNS_SYNC_PLUGIN_PATH/<plugin_name>/
|
||||||
|
|
||||||
|
for an external plugin.
|
||||||
|
|
||||||
|
The canonical package layout is:
|
||||||
|
|
||||||
|
<plugin_name>/
|
||||||
|
├── __init__.py
|
||||||
|
└── client.py
|
||||||
|
|
||||||
|
`__init__.py` is the dns-sync adapter boundary. It owns endpoint discovery,
|
||||||
|
generic capabilities, native-to-normalized translation, apply, verify, and
|
||||||
|
optional retirement behavior.
|
||||||
|
|
||||||
|
`client.py` is private to the plugin. It owns authentication, transport,
|
||||||
|
application-specific models, native request/response handling, and endpoint
|
||||||
|
operations.
|
||||||
|
|
||||||
|
## Before enabling a copied plugin
|
||||||
|
|
||||||
|
1. Rename `example`, `ExampleClient`, and `ExampleRecord`.
|
||||||
|
2. Implement `configured_endpoints(config)`.
|
||||||
|
3. Implement the native client methods.
|
||||||
|
4. Declare only capabilities the plugin really supports.
|
||||||
|
5. Implement discovery for those object types.
|
||||||
|
6. Translate normalized desired state into native state in `apply()`.
|
||||||
|
7. Re-discover mutations and return the real endpoint-local key.
|
||||||
|
8. Implement `verify()`.
|
||||||
|
9. Add `retire()` only when retirement/deletion is safely supported.
|
||||||
|
10. Add focused adapter/client tests.
|
||||||
|
11. Validate using read-only reconciliation before enabling mutations.
|
||||||
|
|
||||||
|
See the root `README.md` for the complete plugin contract and architecture.
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Canonical dns-sync adapter plugin template."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from dns_sync.adapter_support import (
|
||||||
|
require_action_endpoint,
|
||||||
|
require_desired,
|
||||||
|
require_endpoint_plugin,
|
||||||
|
)
|
||||||
|
from dns_sync.adapters import (
|
||||||
|
DNS_RECORDS,
|
||||||
|
ApplyResult,
|
||||||
|
Capability,
|
||||||
|
CapabilityOperation,
|
||||||
|
DiscoveredObject,
|
||||||
|
NormalizedObject,
|
||||||
|
PlanAction,
|
||||||
|
PlanOperation,
|
||||||
|
)
|
||||||
|
from dns_sync.config import parse_endpoint_list
|
||||||
|
|
||||||
|
from .client import ExampleClient, ExampleRecord
|
||||||
|
|
||||||
|
|
||||||
|
PLUGIN_NAME = "example"
|
||||||
|
|
||||||
|
|
||||||
|
def configured_endpoints(config):
|
||||||
|
"""Return every endpoint owned by this plugin."""
|
||||||
|
return parse_endpoint_list(
|
||||||
|
PLUGIN_NAME,
|
||||||
|
os.getenv(
|
||||||
|
"EXAMPLE_ENDPOINTS",
|
||||||
|
"example.test",
|
||||||
|
),
|
||||||
|
443,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin:
|
||||||
|
"""dns-sync adapter for one configured endpoint."""
|
||||||
|
|
||||||
|
name = PLUGIN_NAME
|
||||||
|
|
||||||
|
capabilities = frozenset(
|
||||||
|
{
|
||||||
|
Capability(
|
||||||
|
DNS_RECORDS,
|
||||||
|
CapabilityOperation.DISCOVER,
|
||||||
|
),
|
||||||
|
Capability(
|
||||||
|
DNS_RECORDS,
|
||||||
|
CapabilityOperation.APPLY,
|
||||||
|
),
|
||||||
|
Capability(
|
||||||
|
DNS_RECORDS,
|
||||||
|
CapabilityOperation.VERIFY,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, config, endpoint):
|
||||||
|
require_endpoint_plugin(
|
||||||
|
endpoint,
|
||||||
|
self.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.endpoint = endpoint
|
||||||
|
self.client = ExampleClient(endpoint)
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
self.client.connect()
|
||||||
|
|
||||||
|
def discover(
|
||||||
|
self,
|
||||||
|
object_type: str,
|
||||||
|
) -> list[DiscoveredObject]:
|
||||||
|
if object_type != DNS_RECORDS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{self.endpoint.identity} cannot discover "
|
||||||
|
f"object type {object_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
DiscoveredObject(
|
||||||
|
object_type=DNS_RECORDS,
|
||||||
|
local_key=record.local_key,
|
||||||
|
payload={
|
||||||
|
"hostname": record.hostname,
|
||||||
|
"ip": record.ip,
|
||||||
|
"record_type": record.record_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for record in self.client.list_records()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _native_record(
|
||||||
|
self,
|
||||||
|
object: NormalizedObject,
|
||||||
|
) -> ExampleRecord:
|
||||||
|
if object.object_type != DNS_RECORDS:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{self.endpoint.identity} cannot handle "
|
||||||
|
f"object type {object.object_type!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = object.payload
|
||||||
|
hostname = str(payload.get("hostname") or "")
|
||||||
|
|
||||||
|
if hostname != object.logical_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
"DNS object hostname does not match its logical key"
|
||||||
|
)
|
||||||
|
|
||||||
|
record_type = str(
|
||||||
|
payload.get("record_type") or ""
|
||||||
|
).upper()
|
||||||
|
|
||||||
|
if record_type != "A":
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Example plugin does not support "
|
||||||
|
f"{record_type!r} records"
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("wildcard") is True:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Example plugin does not support wildcard records"
|
||||||
|
)
|
||||||
|
|
||||||
|
return ExampleRecord(
|
||||||
|
local_key=object.local_key or "",
|
||||||
|
hostname=hostname,
|
||||||
|
ip=str(payload.get("ip") or ""),
|
||||||
|
record_type=record_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
action: PlanAction,
|
||||||
|
) -> ApplyResult | None:
|
||||||
|
require_action_endpoint(
|
||||||
|
action,
|
||||||
|
self.endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
if action.operation in {
|
||||||
|
PlanOperation.NOOP,
|
||||||
|
PlanOperation.SKIP,
|
||||||
|
}:
|
||||||
|
return None
|
||||||
|
|
||||||
|
desired = require_desired(
|
||||||
|
action,
|
||||||
|
context="Example apply action",
|
||||||
|
)
|
||||||
|
|
||||||
|
record = self._native_record(desired)
|
||||||
|
|
||||||
|
if action.operation == PlanOperation.CREATE:
|
||||||
|
detail = self.client.add_record(record)
|
||||||
|
elif action.operation == PlanOperation.UPDATE:
|
||||||
|
detail = self.client.update_record(record)
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Example adapter does not support "
|
||||||
|
f"{action.operation.value} actions"
|
||||||
|
)
|
||||||
|
|
||||||
|
records = self.client.list_records()
|
||||||
|
applied = next(
|
||||||
|
(
|
||||||
|
candidate
|
||||||
|
for candidate in records
|
||||||
|
if candidate.hostname.lower()
|
||||||
|
== record.hostname.lower()
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if applied is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Example mutation succeeded but the object "
|
||||||
|
"could not be rediscovered"
|
||||||
|
)
|
||||||
|
|
||||||
|
normalized = NormalizedObject(
|
||||||
|
object_type=desired.object_type,
|
||||||
|
logical_key=desired.logical_key,
|
||||||
|
local_key=applied.local_key,
|
||||||
|
payload=dict(desired.payload),
|
||||||
|
references=desired.references,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ApplyResult(
|
||||||
|
object=normalized,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
def verify(
|
||||||
|
self,
|
||||||
|
object: NormalizedObject,
|
||||||
|
) -> bool:
|
||||||
|
return self.client.verify_record(
|
||||||
|
self._native_record(object)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add retire(self, request) only when this plugin supports safe
|
||||||
|
# deletion/replacement retirement.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Private native client for the example dns-sync plugin."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from dns_sync.adapters import Endpoint
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExampleRecord:
|
||||||
|
"""Endpoint-native record used only by this plugin."""
|
||||||
|
|
||||||
|
local_key: str
|
||||||
|
hostname: str
|
||||||
|
ip: str
|
||||||
|
record_type: str = "A"
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleClient:
|
||||||
|
"""Client for one explicit example endpoint."""
|
||||||
|
|
||||||
|
def __init__(self, endpoint: Endpoint):
|
||||||
|
if endpoint.plugin != "example":
|
||||||
|
raise RuntimeError(
|
||||||
|
"ExampleClient requires an example endpoint"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.endpoint = endpoint
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
"""Authenticate or initialize the native connection."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def list_records(self) -> list[ExampleRecord]:
|
||||||
|
"""Return endpoint-native records."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def add_record(
|
||||||
|
self,
|
||||||
|
record: ExampleRecord,
|
||||||
|
) -> str | None:
|
||||||
|
"""Create one native record."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def update_record(
|
||||||
|
self,
|
||||||
|
record: ExampleRecord,
|
||||||
|
) -> str | None:
|
||||||
|
"""Update one native record."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_record(
|
||||||
|
self,
|
||||||
|
record: ExampleRecord,
|
||||||
|
) -> bool:
|
||||||
|
"""Verify endpoint-native state."""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -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,18 +127,17 @@ 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 {
|
||||||
PlanOperation.NOOP,
|
PlanOperation.NOOP,
|
||||||
@@ -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:
|
if endpoint.plugin != "verizon":
|
||||||
self.router_url = os.getenv(
|
raise RuntimeError(
|
||||||
"ROUTER_URL",
|
"VerizonClient requires a verizon endpoint"
|
||||||
"https://192.168.1.1",
|
)
|
||||||
).rstrip("/")
|
|
||||||
else:
|
self.router_url = f"https://{endpoint.address}"
|
||||||
if endpoint.plugin != "verizon":
|
|
||||||
raise RuntimeError(
|
|
||||||
"Verizon provider requires a verizon endpoint"
|
|
||||||
)
|
|
||||||
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")
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
|
||||||
+1005
-130
File diff suppressed because it is too large
Load Diff
+100
-27
@@ -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 = {
|
||||||
|
|||||||
@@ -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",),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
+108
-41
@@ -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,25 +24,54 @@ 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,
|
||||||
):
|
):
|
||||||
return load_adapter(
|
module = registry._load_external_adapter_module(
|
||||||
"verizon",
|
"verizon"
|
||||||
config,
|
|
||||||
config.verizon_endpoints[0],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
"verizon",
|
||||||
|
config,
|
||||||
|
endpoint,
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def dns_object(
|
def dns_object(
|
||||||
hostname="app.example.com",
|
hostname="app.example.com",
|
||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user