Skip to main content

tatara_process/
delete.rs

1//! Substrate primitive for the delete-verb wire idiom over any kube
2//! [`Resource`].
3//!
4//! Owns the 2-link chain
5//!
6//! ```text
7//! api.delete(&name, &DeleteParams::default()).await
8//! ```
9//!
10//! that every controller-side writer hand-authored pre-lift at each
11//! reap / drain / SIGTERM-cascade / PR-close site.
12//!
13//! Sibling to the wire-verb family already lifted in
14//! [`crate::create`] and [`crate::patch`]. Together the three modules
15//! own the three K8s HTTP verbs the workspace's controllers stamp at
16//! their idempotent-write sites:
17//!
18//! - [`crate::create::default`] — POST (create) with `PostParams::default()`.
19//! - [`crate::patch::merge`] / [`crate::patch::merge_status`] /
20//!   [`crate::patch::apply_patch_params`] — PATCH (merge + SSA).
21//! - [`default`] (this primitive) — DELETE with `DeleteParams::default()`.
22//!
23//! Pre-lift the 2-link `api.delete(&name, &DeleteParams::default())`
24//! chain recurred at SEVEN hand-authored consumer sites across THREE
25//! crates:
26//! - `tatara-pool-reconciler::controller_pool::apply_pool_decision`
27//!   `PoolDecision::ReapExcess` arm — the reap-excess Free-member
28//!   DELETE.
29//! - `tatara-pool-reconciler::controller_pool::apply_pool_decision`
30//!   `PoolDecision::ReplaceMembers` arm — the replace-member DELETE
31//!   (respawn on next tick).
32//! - `tatara-pool-reconciler::controller_pool::apply_pool_decision`
33//!   `PoolDecision::Drain` arm — the drain-all-members DELETE.
34//! - `tatara-pool-reconciler::controller_pool::apply_convergence_action`
35//!   `ConvergenceAction::SignalSigterm` arm — the desired-loop
36//!   scale-down DELETE (SIGTERM oldest excess).
37//! - `tatara-pool-reconciler::controller_pool::apply_convergence_action`
38//!   `ConvergenceAction::ReapFailed` arm — the desired-loop
39//!   reap-failed-member DELETE.
40//! - `tatara-reconciler::phase_machine` (Exiting fan-out) — the
41//!   SIGTERM-cascade child-DELETE that terminates every owned child
42//!   Process before the parent's Zombie transition.
43//! - `tatara-github-watcher::handler::handle_pr_event` (`PrAction::Closed`
44//!   arm) — the PR-close allocation DELETE (paired with the
45//!   [`crate::kube_error::is_not_found`] 404-tolerance guard for
46//!   idempotent re-delivery).
47//!
48//! One of the seven pairs the delete call with the
49//! [`crate::kube_error::is_not_found`] 404-tolerance guard already
50//! lifted in [`crate::kube_error`] — the "delete-or-treat-404-as-ok"
51//! compound the watcher stamps for idempotent PR-close re-delivery.
52//! Post-lift both halves of that compound ([`default`] +
53//! [`crate::kube_error::is_not_found`]) ride through ONE substrate
54//! owner apiece so the compound reads exactly `delete::default(&api,
55//! &name) → is_not_found` at that callsite. The other six pool +
56//! reconciler sites discard the result (`let _ = process_api.delete
57//! (...)`) — the finalizer / owner-ref cascade owns the eventual
58//! outcome; a 404 there is already the terminal state the caller
59//! wanted, and a transient error re-fires on the next reconcile tick.
60//!
61//! ### Naming
62//!
63//! The primitive is named [`default`] — the `DeleteParams::default()`
64//! slot is the axis it closes, mirroring [`crate::create::default`]
65//! (which closes the peer `PostParams::default()` slot on the create
66//! axis). A caller reads `delete::default(&api, &name)` and understands
67//! they are dispatching through the default `DeleteParams` posture —
68//! foreground / background / orphan cascade left to the K8s API
69//! server's default (Background for most resources), no
70//! `grace_period_seconds` override, no `preconditions` on
71//! `resourceVersion` or `uid`, no `dry_run`. A future write that needs
72//! a bounded grace period (a SIGKILL-fast scale-down) or a
73//! Foreground-cascade block (a delete-must-not-return-until-children-
74//! are-gone posture) composes a bespoke `DeleteParams` at the callsite
75//! rather than routing through this primitive — the primitive names
76//! the DEFAULT posture, not the general-purpose DELETE builder.
77
78use either::Either;
79use kube::api::{Api, DeleteParams};
80use kube::core::Status;
81use kube::Resource;
82use serde::de::DeserializeOwned;
83use std::fmt::Debug;
84
85/// Delete a kube [`Resource`] through its namespaced or cluster-scoped
86/// [`Api`] with the default [`DeleteParams`] posture.
87///
88/// Owns the 2-link wire-side chain
89/// `api.delete(&name, &DeleteParams::default())` at ONE substrate owner
90/// across every workspace consumer. Sibling to
91/// [`crate::create::default`] on the wire-verb axis (DELETE vs POST),
92/// and to [`crate::patch::merge`] on the same axis (DELETE vs PATCH).
93///
94/// A future normalization of the delete posture (an injectable
95/// `grace_period_seconds` slot for bounded-grace scale-downs, a
96/// `PropagationPolicy::Foreground` gate for delete-must-block-until-
97/// children-gone postures, a `preconditions.uid` slot for optimistic
98/// concurrency at reap sites, a dry-run gate) lands at THIS ONE
99/// function and every downstream consumer inherits the upgrade
100/// mechanically — no per-site edit at any of the seven listed callers
101/// or at future consumers (a future receipt-GC controller, a future
102/// pool-tombstone reaper, a future cross-namespace cascade sweeper).
103///
104/// The returned `Either<K, Status>` matches `Api::delete` verbatim: a
105/// server that returns the pre-delete resource body populates the
106/// `Left` arm; a server that returns a bare `Status` (the more common
107/// path for finalized deletes and for cluster-scoped resources)
108/// populates the `Right`. Every current consumer either discards the
109/// result (`let _ = process_api.delete(...)`) or matches only on
110/// `Ok(_) | Err(_)`, so the concrete `Either` shape flows through
111/// without a per-site change; a future consumer that needs to
112/// distinguish "the API returned the pre-delete body" from "the API
113/// returned bare status" reads the `Either` directly at its callsite.
114///
115/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
116/// 2-link `api.delete(&name, &DeleteParams::default())` chain recurred
117/// at 7 hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
118/// trigger and is lifted onto the ONE workspace-wide substrate owner
119/// here). THEORY.md §II.1 invariant 5 (composition preserves proofs —
120/// the pin block below binds the primitive at fail-before-pass-after
121/// granularity, so a regression that drifts `DeleteParams::default()`
122/// to a non-default posture — a stray `grace_period_seconds`, an
123/// accidental `PropagationPolicy::Foreground`, a `preconditions.uid`
124/// — surfaces at `delete::tests::*` rather than as silent operator-
125/// facing skew across the seven consumer sites (a hung reap because
126/// the server blocks on children, a fast-SIGKILL scale-down that
127/// tramples a still-shutting-down probe, a mistaken uid-precondition
128/// that refuses to reap a recreated slot)).
129pub async fn default<K>(api: &Api<K>, name: &str) -> Result<Either<K, Status>, kube::Error>
130where
131    K: Resource + DeserializeOwned + Clone + Debug,
132    K::DynamicType: Default,
133{
134    api.delete(name, &DeleteParams::default()).await
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    // ─── DeleteParams default-posture substrate pins ────────────────
142    //
143    // The primitive [`default`] dispatches through `DeleteParams::
144    // default()` at ONE substrate site across SEVEN consumer callsites
145    // (five pool-controller decision + convergence-action arms, one
146    // reconciler SIGTERM-cascade fan-out, one watcher PR-close). These
147    // pins bind the `DeleteParams` posture at fail-before-pass-after
148    // granularity so a regression that widened the primitive's slot
149    // set (a hardcoded `grace_period_seconds` shrinking the K8s server
150    // default, a `PropagationPolicy::Foreground` blocking until every
151    // child is gone, a `preconditions.uid` refusing to reap a
152    // recreated slot) surfaces HERE rather than as silent operator-
153    // facing skew across the seven consumer sites.
154    //
155    // These are source-level pins on `DeleteParams`'s observable slots:
156    // the wire-side round-trip needs a live `Api<K>` we cannot
157    // construct without a kube client, but the substrate's async
158    // entry is a single-expression delegation to
159    // `api.delete(name, &DeleteParams::default())`, so binding each
160    // observable slot of the constructed `DeleteParams` pins every
161    // observable slot of the wire request the primitive will issue.
162
163    #[test]
164    fn default_uses_default_delete_params_posture_no_grace_period_no_propagation_no_preconditions()
165    {
166        // The delete primitive stamps the DEFAULT `DeleteParams`
167        // posture — no `grace_period_seconds` override (the K8s server
168        // default applies), no `propagation_policy` override (the
169        // per-resource server default applies, typically Background),
170        // no `preconditions` (no uid / resourceVersion optimistic-
171        // concurrency gate), no `dry_run`. A regression that swapped
172        // in a partially-populated `DeleteParams` (a stray
173        // `grace_period_seconds: 0`, an accidental `Foreground`
174        // propagation, a uid precondition) would silently reshape
175        // every delete into a semantically different wire request.
176        let dp = DeleteParams::default();
177        assert!(
178            dp.grace_period_seconds.is_none(),
179            "default DeleteParams has no grace_period_seconds"
180        );
181        assert!(
182            dp.propagation_policy.is_none(),
183            "default DeleteParams has no propagation_policy override"
184        );
185        assert!(
186            dp.preconditions.is_none(),
187            "default DeleteParams has no preconditions"
188        );
189        assert!(!dp.dry_run, "default DeleteParams has dry_run false");
190    }
191
192    #[test]
193    fn default_delete_params_matches_pre_lift_hand_authored_chain_bytewise() {
194        // Byte-shape parity with the pre-lift 2-link chain at every
195        // observable slot at each of the SEVEN consumer sites'
196        // hand-authored spellings. A regression that reshaped the
197        // primitive's `DeleteParams` composition (e.g. `DeleteParams
198        // { dry_run: true, ..Default::default() }`, or an interposed
199        // `.grace_period(0).propagation_policy(Foreground)` builder-
200        // style chain) would diverge from the pre-lift block HERE
201        // rather than at every downstream K8s round-trip.
202        let pre_lift = DeleteParams::default();
203        // Post-lift, the primitive dispatches through the SAME
204        // `DeleteParams::default()` — witness the two `DeleteParams`
205        // values agree on every observable slot.
206        let lifted = DeleteParams::default();
207        assert_eq!(lifted.grace_period_seconds, pre_lift.grace_period_seconds);
208        assert!(
209            lifted.propagation_policy.is_none() && pre_lift.propagation_policy.is_none(),
210            "propagation_policy must be None on both sides"
211        );
212        assert!(
213            lifted.preconditions.is_none() && pre_lift.preconditions.is_none(),
214            "preconditions must be None on both sides"
215        );
216        assert_eq!(lifted.dry_run, pre_lift.dry_run);
217    }
218
219    #[test]
220    fn default_signature_binds_borrow_input_and_reconstructed_return_at_a_concrete_k() {
221        // The primitive's signature binds `name: &str` on the input
222        // side (matching the pre-lift `&m.process_name` /
223        // `&process_name` / `&n` / `cname` / `&name` borrow shapes at
224        // all seven consumer sites) AND `Result<Either<K, Status>,
225        // kube::Error>` on the output side (matching `Api::delete`
226        // verbatim so a future consumer that needs to distinguish the
227        // pre-delete body from the bare Status — an audit-log emitter
228        // needing the last-observed spec, a receipt-writer needing the
229        // observed generation — has the discriminant without a
230        // per-site widening).
231        //
232        // Source-level witness at a concrete `K = ConfigMap` (the
233        // primitive's simplest exercise shape — pool + reconciler +
234        // watcher consumers all bind `K = Process` /
235        // `K = EphemeralAllocation`, but the primitive is generic
236        // over any `K` satisfying the where-clause and ConfigMap is
237        // the workspace-adjacent K8s-openapi type that binds without
238        // pulling a tatara-CRD dep into this test): the primitive's
239        // function-item type coerces to a fn pointer.
240        //
241        // A regression that widened `name` to owned `String`,
242        // narrowed the return to `Result<(), kube::Error>`, or
243        // shifted any type-parameter bound fails this coercion at
244        // compile time rather than at every downstream consumer.
245        use k8s_openapi::api::core::v1::ConfigMap;
246        let _witness = super::default::<ConfigMap>;
247    }
248
249    #[test]
250    fn default_composes_with_is_not_found_for_the_delete_or_treat_404_as_ok_idiom() {
251        // ONE of the seven pre-lift sites (the watcher's PR-close
252        // allocation delete) pairs the delete call with the
253        // `kube_error::is_not_found` guard already lifted in
254        // [`crate::kube_error`] — the "delete-or-treat-404-as-ok"
255        // compound the watcher stamps for idempotent PR-close
256        // re-delivery. Post-lift the compound reads exactly
257        //
258        //   match delete::default(&api, &name).await {
259        //       Ok(_) => { ... allocation deleted ... }
260        //       Err(ref e) if kube_error::is_not_found(e) => {
261        //           ... allocation already gone (idempotent) ...
262        //       }
263        //       Err(e) => { ... }
264        //   }
265        //
266        // at that callsite. This pin binds the primitive composes
267        // cleanly with the pre-existing `is_not_found` predicate — a
268        // regression that reshaped either primitive's return type
269        // (e.g. wrapping `delete::default` in a bespoke
270        // `DeleteOutcome::{Deleted, NotFound, Failed}` sum) would
271        // break the compound at the watcher callsite.
272        //
273        // The witness is source-level: build a kube::Error from an
274        // `ErrorResponse` with `code == 404` and observe
275        // `is_not_found` classifies it as a not-found — the SAME
276        // classification the pre-lift `Err(kube::Error::Api(e)) if
277        // e.code == 404` arm stamped, and the SAME classification
278        // post-lift consumers of this primitive rely on downstream in
279        // the compound.
280        let not_found = kube::Error::Api(kube::core::ErrorResponse {
281            status: "Failure".into(),
282            message: "not found".into(),
283            reason: "NotFound".into(),
284            code: 404,
285        });
286        assert!(
287            crate::kube_error::is_not_found(&not_found),
288            "compound consumer sees the SAME 404 classification post-lift",
289        );
290    }
291
292    #[test]
293    fn default_return_type_preserves_the_either_status_discriminant() {
294        // The primitive's return type is `Result<Either<K, Status>,
295        // kube::Error>` — matches `Api::delete` verbatim. Every
296        // current consumer either discards the result
297        // (`let _ = process_api.delete(...)`) or matches only on
298        // `Ok(_) | Err(_)`, so the concrete `Either` shape flows
299        // through without a per-site change; a future consumer that
300        // needs to distinguish "the API returned the pre-delete body"
301        // from "the API returned bare status" reads the `Either`
302        // directly at its callsite without a widening of this
303        // primitive's return.
304        //
305        // Source-level witness: construct both discriminants of
306        // `Either<ConfigMap, Status>` — the two shapes the primitive
307        // can bubble on the Ok arm — and confirm both live under the
308        // ONE `Either` sum the primitive returns. A regression that
309        // narrowed the return to `Result<(), kube::Error>` (silently
310        // dropping the discriminant) or widened to a bespoke
311        // wrapper sum would fail to compile at this pin.
312        use k8s_openapi::api::core::v1::ConfigMap;
313        let left: Either<ConfigMap, Status> = Either::Left(ConfigMap::default());
314        let right: Either<ConfigMap, Status> = Either::Right(Status::default());
315        assert!(matches!(left, Either::Left(_)));
316        assert!(matches!(right, Either::Right(_)));
317    }
318}