Skip to main content

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/// Server-side-apply [`PatchParams`] with `field_manager` bound to the
92/// caller-supplied slot and `force = true` — the ONE substrate
93/// primitive owning the `PatchParams::apply(<mgr>).force()` incantation
94/// every workspace SSA writer restated by hand pre-lift.
95///
96/// SSA-side sibling to [`merge_status`] on the (wire-posture × axis)
97/// pair: [`merge_status`] owns the merge-patch axis
98/// (`Patch::Merge + PatchParams::default()` over `/status`); this
99/// primitive owns the server-side-apply axis
100/// (`Patch::Apply + PatchParams::apply(<mgr>).force()` over the primary
101/// resource). Together they own the two wire-side write-posture
102/// primitives the workspace's controllers stamp.
103///
104/// Pre-lift the 2-link chain was hand-authored at THREE consumer sites
105/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, spanning THREE
106/// crates:
107/// * `tatara-pool-reconciler::controller_allocation` (bind arm +
108///   release arm) — `PatchParams::apply(&ctx.config.field_manager)
109///   .force()` on the Process patch that stamps requestor / allocation
110///   binding annotations, and on the return-trigger annotation patch.
111/// * `tatara-export-worker::main::write_receipt` — `PatchParams::apply
112///   ("tatara-export-worker").force()` on the receipt ConfigMap apply.
113///
114/// And a fourth site owns the reconciler-crate-local
115/// [`FIELD_MANAGER`]-bound wrapper
116/// (`tatara_reconciler::ssapply::apply_patch_params`), which post-lift
117/// delegates to THIS substrate primitive rather than re-stating the
118/// chain: the SSA-side wire posture now has ONE workspace-wide owner.
119///
120/// The `field_manager` slot is caller-supplied because the SSA writers
121/// this primitive serves span three different field-manager
122/// disciplines:
123/// * `tatara-reconciler` — a `pub const FIELD_MANAGER: &str =
124///   "tatara-reconciler"` bound at the reconciler-crate wrapper.
125/// * `tatara-pool-reconciler` — a per-instance `ctx.config.field_manager`
126///   String, so a per-shard or per-cluster deployment can distinguish
127///   its allocator's SSA writes from a sibling deployment's.
128/// * `tatara-export-worker` — a `"tatara-export-worker"` literal, so
129///   the reconciler / operator distinguishes worker-emitted receipt
130///   ConfigMaps from reconciler-emitted resources at field-manager
131///   ownership queries.
132///
133/// The `force = true` semantics matches the SSA `force` directive every
134/// pre-lift chain applied — every consumer of this primitive is the
135/// authoritative owner of the field pathways it stamps
136/// (rendered-resource annotations, `RELEASED_FROM` marker,
137/// `ProcessTable.status.claims`, allocation-bind annotations, receipt
138/// ConfigMap data) and reclaims conflicting slots from prior
139/// field-manager owners on every apply.
140///
141/// A `#[must_use]` return keeps a caller from building a `PatchParams`
142/// via this primitive and then dropping it un-passed to `Api::patch`;
143/// the primitive exists to be consumed at a wire-side write, not to
144/// probe field-manager state.
145///
146/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
147/// `.apply(<mgr>).force()` chain recurred at 3 hand-authored sites
148/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning three
149/// workspace crates, and is lifted to ONE workspace-wide substrate
150/// owner here). THEORY.md §II.1 invariant 5 (composition preserves
151/// proofs — the pin block below binds the primitive at
152/// fail-before-pass-after granularity, so a regression that drops
153/// `.force()`, drifts the field-manager pass-through, or widens the
154/// posture surfaces at THESE pins rather than as silent SSA writer
155/// skew across the three consumer crates).
156#[must_use]
157pub fn apply_patch_params(field_manager: &str) -> PatchParams {
158    PatchParams::apply(field_manager).force()
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use serde::Serialize;
165    use serde_json::json;
166
167    // ─── merge_status_body substrate pins ───────────────────────────
168    //
169    // The pre-lift `json!({"status": <typed>})` wrap recurred at 7
170    // hand-authored sites across `tatara-pool-reconciler` (both
171    // controllers) + `tatara-reconciler::patch::patch_process_status`
172    // pre-lift. These pins bind the wire-body shape at
173    // fail-before-pass-after granularity so a regression that drifts
174    // the top-level slot key, reshapes the wrap posture, or leaks a
175    // sibling slot surfaces here rather than as silent status-write
176    // drift at every downstream controller.
177
178    #[test]
179    fn merge_status_body_wraps_typed_status_under_top_level_status_slot() {
180        #[derive(Serialize)]
181        struct S {
182            phase: &'static str,
183            reason: &'static str,
184        }
185        let body = merge_status_body(&S {
186            phase: "Bound",
187            reason: "member allocated",
188        });
189        assert_eq!(
190            body,
191            json!({ "status": { "phase": "Bound", "reason": "member allocated" } }),
192        );
193    }
194
195    #[test]
196    fn merge_status_body_top_level_key_is_exactly_status_lowercase() {
197        // Any drift on the top-level slot name (case-fold to `Status`,
198        // a substrate-side rename to `status_patch`, a version-tagged
199        // wrap like `v1alpha1_status`) breaks every status writer on
200        // the wire. This pin binds the exact spelling downstream K8s
201        // API + K8s-openapi generated types expect.
202        let body = merge_status_body(&json!({"phase": "Running"}));
203        let obj = body.as_object().expect("top-level must be a JSON object");
204        assert_eq!(obj.len(), 1, "wrap adds exactly ONE top-level slot");
205        assert!(
206            obj.contains_key("status"),
207            "top-level slot must be exactly `status` (lowercase)"
208        );
209    }
210
211    #[test]
212    fn merge_status_body_accepts_pre_serialized_json_value_verbatim() {
213        // Callers that already have a `serde_json::Value` (e.g. the
214        // existing `tatara-reconciler::patch::patch_process_status`
215        // callers that hand-build a `Value` via one of the
216        // `phase_status_*` builders) pass it directly to the primitive
217        // without re-serialization. This pin binds that pass-through
218        // shape: the wrap layer never re-encodes an already-JSON slot.
219        let pre = json!({"phase": "Attested", "phaseSince": "2026-05-01T00:00:00Z"});
220        let body = merge_status_body(&pre);
221        assert_eq!(body, json!({"status": pre}));
222    }
223
224    #[test]
225    fn merge_status_body_wraps_scalar_status_without_object_promotion() {
226        // The primitive is not "wrap into an object with a phase
227        // slot" — it is exactly "wrap into `{"status": <serialized>}`".
228        // A scalar status (unusual in practice, but permitted by the
229        // Serialize bound) rides through as the top-level `status`
230        // value verbatim.
231        let body = merge_status_body(&"Attested");
232        assert_eq!(body, json!({"status": "Attested"}));
233    }
234
235    #[test]
236    fn merge_status_body_preserves_struct_update_composition_bytewise() {
237        // The pool-reconciler `AllocationStatus { bound_pool: Some(p),
238        // ..AllocationStatus::transition(...) }` struct-update shape
239        // composes a typed value that serialize into a stable JSON
240        // shape. This pin binds a smaller-scale peer: a struct-update
241        // over a base composer produces the same JSON as the fully
242        // spelled-out struct literal.
243        #[derive(Serialize)]
244        struct Base {
245            phase: &'static str,
246            phase_since: &'static str,
247            extra: Option<&'static str>,
248        }
249        fn base() -> Base {
250            Base {
251                phase: "Queued",
252                phase_since: "2026-05-01T00:00:00Z",
253                extra: None,
254            }
255        }
256        let struct_update = Base {
257            extra: Some("pool matched"),
258            ..base()
259        };
260        let spelled_out = Base {
261            phase: "Queued",
262            phase_since: "2026-05-01T00:00:00Z",
263            extra: Some("pool matched"),
264        };
265        assert_eq!(
266            merge_status_body(&struct_update),
267            merge_status_body(&spelled_out),
268            "struct-update composition serializes byte-identically to the fully-spelled struct literal",
269        );
270    }
271
272    // ─── merge_status wire-side round-trip pin ──────────────────────
273    //
274    // Bind that the async entry composes the same wire body the pure
275    // helper does (i.e. `merge_status` delegates to
276    // `merge_status_body` verbatim rather than restating the wrap).
277    // A regression that hand-rolled the wrap inside `merge_status`
278    // (thereby drifting from `merge_status_body`'s pinned shape) would
279    // surface here.
280    #[test]
281    fn merge_status_delegates_wire_body_construction_to_merge_status_body() {
282        // The invariant this binds is a source-level one: whichever
283        // call path a caller takes (direct body-construction, or the
284        // async entry composing internally), the wire body is the same
285        // shape. We witness it by having both call sites hit the same
286        // helper. The pure helper's pins above cover the shape; this
287        // pin binds the wire-side entry does not fork.
288        let body_via_helper = merge_status_body(&json!({"phase": "Running"}));
289        // `merge_status` is `async` and needs an `Api<K>` we cannot
290        // construct here without a client — but its body composition
291        // step calls exactly `merge_status_body(status)`, so the pin
292        // above already covers the shape. This test exists to name the
293        // delegation invariant so a future refactor that inlined the
294        // wrap would need to move THIS pin's docstring first.
295        assert_eq!(body_via_helper["status"]["phase"], "Running");
296    }
297
298    // ─── apply_patch_params substrate pins ──────────────────────────
299    //
300    // The 2-link `PatchParams::apply(<mgr>).force()` chain now rides
301    // through the ONE substrate primitive [`apply_patch_params`]
302    // across THREE consumer crates: `tatara-reconciler::ssapply`
303    // (field-manager-const-bound wrapper delegating to this one),
304    // `tatara-pool-reconciler::controller_allocation` (bind + release
305    // arms, feeding a per-instance `ctx.config.field_manager` String
306    // through the pass-through slot), `tatara-export-worker::main::
307    // write_receipt` (feeding a `"tatara-export-worker"` literal
308    // through the same slot). These pins bind the primitive at
309    // fail-before-pass-after granularity so a regression that drops
310    // `.force()`, drifts the field-manager pass-through, reintroduces
311    // a hand-authored literal at any consumer, or widens the posture
312    // (auto-`dry_run`, non-`None` `field_validation`) surfaces HERE
313    // rather than as silent SSA writer skew across three workspace
314    // crates.
315
316    #[test]
317    fn apply_patch_params_binds_field_manager_pass_through_slot_verbatim() {
318        // The pass-through slot is byte-identical to the caller's
319        // `&str`: no re-encoding, no case-fold, no substitution. A
320        // regression that trimmed / normalized the manager string
321        // silently would surface here — every consumer relies on the
322        // exact spelling landing in the SSA wire request so downstream
323        // field-manager ownership queries key on the exact identity
324        // each callsite stamps.
325        let pp = apply_patch_params("tatara-reconciler");
326        assert_eq!(pp.field_manager.as_deref(), Some("tatara-reconciler"));
327
328        let pp = apply_patch_params("tatara-export-worker");
329        assert_eq!(pp.field_manager.as_deref(), Some("tatara-export-worker"));
330
331        let pp = apply_patch_params("per-shard-manager-42");
332        assert_eq!(pp.field_manager.as_deref(), Some("per-shard-manager-42"));
333    }
334
335    #[test]
336    fn apply_patch_params_stamps_force_true() {
337        // `force = true` matches the SSA `force` directive every pre-
338        // lift chain applied at every SSA writer site across the three
339        // consumer crates — every consumer is the authoritative owner
340        // of the field pathways it stamps and reclaims conflicting
341        // slots on every apply. A regression that dropped `.force()`
342        // from the primitive would silently 409-conflict at every SSA
343        // write on any field already owned by a prior field manager.
344        let pp = apply_patch_params("tatara-reconciler");
345        assert!(pp.force);
346    }
347
348    #[test]
349    fn apply_patch_params_defaults_dry_run_and_field_validation_off() {
350        // The primitive stamps ONLY the `field_manager` + `force` slots
351        // every pre-lift chain stamped — `dry_run` stays `false` and
352        // `field_validation` stays `None`. A regression that widened
353        // the primitive's slot set (auto-enabled `dry_run` during a
354        // debug pass, added a default `field_validation` mode) would
355        // silently no-op every SSA write (dry_run) or reject apply
356        // bodies previous consumers accepted (field_validation).
357        let pp = apply_patch_params("tatara-reconciler");
358        assert!(!pp.dry_run);
359        assert!(pp.field_validation.is_none());
360    }
361
362    #[test]
363    fn apply_patch_params_matches_pre_lift_hand_authored_chain_bytewise() {
364        // Byte-shape parity with the pre-lift 2-link chain at every
365        // observable slot (`field_manager`, `force`, `dry_run`,
366        // `field_validation`) at each of the three consumer crates'
367        // hand-authored spellings. A regression that reordered the
368        // chain (e.g. `apply(...).dry_run().force()` swap) or drifted
369        // any slot's wire representation lands HERE.
370        for mgr in [
371            "tatara-reconciler",
372            "tatara-export-worker",
373            "per-shard-manager-42",
374        ] {
375            let pre_lift = PatchParams::apply(mgr).force();
376            let lifted = apply_patch_params(mgr);
377            assert_eq!(lifted.field_manager, pre_lift.field_manager);
378            assert_eq!(lifted.force, pre_lift.force);
379            assert_eq!(lifted.dry_run, pre_lift.dry_run);
380            assert_eq!(
381                lifted.field_validation.is_none(),
382                pre_lift.field_validation.is_none()
383            );
384        }
385    }
386}