import argparse 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 from .registry import ( enabled_plugins, load_adapter, load_provider, load_source, ) from .reporting import print_endpoint_plans, print_plan def _nonnegative_int(value: str) -> int: try: parsed = int(value) except ValueError as exc: raise argparse.ArgumentTypeError( "must be a non-negative integer" ) from exc if parsed < 0: raise argparse.ArgumentTypeError( "must be a non-negative integer" ) return parsed 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", ) reconcile.add_argument( "--state-db", default=os.getenv( "DNS_SYNC_STATE_DB", "dns-sync-state.sqlite3", ), help="SQLite state database path", ) reconcile.add_argument( "--include-disabled", action="store_true", help="Include disabled proxy hosts in desired DNS state", ) reconcile.add_argument( "--apply", action="store_true", help="Apply and verify planned changes; default is read-only", ) reconcile.add_argument( "--max-changes", type=_nonnegative_int, default=None, 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): enabled = enabled_plugins() endpoints = [] if "npm" in enabled: endpoints.extend(config.npm_endpoints) if "verizon" in enabled: endpoints.extend(config.verizon_endpoints) return [ load_adapter(endpoint.plugin, config, endpoint) for endpoint in endpoints ] def run_reconcile(args): config = AppConfig.from_env( include_disabled=args.include_disabled ) adapters = _configured_adapters(config) with SQLiteStateStore(args.state_db) as store: result = discover_reconcile_plan( adapters, store, target_ip=config.target_ip, include_disabled=config.include_disabled, domain_matching=config.domain_matching, domain=config.domain, ) for failure in result.failures: print( "WARNING: discovery failed for " f"{failure.endpoint_id} " f"{failure.object_type}: {failure.error}", file=sys.stderr, ) mode = "APPLY" if args.apply else "READ ONLY" print(f"Generic reconciliation mode: {mode}") print(f"State database: {args.state_db}") print(f"Configured endpoints: {len(adapters)}") print(f"Cached/live observations: {len(result.observations)}") print(f"Canonical DNS records: {len(result.desired)}") print(f"Discovery failures: {len(result.failures)}") print() print("Per-endpoint plans:") print_endpoint_plans(result.plans) create_count = sum( action.operation.value == "create" for plan in result.plans for action in plan.actions ) update_count = sum( action.operation.value == "update" for plan in result.plans for action in plan.actions ) change_count = create_count + update_count noop_count = sum( action.operation.value == "noop" for plan in result.plans for action in plan.actions ) retirement_count = sum( len(plan.retirements) for plan in result.plans ) mutation_count = ( change_count + retirement_count ) print() print( "Plan summary: " f"{create_count} create, " f"{update_count} update, " f"{retirement_count} retirement, " f"{noop_count} unchanged" ) if not args.apply: print("READ ONLY: no endpoint changes were applied.") return 0 if result.failures: raise RuntimeError( "Refusing to apply while discovery failures are present" ) if ( args.max_changes is not None and mutation_count > args.max_changes ): raise RuntimeError( f"Refusing to apply {mutation_count} mutation(s); " f"--max-changes allows {args.max_changes}" ) if args.max_changes is not None: print(f"Apply mutation budget: {args.max_changes}") with SQLiteStateStore(args.state_db) as store: execution = execute_endpoint_plans( adapters, result.plans, store, ) print( "APPLY COMPLETE: " f"{len(execution.applied)} action(s) applied and verified; " f"{len(execution.retired)} retirement(s) completed; " f"{len(execution.skipped)} unchanged/skipped." ) return 0 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) return 1