Expose persistent read-only reconciliation CLI
This commit is contained in:
@@ -3,3 +3,6 @@ __pycache__/
|
|||||||
*.swp
|
*.swp
|
||||||
*.tmp
|
*.tmp
|
||||||
.env
|
.env
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-shm
|
||||||
|
*.sqlite3-wal
|
||||||
|
|||||||
+97
-2
@@ -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}")
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user