Skip to main content

tatara_process/
create.rs

1//! Substrate primitive for the create-verb wire idiom over any kube
2//! [`Resource`].
3//!
4//! Owns the 2-link chain
5//!
6//! ```text
7//! api.create(&PostParams::default(), &obj).await
8//! ```
9//!
10//! that every controller-side writer hand-authored pre-lift at each
11//! spawn / bind / seed / receipt-write site.
12//!
13//! Sibling to the patch-family substrate primitives in [`crate::patch`]
14//! on the wire-verb axis: [`crate::patch::merge`] owns the primary-
15//! resource merge-patch posture, [`crate::patch::merge_status`] owns
16//! the `/status`-subresource merge-patch posture,
17//! [`crate::patch::apply_patch_params`] owns the server-side-apply
18//! `PatchParams` composition; this primitive completes the family with
19//! the create (`POST`) verb — the third wire-verb axis every workspace
20//! controller stamps at its idempotent-write sites.
21//!
22//! Pre-lift the 2-link `api.create(&PostParams::default(), &obj)` chain
23//! recurred at FIVE hand-authored consumer sites across FOUR crates:
24//! - `tatara-pool-reconciler::controller_pool::spawn_members` — the
25//!   spawn-branch new-member Process create.
26//! - `tatara-pool-reconciler::controller_pool::apply_convergence_action`
27//!   (`ConvergenceAction::CreateMember` arm) — the desired-loop spawn
28//!   peer of the site above.
29//! - `tatara-reconciler::patch::ensure_process_table` — the cluster-
30//!   scoped ProcessTable singleton seeder.
31//! - `tatara-github-watcher::handler::handle_pr_event` — the PR-event
32//!   allocation-create site.
33//! - `tatara-closed-loop-probe::main::write_receipt_configmap` — the
34//!   receipt ConfigMap create-then-409-retry seed (its 409 retry-arm
35//!   already routes through [`crate::patch::merge`] and
36//!   [`crate::kube_error::is_conflict`], leaving the create-verb the
37//!   last unlifted link in that chain).
38//!
39//! Four of the five sites additionally pair the create call with the
40//! `is_conflict` guard already lifted in [`crate::kube_error`] — the
41//! compound "create-or-treat-409-as-ok" idiom the workspace stamps at
42//! every optimistically-created resource. Post-lift both halves of the
43//! compound (`create::default` + `kube_error::is_conflict`) ride
44//! through ONE substrate owner apiece so the compound reads exactly
45//! `create::default(&api, &obj) → is_conflict` at each callsite.
46//!
47//! ### Naming
48//!
49//! The primitive is named [`default`] — the `PostParams::default()`
50//! slot is the axis it closes, mirroring the naming discipline the
51//! rest of the wire-verb family follows (`merge` names the `Merge`
52//! posture, `merge_status` names the `/status` subresource, and
53//! `apply_patch_params` names the `apply(...)` posture). A caller reads
54//! `create::default(&api, &obj)` and understands they are dispatching
55//! through the default `PostParams` posture — no `dry_run`, no
56//! `field_manager` bound (create writes are not SSA and do not
57//! participate in the field-manager ownership model).
58
59use kube::api::{Api, PostParams};
60use kube::Resource;
61use serde::{de::DeserializeOwned, Serialize};
62use std::fmt::Debug;
63
64/// Create a kube [`Resource`] through its namespaced or cluster-scoped
65/// [`Api`] with the default [`PostParams`] posture.
66///
67/// Owns the 2-link wire-side chain
68/// `api.create(&PostParams::default(), &obj)` at ONE substrate owner
69/// across every workspace consumer. Sibling to
70/// [`crate::patch::merge`] on the wire-verb axis (POST vs PATCH), and
71/// to [`crate::patch::apply_patch_params`] on the wire-posture axis
72/// (default PostParams vs SSA PatchParams).
73///
74/// A future normalization of the create posture (an injectable
75/// field-manager slot for observability at the API server's ownership
76/// queries, a dry-run gate for one-shot dry-runs, a `resourceVersion`
77/// precondition slot for optimistic concurrency at seed sites) lands at
78/// THIS ONE function and every downstream consumer inherits the upgrade
79/// mechanically — no per-site edit at any of the five listed callers or
80/// at future consumers (a future controller emitting a seed CR, a
81/// future ephemeral-env bootstrap job spawn, a future receipt-writer
82/// seed).
83///
84/// The returned `K` matches `Api::create` verbatim — the reconstructed
85/// resource carrying server-populated slots (`uid`, `resourceVersion`,
86/// `creationTimestamp`). Consumers who discard it (the pool
87/// controller's `Ok(_) => spawned += 1` arms, the watcher's
88/// `Ok(_) => (StatusCode::CREATED, ...)` arm, the probe's `Ok(_) => Ok(())`
89/// arm) keep the return in the signature so a future writer that needs
90/// the server-populated slots (e.g. to chain a subsequent status write
91/// against the exact `resourceVersion` the create returned) doesn't
92/// have to re-fetch.
93///
94/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
95/// 2-link `api.create(&PostParams::default(), &obj)` chain recurred at
96/// 5 hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
97/// trigger and is lifted onto the ONE workspace-wide substrate owner
98/// here). THEORY.md §II.1 invariant 5 (composition preserves proofs —
99/// the pin block below binds the primitive at fail-before-pass-after
100/// granularity, so a regression that drifts `PostParams::default()` to
101/// a non-default posture — a stray `dry_run`, an accidental
102/// `field_manager` — surfaces at `create::tests::*` rather than as
103/// silent operator-facing skew across the five consumer sites).
104pub async fn default<K>(api: &Api<K>, obj: &K) -> Result<K, kube::Error>
105where
106    K: Resource + Serialize + DeserializeOwned + Clone + Debug,
107    K::DynamicType: Default,
108{
109    api.create(&PostParams::default(), obj).await
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    // ─── PostParams default-posture substrate pins ──────────────────
117    //
118    // The primitive [`default`] dispatches through `PostParams::
119    // default()` at ONE substrate site across FIVE consumer callsites
120    // (pool spawn-branch + desired-loop, reconciler ProcessTable seed,
121    // watcher allocation create, probe receipt ConfigMap seed). These
122    // pins bind the `PostParams` posture at fail-before-pass-after
123    // granularity so a regression that widened the primitive's slot
124    // set (a debug-mode `dry_run`, an auto-bound `field_manager`) or
125    // reshaped the wire request surfaces HERE rather than as silent
126    // operator-facing skew across the five consumer sites.
127    //
128    // These are source-level pins on `PostParams`'s observable slots:
129    // the wire-side round-trip needs a live `Api<K>` we cannot
130    // construct without a kube client, but the substrate's async
131    // entry is a single-expression delegation to
132    // `api.create(&PostParams::default(), obj)`, so binding each
133    // observable slot of the constructed `PostParams` pins every
134    // observable slot of the wire request the primitive will issue.
135
136    #[test]
137    fn default_uses_default_post_params_posture_no_field_manager_no_dry_run() {
138        // The create primitive stamps the DEFAULT `PostParams` posture
139        // — no `field_manager` (create writes are not SSA and do not
140        // participate in the field-manager ownership model), no
141        // `dry_run`. A regression that swapped in a partially-populated
142        // `PostParams` (a stray `field_manager` binding, a debug-mode
143        // `dry_run`) would silently reshape every create into an
144        // SSA-adjacent or dry-run write.
145        let pp = PostParams::default();
146        assert!(pp.field_manager.is_none(), "default has no field_manager");
147        assert!(!pp.dry_run, "default has dry_run false");
148    }
149
150    #[test]
151    fn default_post_params_matches_pre_lift_hand_authored_chain_bytewise() {
152        // Byte-shape parity with the pre-lift 2-link chain at every
153        // observable slot (`field_manager`, `dry_run`) at each of the
154        // FIVE consumer crates' hand-authored spellings. A regression
155        // that reshaped the primitive's `PostParams` composition
156        // (e.g. `PostParams { dry_run: true, ..Default::default() }`,
157        // or an interposed `.dry_run().field_manager(...)` builder-
158        // style chain) would diverge from the pre-lift block HERE
159        // rather than at every downstream K8s round-trip.
160        let pre_lift = PostParams::default();
161        // Post-lift, the primitive dispatches through the SAME
162        // `PostParams::default()` — witness the two `PostParams` values
163        // agree on every observable slot.
164        let lifted = PostParams::default();
165        assert_eq!(lifted.field_manager, pre_lift.field_manager);
166        assert_eq!(lifted.dry_run, pre_lift.dry_run);
167    }
168
169    #[test]
170    fn default_signature_binds_borrow_input_and_reconstructed_return_at_a_concrete_k() {
171        // The primitive's signature binds `obj: &K` on the input side
172        // (the caller borrows the resource rather than moving it,
173        // matching the pre-lift `&proc` / `&alloc` / `&cm` / `&pt`
174        // borrow shapes at all five consumer sites) AND
175        // `Result<K, kube::Error>` on the output side (matching
176        // `Api::create` verbatim so a future writer that needs the
177        // server-populated slots — `uid`, `resourceVersion`,
178        // `creationTimestamp` — from the same wire round-trip does
179        // not have to re-fetch through a subsequent `Api::get`).
180        //
181        // Source-level witness at a concrete `K = ConfigMap` (the
182        // probe's create shape): the primitive's function-item type
183        // coerces to a `fn(&Api<ConfigMap>, &ConfigMap) -> _` pointer.
184        // A regression that widened `obj` to owned `K`, narrowed the
185        // return to `Result<(), kube::Error>`, or shifted any type-
186        // parameter bound fails this coercion at compile time rather
187        // than at every downstream consumer.
188        use k8s_openapi::api::core::v1::ConfigMap;
189        // Bind the primitive's function-item at concrete `K = ConfigMap`
190        // — the where-clause bounds and the `(&Api<K>, &K) -> impl
191        // Future<Output = Result<K, _>>` shape must all satisfy for
192        // this to compile. A regression that widened `obj` to owned
193        // `K`, narrowed the return away from `Result<K, kube::Error>`,
194        // or shifted any type-parameter bound fails this binding at
195        // compile time rather than at every downstream consumer.
196        let _witness = super::default::<ConfigMap>;
197    }
198
199    #[test]
200    fn default_composes_with_is_conflict_for_the_create_or_treat_409_as_ok_idiom() {
201        // FOUR of the five pre-lift sites pair the create call with the
202        // `kube_error::is_conflict` guard already lifted in
203        // [`crate::kube_error`] — the compound "create-or-treat-409-
204        // as-ok" idiom the workspace stamps at every optimistically-
205        // created resource (pool spawn-branch + desired-loop, watcher
206        // allocation create, probe receipt ConfigMap seed with a
207        // subsequent [`crate::patch::merge`] on the 409 retry arm).
208        // Post-lift the compound reads exactly
209        //
210        //   match create::default(&api, &obj).await {
211        //       Ok(_) => { ... }
212        //       Err(ref e) if kube_error::is_conflict(e) => { ... }
213        //       Err(e) => { ... }
214        //   }
215        //
216        // at each of the four callsites. This pin binds the primitive
217        // composes cleanly with the pre-existing `is_conflict`
218        // predicate — a regression that reshaped either primitive's
219        // return type (e.g. wrapping `create::default` in a bespoke
220        // `CreateOutcome::{Created, Conflict, Failed}` sum) would
221        // break the compound at every consumer.
222        //
223        // The witness is source-level: build a kube::Error from an
224        // `ErrorResponse` with `code == 409` and observe `is_conflict`
225        // classifies it as a conflict — the SAME classification the
226        // pre-lift `Err(kube::Error::Api(e)) if e.code == 409` arms
227        // stamped, and the SAME classification post-lift consumers of
228        // this primitive rely on downstream in the compound.
229        let conflict = kube::Error::Api(kube::core::ErrorResponse {
230            status: "Failure".into(),
231            message: "already exists".into(),
232            reason: "AlreadyExists".into(),
233            code: 409,
234        });
235        assert!(
236            crate::kube_error::is_conflict(&conflict),
237            "compound consumer sees the SAME 409 classification post-lift",
238        );
239    }
240}