Skip to main content

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}