tatara_process/kube_error.rs
1//! Substrate primitives over [`kube::Error`] — the semantic layer
2//! every controller's `match … { Err(kube::Error::Api(e)) if e.code
3//! == <N> => … }` guard AND every consumer's
4//! `.map_err(|e| anyhow::anyhow!("<ctx>: {e}"))?` error-wrap restates
5//! by hand pre-lift.
6//!
7//! Owns two closed-set predicates over the `kube::Error::Api`
8//! sub-variant's HTTP status code:
9//!
10//! * [`is_conflict`] — HTTP 409 (Conflict) — the K8s API server
11//! refused a `create` because a resource with the same key already
12//! exists, or refused a `patch` because of an optimistic-concurrency
13//! generation mismatch. Every controller's create-branch reads this
14//! arm as "someone else got here first; treat the intended write as
15//! already-done" or "refresh via PATCH".
16//! * [`is_not_found`] — HTTP 404 (Not Found) — the K8s API server has
17//! no resource with the given key. Every controller's delete-branch
18//! reads this arm as "already deleted / never existed; the intended
19//! state (absence) is already true".
20//!
21//! Plus the [`KubeResultExt`] extension trait on `Result<T, kube::Error>`
22//! that owns the display-prefix wrap the phase-machine / signal-effect
23//! / pool-reconciler consumers thread every K8s round-trip through into
24//! their anyhow-returning handler:
25//!
26//! * [`KubeResultExt::kube_ctx`] — attach a `&'static str` context slug
27//! to a `Result<_, kube::Error>` and get an
28//! [`anyhow::Result`] whose `Display` reads
29//! `"<ctx>: <kube::Error display>"` (byte-identical to the pre-lift
30//! `.map_err(|e| anyhow!("<ctx>: {e}"))` chain).
31//! * [`KubeResultExt::kube_ctx_with`] — owned-`String` peer for
32//! consumers that compose the slug via `format!` at runtime.
33//!
34//! See the trait's own docstring for the full consumer inventory + the
35//! naming rationale (why `kube_ctx` and not `anyhow::Context::context`).
36//!
37//! Both predicates lift the 2-link `matches!(err, kube::Error::Api(e)
38//! if e.code == <N>)` shape past the ★★ PRIME-DIRECTIVE ≥ 2
39//! duplication trigger. Pre-lift the SAME chain was hand-authored at
40//! FIVE workspace-wide sites, each interpreting the same HTTP status
41//! code with the same semantic:
42//!
43//! * `tatara-closed-loop-probe::write_receipt_configmap` — 409 arm on
44//! `api.create(...)` → falls through to a merge-patch of the `data`
45//! field so the receipt payload lands idempotently.
46//! * `tatara-github-watcher::handler::handle_pr_event` — 404 arm on
47//! `api.delete(...)` → returns `200 OK "allocation already gone"`
48//! so a closed-PR event is a no-op past the first delivery.
49//! * `tatara-github-watcher::handler::handle_pr_event` — 409 arm on
50//! `api.create(...)` → returns `200 OK "allocation already exists
51//! (synchronize)"` so a re-delivery of an `opened` PR event maps
52//! onto the existing allocation.
53//! * `tatara-pool-reconciler::controller_pool` (spawn branch, spawn
54//! loop) — 409 arm on `process_api.create(...)` → treats the race
55//! as a successful spawn, incrementing `spawned` past the arm.
56//! * `tatara-pool-reconciler::controller_pool` (desired-loop branch)
57//! — 409 arm on `process_api.create(...)` → treats the race as a
58//! no-op so the next reconcile picks up the existing Process.
59//!
60//! All FIVE sites walked the SAME two-link shape — destructure the
61//! `kube::Error::Api` sub-variant, guard on `e.code == <N>` — and
62//! interpret the code identically ("write already succeeded" / "delete
63//! already succeeded"). The `e: ErrorResponse` binding is bound but
64//! unused at every callsite; the body reads the SEMANTIC (conflict /
65//! not-found) rather than the specific fields (`e.reason`, `e.message`).
66//! Post-lift each callsite reads
67//! `Err(ref e) if kube_error::is_conflict(e) => { ... }` (or
68//! `is_not_found`), and the two-link shape lives at ONE substrate
69//! owner.
70//!
71//! ### Semantic axis (why predicates, not raw codes)
72//!
73//! The K8s API server sends the same HTTP status code for a set of
74//! semantically identical outcomes (a 404 on `get` and a 404 on
75//! `delete` both mean "the resource is not present"); it also
76//! occasionally sends the same code for OTHER outcomes with subtly
77//! different meanings (a 404 on a subresource whose parent exists,
78//! for instance). Lifting the raw-code check to a NAMED predicate
79//! moves every consumer onto the semantic axis, so a future
80//! normalization (a version of `is_not_found` that also matches
81//! `kube::Error::Api(ErrorResponse { reason: "NotFound", .. })` for
82//! servers that stamp the reason but not the code, or a version of
83//! `is_conflict` that folds the `AlreadyExists`, `Conflict`, and
84//! generation-mismatch reasons together) lands at THIS ONE substrate
85//! owner and every downstream idempotent-write consumer inherits the
86//! upgrade mechanically — no per-site edit at any of the FIVE listed
87//! callers or at future consumers (an allocation delete-branch, a
88//! pool-owned Process reap idempotent gate, a table-controller stale-
89//! claim strip that must survive a race with cluster-side GC).
90//!
91//! ### `#[must_use]`
92//!
93//! Every consumer either drives a match-arm guard on the returned
94//! bool or short-circuits a fallthrough branch on it. Dropping the
95//! return means the predicate was computed for no observable reason —
96//! the attribute surfaces that as a warning at every call site.
97
98use kube::Error;
99
100/// The kube error names an HTTP 409 Conflict response — a `create`
101/// refused because the resource already exists, or a `patch` refused
102/// because of an optimistic-concurrency generation mismatch.
103///
104/// See the module docs for the full callsite audit and the semantic-
105/// axis rationale.
106#[must_use]
107pub fn is_conflict(err: &Error) -> bool {
108 matches!(err, Error::Api(e) if e.code == 409)
109}
110
111/// The kube error names an HTTP 404 Not Found response — the K8s API
112/// server has no resource with the given key.
113///
114/// See the module docs for the full callsite audit and the semantic-
115/// axis rationale.
116#[must_use]
117pub fn is_not_found(err: &Error) -> bool {
118 matches!(err, Error::Api(e) if e.code == 404)
119}
120
121/// Substrate extension trait over `Result<T, kube::Error>` — the ONE
122/// substrate owner of the `.map_err(|e| anyhow::anyhow!("<ctx>: {e}"))`
123/// wrap-shape every reconciler consumer restates by hand at the
124/// K8s round-trip → anyhow error boundary.
125///
126/// Pre-lift this shape was hand-authored at 25+ sites across
127/// `tatara-reconciler` + `tatara-pool-reconciler` — every consumer
128/// that awaited a `Result<_, kube::Error>` and needed to thread it
129/// into an anyhow-returning phase-handler / signal-effect handler /
130/// pool-reconciler tick. Each site restated the SAME closure — capture
131/// a [`kube::Error`], prepend a static (or `format!`-owned) context
132/// slug, delegate the tail to [`kube::Error`]'s `Display` impl via the
133/// `{e}` slot — differing only in the context slug prefix each
134/// handler stamped. The consumer inventory spans the full reconciler
135/// phase machine (`install finalizer`, `patch status`, `ensure
136/// ProcessTable`, `bump nextSequence`, `patch pid`, every phase
137/// transition wrap, `patch fluxResources`, `patch postconditions`,
138/// `patch attestation`, `list export jobs`, `list processes`), every
139/// signal-effect handler in `signals.rs` (`transition via signal`,
140/// `force attest`, `suspend`, `resume`, `remediate`), and the pool-
141/// reconciler's controller entry points.
142///
143/// Post-lift each callsite reads
144/// `<kube-returning-call>().await.kube_ctx("<slug>")?` and the
145/// wrap-shape lives at ONE substrate owner here. The composed
146/// [`anyhow::Error`]'s `Display` is byte-identical to the pre-lift
147/// chain (`format!("{ctx}: {e}")`, threading the [`kube::Error`]'s
148/// own `Display` verbatim into the `{e}` slot), so operator-facing
149/// log output and error-chain greps still match bytewise. A regression
150/// that drifts the separator, swaps the two slots, or wraps the
151/// [`kube::Error`] with a chain-form `source` (which would change
152/// `Display` output on the `err` slot) surfaces at
153/// [`tests::kube_ctx_static_str_context_matches_pre_lift_format_bytewise`]
154/// rather than as silent operator-facing drift across every one of
155/// the 25+ pre-lift consumers.
156///
157/// ### Two flavors: `kube_ctx` + `kube_ctx_with`
158///
159/// * [`Self::kube_ctx`] takes a `&'static str` context — the most
160/// common shape, matching every static-slug consumer
161/// (`"install finalizer"`, `"patch attestation"`, etc.). Static
162/// binding keeps the compile-time contract that the context slug
163/// is a bare literal, no allocation, no dynamic content leaking
164/// into an error stream downstream operators grep on.
165/// * [`Self::kube_ctx_with`] takes an owned [`String`] context — the
166/// escape hatch for the two dynamic-slug consumers
167/// (`format!("patch (releasing→{next})")`, the `next` variant
168/// name is only known at runtime), matching the pre-lift shape
169/// where a `format!` composed the slug per-call.
170///
171/// Naming — `kube_ctx` rather than the anyhow crate's `.context(...)` —
172/// is deliberate. `anyhow::Context::context` wraps the source in a
173/// chain (so `Display` emits only the context slug and callers reach
174/// the `kube::Error` via [`std::error::Error::source`] traversal), while
175/// this trait's `kube_ctx` FLATTENS to a display-prefix shape
176/// (`"<ctx>: <kube::Error display>"`) — the pre-lift wire format every
177/// consumer's log output already encoded. Sharing the name would let
178/// a caller who has `anyhow::Context` in scope resolve to the WRONG
179/// method (a chain-wrap instead of the display-prefix flatten) and
180/// silently change every operator log message.
181///
182/// The `#[must_use]` attribute rides through from the trait method —
183/// dropping the return of a K8s round-trip wrap means the error is
184/// swallowed entirely, which is never the intended semantic at any of
185/// the 25+ pre-lift consumers (each threads the `?` short-circuit onto
186/// its handler's `Result<Action, anyhow::Error>` return).
187///
188/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
189/// KubeError → anyhow-with-display-prefix wrap-shape recurred at 25+
190/// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
191/// trigger, and is lifted to ONE substrate owner here). THEORY.md
192/// §II.1 invariant 5 (composition preserves proofs — a regression
193/// that drifts the display-prefix separator or the byte-shape at ONE
194/// site surfaces here at the substrate pin rather than as silent
195/// operator-facing skew across every reconciler / signal / pool tick).
196pub trait KubeResultExt<T>: Sized {
197 /// Wrap the `kube::Error` (if any) with a static context prefix,
198 /// producing an [`anyhow::Result`] whose error `Display` reads
199 /// exactly `"<context>: <kube::Error display>"`.
200 #[must_use = "an error wrap that isn't threaded via `?` swallows the K8s round-trip failure"]
201 fn kube_ctx(self, context: &'static str) -> anyhow::Result<T>;
202
203 /// Owned-string peer of [`Self::kube_ctx`] — the escape hatch for
204 /// consumers that compose the context slug via `format!` (e.g.
205 /// `format!("patch (releasing→{next})")` where the tail is only
206 /// known at runtime).
207 #[must_use = "an error wrap that isn't threaded via `?` swallows the K8s round-trip failure"]
208 fn kube_ctx_with(self, context: String) -> anyhow::Result<T>;
209}
210
211impl<T> KubeResultExt<T> for Result<T, Error> {
212 #[inline]
213 fn kube_ctx(self, context: &'static str) -> anyhow::Result<T> {
214 self.map_err(|e| anyhow::anyhow!("{context}: {e}"))
215 }
216
217 #[inline]
218 fn kube_ctx_with(self, context: String) -> anyhow::Result<T> {
219 self.map_err(|e| anyhow::anyhow!("{context}: {e}"))
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226 use kube::core::ErrorResponse;
227
228 fn api_err(code: u16) -> Error {
229 Error::Api(ErrorResponse {
230 status: "Failure".into(),
231 message: format!("test code {code}"),
232 reason: match code {
233 404 => "NotFound".into(),
234 409 => "AlreadyExists".into(),
235 _ => "Test".into(),
236 },
237 code,
238 })
239 }
240
241 #[test]
242 fn conflict_matches_only_409_api_variant() {
243 // The 409-code pin — a regression that widened the predicate
244 // to any non-2xx code, that swapped the equality-check for
245 // an inequality (`!= 200`), or that dropped the `Api` variant
246 // guard (matching on Auth / Discovery / other sub-variants
247 // whose interior doesn't carry a `code` slot) would surface
248 // HERE rather than as silent already-exists-branch drift at
249 // every one of the FIVE consumer sites.
250 assert!(is_conflict(&api_err(409)));
251 assert!(!is_conflict(&api_err(200)));
252 assert!(!is_conflict(&api_err(400)));
253 assert!(!is_conflict(&api_err(404)));
254 assert!(!is_conflict(&api_err(410)));
255 assert!(!is_conflict(&api_err(500)));
256 }
257
258 #[test]
259 fn not_found_matches_only_404_api_variant() {
260 // The 404-code pin — sibling to the 409 pin above. A
261 // regression that folded 404 and 410 (Gone) together, or that
262 // aliased 404 to any client-error code, would surface HERE
263 // rather than as silent already-gone-branch drift at the
264 // watcher's delete-branch (which reads the arm as "the
265 // allocation is not present, return 200 OK to the webhook").
266 assert!(is_not_found(&api_err(404)));
267 assert!(!is_not_found(&api_err(200)));
268 assert!(!is_not_found(&api_err(400)));
269 assert!(!is_not_found(&api_err(409)));
270 assert!(!is_not_found(&api_err(410)));
271 assert!(!is_not_found(&api_err(500)));
272 }
273
274 #[test]
275 fn conflict_and_not_found_are_mutually_exclusive() {
276 // Every kube::Error the predicates are asked about maps onto
277 // at most ONE of the two semantics — 404 and 409 are distinct
278 // HTTP status codes, and the K8s API server sends them for
279 // distinct outcomes. Pin the mutual exclusivity so a future
280 // normalization that widened the interior match on ONE
281 // predicate can't silently start matching the OTHER's code
282 // and start double-firing at every match with both arms.
283 for code in [200u16, 400, 404, 409, 410, 500, 503] {
284 let e = api_err(code);
285 assert!(
286 !(is_conflict(&e) && is_not_found(&e)),
287 "conflict + not_found both fired for code {code}"
288 );
289 }
290 }
291
292 #[test]
293 fn non_api_variants_return_false_for_both_predicates() {
294 // The `Api`-variant guard is load-bearing — every other
295 // `kube::Error` sub-variant (transport, auth, discovery, …)
296 // has no `code` slot to inspect, so the predicate MUST return
297 // `false` rather than panic or match by accident. Pin one of
298 // the codeless sub-variants so a future refactor that swept
299 // the `Api` guard out of the `matches!` shape (leaving only
300 // the code arithmetic) surfaces HERE as a compile-time /
301 // pattern-shape defect.
302 let sd_err = kube::Error::LinesCodecMaxLineLengthExceeded;
303 assert!(!is_conflict(&sd_err));
304 assert!(!is_not_found(&sd_err));
305 }
306
307 // ─── KubeResultExt::kube_ctx substrate pins ──────────────────────
308 //
309 // Fail-before-pass-after granularity: the `KubeResultExt::kube_ctx`
310 // trait method did not exist before this commit, so each test
311 // below fails to compile pre-lift. Post-lift they collectively pin
312 // the display-prefix wrap-shape at ONE substrate owner — a
313 // regression that drifts the separator, swaps the two slots, wraps
314 // the `kube::Error` with a chain-form `source`, or promotes the
315 // pass-through arm to a synthesis (an empty `Ok(())`, a mutated
316 // context slug) surfaces HERE rather than as silent operator-facing
317 // skew across the 25+ pre-lift consumers whose log output already
318 // encoded the flat `"<ctx>: <kube display>"` shape.
319
320 #[test]
321 fn kube_ctx_static_str_context_matches_pre_lift_format_bytewise() {
322 // Byte-shape parity pin: the wrap output of `kube_ctx("<slug>")`
323 // MUST be `Display`-identical to the pre-lift hand-authored
324 // `.map_err(|e| anyhow!("<slug>: {e}"))` chain. A regression
325 // that inserted a separator character (`"<slug>:: <kube>"`),
326 // dropped the space after the colon, or swapped the two slots
327 // (`"<kube>: <slug>"`) surfaces HERE rather than as silent
328 // drift at every downstream log-output consumer.
329 let raw: Result<(), Error> = Err(api_err(404));
330 let via_trait = raw.kube_ctx("install finalizer").unwrap_err();
331 let pre_lift = anyhow::anyhow!("install finalizer: {}", api_err(404));
332 assert_eq!(
333 format!("{via_trait}"),
334 format!("{pre_lift}"),
335 "kube_ctx wrap must be Display-identical to pre-lift anyhow! chain"
336 );
337 }
338
339 #[test]
340 fn kube_ctx_ok_arm_is_a_pure_passthrough() {
341 // Ok-arm invariant: `kube_ctx` on `Ok(t)` MUST return `Ok(t)`
342 // verbatim — no side-effect on the payload, no synthesis of a
343 // context-tagged error, no allocation. Peer to the Err-arm
344 // byte-shape pin; a regression that promoted the Ok arm to
345 // ALWAYS produce a synthesis Error would silently break every
346 // successful K8s round-trip in the pre-lift consumer set.
347 let raw: Result<i32, Error> = Ok(42);
348 assert_eq!(raw.kube_ctx("noop").unwrap(), 42);
349 }
350
351 #[test]
352 fn kube_ctx_with_owned_string_matches_pre_lift_format_bytewise() {
353 // Owned-string peer's byte-shape pin — same discipline as the
354 // static-`&str` peer above. Consumers that compose the context
355 // slug via `format!` (e.g. `format!("patch (releasing→{next})")`)
356 // route through this method and inherit the SAME display-prefix
357 // discipline as the static-slug peer, so mixing the two forms
358 // across the reconciler's log stream never surfaces as a
359 // format-string skew.
360 let raw: Result<(), Error> = Err(api_err(409));
361 let dynamic_slug = format!("patch (releasing→{})", "Exiting");
362 let via_trait = raw.kube_ctx_with(dynamic_slug.clone()).unwrap_err();
363 let pre_lift = anyhow::anyhow!("{}: {}", dynamic_slug, api_err(409));
364 assert_eq!(
365 format!("{via_trait}"),
366 format!("{pre_lift}"),
367 "kube_ctx_with wrap must be Display-identical to pre-lift anyhow! chain"
368 );
369 }
370
371 #[test]
372 fn kube_ctx_static_and_owned_peers_produce_identical_output_for_the_same_slug() {
373 // Cross-peer coherence pin: given the SAME context slug via
374 // both peers (a `&'static str` passed to `kube_ctx` and the
375 // owned `String` produced by `.to_string()` passed to
376 // `kube_ctx_with`), the wrapped `anyhow::Error` MUST have
377 // byte-identical `Display` output. A regression that drifted
378 // one peer's format string away from the other would surface
379 // HERE rather than as silent operator-facing skew between
380 // static-slug consumers and format!-slug consumers in the
381 // same log stream.
382 let slug = "patch attestation";
383 let a: Result<(), Error> = Err(api_err(500));
384 let b: Result<(), Error> = Err(api_err(500));
385 assert_eq!(
386 format!("{}", a.kube_ctx(slug).unwrap_err()),
387 format!("{}", b.kube_ctx_with(slug.to_string()).unwrap_err()),
388 "static-str and owned-string peers must produce identical Display output"
389 );
390 }
391
392 #[test]
393 fn kube_ctx_threads_the_underlying_kube_error_display_verbatim() {
394 // Display-tail invariant: the wrapped `anyhow::Error`'s
395 // `Display` output MUST contain the `kube::Error`'s own
396 // `Display` output verbatim as the tail past `"<ctx>: "`.
397 // A regression that inserted a normalization (uppercase, JSON
398 // encoding, truncation) between the composed `{e}` slot and
399 // the underlying `Display` impl would surface HERE rather
400 // than as silent K8s-error-detail loss across the reconciler's
401 // error stream.
402 let underlying = api_err(404);
403 let underlying_display = format!("{underlying}");
404 let raw: Result<(), Error> = Err(underlying);
405 let wrapped = raw.kube_ctx("list processes").unwrap_err();
406 let wrapped_display = format!("{wrapped}");
407 assert!(
408 wrapped_display.ends_with(&underlying_display),
409 "wrapped Display `{wrapped_display}` must end with underlying kube Display `{underlying_display}`"
410 );
411 assert!(
412 wrapped_display.starts_with("list processes: "),
413 "wrapped Display `{wrapped_display}` must start with `\"<ctx>: \"`"
414 );
415 }
416}