288 lines
8.3 KiB
Python
288 lines
8.3 KiB
Python
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
|