From 86396447d9ef57ed4cb3b9803d18fc345b48aa41 Mon Sep 17 00:00:00 2001 From: Lyncoln Date: Sat, 19 Sep 2026 18:56:54 -0400 Subject: [PATCH] Expose persistent read-only reconciliation CLI --- .gitignore | 3 + dns_sync/cli.py | 99 ++++++++++++++++++- dns_sync/reporting.py | 36 +++++++ tests/test_cli_reconcile.py | 186 ++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+), 2 deletions(-) create mode 100644 tests/test_cli_reconcile.py diff --git a/.gitignore b/.gitignore index 3c47cbc..cd405a8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.swp *.tmp .env +*.sqlite3 +*.sqlite3-shm +*.sqlite3-wal diff --git a/dns_sync/cli.py b/dns_sync/cli.py index fd55d23..f72c963 100644 --- a/dns_sync/cli.py +++ b/dns_sync/cli.py @@ -1,4 +1,5 @@ import argparse +import os import sys from .config import AppConfig @@ -9,8 +10,10 @@ from .engine import ( summarize_plan, validate_desired, ) -from .registry import load_provider, load_source -from .reporting import print_plan +from .orchestrator import discover_reconcile_plan +from .persistence import SQLiteStateStore +from .registry import load_adapter, load_provider, load_source +from .reporting import print_endpoint_plans, print_plan def build_parser(): @@ -21,6 +24,24 @@ def build_parser(): 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 engine without applying changes", + ) + 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", + ) + sub.add_parser("plugins", help="Show the configured source/provider plugins") return parser @@ -92,12 +113,86 @@ def run_sync(args): return 0 +def _configured_adapters(config): + endpoints = ( + *config.npm_endpoints, + *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, + ) + + print("Generic reconciliation mode: READ ONLY") + 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 + ) + noop_count = sum( + action.operation.value == "noop" + for plan in result.plans + for action in plan.actions + ) + + print() + print( + "Plan summary: " + f"{create_count} create, " + f"{update_count} update, " + f"{noop_count} unchanged" + ) + print("READ ONLY: no endpoint changes were applied.") + 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}") diff --git a/dns_sync/reporting.py b/dns_sync/reporting.py index 36bcf1a..92e0c1e 100644 --- a/dns_sync/reporting.py +++ b/dns_sync/reporting.py @@ -13,3 +13,39 @@ def print_plan(plan): print(f"{'SKIP':<10} {h:<45} {item.reason}") else: print(f"{'UNCHANGED':<10} {h:<45} {item.desired.ip}") + + +def print_endpoint_plans(plans): + if not plans: + print("No apply-capable endpoints have planned actions.") + return + + for plan in plans: + print(f"Endpoint: {plan.endpoint_id}") + if not plan.actions: + print(" No desired objects for this endpoint.") + continue + + for action in plan.actions: + desired = action.desired + if desired is None: + identity = "(no desired object)" + else: + identity = ( + f"{desired.object_type} " + f"{desired.logical_key}" + ) + + suffix = "" + if ( + desired is not None + and desired.object_type == "dns_records" + ): + target_ip = desired.payload.get("ip") + if target_ip: + suffix = f" -> {target_ip}" + + print( + f" {action.operation.value.upper():<8} " + f"{identity}{suffix}" + ) diff --git a/tests/test_cli_reconcile.py b/tests/test_cli_reconcile.py new file mode 100644 index 0000000..833cbe3 --- /dev/null +++ b/tests/test_cli_reconcile.py @@ -0,0 +1,186 @@ +import io +import os +import tempfile +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +from dns_sync.adapters import ( + DNS_RECORDS, + PROXY_HOSTS, + Capability, + CapabilityOperation, + DiscoveredObject, + Endpoint, +) +from dns_sync.cli import build_parser, run_reconcile + + +class FakeAdapter: + def __init__( + self, + endpoint, + capabilities, + discovered, + ): + self.endpoint = endpoint + self.capabilities = capabilities + self.discovered = discovered + self.apply_count = 0 + self.verify_count = 0 + + def connect(self): + pass + + def discover(self, object_type): + return list(self.discovered.get(object_type, [])) + + def apply(self, action): + self.apply_count += 1 + raise AssertionError("read-only CLI called apply") + + def verify(self, object): + self.verify_count += 1 + raise AssertionError("read-only CLI called verify") + + +class ReconcileCliTests(unittest.TestCase): + def test_parser_exposes_read_only_reconcile_command(self): + args = build_parser().parse_args( + [ + "reconcile", + "--state-db", + "/tmp/test-state.sqlite3", + "--include-disabled", + ] + ) + + self.assertEqual(args.command, "reconcile") + self.assertEqual( + args.state_db, + "/tmp/test-state.sqlite3", + ) + self.assertTrue(args.include_disabled) + + def test_reconcile_builds_plan_without_applying_it(self): + npm_endpoint = Endpoint("npm", "192.168.1.3:81") + verizon_endpoint = Endpoint( + "verizon", + "192.168.1.1:443", + ) + npm = FakeAdapter( + npm_endpoint, + frozenset( + { + Capability( + PROXY_HOSTS, + CapabilityOperation.DISCOVER, + ) + } + ), + { + PROXY_HOSTS: [ + DiscoveredObject( + object_type=PROXY_HOSTS, + local_key="12", + payload={ + "domain_names": [ + "app.example.com" + ], + "forward_scheme": "http", + "forward_host": "192.168.1.50", + "forward_port": 8080, + "enabled": True, + }, + ) + ] + }, + ) + verizon = FakeAdapter( + verizon_endpoint, + frozenset( + { + Capability( + DNS_RECORDS, + CapabilityOperation.DISCOVER, + ), + Capability( + DNS_RECORDS, + CapabilityOperation.APPLY, + ), + } + ), + { + DNS_RECORDS: [ + DiscoveredObject( + object_type=DNS_RECORDS, + local_key="41", + payload={ + "hostname": "app.example.com", + "ip": "192.168.1.50", + "record_type": "A", + }, + ) + ] + }, + ) + adapters = { + npm_endpoint.identity: npm, + verizon_endpoint.identity: verizon, + } + + def loader(name, config, endpoint): + return adapters[endpoint.identity] + + with tempfile.TemporaryDirectory() as directory: + state_path = Path(directory) / "state.sqlite3" + stdout = io.StringIO() + stderr = io.StringIO() + + with patch.dict( + os.environ, + { + "NPM_ENDPOINTS": "192.168.1.3", + "VERIZON_ENDPOINTS": "192.168.1.1", + "DNS_TARGET_IP": "192.168.1.101", + }, + clear=True, + ), patch( + "dns_sync.cli.load_adapter", + side_effect=loader, + ), redirect_stdout(stdout), redirect_stderr(stderr): + result = run_reconcile( + SimpleNamespace( + state_db=str(state_path), + include_disabled=False, + ) + ) + + self.assertTrue(state_path.exists()) + + output = stdout.getvalue() + self.assertEqual(result, 0) + self.assertEqual(stderr.getvalue(), "") + self.assertIn( + "Generic reconciliation mode: READ ONLY", + output, + ) + self.assertIn( + "UPDATE dns_records app.example.com " + "-> 192.168.1.101", + output, + ) + self.assertIn( + "READ ONLY: no endpoint changes were applied.", + output, + ) + self.assertEqual(npm.apply_count, 0) + self.assertEqual(verizon.apply_count, 0) + self.assertEqual(npm.verify_count, 0) + self.assertEqual(verizon.verify_count, 0) + + +if __name__ == "__main__": + unittest.main()