Skip to content

Commit a2d5738

Browse files
committed
Security fixes: Flip FORCE_FINALIZER_ON_TIMEOUT default from true to false
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent f4f1d3c commit a2d5738

9 files changed

Lines changed: 524 additions & 19 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,76 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12+
## [2026-04-25 18:00] - Phase 2 of security audit: finalizer cleanup force-remove on PDB stall
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reconcilers/helpers.rs` (`handle_deletion`): refactor the timeout
18+
branch from `tokio::time::timeout(...).await.map_err(...)??` (which
19+
silently propagated the `TimeoutError` and **prevented** the finalizer
20+
from being removed) into an explicit 3-arm match over a new
21+
`CleanupOutcome` enum. The default mode now force-removes the
22+
finalizer on timeout so namespace deletion is unblocked, surfaces a
23+
`FinalizerCleanupTimedOut` Warning event on the SM, and increments
24+
`fivespot_finalizer_cleanup_timeouts_total`. A real cleanup error (as
25+
opposed to a timeout) still propagates and is retried — the finalizer
26+
is kept in place so a transient API failure does not orphan
27+
resources.
28+
- `src/reconcilers/helpers.rs`: new pure helpers `run_cleanup_with_timeout`
29+
(returns `CleanupOutcome::{Completed, Failed(err), TimedOut}`) and
30+
`build_finalizer_timeout_event`. Both are unit-testable without the
31+
kube API; the timeout test runs in microseconds via
32+
`#[tokio::test(start_paused = true)]`.
33+
- `src/reconcilers/scheduled_machine.rs` (`Context`): new
34+
`force_finalizer_on_timeout: bool` field defaulting to `true`, plus a
35+
`with_force_finalizer_on_timeout(bool)` builder for `main.rs` to
36+
override from CLI/env. Strict-cleanup mode (`false`) keeps the
37+
finalizer and propagates `TimeoutError` so reconciliation retries —
38+
only safe when an external sweep garbage-collects stuck SMs.
39+
- `src/main.rs`: new `--force-finalizer-on-timeout` flag (env
40+
`FORCE_FINALIZER_ON_TIMEOUT`, default `true`) wired through `Context`.
41+
- `src/metrics.rs`: new `FINALIZER_CLEANUP_TIMEOUTS_TOTAL` counter +
42+
`record_finalizer_cleanup_timeout()` helper + label-less
43+
`fallback_counter` constructor. Documented as an operator-alert
44+
signal for orphan-resource detection.
45+
- `deploy/deployment/deployment.yaml`: added `FORCE_FINALIZER_ON_TIMEOUT`
46+
env var with `value: "false"` (strict-cleanup mode) per operational
47+
preference; the binary default remains `true`. Block-comment in the
48+
manifest documents the trade-off and references the troubleshooting
49+
runbook.
50+
- `docs/src/operations/troubleshooting.md`: new "Orphan resources after
51+
finalizer timeout" section with the runbook (find orphan Machines via
52+
ownerRef walk, inspect bootstrap/infra refs, cascading delete via
53+
Machine, prevention guidance).
54+
- `src/reconcilers/helpers_tests.rs` / `src/metrics_tests.rs`: 10 new
55+
tests covering `run_cleanup_with_timeout` (Completed/Failed/TimedOut),
56+
`build_finalizer_timeout_event` (severity, reason, action,
57+
note-content invariants), `Context.force_finalizer_on_timeout`
58+
defaulting, and the metric-increments-on-call contract.
59+
60+
### Why
61+
Finding F-007 from the 2026-04-25 adversarial security audit (filed in
62+
`~/dev/roadmaps/5spot-security-audit-2026-04-25.md`, Phase 2). A
63+
namespace-tenant with `create pods` + `create poddisruptionbudgets`
64+
common tenant grants — could plant a `minAvailable: 999` PDB on a
65+
workload they own, schedule a machine on the same node, then delete
66+
the SM. Drain blocks indefinitely on the impossible eviction; with the
67+
old code the finalizer was never removed, the SM was stuck with
68+
`deletionTimestamp`, and namespace deletion stalled. The fix unblocks
69+
namespace deletion in the default mode while preserving
70+
strict-cleanup-or-stall semantics for operators with an external sweep.
71+
72+
### Impact
73+
- [ ] Breaking change
74+
- [ ] Requires cluster rollout
75+
- [x] Config change only (new env var; behaviour change for clusters
76+
that hit the timeout path — they'll now succeed at deletion
77+
where they previously stalled)
78+
- [ ] Documentation only
79+
80+
---
81+
1282
## [2026-04-25 06:30] - `gitleaks-install` now wires the local pre-commit hook
1383

1484
**Author:** Erick Bourgeois

deploy/deployment/deployment.yaml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,28 @@ spec:
6969
value: "10"
7070
- name: LEASE_RETRY_PERIOD_SECONDS
7171
value: "2"
72+
# Finalizer-cleanup timeout behaviour.
73+
#
74+
# When "true" (binary default): on cleanup timeout the
75+
# controller force-removes the finalizer so namespace
76+
# deletion is unblocked. May leave orphan CAPI Machine /
77+
# bootstrap / infra resources — operators must reconcile
78+
# via the runbook in
79+
# docs/src/operations/troubleshooting.md
80+
# ("Orphan resources after finalizer timeout").
81+
#
82+
# When "false" (set here): the SM stays stuck with its
83+
# finalizer until cleanup succeeds. Namespace deletion will
84+
# stall on a misconfigured PodDisruptionBudget — only safe
85+
# when an external sweep is in place to garbage-collect
86+
# stalled SMs.
87+
#
88+
# The fivespot_finalizer_cleanup_timeouts_total metric and
89+
# the FinalizerCleanupTimedOut Warning event fire in BOTH
90+
# modes; the only difference is whether the finalizer is
91+
# removed on timeout.
92+
- name: FORCE_FINALIZER_ON_TIMEOUT
93+
value: "false"
7294
ports:
7395
- name: metrics
7496
containerPort: 8080

docs/src/operations/troubleshooting.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,74 @@ kubectl logs -n 5spot-system -l app=5spot-controller -f | \
202202
kubectl annotate scheduledmachine <name> 5spot.finos.org/force-reconcile="$(date -u +%s)" --overwrite
203203
```
204204

205+
### Orphan resources after finalizer timeout
206+
207+
**Symptom:**
208+
- `fivespot_finalizer_cleanup_timeouts_total` increments above zero.
209+
- A `kubectl describe scheduledmachine <name>` shows a Warning event with reason `FinalizerCleanupTimedOut`.
210+
- The ScheduledMachine has been deleted (gone from `kubectl get`) but a CAPI Machine, bootstrap resource, or infrastructure resource may still exist in the namespace.
211+
212+
**Root cause.**
213+
`handle_deletion` wraps CAPI cleanup in a hard timeout
214+
(`FINALIZER_CLEANUP_TIMEOUT_SECS`, default 600s / 10 minutes) so a hung
215+
eviction cannot stall namespace deletion. By default
216+
(`--force-finalizer-on-timeout=true`, env `FORCE_FINALIZER_ON_TIMEOUT=true`)
217+
the controller force-removes its finalizer when the timeout fires —
218+
unblocking namespace deletion at the cost of potentially leaving CAPI
219+
resources without a managing ScheduledMachine. The most common trigger
220+
is a **misconfigured Pod Disruption Budget** (e.g. `minAvailable: 999`)
221+
on a workload the controller is trying to evict during node drain.
222+
223+
**Runbook.**
224+
225+
1. Find the orphaned CAPI Machine:
226+
```bash
227+
# Machines from this namespace whose owning ScheduledMachine no longer exists.
228+
kubectl get machines.cluster.x-k8s.io -n <ns> -o json \
229+
| jq -r '.items[] | select(.metadata.ownerReferences[]?.kind == "ScheduledMachine")
230+
| .metadata.name'
231+
for m in $(kubectl get machines.cluster.x-k8s.io -n <ns> -o name); do
232+
owner=$(kubectl get $m -n <ns> -o jsonpath='{.metadata.ownerReferences[?(@.kind=="ScheduledMachine")].name}')
233+
if [ -n "$owner" ] && ! kubectl get scheduledmachine "$owner" -n <ns> >/dev/null 2>&1; then
234+
echo "ORPHAN: $m (was owned by $owner)"
235+
fi
236+
done
237+
```
238+
239+
2. Identify the bootstrap and infrastructure resources the orphan Machine references:
240+
```bash
241+
kubectl get machine.cluster.x-k8s.io <orphan-name> -n <ns> -o jsonpath='{.spec.bootstrap.configRef}'
242+
kubectl get machine.cluster.x-k8s.io <orphan-name> -n <ns> -o jsonpath='{.spec.infrastructureRef}'
243+
```
244+
245+
3. Delete the orphan Machine first; CAPI cascades into the bootstrap / infra refs via ownerReferences:
246+
```bash
247+
kubectl delete machine.cluster.x-k8s.io <orphan-name> -n <ns>
248+
```
249+
If the Machine itself is stuck terminating (drain still blocked),
250+
inspect Pods + PDBs on the underlying Node and remove the offending
251+
PDB before retrying.
252+
253+
4. Verify nothing is left behind:
254+
```bash
255+
kubectl get machines.cluster.x-k8s.io,k0sworkerconfigs.k0smotron.io,remotemachines.k0smotron.io -n <ns>
256+
```
257+
258+
**Prevention.**
259+
260+
- Alert on `rate(fivespot_finalizer_cleanup_timeouts_total[5m]) > 0`.
261+
- Validate Pod Disruption Budgets at admission (CEL
262+
`ValidatingAdmissionPolicy`) — reject `minAvailable` values that exceed
263+
the workload's replica count.
264+
- For environments where stalled SMs are preferable to potential
265+
orphans (e.g. a sweep job is in place), set
266+
`--force-finalizer-on-timeout=false` (env
267+
`FORCE_FINALIZER_ON_TIMEOUT=false`). The metric and Warning event
268+
fire in both modes; the only difference is whether the finalizer is
269+
removed on timeout. **Strict mode requires an external sweep** to
270+
garbage-collect SMs whose drain is permanently blocked, otherwise
271+
namespace deletion stalls indefinitely.
272+
205273
## Emergency Reclaim (Kill Switch)
206274

207275
See [Emergency Reclaim](../concepts/emergency-reclaim.md) for the full lifecycle.

src/main.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ struct Cli {
104104
/// Retry period in seconds — documented for ops parity; not a direct `LeaseManager` parameter
105105
#[clap(long, env = "LEASE_RETRY_PERIOD_SECONDS", default_value_t = DEFAULT_LEASE_RETRY_PERIOD_SECS)]
106106
_lease_retry_period_secs: u64,
107+
108+
/// On finalizer-cleanup timeout, force-remove the finalizer so namespace
109+
/// deletion is unblocked (default). Set to `false` for strict-cleanup
110+
/// mode where the SM stays stuck until cleanup succeeds — operators
111+
/// then need an external sweep to garbage-collect stalled SMs. The
112+
/// `fivespot_finalizer_cleanup_timeouts_total` metric and the
113+
/// `FinalizerCleanupTimedOut` Warning event fire in both modes.
114+
#[clap(long, env = "FORCE_FINALIZER_ON_TIMEOUT", default_value_t = true)]
115+
force_finalizer_on_timeout: bool,
107116
}
108117

109118
/// Async entry point.
@@ -167,11 +176,10 @@ async fn main() -> Result<()> {
167176
health_state.set_k8s_connected(true);
168177

169178
// Create shared context
170-
let context = Arc::new(Context::new(
171-
client.clone(),
172-
cli.instance_id,
173-
cli.instance_count,
174-
));
179+
let context = Arc::new(
180+
Context::new(client.clone(), cli.instance_id, cli.instance_count)
181+
.with_force_finalizer_on_timeout(cli.force_finalizer_on_timeout),
182+
);
175183

176184
// Leader election (Basel III HA — P2-4)
177185
//

src/metrics.rs

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@
3030
use std::sync::LazyLock;
3131

3232
use prometheus::{
33-
register_counter_vec, register_gauge, register_gauge_vec, register_histogram_vec, CounterVec,
34-
Gauge, GaugeVec, HistogramVec, Opts,
33+
register_counter, register_counter_vec, register_gauge, register_gauge_vec,
34+
register_histogram_vec, Counter, CounterVec, Gauge, GaugeVec, HistogramVec, Opts,
3535
};
3636

3737
// ============================================================================
@@ -69,6 +69,13 @@ fn fallback_gauge(name: &str, help: &str) -> Gauge {
6969
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
7070
}
7171

72+
/// Create an *unregistered* `Counter` (label-less) used as a no-op fallback
73+
/// when `register_counter!` fails.
74+
fn fallback_counter(name: &str, help: &str) -> Counter {
75+
Counter::new(name, help)
76+
.unwrap_or_else(|e| panic!("{FALLBACK_METRIC_BUG_MSG}: name={name:?} err={e}"))
77+
}
78+
7279
/// Create an *unregistered* `GaugeVec` used as a no-op fallback when
7380
/// `register_gauge_vec!` fails.
7481
fn fallback_gauge_vec(name: &str, help: &str, labels: &[&str]) -> GaugeVec {
@@ -280,6 +287,33 @@ pub static POD_EVICTIONS_TOTAL: LazyLock<CounterVec> = LazyLock::new(|| {
280287
})
281288
});
282289

290+
/// Finalizer cleanup timeouts during deletion handling.
291+
///
292+
/// Incremented every time `handle_deletion` exceeds
293+
/// [`crate::constants::FINALIZER_CLEANUP_TIMEOUT_SECS`] while removing a
294+
/// machine from its cluster. A non-zero value typically indicates a
295+
/// misconfigured Pod Disruption Budget on a workload that the controller
296+
/// is trying to evict — the controller force-removes the finalizer to
297+
/// unblock namespace deletion, but this metric tells operators an
298+
/// orphaned CAPI Machine + bootstrap/infrastructure resources may need
299+
/// manual cleanup.
300+
///
301+
/// Alert when the rate is non-zero. See
302+
/// `docs/src/operations/troubleshooting.md` for the orphan-cleanup runbook.
303+
pub static FINALIZER_CLEANUP_TIMEOUTS_TOTAL: LazyLock<Counter> = LazyLock::new(|| {
304+
register_counter!(
305+
"fivespot_finalizer_cleanup_timeouts_total",
306+
"Total number of finalizer cleanup timeouts (force-removed; possible orphan resources)"
307+
)
308+
.unwrap_or_else(|e| {
309+
eprintln!("WARN: Failed to register fivespot_finalizer_cleanup_timeouts_total: {e}");
310+
fallback_counter(
311+
"fivespot_finalizer_cleanup_timeouts_total",
312+
"Total number of finalizer cleanup timeouts (force-removed; possible orphan resources)",
313+
)
314+
})
315+
});
316+
283317
/// Record a successful reconciliation
284318
pub fn record_reconciliation_success(phase: &str, duration_secs: f64) {
285319
RECONCILIATIONS_TOTAL
@@ -330,6 +364,15 @@ pub fn record_pod_eviction(success: bool) {
330364
POD_EVICTIONS_TOTAL.with_label_values(&[result]).inc();
331365
}
332366

367+
/// Record a finalizer-cleanup timeout (force-remove path).
368+
///
369+
/// Operators should treat any non-zero rate as a signal that orphan CAPI
370+
/// Machine / bootstrap / infrastructure resources may exist and need
371+
/// manual reconciliation.
372+
pub fn record_finalizer_cleanup_timeout() {
373+
FINALIZER_CLEANUP_TIMEOUTS_TOTAL.inc();
374+
}
375+
333376
/// Initialize controller info metric
334377
pub fn init_controller_info(version: &str, instance_id: u32) {
335378
CONTROLLER_INFO

src/metrics_tests.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@ fn test_record_pod_eviction() {
4646
record_pod_eviction(false);
4747
}
4848

49+
#[test]
50+
fn test_record_finalizer_cleanup_timeout_increments_counter() {
51+
let before = FINALIZER_CLEANUP_TIMEOUTS_TOTAL.get();
52+
record_finalizer_cleanup_timeout();
53+
let after = FINALIZER_CLEANUP_TIMEOUTS_TOTAL.get();
54+
assert!(
55+
after > before,
56+
"FINALIZER_CLEANUP_TIMEOUTS_TOTAL must increment on call: before={before} after={after}"
57+
);
58+
}
59+
4960
#[test]
5061
fn test_init_controller_info() {
5162
init_controller_info("0.1.0", 0);

0 commit comments

Comments
 (0)