Remove legacy source provider sync path

This commit is contained in:
Lyncoln
2026-09-20 20:36:09 -04:00
parent ed97b644e8
commit 71d8b34793
15 changed files with 22 additions and 399 deletions
+10 -20
View File
@@ -4,27 +4,17 @@ A plugin-based DNS reconciliation service.
## Plugin contract
Source modules live under `dns_sync/plugins/sources/` and export a class named `Plugin` with:
The reconciliation runtime is adapter-based.
- `name`
- `discover() -> list[DesiredRecord]`
Adapter plugins expose:
Provider modules live under `dns_sync/plugins/providers/` and export `Plugin` with:
- `Plugin`
- `configured_endpoints(config)`
- `name`
- `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:
- `PROXY=npm` loads `dns_sync.plugins.sources.npm.Plugin`
- `DNS_PROVIDER=verizon` loads `dns_sync.plugins.providers.verizon.Plugin`
Each adapter declares object-specific discovery, apply, and verification
capabilities. 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`.
## Plugins
@@ -109,8 +99,8 @@ ROUTER_PASSWORD=...
## Domain filtering
Filtering is performed by the central engine after source discovery and before
reconciliation, so all source plugins get identical behavior.
Filtering is performed centrally after adapter discovery and before
reconciliation, so endpoint-specific adapters get identical behavior.
```text
DOMAIN_MATCHING=true
-88
View File
@@ -3,13 +3,6 @@ import os
import sys
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 .orchestrator import discover_reconcile_plan
from .persistence import SQLiteStateStore
@@ -17,8 +10,6 @@ from .registry import (
configured_adapter_endpoints,
enabled_plugins,
load_adapter,
load_provider,
load_source,
)
from .reporting import print_endpoint_plans, print_plan
@@ -41,10 +32,6 @@ def build_parser():
parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider")
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",
help="Run the persistent generic reconciliation engine",
@@ -74,77 +61,9 @@ def build_parser():
help="Maximum planned mutations allowed with --apply",
)
sub.add_parser("plugins", help="Show the configured source/provider plugins")
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):
adapters = []
@@ -275,15 +194,8 @@ def main():
parser = build_parser()
args = parser.parse_args()
try:
if args.command == "sync":
return run_sync(args)
if args.command == "reconcile":
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")
except Exception as exc:
print(f"ERROR: {exc}", file=sys.stderr)
-7
View File
@@ -97,8 +97,6 @@ def env_bool(name: str, default: bool) -> bool:
@dataclass(frozen=True)
class AppConfig:
source_name: str
provider_name: str
npm_endpoints: tuple[Endpoint, ...]
proxy_ip: str
proxy_api_port: int
@@ -118,9 +116,6 @@ class AppConfig:
@classmethod
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()
try:
port = int(os.getenv("PROXY_API_PORT", "81"))
except ValueError as exc:
@@ -185,8 +180,6 @@ class AppConfig:
)
return cls(
source_name=source_name,
provider_name=provider_name,
npm_endpoints=npm_endpoints,
proxy_ip=proxy_ip,
proxy_api_port=port,
-110
View File
@@ -1,110 +0,0 @@
from .models import DesiredRecord, ProviderRecord, SyncAction
def hostname_matches_domain(hostname: str, domain: str) -> bool:
"""Match a domain apex and any subdomain, without suffix false positives."""
candidate = hostname.lower().rstrip(".")
if candidate.startswith("*."):
candidate = candidate[2:]
base = domain.lower().rstrip(".")
return candidate == base or candidate.endswith("." + base)
def filter_desired_by_domain(
records: list[DesiredRecord],
domain: str | None,
enabled: bool,
) -> tuple[list[DesiredRecord], int]:
if not enabled:
return records, 0
if not domain:
raise RuntimeError(
"Domain filtering enabled without a configured DOMAIN"
)
matched = [
record
for record in records
if hostname_matches_domain(record.hostname, domain)
]
return matched, len(records) - len(matched)
def validate_desired(records: list[DesiredRecord]) -> tuple[dict[str, DesiredRecord], list[str]]:
desired: dict[str, DesiredRecord] = {}
warnings: list[str] = []
for record in records:
key = record.hostname.lower()
if key in desired:
warnings.append(
f"DUPLICATE {record.hostname} from {record.source}"
+ (f" id={record.source_id}" if record.source_id else "")
+ "; keeping first occurrence"
)
continue
desired[key] = record
return dict(sorted(desired.items())), warnings
def build_sync_plan(desired, current: dict[str, ProviderRecord], capabilities):
plan: list[SyncAction] = []
for hostname, record in desired.items():
if record.record_type not in capabilities.record_types:
plan.append(SyncAction("SKIP", record, reason=f"record type {record.record_type} unsupported"))
continue
if record.wildcard and not capabilities.wildcard_records:
plan.append(SyncAction("SKIP", record, reason="wildcard records unsupported"))
continue
existing = current.get(hostname.lower())
if existing is None:
if not capabilities.supports_create:
plan.append(SyncAction("SKIP", record, reason="provider cannot create records"))
else:
plan.append(SyncAction("ADD", record))
elif existing.ip == record.ip and existing.record_type == record.record_type:
plan.append(SyncAction("UNCHANGED", record, current=existing))
elif not capabilities.supports_update:
plan.append(SyncAction("SKIP", record, current=existing, reason="provider cannot update records"))
else:
plan.append(SyncAction("UPDATE", record, current=existing))
return plan
def summarize_plan(plan):
counts = {name: 0 for name in ("ADD", "UPDATE", "UNCHANGED", "SKIP")}
for item in plan:
counts[item.action] = counts.get(item.action, 0) + 1
return counts
def execute_sync(provider, plan):
counts = summarize_plan(plan)
for item in plan:
if item.action in {"UNCHANGED", "SKIP"}:
continue
if item.action == "ADD":
detail = provider.add_record(item.desired)
elif item.action == "UPDATE":
detail = provider.update_record(item.desired)
else:
raise RuntimeError(f"Unknown sync action: {item.action}")
if not provider.verify_record(item.desired):
raise RuntimeError(f"{item.desired.hostname}: {item.action.lower()} did not verify")
suffix = f" ({detail})" if detail else ""
print(f"APPLIED {item.action:<6} {item.desired.hostname} -> {item.desired.ip}{suffix}")
return counts
-22
View File
@@ -8,7 +8,6 @@ from .adapters import (
NormalizedObject,
PlanAction,
)
from .models import DesiredRecord, ProviderCapabilities, ProviderRecord
class AdapterPlugin(Protocol):
@@ -20,24 +19,3 @@ class AdapterPlugin(Protocol):
def discover(self, object_type: str) -> list[DiscoveredObject]: ...
def apply(self, action: PlanAction) -> ApplyResult | None: ...
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]: ...
-8
View File
@@ -28,11 +28,3 @@ class ProviderCapabilities:
supports_update: bool = True
supports_delete: bool = False
max_records: int | None = None
@dataclass(frozen=True)
class SyncAction:
action: str
desired: DesiredRecord
current: ProviderRecord | None = None
reason: str | None = None
+10
View File
@@ -1,6 +1,16 @@
import ipaddress
import 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])?$")
+1 -1
View File
@@ -15,7 +15,7 @@ from .adapters import (
NormalizedObject,
Observation,
)
from .engine import hostname_matches_domain
from .normalizer import hostname_matches_domain
from .object_normalizer import normalize_observations
from .persistence import SQLiteStateStore
from .projections import project_proxy_hosts_to_dns
-39
View File
@@ -1,39 +0,0 @@
import os
import yaml
from ...models import DesiredRecord
from ...normalizer import normalize_hostname, normalize_ipv4
class Plugin:
name = "file"
def __init__(self, config):
self.config = config
self.path = os.getenv("DNS_SYNC_FILE")
if not self.path:
raise RuntimeError("DNS_SYNC_FILE is required when PROXY=file")
def discover(self):
try:
with open(self.path, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except (OSError, yaml.YAMLError) as exc:
raise RuntimeError(f"Could not read desired-state file {self.path}: {exc}") from exc
if not isinstance(data, dict) or not isinstance(data.get("records"), dict):
raise RuntimeError("Desired-state YAML must contain a top-level records mapping")
result = []
for raw_hostname, raw_ip in data["records"].items():
hostname, wildcard = normalize_hostname(str(raw_hostname), allow_wildcard=True)
result.append(
DesiredRecord(
hostname=hostname,
ip=normalize_ipv4(str(raw_ip), hostname),
wildcard=wildcard,
source=self.name,
)
)
return result
-41
View File
@@ -1,41 +0,0 @@
from ...models import DesiredRecord
from ...normalizer import normalize_hostname
from ...npm_client import NpmClient
class Plugin:
name = "npm"
def __init__(self, config):
self.config = config
self.client = NpmClient(config)
def discover(self):
records: list[DesiredRecord] = []
for host in self.client.proxy_hosts():
enabled = bool(host.get("enabled", False))
if not enabled and not self.config.include_disabled:
continue
source_id = str(host.get("id", "")) or None
for raw_domain in host.get("domain_names", []):
hostname, wildcard = normalize_hostname(
str(raw_domain),
allow_wildcard=True,
)
records.append(
DesiredRecord(
hostname=hostname,
ip=self.config.target_ip,
record_type="A",
wildcard=wildcard,
source=self.name,
source_id=source_id,
)
)
return records
-8
View File
@@ -258,14 +258,6 @@ def _load(kind: str, name: str, config, *args):
)
def load_source(name: str, config):
return _load("sources", name, config)
def load_provider(name: str, config):
return _load("providers", name, config)
def load_adapter(name: str, config, endpoint):
return _load(
"adapters",
+1 -1
View File
@@ -59,7 +59,7 @@ class Plugin:
self.password = os.getenv("ROUTER_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.session = requests.Session()
-54
View File
@@ -1,54 +0,0 @@
import unittest
from dns_sync.engine import build_sync_plan
from dns_sync.models import (
DesiredRecord,
ProviderCapabilities,
ProviderRecord,
)
class LegacyDnsEngineTests(unittest.TestCase):
def test_existing_dns_plan_still_adds_updates_and_preserves_matches(self):
desired = {
"add.example.com": DesiredRecord(
hostname="add.example.com",
ip="192.168.1.101",
),
"keep.example.com": DesiredRecord(
hostname="keep.example.com",
ip="192.168.1.101",
),
"update.example.com": DesiredRecord(
hostname="update.example.com",
ip="192.168.1.101",
),
}
current = {
"keep.example.com": ProviderRecord(
hostname="keep.example.com",
ip="192.168.1.101",
),
"update.example.com": ProviderRecord(
hostname="update.example.com",
ip="192.168.1.50",
),
}
plan = build_sync_plan(
desired,
current,
ProviderCapabilities(),
)
actions = {
item.desired.hostname: item.action
for item in plan
}
self.assertEqual(actions["add.example.com"], "ADD")
self.assertEqual(actions["keep.example.com"], "UNCHANGED")
self.assertEqual(actions["update.example.com"], "UPDATE")
if __name__ == "__main__":
unittest.main()