Expose persistent read-only reconciliation CLI

This commit is contained in:
Lyncoln
2026-09-19 18:56:54 -04:00
parent 5cd1bc5ba3
commit 86396447d9
4 changed files with 322 additions and 2 deletions
+3
View File
@@ -3,3 +3,6 @@ __pycache__/
*.swp *.swp
*.tmp *.tmp
.env .env
*.sqlite3
*.sqlite3-shm
*.sqlite3-wal
+97 -2
View File
@@ -1,4 +1,5 @@
import argparse import argparse
import os
import sys import sys
from .config import AppConfig from .config import AppConfig
@@ -9,8 +10,10 @@ from .engine import (
summarize_plan, summarize_plan,
validate_desired, validate_desired,
) )
from .registry import load_provider, load_source from .orchestrator import discover_reconcile_plan
from .reporting import print_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(): 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("--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") 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") sub.add_parser("plugins", help="Show the configured source/provider plugins")
return parser return parser
@@ -92,12 +113,86 @@ def run_sync(args):
return 0 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(): def main():
parser = build_parser() parser = build_parser()
args = parser.parse_args() args = parser.parse_args()
try: try:
if args.command == "sync": if args.command == "sync":
return run_sync(args) return run_sync(args)
if args.command == "reconcile":
return run_reconcile(args)
if args.command == "plugins": if args.command == "plugins":
config = AppConfig.from_env() config = AppConfig.from_env()
print(f"Source plugin: {config.source_name}") print(f"Source plugin: {config.source_name}")
+36
View File
@@ -13,3 +13,39 @@ def print_plan(plan):
print(f"{'SKIP':<10} {h:<45} {item.reason}") print(f"{'SKIP':<10} {h:<45} {item.reason}")
else: else:
print(f"{'UNCHANGED':<10} {h:<45} {item.desired.ip}") 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}"
)
+186
View File
@@ -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()