Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/app/gitops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
}
Expand Down
183 changes: 183 additions & 0 deletions src/app/tests/flux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<_>>(),
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<_>>(),
vec![path]
);
}
91 changes: 91 additions & 0 deletions src/gitops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,97 @@ pub fn depends_on(owner: &DynamicObject) -> Vec<FluxRef> {
.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<Finding> {
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.
Expand Down