From 71d8b3479360d0104fdd8af439d2b5d088202208 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sun, 20 Sep 2026 20:36:09 -0400 Subject: [PATCH] Remove legacy source provider sync path --- README.md | 30 +++---- dns_sync/cli.py | 88 -------------------- dns_sync/config.py | 7 -- dns_sync/engine.py | 110 ------------------------- dns_sync/interfaces.py | 22 ----- dns_sync/models.py | 8 -- dns_sync/normalizer.py | 10 +++ dns_sync/orchestrator.py | 2 +- dns_sync/plugins/providers/__init__.py | 0 dns_sync/plugins/sources/__init__.py | 0 dns_sync/plugins/sources/file.py | 39 --------- dns_sync/plugins/sources/npm.py | 41 --------- dns_sync/registry.py | 8 -- external_plugins/verizon/provider.py | 2 +- tests/test_legacy_dns_engine.py | 54 ------------ 15 files changed, 22 insertions(+), 399 deletions(-) delete mode 100644 dns_sync/engine.py delete mode 100644 dns_sync/plugins/providers/__init__.py delete mode 100644 dns_sync/plugins/sources/__init__.py delete mode 100644 dns_sync/plugins/sources/file.py delete mode 100644 dns_sync/plugins/sources/npm.py delete mode 100644 tests/test_legacy_dns_engine.py diff --git a/README.md b/README.md index 031c69d..2e0214d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/dns_sync/cli.py b/dns_sync/cli.py index 26a330e..860eaf7 100644 --- a/dns_sync/cli.py +++ b/dns_sync/cli.py @@ -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) diff --git a/dns_sync/config.py b/dns_sync/config.py index 215417c..4532d81 100644 --- a/dns_sync/config.py +++ b/dns_sync/config.py @@ -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, diff --git a/dns_sync/engine.py b/dns_sync/engine.py deleted file mode 100644 index 976ea8b..0000000 --- a/dns_sync/engine.py +++ /dev/null @@ -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 diff --git a/dns_sync/interfaces.py b/dns_sync/interfaces.py index eb08ea2..53ddce1 100644 --- a/dns_sync/interfaces.py +++ b/dns_sync/interfaces.py @@ -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]: ... diff --git a/dns_sync/models.py b/dns_sync/models.py index ae999a7..60aebec 100644 --- a/dns_sync/models.py +++ b/dns_sync/models.py @@ -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 diff --git a/dns_sync/normalizer.py b/dns_sync/normalizer.py index 73d9bf5..581f6e9 100644 --- a/dns_sync/normalizer.py +++ b/dns_sync/normalizer.py @@ -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])?$") diff --git a/dns_sync/orchestrator.py b/dns_sync/orchestrator.py index 9d19fc6..e33793f 100644 --- a/dns_sync/orchestrator.py +++ b/dns_sync/orchestrator.py @@ -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 diff --git a/dns_sync/plugins/providers/__init__.py b/dns_sync/plugins/providers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dns_sync/plugins/sources/__init__.py b/dns_sync/plugins/sources/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/dns_sync/plugins/sources/file.py b/dns_sync/plugins/sources/file.py deleted file mode 100644 index c30a85d..0000000 --- a/dns_sync/plugins/sources/file.py +++ /dev/null @@ -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 diff --git a/dns_sync/plugins/sources/npm.py b/dns_sync/plugins/sources/npm.py deleted file mode 100644 index 1df1b53..0000000 --- a/dns_sync/plugins/sources/npm.py +++ /dev/null @@ -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 diff --git a/dns_sync/registry.py b/dns_sync/registry.py index 7a788ff..d57f948 100644 --- a/dns_sync/registry.py +++ b/dns_sync/registry.py @@ -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", diff --git a/external_plugins/verizon/provider.py b/external_plugins/verizon/provider.py index 91bfbe5..167b8c9 100644 --- a/external_plugins/verizon/provider.py +++ b/external_plugins/verizon/provider.py @@ -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() diff --git a/tests/test_legacy_dns_engine.py b/tests/test_legacy_dns_engine.py deleted file mode 100644 index ca3a8ae..0000000 --- a/tests/test_legacy_dns_engine.py +++ /dev/null @@ -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()