import argparse import sys from .config import AppConfig from .engine import ( build_sync_plan, execute_sync, filter_desired_by_domain, summarize_plan, validate_desired, ) from .registry import load_provider, load_source from .reporting import print_plan 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") 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 main(): parser = build_parser() args = parser.parse_args() try: if args.command == "sync": return run_sync(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