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}