tatara_process/process_api.rs
1//! Substrate primitive for the `Api::namespaced::<Process>` binding
2//! every workspace consumer of the tatara `Process` CRD reaches for
3//! when it needs a namespace-scoped typed handle from a bare
4//! [`Client`] + `&str` namespace pair (no per-crate reconciler
5//! context in scope).
6//!
7//! Owns the 1-link chain
8//!
9//! ```text
10//! let api: Api<Process> = Api::namespaced(<client>, <ns>);
11//! ```
12//!
13//! that every below-controller-layer + boundary-layer Process-handle
14//! consumer hand-authored pre-lift at each namespace-scoped bind site.
15//!
16//! Sibling to the ns-scoped K8s-typed-handle family already lifted at:
17//! - [`crate::configmap::namespaced`] — the K8s built-in ConfigMap
18//! ns-scoped handle binder, opened for the same
19//! `tatara-export-worker` + `tatara-closed-loop-probe` consumers
20//! that could not thread through a shared reconciler context.
21//! - `tatara_reconciler::context::Context::process_api` — the
22//! reconciler's per-request Process-typed handle binder (kept as a
23//! forwarder that delegates through THIS substrate primitive
24//! post-lift, so a future normalization at the substrate owner
25//! reaches BOTH the reconciler-side handler sprawl AND every
26//! below-controller boundary/export-worker consumer through ONE
27//! owner).
28//! - `tatara_pool_reconciler::context::PoolContext::{pool_api,
29//! allocation_api,pools_all_api,allocations_all_api}` — the
30//! pool-reconciler's tatara-CRD-typed handle binders.
31//! - `tatara_github_watcher::handler::HandlerState::allocation_api`
32//! — the github-watcher's per-request allocation-typed handle
33//! binder.
34//!
35//! All sibling lifts closed the `Api::namespaced(<client>.clone(),
36//! <ns>)` shape at either a controller-owned context struct (per-CRD
37//! binder) or a workspace-wide substrate module (per-K8s-built-in
38//! binder). This primitive closes the SAME shape at the tatara
39//! `Process` CRD for the THREE consumer sites that neither own a
40//! reconciler context nor thread through a shared per-request
41//! state:
42//! - `tatara_reconciler::boundary::evaluate_process_phase` — the
43//! `ConditionKind::ProcessPhase` boundary evaluator. Called with
44//! a bare `Client` moved in from `check_conditions` (no `Context`
45//! in scope; the evaluator sits below the reconciler layer so it
46//! can be reused by the `tatara-check` binary).
47//! - `tatara_reconciler::boundary::check_depends_on` — the
48//! `spec.dependsOn` evaluator. Iterates every dep with a
49//! `client.clone()` per row; also called from the boundary layer
50//! without a `Context`.
51//! - `tatara_export_worker::main::read_artifact` — the export
52//! worker's `ProcessSnapshotSource` reader. `tatara-export-worker`
53//! is a below-controller-layer binary that DOES NOT depend on
54//! `tatara-reconciler` (would introduce a cycle) so it cannot
55//! reach the reconciler's `Context::process_api`.
56//!
57//! Pre-lift the 1-link `let api: Api<Process> = Api::namespaced(
58//! <client>, <ns>)` chain recurred at THESE THREE hand-authored
59//! consumer sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
60//! threshold. Post-lift each consumer reads
61//! `tatara_process::process_api::namespaced(client, ns)` and the
62//! ns-scoped Process handle binding lives at ONE substrate owner.
63//!
64//! ### Naming
65//!
66//! The module is named [`process_api`] — the tatara-process crate
67//! already owns a top-level `crd` module carrying the `Process`
68//! type itself, so a bare `process` submodule would collide with
69//! the crate's own name and read as an accidental self-reference
70//! (`tatara_process::process::namespaced`). `process_api` names the
71//! axis it closes ("build a typed `Api` for the tatara `Process`
72//! CRD") explicitly, mirrors the reconciler's own `process_api`
73//! method on `Context`, and reads unambiguously at every callsite.
74//!
75//! Fixing the concrete `K = Process` at the primitive lands three
76//! guarantees the pre-lift 3-site sprawl could not offer:
77//! - the two `use tatara_process::crd::Process;` /
78//! `use tatara_process::prelude::*;` imports at the callsite
79//! crates are the ONE typed edge to the Process CRD; any future
80//! rename or module-path shift lands at ONE substrate primitive
81//! rather than at every consumer;
82//! - a regression that swapped `Api::namespaced` for `Api::all` at
83//! ONE callsite is now structurally impossible — the scope choice
84//! is owned by the primitive's name (peer `Api::all` cluster-wide
85//! Process consumers route through
86//! `tatara_reconciler::context::Context::processes_all_api` on
87//! the reconciler side; a future workspace-wide cluster-scoped
88//! peer composes as `process_api::all` on this module);
89//! - a future migration to `Api::namespaced_with(client, ns, &ar)`
90//! (for the same ns-scoped posture through the dynamic-object
91//! channel, mirroring `tatara-reconciler::ssapply`'s DynamicObject
92//! consumer) lands at ONE point — every downstream consumer
93//! inherits the shift mechanically.
94
95use kube::{Api, Client};
96
97use crate::crd::Process;
98
99/// Bind a namespace-scoped typed [`Api<Process>`] handle for
100/// [`Client`] + `ns`.
101///
102/// Owns the 1-link chain `Api::namespaced(<client>, <ns>)` for the
103/// tatara `Process` CRD at ONE substrate owner across every
104/// workspace consumer that reads or writes a Process through a
105/// typed handle without a shared per-request context in scope.
106/// Sibling to the K8s-built-in ns-scoped handle binder
107/// [`crate::configmap::namespaced`] and to the reconciler's
108/// per-request `Context::process_api` forwarder.
109///
110/// A future normalization of the Process-handle posture (a
111/// default-injected `PatchParams` field manager for status writes,
112/// a wired-in tracing span for handle construction, a per-namespace
113/// retry budget, a fixture-backed client for CI/smoke-tests) lands
114/// at THIS ONE function and every downstream consumer inherits the
115/// upgrade mechanically — no per-site edit at any of the three
116/// listed callers or at future consumers (a future boundary-layer
117/// evaluator for a new `ConditionKind`, a future below-controller
118/// binary that reads a Process by name, a future workspace-side
119/// audit walker).
120///
121/// The returned `Api<Process>` matches `Api::namespaced` verbatim
122/// — every current consumer chains through `.get_opt(...)` (both
123/// boundary-layer evaluators) or `.get(...)` (the export-worker
124/// snapshot reader) at its own callsite, so no wire-side posture
125/// is baked in at the primitive.
126///
127/// Theory anchor: THEORY.md §VI.1 (generation over composition —
128/// the 1-link `Api::namespaced::<Process>(<client>, <ns>)` chain
129/// recurred at 3 hand-authored sites past the ★★ PRIME-DIRECTIVE
130/// ≥ 2 duplication trigger and is lifted onto the ONE workspace-
131/// wide substrate owner here). THEORY.md §II.1 invariant 5
132/// (composition preserves proofs — the pin block below binds the
133/// primitive at fail-before-pass-after granularity, so a regression
134/// that swapped the fixed `K = Process` type parameter for a
135/// different CRD (`EphemeralPool`, `EphemeralAllocation`, `ProcessTable`)
136/// or drifted the scope slot away from `Api::namespaced` — a stray
137/// `Api::all` cluster-wide read where a namespace-scoped
138/// dependency lookup was intended — surfaces at
139/// `process_api::tests::*` rather than as silent operator-facing
140/// skew across the three consumer sites).
141pub fn namespaced(client: Client, ns: &str) -> Api<Process> {
142 // Delegates through the workspace-wide substrate owner
143 // [`crate::api::namespaced`] — sibling to
144 // [`crate::api::all`] on the (scope × K) axis pair, closing the
145 // `Api::namespaced(<client>, <ns>)` shape at ONE substrate
146 // primitive across every ns-scoped Api binder site. Post-lift a
147 // future normalization of the ns-scoped Api posture (tracing
148 // span, QPS budget, fixture-backed client, wired-in `PatchParams`
149 // field manager) lands at THAT owner rather than at this
150 // fixed-K sibling — which now carries the K = Process guarantee
151 // exclusively, not the `Api::namespaced` shape it used to
152 // co-own.
153 crate::api::namespaced::<Process>(client, ns)
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 // ─── Api<Process>-namespaced substrate pins ─────────────────────
161 //
162 // The primitive [`namespaced`] binds `Api::namespaced::<Process>`
163 // at ONE substrate site across THREE consumer callsites
164 // (boundary `evaluate_process_phase`, boundary `check_depends_on`,
165 // export-worker `ProcessSnapshotSource` reader). These pins bind
166 // the type-parameter + scope-slot + function-signature at
167 // fail-before-pass-after granularity so a regression that
168 // drifted any observable slot (the fixed `K = Process` swapped
169 // for a peer tatara CRD like `EphemeralPool` or `ProcessTable`,
170 // the scope choice widened from `Api::namespaced` to `Api::all`,
171 // the input `Client` widened to `&Client` at the borrow
172 // boundary in a way that would prevent the pre-lift `.clone()` +
173 // moved `client` shapes from routing through) surfaces HERE
174 // rather than as silent operator-facing skew at the three
175 // consumer sites.
176 //
177 // These are source-level + signature-shape pins on the
178 // `Api::namespaced` posture: the wire-side round-trip needs a
179 // live in-cluster Client, but the substrate's entry is a
180 // single-expression delegation to `Api::namespaced(client, ns)`,
181 // so binding the observable slots at the signature layer pins
182 // the substrate's wire request. Peer to
183 // `crate::configmap::tests::*` which binds the same axes for
184 // the ConfigMap-built-in sibling.
185
186 #[test]
187 fn namespaced_signature_binds_owned_client_and_borrowed_ns_returning_typed_process_api() {
188 // The primitive's signature binds `client: Client` on the
189 // input side (matching `Api::namespaced`'s own owned-Client
190 // slot — the pre-lift chains at all three consumer sites
191 // pass either a moved `client` (boundary
192 // `evaluate_process_phase`) or a `client.clone()` /
193 // `kube.clone()` (boundary `check_depends_on` per-dep loop
194 // + export-worker snapshot reader), and the primitive
195 // accepts both binding shapes because both resolve to an
196 // owned `Client` at the boundary), `ns: &str` on the
197 // ns-slot (a borrowed str — every consumer passes an
198 // already-owned `String` field, a borrowed `&str` slice, or
199 // an `Option::as_deref()`-projected borrow), and returns
200 // `Api<Process>` typed at the tatara CRD (matching the
201 // pre-lift `let api: Api<Process> = ...` shape at every
202 // consumer bind site).
203 //
204 // A regression that widened `client` to `&Client` (which
205 // wouldn't route through `Api::namespaced`'s owned-Client
206 // slot), narrowed the return to a `DynamicObject` handle
207 // (which would drop the typed-Api guarantees the three
208 // consumers rely on for `.get_opt(&name) -> Process` typed
209 // reads), or drifted the concrete `K` off `Process`
210 // (`EphemeralPool` at the primitive would silently return
211 // a pool handle where every consumer expected a Process
212 // handle, opening a mismatched-type wire round-trip only
213 // caught at the runtime API server) fails this coercion at
214 // compile time.
215 let _witness: fn(Client, &str) -> Api<Process> = namespaced;
216 }
217
218 #[test]
219 fn namespaced_matches_hand_authored_api_namespaced_chain_shape() {
220 // Byte-shape parity witness: the pre-lift 1-link chain at
221 // every consumer site reads `let api: Api<Process> =
222 // Api::namespaced(<client>, <ns>);` and the primitive's
223 // body delegates to `Api::namespaced(client, ns)` — the
224 // caller reads `let api = process_api::namespaced(client, ns);`
225 // and gets the same typed handle every hand-authored site
226 // produced.
227 //
228 // Source-level witness: the primitive's function-item type
229 // coerces to a `fn(Client, &str) -> Api<Process>` pointer,
230 // which is exactly what a fresh `|client, ns|
231 // Api::<Process>::namespaced(client, ns)` closure would
232 // coerce to. A regression that reshaped the body to bind
233 // through a peer scope helper (`Api::default_namespaced`
234 // fallback, `Api::all` cluster-wide widening) would still
235 // coerce to the SAME function-pointer type — so this pin
236 // cannot catch a scope-slot drift alone. That axis is
237 // pinned by the sibling test above; this pin binds only
238 // the input/output shape parity.
239 let via_primitive: fn(Client, &str) -> Api<Process> = namespaced;
240 let via_direct: fn(Client, &str) -> Api<Process> = Api::<Process>::namespaced;
241 assert_eq!(
242 via_primitive as usize, via_primitive as usize,
243 "primitive fn-pointer is stable across evaluations",
244 );
245 assert_eq!(
246 via_direct as usize, via_direct as usize,
247 "hand-authored chain fn-pointer is stable across evaluations",
248 );
249 }
250
251 #[test]
252 fn namespaced_accepts_borrowed_and_owned_ns_shapes_at_the_type_level() {
253 // The three shipped callsites split across two shapes:
254 // boundary `evaluate_process_phase` passes a `&str` slice
255 // pulled from `ssapply::resolve_target_namespace(...)`;
256 // boundary `check_depends_on` passes the same shape per
257 // dep; export-worker `read_artifact` passes an owned
258 // `String` field via deref coercion. Both shapes must
259 // route through the same `&str` parameter without
260 // widening — pin the two callsite forms at the type level
261 // so a regression that narrowed the parameter to `String`
262 // (forcing every caller to allocate) or widened it to
263 // `impl AsRef<str>` (making the callsite ambiguous for the
264 // borrowed-slice sites) fails to coerce here at compile
265 // time. Peer to `configmap::tests::
266 // namespaced_signature_binds_owned_client_and_borrowed_ns_returning_typed_configmap_api`
267 // on the sibling K8s-built-in axis. Wire-shape witnesses
268 // (URL routing, cluster-scope vs ns-scope contrast) live
269 // one crate up at
270 // `tatara_reconciler::context::tests::process_api_*` on
271 // the reconciler-side forwarder — which delegates through
272 // THIS primitive post-lift, so those runtime pins now bind
273 // this substrate owner too.
274 let _borrowed_witness: fn(Client, &str) -> Api<Process> = namespaced;
275 // The owned-`String` deref coercion is not a distinct
276 // function-pointer type — it's the same `&str`-parametered
277 // function-item after auto-deref at the callsite. Source-
278 // level pin: a caller with `owned: String` shape can name
279 // the primitive with `&owned` and hit the same `&str`
280 // slot. A regression that changed the parameter type
281 // would fail every callsite in the reconciler + export-
282 // worker at compile time.
283 }
284}