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/// Compose the diagnostic-body head every namespace-scoped
124/// [`default`] failure wraps around the underlying [`kube::Error`] via
125/// [`crate::kube_error::KubeResultExt::kube_ctx_with`].
126///
127/// Owns the fixed `"list <PluralKind> in <ns>"` shape as ONE substrate
128/// site. Sibling to [`crate::configmap::error_ctx`] on the (per-wire-
129/// verb × substrate-owned error-slug) axis-family:
130///
131/// - [`crate::configmap::error_ctx`] owns the
132///   `"<verb> ConfigMap <ns>/<name>"` shape — a per-CM write with
133///   an explicit resource name.
134/// - [`error_ctx`] (this primitive) owns the
135///   `"list <PluralKind> in <ns>"` shape — a namespace-wide GET-list
136///   with no resource name (the returned list carries every visible
137///   resource of that kind).
138///
139/// Both share the discipline of routing the failure-diagnostic shape
140/// through ONE substrate composer per wire-verb rather than restating
141/// the shape as a bare `format!(…)` chain at every consumer.
142///
143/// Pre-lift the 2-slot `format!("list <PluralKind> in {ns}")` chain
144/// recurred at TWO hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
145/// duplication threshold, both wrapping the same [`default`] primitive
146/// against a namespace-scoped `Api<K>`:
147///
148/// - `tatara-pool-reconciler::controller_pool::reconcile_pool` — kind
149///   `"Processes"` — the namespace-wide Process enumeration the pool
150///   controller walks to find its own owned members.
151/// - `tatara-pool-reconciler::controller_allocation::reconcile_inner`
152///   — kind `"Pools"` — the namespace-wide EphemeralPool enumeration
153///   the allocation controller walks to build its pool-name → members
154///   lookup.
155///
156/// Both sites walked the SAME shape — take a PascalCase plural kind
157/// label + the target namespace — and produced the SAME
158/// `"list <PluralKind> in <ns>"` diagnostic. Post-lift each callsite
159/// reads `list::error_ctx(<kind_plural>, ns)` and pipes the returned
160/// context string through
161/// [`crate::kube_error::KubeResultExt::kube_ctx_with`], which owns the
162/// `": {e}"` tail; the two halves compose to the byte-identical
163/// pre-lift diagnostic.
164///
165/// A future normalization step — a `tracing`-annotated span carrying
166/// the kind + namespace for post-hoc audit, a per-kind structured-
167/// error variant so operators filter by list-kind rather than
168/// substring-match on the message body, a wire-time hedging of the
169/// preposition (`"in"` vs `"@"` per a fleet convention), a namespace-
170/// prefix injection for a shared-controller deployment — lands at
171/// THIS ONE substrate primitive and every downstream namespace-scoped
172/// list-diagnostic across the fleet picks up the upgrade
173/// mechanically.
174///
175/// # Naming
176///
177/// The `kind_plural` slot names the K8s resource kind in its
178/// PascalCase plural form the way an operator would read it in
179/// `kubectl get <kind>` output (`"Pools"`, `"Processes"`,
180/// `"HelmReleases"`, `"Kustomizations"`). Both current consumers pass
181/// a `&'static str` literal; the signature takes `&str` so a future
182/// caller composing the plural from a typed
183/// [`crate::flux_resource::FluxResource`] /
184/// [`crate::k8s_builtin_resource::K8sBuiltinResource`] variant rides
185/// through the same composer without a widening.
186///
187/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
188/// 2-slot `format!(...)` chain recurred at 2 hand-authored sites past
189/// the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger and is lifted onto
190/// the ONE workspace-wide substrate owner here). THEORY.md §II.1
191/// invariant 5 (composition preserves proofs — the pin block below
192/// binds the composer at fail-before-pass-after granularity, so a
193/// regression that reordered the head slots, dropped the `"in "`
194/// preposition, or drifted the kind slot from the caller's typed
195/// label surfaces at `list::tests::error_ctx_*` rather than as silent
196/// operator-facing skew across the two consumer sites).
197#[must_use]
198pub fn error_ctx(kind_plural: &str, ns: &str) -> String {
199    format!("list {kind_plural} in {ns}")
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    // ─── ListParams default-posture substrate pins ──────────────────
207    //
208    // The primitive [`default`] dispatches through `ListParams::
209    // default()` at ONE substrate site across FOUR consumer callsites
210    // (reconciler claim-arbiter cluster-wide walk, reconciler SIGTERM-
211    // cascade cluster-wide walk, pool controller namespace-wide
212    // Process walk, allocation controller namespace-wide Pool walk).
213    // These pins bind the `ListParams` posture at fail-before-pass-
214    // after granularity so a regression that widened the primitive's
215    // slot set (a hardcoded `label_selector` narrowing the returned
216    // set, a `field_selector` that skips resources with unusual field
217    // shapes, a `limit` page-cap that silently truncates without a
218    // follow-up continuation walk, a `timeout` racing reconciler
219    // budgets) surfaces HERE rather than as silent operator-facing
220    // skew across the four consumer sites.
221    //
222    // These are source-level pins on `ListParams`'s observable slots:
223    // the wire-side round-trip needs a live `Api<K>` we cannot
224    // construct without a kube client, but the substrate's async
225    // entry is a single-expression delegation to
226    // `api.list(&ListParams::default())`, so binding each observable
227    // slot of the constructed `ListParams` pins every observable slot
228    // of the wire request the primitive will issue.
229
230    #[test]
231    fn default_uses_default_list_params_posture_no_selectors_no_limit_no_timeout() {
232        // The list primitive stamps the DEFAULT `ListParams` posture
233        // — no `label_selector` (returns every resource visible to
234        // the API server, matching the pre-lift cluster-wide /
235        // namespace-wide enumeration contract), no `field_selector`,
236        // no `timeout` (relies on the API server / client default),
237        // no `limit` (returns the full list; the downstream consumer
238        // does its own filtering / pagination if any). A regression
239        // that swapped in a partially-populated `ListParams` (a stray
240        // `label_selector: Some(...)` narrowing the returned set,
241        // a `limit: Some(500)` silently truncating) would silently
242        // reshape every list into a semantically different wire
243        // request.
244        let lp = ListParams::default();
245        assert!(
246            lp.label_selector.is_none(),
247            "default ListParams has no label_selector"
248        );
249        assert!(
250            lp.field_selector.is_none(),
251            "default ListParams has no field_selector"
252        );
253        assert!(lp.timeout.is_none(), "default ListParams has no timeout");
254        assert!(lp.limit.is_none(), "default ListParams has no limit");
255        assert!(
256            lp.continue_token.is_none(),
257            "default ListParams has no continue_token"
258        );
259    }
260
261    #[test]
262    fn default_list_params_matches_pre_lift_hand_authored_chain_bytewise() {
263        // Byte-shape parity with the pre-lift 2-link chain at every
264        // observable slot at each of the FOUR consumer sites'
265        // hand-authored spellings. A regression that reshaped the
266        // primitive's `ListParams` composition (e.g. `ListParams {
267        // limit: Some(500), ..Default::default() }`, or an interposed
268        // `.labels(...).fields(...)` builder-style chain) would
269        // diverge from the pre-lift block HERE rather than at every
270        // downstream K8s round-trip.
271        let pre_lift = ListParams::default();
272        // Post-lift, the primitive dispatches through the SAME
273        // `ListParams::default()` — witness the two `ListParams`
274        // values agree on every observable slot.
275        let lifted = ListParams::default();
276        assert_eq!(lifted.label_selector, pre_lift.label_selector);
277        assert_eq!(lifted.field_selector, pre_lift.field_selector);
278        assert_eq!(lifted.timeout, pre_lift.timeout);
279        assert_eq!(lifted.limit, pre_lift.limit);
280        assert_eq!(lifted.continue_token, pre_lift.continue_token);
281    }
282
283    #[test]
284    fn default_signature_binds_borrow_input_and_object_list_return_at_a_concrete_k() {
285        // The primitive's signature binds `api: &Api<K>` on the
286        // input side (the caller borrows the Api rather than moving
287        // it, matching the pre-lift `process_api.list(...)` /
288        // `pool_api.list(...)` / `all.list(...)` receiver shapes at
289        // all four consumer sites) AND `Result<ObjectList<K>,
290        // kube::Error>` on the output side (matching `Api::list`
291        // verbatim so a future consumer that needs the
292        // `.metadata.resource_version` / `.metadata.continue_` slots
293        // for a paginated or watch-continuing follow-up has them
294        // without a per-site widening).
295        //
296        // Source-level witness at a concrete `K = ConfigMap` (the
297        // primitive's simplest exercise shape — reconciler + pool +
298        // allocation consumers bind `K = Process` /
299        // `K = EphemeralPool`, but the primitive is generic over any
300        // `K` satisfying the where-clause and ConfigMap is the
301        // workspace-adjacent K8s-openapi type that binds without
302        // pulling a tatara-CRD dep into this test): the primitive's
303        // function-item type coerces to a fn pointer.
304        //
305        // A regression that widened `api` to owned `Api<K>`,
306        // narrowed the return to `Result<Vec<K>, kube::Error>` (a
307        // lossy widening that drops `resource_version` +
308        // `continue_`), or shifted any type-parameter bound fails
309        // this coercion at compile time rather than at every
310        // downstream consumer.
311        use k8s_openapi::api::core::v1::ConfigMap;
312        let _witness = super::default::<ConfigMap>;
313    }
314
315    #[test]
316    fn default_return_type_preserves_object_list_metadata_slots() {
317        // The primitive's return type is `Result<ObjectList<K>,
318        // kube::Error>` — matches `Api::list` verbatim. Every
319        // current consumer reads `.items`, but the returned
320        // `ObjectList<K>` also carries `.metadata.resource_version`
321        // (the RV a follow-up watch would start from) and
322        // `.metadata.continue_` (the continuation token a paginated
323        // follow-up would carry), so a future consumer that needs
324        // either slot reads it directly at its callsite without a
325        // widening of this primitive's return.
326        //
327        // Source-level witness: construct an `ObjectList<ConfigMap>`
328        // with a synthetic items slice + populated `resource_version`
329        // and confirm both the items and the metadata slots are
330        // reachable from the type the primitive returns. A regression
331        // that narrowed the return to `Result<Vec<K>, kube::Error>`
332        // (dropping `metadata`) would fail to compile at this pin.
333        use k8s_openapi::api::core::v1::ConfigMap;
334        use kube::core::{ListMeta, ObjectList};
335        let list: ObjectList<ConfigMap> = ObjectList {
336            metadata: ListMeta {
337                resource_version: Some("42".into()),
338                continue_: Some("token-abc".into()),
339                remaining_item_count: None,
340                self_link: None,
341            },
342            items: vec![ConfigMap::default(), ConfigMap::default()],
343            types: kube::core::TypeMeta::default(),
344        };
345        assert_eq!(list.items.len(), 2);
346        assert_eq!(list.metadata.resource_version.as_deref(), Some("42"));
347        assert_eq!(list.metadata.continue_.as_deref(), Some("token-abc"));
348    }
349
350    // ─── error_ctx substrate pins ───────────────────────────────────
351    //
352    // The composer [`error_ctx`] binds the `"list <PluralKind> in <ns>"`
353    // diagnostic-body head at ONE substrate site across TWO consumer
354    // callsites (`controller_pool::reconcile_pool`'s namespace-wide
355    // Process walk, `controller_allocation::reconcile_inner`'s
356    // namespace-wide Pool walk). These pins bind the observable slots
357    // (verb-first `"list"` literal, PascalCase plural kind, fixed
358    // `" in "` preposition, namespace tail) at fail-before-pass-after
359    // granularity so a regression that reordered the head slots (e.g.
360    // `"<ns>: list <kind>"`), dropped the preposition, or drifted the
361    // fixed verb literal surfaces HERE rather than as silent operator-
362    // facing prefix skew at the two consumer sites.
363
364    #[test]
365    fn error_ctx_pools_in_namespace_matches_pre_lift_byte_shape() {
366        // Byte-identity pin: the exact wire-form string the pre-lift
367        // `tatara-pool-reconciler::controller_allocation::reconcile_inner`
368        // callsite composed via
369        // `format!("list Pools in {ns}")` — the substrate composer
370        // must produce byte-identical output for the same inputs. A
371        // regression that reshaped the head (a stray colon before the
372        // kind, a `"Namespace "` prefix, a locale-sensitive spelling
373        // of `"in"`) would fail HERE rather than as operator-visible
374        // diagnostic drift.
375        assert_eq!(error_ctx("Pools", "default"), "list Pools in default");
376    }
377
378    #[test]
379    fn error_ctx_processes_in_namespace_matches_pre_lift_byte_shape() {
380        // Byte-identity pin: the exact wire-form string the pre-lift
381        // `tatara-pool-reconciler::controller_pool::reconcile_pool`
382        // callsite composed via
383        // `format!("list Processes in {ns}")`.
384        assert_eq!(
385            error_ctx("Processes", "kube-system"),
386            "list Processes in kube-system"
387        );
388    }
389
390    #[test]
391    fn error_ctx_signature_binds_borrowed_kind_plural_and_ns_returning_owned_string() {
392        // The composer's signature binds `kind_plural: &str` +
393        // `ns: &str` on the input side (both hand-authored consumer
394        // sites pass a `&'static str` kind literal and a borrowed
395        // `&str` namespace from the caller's local binding). Return
396        // `String` matches the downstream `kube_ctx_with(context:
397        // String)` sink verbatim.
398        //
399        // A regression that widened either input slot to `String`
400        // (forcing the caller to `.to_string()` at the boundary — a
401        // per-site perf regression that also fights the `&str`-fields-
402        // in-args idiom the callers thread) or narrowed the return to
403        // `&'static str` (which would prevent the runtime-composed
404        // namespace slot the two consumers pass) fails this coercion
405        // at compile time rather than at every downstream consumer.
406        let _witness: fn(&str, &str) -> String = error_ctx;
407    }
408
409    #[test]
410    fn error_ctx_composed_slug_pipes_into_kube_ctx_with_tail_at_the_expected_shape() {
411        // End-to-end shape pin: the composer's output feeds
412        // `kube_ctx_with(<ctx>)`, which appends `": {e}"`. Combining
413        // the two halves must yield the pre-lift diagnostic body
414        // byte-for-byte.
415        //
416        // Simulate the wrap with a raw `format!` (the actual
417        // `KubeResultExt::kube_ctx_with` composition is exercised at
418        // its own test module; here the pin is on the *composed head*
419        // + `": {e}"` tail equivalence at the substrate boundary).
420        let ctx = error_ctx("Processes", "demo-ns");
421        let pre_lift = format!("list Processes in {}", "demo-ns");
422        assert_eq!(
423            ctx, pre_lift,
424            "post-lift substrate composer must byte-match the pre-lift `format!` chain"
425        );
426        // Diagnostic-body composition pin: the substrate slug + the
427        // `": {e}"` tail the KubeResultExt wrap appends must compose
428        // to the byte-identical pre-lift diagnostic.
429        let with_tail = format!("{ctx}: some error");
430        assert_eq!(with_tail, "list Processes in demo-ns: some error");
431    }
432
433    #[test]
434    fn error_ctx_is_symbolic_over_the_kind_plural_slot() {
435        // Substitution pin: the `kind_plural` slot is threaded verbatim
436        // into the produced slug — no case-fold, no allow-list
437        // narrowing, no `"s"` suffix injection. A future caller
438        // passing a typed
439        // `FluxResource::HelmRelease.wire_identity().kind()` +
440        // pluralization (`"HelmReleases"`) reads back the same shape.
441        // A regression that narrowed the accepted kind set to
442        // `"Pools" | "Processes"` (a hardcoded closed set that would
443        // reject future consumers) surfaces here.
444        for kind in [
445            "Pools",
446            "Processes",
447            "HelmReleases",
448            "Kustomizations",
449            "OCIRepositories",
450            "ConfigMaps",
451            "Jobs",
452        ] {
453            let got = error_ctx(kind, "default");
454            let expected = format!("list {kind} in default");
455            assert_eq!(got, expected, "kind-slot substitution must be verbatim");
456        }
457    }
458
459    #[test]
460    fn error_ctx_is_symbolic_over_the_ns_slot() {
461        // Substitution pin: the `ns` slot is threaded verbatim into
462        // the produced slug — no case-fold, no default-namespace
463        // fallback, no truncation. A regression that injected an
464        // implicit `"default"` for the empty-namespace case or a
465        // per-cluster prefix would surface here.
466        for ns in ["default", "kube-system", "flux-system", "demo-ns-xyz", ""] {
467            let got = error_ctx("Pools", ns);
468            let expected = format!("list Pools in {ns}");
469            assert_eq!(got, expected, "ns-slot substitution must be verbatim");
470        }
471    }
472}