Skip to main content

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}