From 88fe6a8007420e78378bd943f8e6715ad5ac4ed5 Mon Sep 17 00:00:00 2001 From: Erfan Yousefi Date: Thu, 25 Jun 2026 16:57:13 +0200 Subject: [PATCH 1/2] feat(ui): Enter drill-down from owners to their pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enter on a Deployment/ReplicaSet/StatefulSet/DaemonSet now opens a Pods view scoped to that owner's pods; Enter on a Node shows the pods scheduled on it. Closes the top gap from the k9s readonly parity audit (4.7). - Live filter, not a snapshot: a DrillFilter on ViewRequest is applied in the projector, so rollouts/new pods appear/disappear in real time. Deployment ownership is resolved transitively through the pod's ReplicaSet (reusing the owner chain the log fan-in relies on); other workloads and Node match directly (ownerReferences / scheduled node). - The Pods title shows `[DRILL] → Pods`; `esc` pops back to the owner's list (krust has no view stack). Drill clears on any kind/namespace change. - Leaf kinds keep the previous behavior (Enter → describe; namespace → its resources). svc→endpoints drill deferred (needs the service selector, not currently extracted) — noted in the audit/ROADMAP. Tests: projector owner-chain/node filtering, and an Enter→drill render integration test. Co-Authored-By: Claude Opus 4.8 (1M context) --- ROADMAP.md | 6 +- docs/operator-guide.md | 4 +- src/ui/app.rs | 91 ++++++++++++++++++++- src/ui/app/command_mode.rs | 49 +++++++++++ src/ui/app/render_loop.rs | 20 +++-- src/view/mod.rs | 3 +- src/view/projector.rs | 163 +++++++++++++++++++++++++++++++++++++ 7 files changed, 326 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 3213d7c..cbfe8e0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -194,8 +194,10 @@ Root problem: full object JSON kept for every entity. full k9s readonly surface vs krust (grounded in the k9s README keybindings + commands docs) and marks each ✅/🟡/❌/⛔. Mutations explicitly scoped out (readonly-first v1). Prioritized gaps to close next, highest value first: - - **Enter drill-down** (deploy/rs/sts/ds→pods, node→pods, svc→endpoints/pods) — biggest gap; the - owner→pod mapping already exists (log fan-in). + - [x] **Enter drill-down** — DONE (deploy/rs/sts/ds→pods via owner chain, node→pods via + scheduled-node). Live filter on the Pods view (`DrillFilter` in `ViewRequest`), `[DRILL]` title, + `esc` pops back to the owner list; cleared on any kind/namespace change. (svc→endpoints deferred + — needs the service selector, not currently extracted.) - **Previous logs** (`--previous` toggle) for crashloops. - **xray** relationship-tree view (replace stub). - Medium: jump-to-owner (`Shift-J`), UsedBy/dependents (`U`), log timestamps, pod metric-column diff --git a/docs/operator-guide.md b/docs/operator-guide.md index 347cf94..16d9138 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -52,7 +52,9 @@ krust --all-contexts - `tab` / `shift+tab`: switch context tabs - `j` / `k` (or arrows): move selection (table) / scroll (detail, logs) - `g` / `G`: top / bottom (in detail panes use `gg` for top) -- `Enter`: select namespace in namespace view; otherwise open describe +- `Enter`: drill down where it makes sense — namespace → its resources; Deployment/ReplicaSet/ + StatefulSet/DaemonSet → their pods; Node → pods scheduled on it. Other kinds open describe. + In a drill-down the title shows `[DRILL] → Pods`; `esc` pops back to the owner list. - `d`: describe selected resource (toggles back to table) - `v`: view YAML; `t`: back to table; `E`: events pane (this resource's events); `l`: logs - `n`: cycle namespace; `s`: cycle sort column; `r`: reverse sort order diff --git a/src/ui/app.rs b/src/ui/app.rs index f1dd94b..8aaeb9f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -70,7 +70,9 @@ use crate::{ StateDelta, }, state::StateStore, - view::{SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, materialize_row}, + view::{ + DrillFilter, SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, materialize_row, + }, }; mod bench; @@ -99,6 +101,9 @@ struct ContextTabState { descending: bool, /// Show Helm release secrets in the Secrets list (default false — they're hidden clutter). show_helm_secrets: bool, + /// Active Enter drill-down (Pods view scoped to an owner). Cleared on kind/namespace change + /// or Esc in the table. + drill: Option, pane: Pane, } @@ -622,6 +627,12 @@ impl App { sort: tab.sort, descending: tab.descending, show_helm_secrets: tab.show_helm_secrets, + // Drill-down only applies to the Pods view. + drill: if tab.kind() == ResourceKind::Pods { + tab.drill.clone() + } else { + None + }, } } @@ -904,6 +915,7 @@ impl App { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, pane: Pane::Table, }) .collect(); @@ -1275,6 +1287,11 @@ impl App { self.status_line = "Closed view".to_string(); } else if self.clear_active_filter_value() { self.status_line = self.filter_status_message(""); + } else if let Some(owner_kind) = self.current_tab().drill.as_ref().map(|d| d.owner_kind) + { + // Pop the drill-down back to the owner's list (krust has no view stack). + self.set_active_kind(owner_kind); + self.status_line = format!("Drill-down cleared → {owner_kind}"); } else { self.status_line = "Nothing to cancel".to_string(); } @@ -1531,6 +1548,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; self.overlay = None; } @@ -1548,6 +1566,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; self.overlay = None; } @@ -1561,6 +1580,7 @@ impl App { let tab = self.current_tab_mut(); tab.namespace = None; tab.selected = 0; + tab.drill = None; tab.pane = Pane::Table; self.status_line = "Namespace filter cleared".to_string(); return; @@ -1584,6 +1604,7 @@ impl App { } } tab.selected = 0; + tab.drill = None; tab.pane = Pane::Table; tab.namespace.clone() }; @@ -1672,6 +1693,7 @@ impl App { tab.selected = 0; tab.detail_scroll = 0; tab.detail_hscroll = 0; + tab.drill = None; tab.pane = Pane::Table; tab.kind().to_string() }; @@ -3211,6 +3233,73 @@ mod tests { assert!(snap.contains("helm shown"), "title hint: {snap}"); } + #[test] + fn enter_on_deployment_drills_into_its_pods() { + let mut app = test_app(); + let dep_idx = ResourceKind::ORDERED + .iter() + .position(|k| *k == ResourceKind::Deployments) + .expect("deployments in ORDERED"); + app.current_tab_mut().kind_idx = dep_idx; + + // dep1 -> rs1 -> p1 ; unrelated p2 + app.store.apply(StateDelta::Upsert(mk_entity( + "ctx-dev", + ResourceKind::Deployments, + Some("default"), + "dep1", + "1/1 ready", + ))); + let mut rs = mk_entity( + "ctx-dev", + ResourceKind::ReplicaSets, + Some("default"), + "rs1", + "-", + ); + rs.extracted.owners = vec![crate::model::OwnerRef { + kind: "Deployment".to_string(), + name: "dep1".to_string(), + }]; + app.store.apply(StateDelta::Upsert(rs)); + let mut p1 = mk_entity( + "ctx-dev", + ResourceKind::Pods, + Some("default"), + "p1", + "Running", + ); + p1.extracted.owners = vec![crate::model::OwnerRef { + kind: "ReplicaSet".to_string(), + name: "rs1".to_string(), + }]; + app.store.apply(StateDelta::Upsert(p1)); + app.store.apply(StateDelta::Upsert(mk_entity( + "ctx-dev", + ResourceKind::Pods, + Some("default"), + "p2", + "Running", + ))); + + app.handle_enter_key(); + + // Switched to a Pods view scoped to dep1. + assert_eq!(app.current_tab().kind(), ResourceKind::Pods); + let drill = app.current_tab().drill.as_ref().expect("drill set"); + assert_eq!(drill.owner_kind, ResourceKind::Deployments); + assert_eq!(drill.owner_name, "dep1"); + + let snap = render_snapshot(&mut app, 140, 20); + assert!( + snap.contains("[DRILL] deploy/dep1"), + "drill indicator: {snap}" + ); + // p1 (owned by dep1 via rs1) shows; the unrelated p2 is filtered out. + assert!(snap.contains("default"), "owned pod row present: {snap}"); + assert!(!snap.contains("p2"), "unrelated pod hidden: {snap}"); + } + #[test] fn events_pane_shows_correlated_events_for_a_pod() { let mut app = test_app(); diff --git a/src/ui/app/command_mode.rs b/src/ui/app/command_mode.rs index 0a22c59..3a5f534 100644 --- a/src/ui/app/command_mode.rs +++ b/src/ui/app/command_mode.rs @@ -1,5 +1,19 @@ use super::*; +/// Kinds whose Enter drills into a filtered Pods view. Returns the owner kind itself when it +/// supports drill-down, else None (leaf kinds fall through to describe). +fn drill_owner_kind(kind: ResourceKind) -> Option { + matches!( + kind, + ResourceKind::Deployments + | ResourceKind::ReplicaSets + | ResourceKind::StatefulSets + | ResourceKind::DaemonSets + | ResourceKind::Nodes + ) + .then_some(kind) +} + impl App { pub(super) fn handle_enter_key(&mut self) { if self.current_tab().pane != Pane::Table { @@ -41,12 +55,47 @@ impl App { return; } + // Drill-down: Enter on a workload or node opens its pods (filtered Pods view). + if drill_owner_kind(row.key.kind).is_some() { + self.drill_into(&row); + return; + } + self.current_tab_mut().pane = Pane::Describe; self.current_tab_mut().detail_scroll = 0; self.current_tab_mut().detail_hscroll = 0; self.status_line = format!("Describe: {} {}", row.key.kind.short_name(), row.key.name); } + /// Switch to the Pods view scoped to the selected owner (Deployment/RS/STS/DS/Node). + fn drill_into(&mut self, row: &crate::view::ViewRow) { + let pods_idx = ResourceKind::ORDERED + .iter() + .position(|kind| *kind == ResourceKind::Pods) + .unwrap_or(0); + let owner_kind = row.key.kind; + let owner_name = row.key.name.clone(); + let label = format!("{}/{}", owner_kind.short_name(), owner_name); + let tab = self.current_tab_mut(); + // Node pods span namespaces; workload pods live in the owner's namespace. + if owner_kind != ResourceKind::Nodes { + tab.namespace = row.key.namespace.clone(); + } else { + tab.namespace = None; + } + tab.drill = Some(DrillFilter { + owner_kind, + owner_name, + }); + tab.kind_idx = pods_idx; + tab.last_non_namespace_kind_idx = pods_idx; + tab.selected = 0; + tab.table_offset = 0; + tab.pane = Pane::Table; + self.overlay = None; + self.status_line = format!("Drill-down: pods of {label} (esc to clear)"); + } + pub(super) async fn handle_command_key(&mut self, key: KeyEvent) -> anyhow::Result { match key.code { KeyCode::Esc => { diff --git a/src/ui/app/render_loop.rs b/src/ui/app/render_loop.rs index 07e12e0..d58116b 100644 --- a/src/ui/app/render_loop.rs +++ b/src/ui/app/render_loop.rs @@ -769,12 +769,22 @@ impl App { (header, widths) }; let title = if pods_view { - let legend = if self.pod_metrics.is_empty() { - "CPU/MEM: metrics-server n/a" + if let Some(drill) = &active.drill { + // Drill-down: lead with the owner so it's always visible (esc clears). + format!( + "[DRILL] {}/{} → Pods ({}) · esc clears", + drill.owner_kind.short_name(), + drill.owner_name, + vm.len() + ) } else { - "CPU/MEM used · CR/CL = cpu %req/%lim · MR/ML = mem %req/%lim" - }; - format!("[KIND] {} ({}) · {legend}", active.kind(), vm.len()) + let legend = if self.pod_metrics.is_empty() { + "CPU/MEM: metrics-server n/a" + } else { + "CPU/MEM used · CR/CL = cpu %req/%lim · MR/ML = mem %req/%lim" + }; + format!("[KIND] {} ({}) · {legend}", active.kind(), vm.len()) + } } else if active.kind() == ResourceKind::Secrets { let helm = if active.show_helm_secrets { format!( diff --git a/src/view/mod.rs b/src/view/mod.rs index ea7d806..2303cdd 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -1,5 +1,6 @@ mod projector; pub use projector::{ - SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow, materialize_row, + DrillFilter, SimpleViewProjector, ViewModel, ViewProjector, ViewRequest, ViewRow, + materialize_row, }; diff --git a/src/view/projector.rs b/src/view/projector.rs index ca3d2cf..dddd344 100644 --- a/src/view/projector.rs +++ b/src/view/projector.rs @@ -16,6 +16,53 @@ pub struct ViewRequest { /// When false (default), Helm release secrets (`type: helm.sh/release.v1`) are hidden from the /// Secrets list to cut clutter. No effect on other kinds. pub show_helm_secrets: bool, + /// When set (Pods view only), restrict the list to pods belonging to an owning resource — + /// a workload (Deployment/ReplicaSet/StatefulSet/DaemonSet) or a Node. Drives Enter drill-down. + pub drill: Option, +} + +/// Identifies an owner whose child pods a drill-down view should show. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DrillFilter { + pub owner_kind: ResourceKind, + pub owner_name: String, +} + +impl DrillFilter { + /// True if `pod` belongs to this owner. For Deployments the ownership is transitive through + /// the pod's ReplicaSet (resolved via `store`); other workloads and Nodes match directly. + fn matches( + &self, + store: &StateStore, + context: &str, + pod: &crate::model::ResourceEntity, + ) -> bool { + match self.owner_kind { + ResourceKind::ReplicaSets => pod.extracted.owned_by("ReplicaSet", &self.owner_name), + ResourceKind::StatefulSets => pod.extracted.owned_by("StatefulSet", &self.owner_name), + ResourceKind::DaemonSets => pod.extracted.owned_by("DaemonSet", &self.owner_name), + ResourceKind::Nodes => { + pod.extracted.node_name.as_deref() == Some(self.owner_name.as_str()) + } + ResourceKind::Deployments => pod + .extracted + .owners + .iter() + .filter(|owner| owner.kind == "ReplicaSet") + .any(|owner| { + let rs_key = ResourceKey::new( + context, + ResourceKind::ReplicaSets, + pod.key.namespace.clone(), + owner.name.clone(), + ); + store + .get(&rs_key) + .is_some_and(|rs| rs.extracted.owned_by("Deployment", &self.owner_name)) + }), + _ => true, + } + } } #[derive(Debug, Clone)] @@ -71,6 +118,13 @@ impl ViewProjector for SimpleViewProjector { entities.retain(|entity| !entity.is_helm_release()); } + // Enter drill-down: restrict the Pods view to one owner's pods. + if request.kind == ResourceKind::Pods + && let Some(drill) = &request.drill + { + entities.retain(|entity| drill.matches(store, &request.context, entity)); + } + entities.sort_by(|a, b| { let ord = match request.sort { SortColumn::Name => a.key.name.cmp(&b.key.name), @@ -288,6 +342,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }, ); @@ -296,6 +351,111 @@ mod tests { assert_eq!(row.name, "worker"); } + fn put_entity( + store: &mut StateStore, + kind: ResourceKind, + name: &str, + extracted: crate::model::Extracted, + ) { + store.apply(StateDelta::Upsert(ResourceEntity { + key: ResourceKey::new("ctx", kind, Some("ns".to_string()), name), + status: "-".to_string(), + age: Some(Utc::now()), + labels: vec![], + columns: vec![], + extracted, + })); + } + + fn owner(kind: &str, name: &str) -> crate::model::OwnerRef { + crate::model::OwnerRef { + kind: kind.to_string(), + name: name.to_string(), + } + } + + #[test] + fn drill_down_filters_pods_by_owner_chain_and_node() { + use crate::model::Extracted; + let mut store = StateStore::default(); + // dep1 -> rs1 -> p1 ; unrelated p2 ; p3 on node-a + put_entity( + &mut store, + ResourceKind::ReplicaSets, + "rs1", + Extracted { + owners: vec![owner("Deployment", "dep1")], + ..Default::default() + }, + ); + put_entity( + &mut store, + ResourceKind::Pods, + "p1", + Extracted { + owners: vec![owner("ReplicaSet", "rs1")], + ..Default::default() + }, + ); + put_entity(&mut store, ResourceKind::Pods, "p2", Extracted::default()); + put_entity( + &mut store, + ResourceKind::Pods, + "p3", + Extracted { + node_name: Some("node-a".to_string()), + ..Default::default() + }, + ); + + let projector = SimpleViewProjector; + let req = |drill: Option| ViewRequest { + context: "ctx".to_string(), + kind: ResourceKind::Pods, + namespace: None, + filter: String::new(), + sort: SortColumn::Name, + descending: false, + show_helm_secrets: false, + drill, + }; + let names = + |vm: ViewModel| -> Vec { vm.order.iter().map(|k| k.name.clone()).collect() }; + + // Transitive deployment ownership: only p1. + let dep = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::Deployments, + owner_name: "dep1".to_string(), + })), + ); + assert_eq!(names(dep), vec!["p1".to_string()]); + + // Direct ReplicaSet ownership: only p1. + let rs = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::ReplicaSets, + owner_name: "rs1".to_string(), + })), + ); + assert_eq!(names(rs), vec!["p1".to_string()]); + + // Node scheduling: only p3. + let node = projector.project( + &store, + &req(Some(DrillFilter { + owner_kind: ResourceKind::Nodes, + owner_name: "node-a".to_string(), + })), + ); + assert_eq!(names(node), vec!["p3".to_string()]); + + // No drill: all three pods. + assert_eq!(names(projector.project(&store, &req(None))).len(), 3); + } + fn put_secret(store: &mut StateStore, name: &str, secret_type: &str) { store.apply(StateDelta::Upsert(ResourceEntity { key: ResourceKey::new("ctx", ResourceKind::Secrets, Some("ns".to_string()), name), @@ -326,6 +486,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets, + drill: None, }; // Default: the helm release secret is filtered out. @@ -364,6 +525,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }, ) .order @@ -432,6 +594,7 @@ mod tests { sort: SortColumn::Name, descending: false, show_helm_secrets: false, + drill: None, }; let start = Instant::now(); From 2db9691f9e8423a097761e8bea7473265c164388 Mon Sep 17 00:00:00 2001 From: Erfan Yousefi Date: Thu, 25 Jun 2026 17:29:29 +0200 Subject: [PATCH 2/2] fix(ui): resolve deployment drill-down showing zero pods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deployment→pod chain resolves transitively through each pod's ReplicaSet, but ReplicaSets were only watched in the Logs pane — during a drill the active kind is Pods (Table pane), so the RS were never loaded and every pod was filtered out (zero pods). Two-part fix: - Watch ReplicaSets for the drilled namespace while a Deployment drill is active, so the chain resolves authoritatively from store state. - Add a name-convention fallback in DrillFilter: a deployment's RS is always named `-` (hash has no `-`), so pods match immediately even before the RS watch populates or if RS are RBAC-denied. The no-`-` check keeps `web` from matching `web-api`'s ReplicaSets. Test: projector deployment drill now covers a pod whose RS is absent from the store (name-fallback path). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/app.rs | 12 +++++++++ src/view/projector.rs | 61 +++++++++++++++++++++++++++++-------------- 2 files changed, 53 insertions(+), 20 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 8aaeb9f..6c5de1a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1745,6 +1745,18 @@ impl App { }); } + // A Deployment drill-down resolves pods transitively through their ReplicaSet, so the RS + // for the drilled namespace must be watched too (otherwise the chain finds nothing). + if tab.kind() == ResourceKind::Pods + && let Some(drill) = &tab.drill + && drill.owner_kind == ResourceKind::Deployments + { + set.insert(WatchTarget { + kind: ResourceKind::ReplicaSets, + namespace: tab.namespace.clone(), + }); + } + let mut out: Vec = set.into_iter().collect(); out.sort_by(|a, b| { a.kind diff --git a/src/view/projector.rs b/src/view/projector.rs index dddd344..3ad0d21 100644 --- a/src/view/projector.rs +++ b/src/view/projector.rs @@ -44,22 +44,33 @@ impl DrillFilter { ResourceKind::Nodes => { pod.extracted.node_name.as_deref() == Some(self.owner_name.as_str()) } - ResourceKind::Deployments => pod - .extracted - .owners - .iter() - .filter(|owner| owner.kind == "ReplicaSet") - .any(|owner| { - let rs_key = ResourceKey::new( - context, - ResourceKind::ReplicaSets, - pod.key.namespace.clone(), - owner.name.clone(), - ); - store - .get(&rs_key) - .is_some_and(|rs| rs.extracted.owned_by("Deployment", &self.owner_name)) - }), + ResourceKind::Deployments => { + let prefix = format!("{}-", self.owner_name); + pod.extracted + .owners + .iter() + .filter(|owner| owner.kind == "ReplicaSet") + .any(|owner| { + // Authoritative: the RS is in the store and owned by this deployment. + let rs_key = ResourceKey::new( + context, + ResourceKind::ReplicaSets, + pod.key.namespace.clone(), + owner.name.clone(), + ); + let by_store = store.get(&rs_key).is_some_and(|rs| { + rs.extracted.owned_by("Deployment", &self.owner_name) + }); + // Fallback when the RS isn't loaded yet (or RBAC-denied): a deployment's + // RS is always named `-`, hash having no `-`, + // which keeps `web` from matching `web-api`'s ReplicaSets. + let by_name = owner + .name + .strip_prefix(&prefix) + .is_some_and(|hash| !hash.is_empty() && !hash.contains('-')); + by_store || by_name + }) + } _ => true, } } @@ -407,6 +418,16 @@ mod tests { ..Default::default() }, ); + // p4's ReplicaSet ("dep1-77c") isn't in the store — must still match dep1 by name convention. + put_entity( + &mut store, + ResourceKind::Pods, + "p4", + Extracted { + owners: vec![owner("ReplicaSet", "dep1-77c")], + ..Default::default() + }, + ); let projector = SimpleViewProjector; let req = |drill: Option| ViewRequest { @@ -422,7 +443,7 @@ mod tests { let names = |vm: ViewModel| -> Vec { vm.order.iter().map(|k| k.name.clone()).collect() }; - // Transitive deployment ownership: only p1. + // Deployment ownership: p1 (transitive via stored rs1) + p4 (RS not stored, name fallback). let dep = projector.project( &store, &req(Some(DrillFilter { @@ -430,7 +451,7 @@ mod tests { owner_name: "dep1".to_string(), })), ); - assert_eq!(names(dep), vec!["p1".to_string()]); + assert_eq!(names(dep), vec!["p1".to_string(), "p4".to_string()]); // Direct ReplicaSet ownership: only p1. let rs = projector.project( @@ -452,8 +473,8 @@ mod tests { ); assert_eq!(names(node), vec!["p3".to_string()]); - // No drill: all three pods. - assert_eq!(names(projector.project(&store, &req(None))).len(), 3); + // No drill: all four pods. + assert_eq!(names(projector.project(&store, &req(None))).len(), 4); } fn put_secret(store: &mut StateStore, name: &str, secret_type: &str) {