Limit reconciliation apply mutations
This commit is contained in:
+34
-1
@@ -17,6 +17,20 @@ from .registry import load_adapter, load_provider, load_source
|
|||||||
from .reporting import print_endpoint_plans, print_plan
|
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():
|
def build_parser():
|
||||||
parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider")
|
parser = argparse.ArgumentParser(prog="dns-sync", description="Synchronize reverse-proxy hostnames to a pluggable DNS provider")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
@@ -27,7 +41,7 @@ def build_parser():
|
|||||||
|
|
||||||
reconcile = sub.add_parser(
|
reconcile = sub.add_parser(
|
||||||
"reconcile",
|
"reconcile",
|
||||||
help="Run the persistent generic engine without applying changes",
|
help="Run the persistent generic reconciliation engine",
|
||||||
)
|
)
|
||||||
reconcile.add_argument(
|
reconcile.add_argument(
|
||||||
"--state-db",
|
"--state-db",
|
||||||
@@ -47,6 +61,12 @@ def build_parser():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Apply and verify planned changes; default is read-only",
|
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")
|
sub.add_parser("plugins", help="Show the configured source/provider plugins")
|
||||||
return parser
|
return parser
|
||||||
@@ -175,6 +195,7 @@ def run_reconcile(args):
|
|||||||
for plan in result.plans
|
for plan in result.plans
|
||||||
for action in plan.actions
|
for action in plan.actions
|
||||||
)
|
)
|
||||||
|
change_count = create_count + update_count
|
||||||
noop_count = sum(
|
noop_count = sum(
|
||||||
action.operation.value == "noop"
|
action.operation.value == "noop"
|
||||||
for plan in result.plans
|
for plan in result.plans
|
||||||
@@ -198,6 +219,18 @@ def run_reconcile(args):
|
|||||||
"Refusing to apply while discovery failures are present"
|
"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:
|
with SQLiteStateStore(args.state_db) as store:
|
||||||
execution = execute_endpoint_plans(
|
execution = execute_endpoint_plans(
|
||||||
adapters,
|
adapters,
|
||||||
|
|||||||
@@ -64,11 +64,24 @@ class ReconcileCliTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertTrue(args.include_disabled)
|
self.assertTrue(args.include_disabled)
|
||||||
self.assertFalse(args.apply)
|
self.assertFalse(args.apply)
|
||||||
|
self.assertIsNone(args.max_changes)
|
||||||
|
|
||||||
apply_args = build_parser().parse_args(
|
apply_args = build_parser().parse_args(
|
||||||
["reconcile", "--apply"]
|
[
|
||||||
|
"reconcile",
|
||||||
|
"--apply",
|
||||||
|
"--max-changes",
|
||||||
|
"0",
|
||||||
|
]
|
||||||
)
|
)
|
||||||
self.assertTrue(apply_args.apply)
|
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):
|
def test_reconcile_builds_plan_without_applying_it(self):
|
||||||
npm_endpoint = Endpoint("npm", "192.168.1.3:81")
|
npm_endpoint = Endpoint("npm", "192.168.1.3:81")
|
||||||
@@ -162,6 +175,7 @@ class ReconcileCliTests(unittest.TestCase):
|
|||||||
state_db=str(state_path),
|
state_db=str(state_path),
|
||||||
include_disabled=False,
|
include_disabled=False,
|
||||||
apply=False,
|
apply=False,
|
||||||
|
max_changes=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +244,7 @@ class ReconcileCliTests(unittest.TestCase):
|
|||||||
state_db=str(state_path),
|
state_db=str(state_path),
|
||||||
include_disabled=False,
|
include_disabled=False,
|
||||||
apply=True,
|
apply=True,
|
||||||
|
max_changes=None,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -244,6 +259,60 @@ class ReconcileCliTests(unittest.TestCase):
|
|||||||
stdout.getvalue(),
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user