diff --git a/docs/features.md b/docs/features.md index 7f33202..e903323 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 fcb79c5..f993a6f 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 298711a..a988f36 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 fc0a325..c5ee8bb 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.