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(¬_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}