tatara_process/patch.rs
1//! Substrate primitive for the merge-patch idiom over the `/status`
2//! subresource of any kube [`Resource`].
3//!
4//! Owns the 2-link chain
5//!
6//! ```text
7//! let body = json!({ "status": <typed> });
8//! api.patch_status(name, &PatchParams::default(), &Patch::Merge(&body)).await
9//! ```
10//!
11//! that every controller-side writer hand-authored pre-lift at each
12//! phase-transition + observed-fanout site.
13//!
14//! Sibling to the SSA-side substrate primitive
15//! [`crate::api_version`]-adjacent `tatara_reconciler::ssapply::apply_patch_params`
16//! (which owns the `PatchParams::apply(<mgr>).force()` peer on the
17//! server-side-apply axis). Together, the two primitives own the two
18//! wire-side write-posture axes the workspace's controllers stamp:
19//!
20//! - `Patch::Merge + PatchParams::default()` — status-subresource
21//! writes, applied here by every phase-transition writer in the
22//! `tatara-pool-reconciler` (allocation controller, pool controller)
23//! and the `tatara-reconciler` (Process status writer).
24//! - `Patch::Apply + PatchParams::apply(<mgr>).force()` — rendered
25//! FluxCD resource applies + `RELEASED_FROM` marker + the
26//! `ProcessTable.status.claims` writer.
27//!
28//! ### Return type + `#[must_use]`
29//!
30//! Returns the reconstructed `K` on success — matches `Api::patch_status`
31//! verbatim. Pool + Process controllers today discard the returned `K`
32//! (`let _ = merge_status(...).await;` after `AllocationDecision` /
33//! phase-transition branches), but the primitive keeps the return in
34//! the signature so a future writer that needs the reconciled
35//! resource-version / observed-generation from the same wire round-trip
36//! doesn't have to re-fetch. `#[must_use]` on the returned `Future`
37//! keeps a caller from building the patch call and dropping it
38//! un-awaited — the same silent-drop defect the pre-lift free-chain
39//! form quietly permitted.
40
41use kube::api::{Api, Patch, PatchParams};
42use kube::Resource;
43use serde::{de::DeserializeOwned, Serialize};
44use serde_json::json;
45use std::fmt::Debug;
46
47/// Compose the merge-patch wire body `{"status": <status>}` — the
48/// pure step [`merge_status`] performs before handing off to
49/// `Api::patch_status`.
50///
51/// Extracted as a standalone helper so the wire-body shape can be
52/// pinned by fail-before-pass-after tests without a live kube client
53/// or tokio reactor. A regression that drifts the top-level slot name
54/// (a `"Status": …` case-fold, a `"status_patch": …` verbose rename,
55/// an accidental array-wrap) surfaces here at every invariant pin
56/// rather than as silent operator-facing drift at each downstream
57/// consumer.
58#[must_use]
59pub fn merge_status_body<S: Serialize + ?Sized>(status: &S) -> serde_json::Value {
60 json!({ "status": status })
61}
62
63/// Merge-patch the `/status` subresource of any kube [`Resource`] with
64/// a typed `status` value.
65///
66/// Owns the 2-step wire-side chain `merge_status_body(status) →
67/// Api::patch_status(name, PatchParams::default(), Patch::Merge)` at
68/// ONE substrate owner across every workspace controller. Pre-lift the
69/// chain recurred at 7 hand-authored sites (4 in
70/// `tatara-pool-reconciler::controller_allocation`, 2 in
71/// `tatara-pool-reconciler::controller_pool`, 1 wrapped inside
72/// `tatara-reconciler::patch::patch_process_status`) past the ★★
73/// PRIME-DIRECTIVE ≥ 2 duplication trigger.
74///
75/// A future normalization of the merge-patch posture (an injectable
76/// field manager for status writes, a strategic-merge escape hatch, a
77/// dry-run gate for one-shot dry-runs, an added `resourceVersion`
78/// precondition slot) lands at THIS ONE function and every downstream
79/// consumer inherits the upgrade mechanically.
80pub async fn merge_status<K, S>(api: &Api<K>, name: &str, status: &S) -> Result<K, kube::Error>
81where
82 K: Resource + DeserializeOwned + Clone + Debug,
83 K::DynamicType: Default,
84 S: Serialize + ?Sized,
85{
86 let body = merge_status_body(status);
87 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&body))
88 .await
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use serde::Serialize;
95 use serde_json::json;
96
97 // ─── merge_status_body substrate pins ───────────────────────────
98 //
99 // The pre-lift `json!({"status": <typed>})` wrap recurred at 7
100 // hand-authored sites across `tatara-pool-reconciler` (both
101 // controllers) + `tatara-reconciler::patch::patch_process_status`
102 // pre-lift. These pins bind the wire-body shape at
103 // fail-before-pass-after granularity so a regression that drifts
104 // the top-level slot key, reshapes the wrap posture, or leaks a
105 // sibling slot surfaces here rather than as silent status-write
106 // drift at every downstream controller.
107
108 #[test]
109 fn merge_status_body_wraps_typed_status_under_top_level_status_slot() {
110 #[derive(Serialize)]
111 struct S {
112 phase: &'static str,
113 reason: &'static str,
114 }
115 let body = merge_status_body(&S {
116 phase: "Bound",
117 reason: "member allocated",
118 });
119 assert_eq!(
120 body,
121 json!({ "status": { "phase": "Bound", "reason": "member allocated" } }),
122 );
123 }
124
125 #[test]
126 fn merge_status_body_top_level_key_is_exactly_status_lowercase() {
127 // Any drift on the top-level slot name (case-fold to `Status`,
128 // a substrate-side rename to `status_patch`, a version-tagged
129 // wrap like `v1alpha1_status`) breaks every status writer on
130 // the wire. This pin binds the exact spelling downstream K8s
131 // API + K8s-openapi generated types expect.
132 let body = merge_status_body(&json!({"phase": "Running"}));
133 let obj = body.as_object().expect("top-level must be a JSON object");
134 assert_eq!(obj.len(), 1, "wrap adds exactly ONE top-level slot");
135 assert!(
136 obj.contains_key("status"),
137 "top-level slot must be exactly `status` (lowercase)"
138 );
139 }
140
141 #[test]
142 fn merge_status_body_accepts_pre_serialized_json_value_verbatim() {
143 // Callers that already have a `serde_json::Value` (e.g. the
144 // existing `tatara-reconciler::patch::patch_process_status`
145 // callers that hand-build a `Value` via one of the
146 // `phase_status_*` builders) pass it directly to the primitive
147 // without re-serialization. This pin binds that pass-through
148 // shape: the wrap layer never re-encodes an already-JSON slot.
149 let pre = json!({"phase": "Attested", "phaseSince": "2026-05-01T00:00:00Z"});
150 let body = merge_status_body(&pre);
151 assert_eq!(body, json!({"status": pre}));
152 }
153
154 #[test]
155 fn merge_status_body_wraps_scalar_status_without_object_promotion() {
156 // The primitive is not "wrap into an object with a phase
157 // slot" — it is exactly "wrap into `{"status": <serialized>}`".
158 // A scalar status (unusual in practice, but permitted by the
159 // Serialize bound) rides through as the top-level `status`
160 // value verbatim.
161 let body = merge_status_body(&"Attested");
162 assert_eq!(body, json!({"status": "Attested"}));
163 }
164
165 #[test]
166 fn merge_status_body_preserves_struct_update_composition_bytewise() {
167 // The pool-reconciler `AllocationStatus { bound_pool: Some(p),
168 // ..AllocationStatus::transition(...) }` struct-update shape
169 // composes a typed value that serialize into a stable JSON
170 // shape. This pin binds a smaller-scale peer: a struct-update
171 // over a base composer produces the same JSON as the fully
172 // spelled-out struct literal.
173 #[derive(Serialize)]
174 struct Base {
175 phase: &'static str,
176 phase_since: &'static str,
177 extra: Option<&'static str>,
178 }
179 fn base() -> Base {
180 Base {
181 phase: "Queued",
182 phase_since: "2026-05-01T00:00:00Z",
183 extra: None,
184 }
185 }
186 let struct_update = Base {
187 extra: Some("pool matched"),
188 ..base()
189 };
190 let spelled_out = Base {
191 phase: "Queued",
192 phase_since: "2026-05-01T00:00:00Z",
193 extra: Some("pool matched"),
194 };
195 assert_eq!(
196 merge_status_body(&struct_update),
197 merge_status_body(&spelled_out),
198 "struct-update composition serializes byte-identically to the fully-spelled struct literal",
199 );
200 }
201
202 // ─── merge_status wire-side round-trip pin ──────────────────────
203 //
204 // Bind that the async entry composes the same wire body the pure
205 // helper does (i.e. `merge_status` delegates to
206 // `merge_status_body` verbatim rather than restating the wrap).
207 // A regression that hand-rolled the wrap inside `merge_status`
208 // (thereby drifting from `merge_status_body`'s pinned shape) would
209 // surface here.
210 #[test]
211 fn merge_status_delegates_wire_body_construction_to_merge_status_body() {
212 // The invariant this binds is a source-level one: whichever
213 // call path a caller takes (direct body-construction, or the
214 // async entry composing internally), the wire body is the same
215 // shape. We witness it by having both call sites hit the same
216 // helper. The pure helper's pins above cover the shape; this
217 // pin binds the wire-side entry does not fork.
218 let body_via_helper = merge_status_body(&json!({"phase": "Running"}));
219 // `merge_status` is `async` and needs an `Api<K>` we cannot
220 // construct here without a client — but its body composition
221 // step calls exactly `merge_status_body(status)`, so the pin
222 // above already covers the shape. This test exists to name the
223 // delegation invariant so a future refactor that inlined the
224 // wrap would need to move THIS pin's docstring first.
225 assert_eq!(body_via_helper["status"]["phase"], "Running");
226 }
227}