Limit reconciliation apply mutations

This commit is contained in:
Lyncoln
2026-09-19 19:17:19 -04:00
parent 60dbd6a656
commit 7288a949bc
2 changed files with 104 additions and 2 deletions
+34 -1
View File
@@ -17,6 +17,20 @@ from .registry import 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)
@@ -27,7 +41,7 @@ def build_parser():
reconcile = sub.add_parser(
"reconcile",
help="Run the persistent generic engine without applying changes",
help="Run the persistent generic reconciliation engine",
)
reconcile.add_argument(
"--state-db",
@@ -47,6 +61,12 @@ def build_parser():
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 CREATE/UPDATE actions allowed with --apply",
)
sub.add_parser("plugins", help="Show the configured source/provider plugins")
return parser
@@ -175,6 +195,7 @@ def run_reconcile(args):
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
@@ -198,6 +219,18 @@ def run_reconcile(args):
"Refusing to apply while discovery failures are present"
)
if (
args.max_changes is not None
and change_count > args.max_changes
):
raise RuntimeError(
f"Refusing to apply {change_count} change(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,
+70 -1
View File
@@ -64,11 +64,24 @@ class ReconcileCliTests(unittest.TestCase):
)
self.assertTrue(args.include_disabled)
self.assertFalse(args.apply)
self.assertIsNone(args.max_changes)
apply_args = build_parser().parse_args(
["reconcile", "--apply"]
[
"reconcile",
"--apply",
"--max-changes",
"0",
]
)
self.assertTrue(apply_args.apply)
self.assertEqual(apply_args.max_changes, 0)
with redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit):
build_parser().parse_args(
["reconcile", "--max-changes", "-1"]
)
def test_reconcile_builds_plan_without_applying_it(self):
npm_endpoint = Endpoint("npm", "192.168.1.3:81")
@@ -162,6 +175,7 @@ class ReconcileCliTests(unittest.TestCase):
state_db=str(state_path),
include_disabled=False,
apply=False,
max_changes=None,
)
)
@@ -230,6 +244,7 @@ class ReconcileCliTests(unittest.TestCase):
state_db=str(state_path),
include_disabled=False,
apply=True,
max_changes=None,
)
)
@@ -244,6 +259,60 @@ class ReconcileCliTests(unittest.TestCase):
stdout.getvalue(),
)
def test_apply_budget_blocks_executor_before_mutation(self):
config = SimpleNamespace(
target_ip="192.168.1.101",
include_disabled=False,
domain_matching=False,
domain=None,
)
adapter = object()
action = SimpleNamespace(
operation=SimpleNamespace(value="create"),
)
plan = SimpleNamespace(actions=(action,))
reconciliation = SimpleNamespace(
observations=(),
desired=(),
plans=(plan,),
failures=(),
)
with tempfile.TemporaryDirectory() as directory:
state_path = Path(directory) / "state.sqlite3"
with patch(
"dns_sync.cli.AppConfig.from_env",
return_value=config,
), patch(
"dns_sync.cli._configured_adapters",
return_value=[adapter],
), patch(
"dns_sync.cli.discover_reconcile_plan",
return_value=reconciliation,
), patch(
"dns_sync.cli.print_endpoint_plans",
), patch(
"dns_sync.cli.execute_endpoint_plans",
) as execute, redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(
RuntimeError,
(
r"Refusing to apply 1 change\(s\); "
r"--max-changes allows 0"
),
):
run_reconcile(
SimpleNamespace(
state_db=str(state_path),
include_disabled=False,
apply=True,
max_changes=0,
)
)
execute.assert_not_called()
if __name__ == "__main__":
unittest.main()