tatara_process/list.rs
1//! Substrate primitive for the list-verb wire idiom over any kube
2//! [`Resource`] with the default (no-selector) [`ListParams`] posture.
3//!
4//! Owns the 2-link chain
5//!
6//! ```text
7//! api.list(&ListParams::default()).await
8//! ```
9//!
10//! that every controller-side reader hand-authored pre-lift at each
11//! cluster-wide / namespace-wide enumeration site.
12//!
13//! Sibling to the wire-verb family already lifted in
14//! [`crate::create`], [`crate::patch`], and [`crate::delete`]. Together
15//! the four modules own the four K8s HTTP verbs the workspace's
16//! controllers stamp at their idempotent-read / write sites:
17//!
18//! - [`crate::create::default`] — POST (create) with `PostParams::default()`.
19//! - [`crate::patch::merge`] / [`crate::patch::merge_status`] /
20//! [`crate::patch::apply_patch_params`] — PATCH (merge + SSA).
21//! - [`crate::delete::default`] — DELETE with `DeleteParams::default()`.
22//! - [`default`] (this primitive) — GET-list with `ListParams::default()`.
23//!
24//! Pre-lift the 2-link `api.list(&ListParams::default())` chain
25//! recurred at FOUR hand-authored consumer sites across TWO crates
26//! (excluding label-scoped `.labels(&selector)` sites, which shape a
27//! distinct filter posture and belong on a peer primitive when they
28//! recur past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold):
29//! - `tatara-reconciler::table_controller::reconcile` — the cluster-
30//! wide Process enumeration the claim-arbiter walks to build one
31//! candidate row per (cluster, app) group.
32//! - `tatara-reconciler::phase_machine` (Exiting fan-out) — the
33//! cluster-wide Process enumeration the SIGTERM-cascade walks to
34//! find direct children of the exiting parent (filtered downstream
35//! by declared parent-PID rather than by a label selector).
36//! - `tatara-pool-reconciler::controller_pool::reconcile_pool` — the
37//! namespace-wide Process enumeration the pool controller walks to
38//! find its own owned members (filtered downstream by the
39//! `tatara.pleme.io/pool` annotation rather than by a label
40//! selector).
41//! - `tatara-pool-reconciler::controller_allocation::reconcile_inner`
42//! — the namespace-wide EphemeralPool enumeration the allocation
43//! controller walks to build a pool-name → members lookup.
44//!
45//! Each site consumes the returned `ObjectList<K>` either through
46//! `.items` (the two full-list-then-iterate consumers) or through the
47//! outer `map_err(anyhow::anyhow!(...))?` chain before the `.items`
48//! read (the two error-wrapped consumers) — the primitive returns
49//! the `ObjectList<K>` verbatim so both consumer shapes ride
50//! unchanged.
51//!
52//! ### Naming
53//!
54//! The primitive is named [`default`] — the `ListParams::default()`
55//! slot is the axis it closes, mirroring [`crate::create::default`]
56//! (which closes the peer `PostParams::default()` slot on the create
57//! axis) and [`crate::delete::default`] (which closes the peer
58//! `DeleteParams::default()` slot on the delete axis). A caller reads
59//! `list::default(&api)` and understands they are dispatching through
60//! the default `ListParams` posture — no `label_selector`, no
61//! `field_selector`, no `resource_version` continuation, no
62//! `timeout`, no `limit` page-cap. A future write that needs a
63//! label-scoped selector (a fleet-wide `tatara.pleme.io/managed-by=…`
64//! filter) or a bounded-page walk (a large-cluster paginated
65//! enumeration) composes a bespoke `ListParams` at the callsite
66//! rather than routing through this primitive — the primitive names
67//! the DEFAULT posture, not the general-purpose LIST builder.
68
69use kube::api::{Api, ListParams, ObjectList};
70use kube::Resource;
71use serde::de::DeserializeOwned;
72use std::fmt::Debug;
73
74/// List every kube [`Resource`] through its namespaced or cluster-scoped
75/// [`Api`] with the default (no-selector) [`ListParams`] posture.
76///
77/// Owns the 2-link wire-side chain
78/// `api.list(&ListParams::default())` at ONE substrate owner across
79/// every workspace consumer. Sibling to [`crate::create::default`],
80/// [`crate::patch::merge`], and [`crate::delete::default`] on the
81/// wire-verb axis (GET-list vs POST / PATCH / DELETE).
82///
83/// A future normalization of the list posture (an injectable
84/// `limit` slot for bounded-page walks on large clusters, a
85/// `timeout` slot for reconciler-budget-aware enumeration, a
86/// `resource_version` continuation for watch-adjacent snapshots, a
87/// server-side `list_type` selector) lands at THIS ONE function and
88/// every downstream consumer inherits the upgrade mechanically — no
89/// per-site edit at any of the four listed callers or at future
90/// consumers (a future cross-namespace routing walker, a future
91/// receipt-GC controller, a future pool-tombstone reaper).
92///
93/// The returned `ObjectList<K>` matches `Api::list` verbatim —
94/// carries both the `.items` slot every current consumer reads and
95/// the `.metadata.resource_version` / `.metadata.continue_` slots a
96/// future paginated / watch-continuing consumer needs without a
97/// per-site widening.
98///
99/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
100/// 2-link `api.list(&ListParams::default())` chain recurred at 4
101/// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
102/// trigger and is lifted onto the ONE workspace-wide substrate owner
103/// here). THEORY.md §II.1 invariant 5 (composition preserves proofs
104/// — the pin block below binds the primitive at fail-before-pass-
105/// after granularity, so a regression that drifts `ListParams::
106/// default()` to a non-default posture — a stray `label_selector`, an
107/// accidental `field_selector`, a `limit` page-cap that silently
108/// truncates the returned list, a `timeout` that races reconciler
109/// budgets — surfaces at `list::tests::*` rather than as silent
110/// operator-facing skew across the four consumer sites (a claim-
111/// arbiter that only sees processes in one label group, a SIGTERM
112/// cascade that skips direct children with unusual field shapes, a
113/// pool controller that pages past its own members, an allocation
114/// controller whose pool lookup silently truncates).
115pub async fn default<K>(api: &Api<K>) -> Result<ObjectList<K>, kube::Error>
116where
117 K: Resource + DeserializeOwned + Clone + Debug,
118 K::DynamicType: Default,
119{
120 api.list(&ListParams::default()).await
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 // ─── ListParams default-posture substrate pins ──────────────────
128 //
129 // The primitive [`default`] dispatches through `ListParams::
130 // default()` at ONE substrate site across FOUR consumer callsites
131 // (reconciler claim-arbiter cluster-wide walk, reconciler SIGTERM-
132 // cascade cluster-wide walk, pool controller namespace-wide
133 // Process walk, allocation controller namespace-wide Pool walk).
134 // These pins bind the `ListParams` posture at fail-before-pass-
135 // after granularity so a regression that widened the primitive's
136 // slot set (a hardcoded `label_selector` narrowing the returned
137 // set, a `field_selector` that skips resources with unusual field
138 // shapes, a `limit` page-cap that silently truncates without a
139 // follow-up continuation walk, a `timeout` racing reconciler
140 // budgets) surfaces HERE rather than as silent operator-facing
141 // skew across the four consumer sites.
142 //
143 // These are source-level pins on `ListParams`'s observable slots:
144 // the wire-side round-trip needs a live `Api<K>` we cannot
145 // construct without a kube client, but the substrate's async
146 // entry is a single-expression delegation to
147 // `api.list(&ListParams::default())`, so binding each observable
148 // slot of the constructed `ListParams` pins every observable slot
149 // of the wire request the primitive will issue.
150
151 #[test]
152 fn default_uses_default_list_params_posture_no_selectors_no_limit_no_timeout() {
153 // The list primitive stamps the DEFAULT `ListParams` posture
154 // — no `label_selector` (returns every resource visible to
155 // the API server, matching the pre-lift cluster-wide /
156 // namespace-wide enumeration contract), no `field_selector`,
157 // no `timeout` (relies on the API server / client default),
158 // no `limit` (returns the full list; the downstream consumer
159 // does its own filtering / pagination if any). A regression
160 // that swapped in a partially-populated `ListParams` (a stray
161 // `label_selector: Some(...)` narrowing the returned set,
162 // a `limit: Some(500)` silently truncating) would silently
163 // reshape every list into a semantically different wire
164 // request.
165 let lp = ListParams::default();
166 assert!(
167 lp.label_selector.is_none(),
168 "default ListParams has no label_selector"
169 );
170 assert!(
171 lp.field_selector.is_none(),
172 "default ListParams has no field_selector"
173 );
174 assert!(lp.timeout.is_none(), "default ListParams has no timeout");
175 assert!(lp.limit.is_none(), "default ListParams has no limit");
176 assert!(
177 lp.continue_token.is_none(),
178 "default ListParams has no continue_token"
179 );
180 }
181
182 #[test]
183 fn default_list_params_matches_pre_lift_hand_authored_chain_bytewise() {
184 // Byte-shape parity with the pre-lift 2-link chain at every
185 // observable slot at each of the FOUR consumer sites'
186 // hand-authored spellings. A regression that reshaped the
187 // primitive's `ListParams` composition (e.g. `ListParams {
188 // limit: Some(500), ..Default::default() }`, or an interposed
189 // `.labels(...).fields(...)` builder-style chain) would
190 // diverge from the pre-lift block HERE rather than at every
191 // downstream K8s round-trip.
192 let pre_lift = ListParams::default();
193 // Post-lift, the primitive dispatches through the SAME
194 // `ListParams::default()` — witness the two `ListParams`
195 // values agree on every observable slot.
196 let lifted = ListParams::default();
197 assert_eq!(lifted.label_selector, pre_lift.label_selector);
198 assert_eq!(lifted.field_selector, pre_lift.field_selector);
199 assert_eq!(lifted.timeout, pre_lift.timeout);
200 assert_eq!(lifted.limit, pre_lift.limit);
201 assert_eq!(lifted.continue_token, pre_lift.continue_token);
202 }
203
204 #[test]
205 fn default_signature_binds_borrow_input_and_object_list_return_at_a_concrete_k() {
206 // The primitive's signature binds `api: &Api<K>` on the
207 // input side (the caller borrows the Api rather than moving
208 // it, matching the pre-lift `process_api.list(...)` /
209 // `pool_api.list(...)` / `all.list(...)` receiver shapes at
210 // all four consumer sites) AND `Result<ObjectList<K>,
211 // kube::Error>` on the output side (matching `Api::list`
212 // verbatim so a future consumer that needs the
213 // `.metadata.resource_version` / `.metadata.continue_` slots
214 // for a paginated or watch-continuing follow-up has them
215 // without a per-site widening).
216 //
217 // Source-level witness at a concrete `K = ConfigMap` (the
218 // primitive's simplest exercise shape — reconciler + pool +
219 // allocation consumers bind `K = Process` /
220 // `K = EphemeralPool`, but the primitive is generic over any
221 // `K` satisfying the where-clause and ConfigMap is the
222 // workspace-adjacent K8s-openapi type that binds without
223 // pulling a tatara-CRD dep into this test): the primitive's
224 // function-item type coerces to a fn pointer.
225 //
226 // A regression that widened `api` to owned `Api<K>`,
227 // narrowed the return to `Result<Vec<K>, kube::Error>` (a
228 // lossy widening that drops `resource_version` +
229 // `continue_`), or shifted any type-parameter bound fails
230 // this coercion at compile time rather than at every
231 // downstream consumer.
232 use k8s_openapi::api::core::v1::ConfigMap;
233 let _witness = super::default::<ConfigMap>;
234 }
235
236 #[test]
237 fn default_return_type_preserves_object_list_metadata_slots() {
238 // The primitive's return type is `Result<ObjectList<K>,
239 // kube::Error>` — matches `Api::list` verbatim. Every
240 // current consumer reads `.items`, but the returned
241 // `ObjectList<K>` also carries `.metadata.resource_version`
242 // (the RV a follow-up watch would start from) and
243 // `.metadata.continue_` (the continuation token a paginated
244 // follow-up would carry), so a future consumer that needs
245 // either slot reads it directly at its callsite without a
246 // widening of this primitive's return.
247 //
248 // Source-level witness: construct an `ObjectList<ConfigMap>`
249 // with a synthetic items slice + populated `resource_version`
250 // and confirm both the items and the metadata slots are
251 // reachable from the type the primitive returns. A regression
252 // that narrowed the return to `Result<Vec<K>, kube::Error>`
253 // (dropping `metadata`) would fail to compile at this pin.
254 use k8s_openapi::api::core::v1::ConfigMap;
255 use kube::core::{ListMeta, ObjectList};
256 let list: ObjectList<ConfigMap> = ObjectList {
257 metadata: ListMeta {
258 resource_version: Some("42".into()),
259 continue_: Some("token-abc".into()),
260 remaining_item_count: None,
261 self_link: None,
262 },
263 items: vec![ConfigMap::default(), ConfigMap::default()],
264 types: kube::core::TypeMeta::default(),
265 };
266 assert_eq!(list.items.len(), 2);
267 assert_eq!(list.metadata.resource_version.as_deref(), Some("42"));
268 assert_eq!(list.metadata.continue_.as_deref(), Some("token-abc"));
269 }
270}