Account for deferred retirements in apply budget

This commit is contained in:
Lyncoln
2026-09-20 13:13:28 -04:00
parent a93e067bfd
commit 572dd9eae4
4 changed files with 112 additions and 10 deletions
+14 -3
View File
@@ -65,7 +65,7 @@ def build_parser():
"--max-changes", "--max-changes",
type=_nonnegative_int, type=_nonnegative_int,
default=None, default=None,
help="Maximum CREATE/UPDATE actions allowed with --apply", help="Maximum planned mutations 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")
@@ -202,11 +202,21 @@ def run_reconcile(args):
for action in plan.actions for action in plan.actions
) )
retirement_count = sum(
len(plan.retirements)
for plan in result.plans
)
mutation_count = (
change_count
+ retirement_count
)
print() print()
print( print(
"Plan summary: " "Plan summary: "
f"{create_count} create, " f"{create_count} create, "
f"{update_count} update, " f"{update_count} update, "
f"{retirement_count} retirement, "
f"{noop_count} unchanged" f"{noop_count} unchanged"
) )
@@ -221,10 +231,10 @@ def run_reconcile(args):
if ( if (
args.max_changes is not None args.max_changes is not None
and change_count > args.max_changes and mutation_count > args.max_changes
): ):
raise RuntimeError( raise RuntimeError(
f"Refusing to apply {change_count} change(s); " f"Refusing to apply {mutation_count} mutation(s); "
f"--max-changes allows {args.max_changes}" f"--max-changes allows {args.max_changes}"
) )
@@ -241,6 +251,7 @@ def run_reconcile(args):
print( print(
"APPLY COMPLETE: " "APPLY COMPLETE: "
f"{len(execution.applied)} action(s) applied and verified; " f"{len(execution.applied)} action(s) applied and verified; "
f"{len(execution.retired)} retirement(s) completed; "
f"{len(execution.skipped)} unchanged/skipped." f"{len(execution.skipped)} unchanged/skipped."
) )
return 0 return 0
+11
View File
@@ -42,6 +42,7 @@ class ExecutedAction:
class ExecutionResult: class ExecutionResult:
applied: tuple[ExecutedAction, ...] applied: tuple[ExecutedAction, ...]
skipped: tuple[PlanAction, ...] skipped: tuple[PlanAction, ...]
retired: tuple[tuple[str, RetireRequest], ...]
@dataclass @dataclass
@@ -555,6 +556,9 @@ def execute_endpoint_plans(
retired_identities: set[ retired_identities: set[
tuple[str, str, str] tuple[str, str, str]
] = set() ] = set()
retired_requests: list[
tuple[str, RetireRequest]
] = []
for endpoint_id, retirement in pending_retirements: for endpoint_id, retirement in pending_retirements:
retired = retirement.object retired = retirement.object
@@ -580,8 +584,15 @@ def execute_endpoint_plans(
) )
retire(retirement) retire(retirement)
retired_requests.append(
(
endpoint_id,
retirement,
)
)
return ExecutionResult( return ExecutionResult(
applied=tuple(applied), applied=tuple(applied),
skipped=tuple(skipped), skipped=tuple(skipped),
retired=tuple(retired_requests),
) )
+75 -6
View File
@@ -220,6 +220,7 @@ class ReconcileCliTests(unittest.TestCase):
execution = SimpleNamespace( execution = SimpleNamespace(
applied=(), applied=(),
skipped=(), skipped=(),
retired=(),
) )
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
@@ -255,11 +256,14 @@ class ReconcileCliTests(unittest.TestCase):
stdout.getvalue(), stdout.getvalue(),
) )
self.assertIn( self.assertIn(
"APPLY COMPLETE: 0 action(s) applied and verified", "APPLY COMPLETE: 0 action(s) applied and verified; "
"0 retirement(s) completed",
stdout.getvalue(), stdout.getvalue(),
) )
def test_apply_budget_blocks_executor_before_mutation(self): def test_apply_budget_counts_planned_retirement_before_mutation(
self,
):
config = SimpleNamespace( config = SimpleNamespace(
target_ip="192.168.1.101", target_ip="192.168.1.101",
include_disabled=False, include_disabled=False,
@@ -267,10 +271,18 @@ class ReconcileCliTests(unittest.TestCase):
domain=None, domain=None,
) )
adapter = object() adapter = object()
action = SimpleNamespace(
operation=SimpleNamespace(value="create"), retirement = SimpleNamespace(
object=SimpleNamespace(
object_type="certificates",
logical_key="app.example.com",
local_key="63",
)
)
plan = SimpleNamespace(
actions=(),
retirements=(retirement,),
) )
plan = SimpleNamespace(actions=(action,))
reconciliation = SimpleNamespace( reconciliation = SimpleNamespace(
observations=(), observations=(),
desired=(), desired=(),
@@ -298,7 +310,64 @@ class ReconcileCliTests(unittest.TestCase):
with self.assertRaisesRegex( with self.assertRaisesRegex(
RuntimeError, RuntimeError,
( (
r"Refusing to apply 1 change\(s\); " r"Refusing to apply 1 mutation\(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()
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,),
retirements=(),
)
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 mutation\(s\); "
r"--max-changes allows 0" r"--max-changes allows 0"
), ),
): ):
+12 -1
View File
@@ -553,7 +553,7 @@ class ExecutionTests(unittest.TestCase):
NOW, NOW,
) )
execute_endpoint_plans( result = execute_endpoint_plans(
[adapter], [adapter],
[plan], [plan],
store, store,
@@ -590,6 +590,17 @@ class ExecutionTests(unittest.TestCase):
) )
], ],
) )
self.assertEqual(
result.retired,
(
(
endpoint.identity,
RetireRequest(
object=old_certificate,
),
),
),
)
def test_failed_verification_is_persisted_and_raises(self): def test_failed_verification_is_persisted_and_raises(self):
endpoint = Endpoint("verizon", "192.168.1.1:443") endpoint = Endpoint("verizon", "192.168.1.1:443")