From b80e04fe734a056a9f9c06f8c31ef193cf398548 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Milojevi=C4=87?= Date: Wed, 16 Sep 2026 14:44:52 +0200 Subject: [PATCH] feat: show Flux inventory resources with navigation Read the owner inventory in the Flux view and provide resource targets by API group and namespace. Prevent local navigation for remote cluster inventories and report entries that cannot be resolved. Closes #612 --- docs/features.md | 10 ++- src/app/gitops.rs | 19 +++++ src/app/tests/flux.rs | 183 ++++++++++++++++++++++++++++++++++++++++++ src/gitops.rs | 91 +++++++++++++++++++++ 4 files changed, 302 insertions(+), 1 deletion(-) diff --git a/docs/features.md b/docs/features.md index 7f332029..e9033236 100644 --- a/docs/features.md +++ b/docs/features.md @@ -430,7 +430,15 @@ concurrent drains, and full kubectl drain parity are outside this feature. Only the latest requested report can update the findings. Closing the view with `esc` or `q` cancels pending results and clears the report progress message. Navigation to a target resource or a palette destination also - cancels pending results. + cancels pending results. The **Managed resources** section shows up to 500 + entries from the owner’s `.status.inventory.entries`, including custom and + cluster-scoped resources. Press `⏎` on an entry to open it. Building this list + does not read the managed resources. Navigation uses the API version available + through cluster discovery. Unknown kinds and invalid entries show a warning. + If the owner has `spec.kubeConfig`, the list is shown without navigation because + its resources can be in another cluster. An absent inventory is reported as + unavailable. Helm hooks and controller-created children are not added to this + list. Navigation uses the normal resource view and its access error handling. - **Argo CD view** (`:argocd` / `:argo`) - the state of the selected Application: sync and health, the project and destination, every source it deploys from with the revision actually deployed from that source, every object in diff --git a/src/app/gitops.rs b/src/app/gitops.rs index fcb79c52..f993a6f1 100644 --- a/src/app/gitops.rs +++ b/src/app/gitops.rs @@ -56,6 +56,22 @@ impl App { let title = self.gitops_title.clone(); let flux = self.flux_kind_map(); + let inventory_kinds: HashMap<_, _> = self + .cluster + .kind_plurals() + .into_iter() + .filter_map(|((kind, group), plural)| { + let key = if group.is_empty() { + plural + } else { + format!("{plural}.{group}") + }; + let target = self.cluster.resolve(&key)?; + (target.ar.group.eq_ignore_ascii_case(&group) + && target.ar.kind.eq_ignore_ascii_case(&kind)) + .then_some(((kind, group), (key, target.namespaced))) + }) + .collect(); let client = self.cluster.client.clone(); let tx = self.tx.clone(); let genr = self.generation; @@ -126,6 +142,9 @@ impl App { deps, }; let mut findings = gitops::describe(&ev); + if let Some(owner) = ev.owner.as_ref().and_then(|n| n.obj.as_ref()) { + findings.extend(gitops::inventory_findings(owner, &inventory_kinds)); + } prepend_warn_finding(&mut findings, warn); Ok((obj, findings)) } diff --git a/src/app/tests/flux.rs b/src/app/tests/flux.rs index 298711a8..a988f364 100644 --- a/src/app/tests/flux.rs +++ b/src/app/tests/flux.rs @@ -413,3 +413,186 @@ async fn flux_menu_cancel_and_readonly_do_not_start_an_action() { } } } + +#[tokio::test] +async fn gitops_inventory_navigation_uses_group_and_scope_without_resource_reads() { + for (plural, owner_kind) in [ + ("kustomizations", "Kustomization"), + ("helmreleases", "HelmRelease"), + ] { + for (id, target_plural, group, namespace) in [ + ("default_web__Service", "services", "", "default"), + ( + "default_web_serving.knative.dev_Service", + "services.serving.knative.dev", + "serving.knative.dev", + "default", + ), + ("_web__Namespace", "namespaces", "", ""), + ] { + let root = json!({"apiVersion":"test/v1", "kind":owner_kind, + "metadata":{"name":"web", "namespace":"default"}, + "status":{"inventory":{"entries":[{"id":id,"v":"v1"}]}}}); + let (mut app, mut rx, responses, requests) = health_report_app(plural, root.clone()); + app.cluster + .register_kind("serving.knative.dev", "Service", "services", true); + app.cluster.register_kind("", "Service", "services", true); + let path = format!( + "/apis/{}/namespaces/default/{plural}/web", + app.kind.as_ref().unwrap().ar.api_version + ); + responses.lock().unwrap().insert(path.clone(), (200, root)); + open_health_report_key(&mut app, true); + receive_health_report(&mut app, &mut rx, true).await; + let index = app + .gitops_items + .iter() + .position(|f| f.target.as_ref().is_some_and(|t| t.plural == target_plural)) + .unwrap(); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .filter(|p| p.as_str() != "/api/v1/namespaces") + .cloned() + .collect::>(), + vec![path] + ); + app.gitops_state.select(Some(index)); + app.handle_key(press(KeyCode::Enter)).unwrap(); + assert_eq!(app.mode, Mode::Table); + assert_eq!(app.kind.as_ref().unwrap().ar.group, group); + assert_eq!(app.namespace, namespace); + assert_eq!(app.fields.as_deref(), Some("metadata.name=web")); + } + } +} + +#[tokio::test] +async fn gitops_inventory_reports_unavailable_entries_and_blocks_navigation() { + for (inventory, kube_config, expected) in [ + (Value::Null, Value::Null, "no inventory reported"), + ( + json!({"entries":{}}), + Value::Null, + "invalid inventory entries", + ), + ( + json!({"entries":[]}), + Value::Null, + "no managed resources reported", + ), + ( + json!({"entries":[{"id":"bad","v":"v1"}]}), + Value::Null, + "invalid inventory entry", + ), + ( + json!({"entries":[{"id":"default_web__Service"}]}), + Value::Null, + "invalid inventory entry", + ), + ( + json!({"entries":[{"id":"default_web_unknown.io_Unknown","v":"v1"}]}), + Value::Null, + "resource kind unavailable", + ), + ( + json!({"entries":[{"id":"_web__Service","v":"v1"}]}), + Value::Null, + "invalid namespace scope", + ), + ( + json!({"entries":[{"id":"default_web__Service","v":"v1"}]}), + json!({"secretRef":{"name":"remote"}}), + "remote cluster configured", + ), + ( + json!({"entries":[{"id":"default_web__Service","v":"v1"}]}), + json!({"configMapRef":{"name":"remote"}}), + "remote cluster configured", + ), + ] { + let root = json!({"apiVersion":"helm.toolkit.fluxcd.io/v2", "kind":"HelmRelease", + "metadata":{"name":"web", "namespace":"default"}, + "spec":{"kubeConfig":kube_config}, "status":{"inventory":inventory}}); + let (mut app, mut rx, responses, _) = health_report_app("helmreleases", root.clone()); + let path = format!( + "/apis/{}/namespaces/default/helmreleases/web", + app.kind.as_ref().unwrap().ar.api_version + ); + responses + .lock() + .unwrap() + .insert(path.clone(), (200, root.clone())); + open_health_report_key(&mut app, true); + receive_health_report(&mut app, &mut rx, true).await; + assert!( + app.gitops_items.iter().any(|f| f.text.contains(expected)), + "{expected}" + ); + let heading = app + .gitops_items + .iter() + .position(|f| f.text == "Managed resources") + .unwrap(); + for index in heading + 1..app.gitops_items.len() { + assert!(app.gitops_items[index].target.is_none()); + app.gitops_state.select(Some(index)); + app.handle_key(press(KeyCode::Enter)).unwrap(); + assert_eq!(app.mode, Mode::Gitops); + } + let mut updated = root; + updated["spec"] = json!({}); + updated["status"]["inventory"] = + json!({"entries":[{"id":"default_web__Service","v":"v1"}]}); + responses.lock().unwrap().insert(path, (200, updated)); + app.handle_key(press(KeyCode::Char('r'))).unwrap(); + receive_health_report(&mut app, &mut rx, true).await; + assert!( + app.gitops_items + .iter() + .any(|f| f.target.as_ref().is_some_and(|t| t.plural == "services")) + ); + } +} + +#[tokio::test] +async fn gitops_inventory_limits_large_lists() { + let entries: Vec<_> = (0..502) + .map(|i| json!({"id":format!("default_web-{i}__Service"),"v":"v1"})) + .collect(); + let root = json!({"apiVersion":"helm.toolkit.fluxcd.io/v2", "kind":"HelmRelease", + "metadata":{"name":"web", "namespace":"default"}, "status":{"inventory":{"entries":entries}}}); + let (mut app, mut rx, responses, requests) = health_report_app("helmreleases", root.clone()); + let path = format!( + "/apis/{}/namespaces/default/helmreleases/web", + app.kind.as_ref().unwrap().ar.api_version + ); + responses.lock().unwrap().insert(path.clone(), (200, root)); + open_health_report_key(&mut app, true); + receive_health_report(&mut app, &mut rx, true).await; + assert_eq!( + app.gitops_items + .iter() + .filter(|f| f.target.is_some()) + .count(), + 500 + ); + assert!( + app.gitops_items + .iter() + .any(|f| f.text == "2 more inventory entries omitted") + ); + assert_eq!( + requests + .lock() + .unwrap() + .iter() + .filter(|p| p.as_str() != "/api/v1/namespaces") + .cloned() + .collect::>(), + vec![path] + ); +} diff --git a/src/gitops.rs b/src/gitops.rs index fc0a3253..c5ee8bb2 100644 --- a/src/gitops.rs +++ b/src/gitops.rs @@ -135,6 +135,97 @@ pub fn depends_on(owner: &DynamicObject) -> Vec { .unwrap_or_default() } +/// Show the inventory without reading each managed resource. +pub fn inventory_findings( + owner: &DynamicObject, + kinds: &std::collections::HashMap<(String, String), (String, bool)>, +) -> Vec { + let mut out = vec![finding(0, Level::Heading, "Managed resources")]; + let Some(entries) = owner.data.pointer("/status/inventory/entries") else { + out.push(finding(1, Level::Info, "no inventory reported")); + return out; + }; + let Some(entries) = entries.as_array() else { + out.push(finding(1, Level::Warn, "invalid inventory entries")); + return out; + }; + let remote = owner + .data + .pointer("/spec/kubeConfig") + .is_some_and(|v| !v.is_null()); + if remote { + out.push(finding( + 1, + Level::Warn, + "remote cluster configured: inventory navigation is unavailable", + )); + } + if entries.is_empty() { + out.push(finding(1, Level::Info, "no managed resources reported")); + } + const MAX_LISTED: usize = 500; + for entry in entries.iter().take(MAX_LISTED) { + let parts: Vec<_> = entry + .get("id") + .and_then(Value::as_str) + .unwrap_or_default() + .split('_') + .collect(); + let version = entry.get("v").and_then(Value::as_str).unwrap_or_default(); + if parts.len() != 4 || parts[1].is_empty() || parts[3].is_empty() || version.is_empty() { + out.push(finding(1, Level::Warn, "invalid inventory entry")); + continue; + } + let (namespace, name, group, kind) = (parts[0], parts[1], parts[2], parts[3]); + let qualified = if group.is_empty() { + kind.to_string() + } else { + format!("{kind}.{group}") + }; + let scope = if namespace.is_empty() { + "cluster" + } else { + namespace + }; + let mut row = finding( + 1, + Level::Info, + format!("{qualified}/{name} ({scope}, {version})"), + ); + if !remote { + match kinds.get(&(kind.to_lowercase(), group.to_lowercase())) { + Some((plural, namespaced)) if *namespaced == !namespace.is_empty() => { + row = row.with_target(Target { + plural: plural.clone(), + namespace: namespaced.then(|| namespace.to_string()), + name: name.to_string(), + }); + } + Some(_) => { + row.level = Level::Warn; + row.text.push_str(": invalid namespace scope"); + } + None => { + row.level = Level::Warn; + row.text.push_str(": resource kind unavailable"); + } + } + } + out.push(row); + } + if entries.len() > MAX_LISTED { + out.push(finding( + 1, + Level::Info, + format!( + "{} more inventory entries omitted", + entries.len() - MAX_LISTED + ), + )); + } + out +} + // ----- object state accessors ---------------------------------------------- /// `(status, reason, message)` of the object's `Ready` condition.