From 549e333c2435ff4d945131aec2137788638c55ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Milojevi=C4=87?= Date: Wed, 16 Sep 2026 14:28:01 +0200 Subject: [PATCH 1/2] fix: enable Flux actions for HelmChart resources HelmChart was missing from the supported Flux resource list. Enable suspend, resume, and reconcile through the existing API patches, with tests and help updates. Closes #610 --- docs/features.md | 2 +- docs/keys.md | 2 +- src/app/actions.rs | 2 +- src/app/mod.rs | 6 +- src/app/tests/flux.rs | 169 ++++++++++++++++++++++++++++++++++++------ src/ui.rs | 2 +- 6 files changed, 153 insertions(+), 30 deletions(-) diff --git a/docs/features.md b/docs/features.md index 5f40f99f..7f332029 100644 --- a/docs/features.md +++ b/docs/features.md @@ -400,7 +400,7 @@ concurrent drains, and full kubectl drain parity are outside this feature. ## GitOps and Helm - **Flux CD controls** (`t`) - a suspend/resume/reconcile-now menu built on - native Kubernetes API patches, for Kustomizations, HelmReleases, git/helm/oci + native Kubernetes API patches, for Kustomizations, HelmReleases, HelmCharts, git/helm/oci repositories, buckets, image automation, and notification alerts and receivers. No `flux` binary needed. Works with bulk multiselect. For HelmRelease resources, **Force reconcile** requests a Helm install or upgrade diff --git a/docs/keys.md b/docs/keys.md index 0364bc65..571de907 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -88,7 +88,7 @@ ownership scope are cleared. Startup still uses the configured default resource. | `i` | set container image | | `r` | rollout restart (workloads) / force-sync (ExternalSecrets/PushSecrets) / refresh (elsewhere) | | `f` / `shift-f` | port-forward (pods/services) — picker shows declared ports, or "Custom…" for manual entry; active forwards show `●` next to the name | -| `t` | Flux: suspend/resume/reconcile (+ force for HelmRelease) · ArgoCD: suspend/resume (+ sync for App) · CronJobs: trigger/suspend/resume · pods: file transfer | +| `t` | Flux: suspend/resume/reconcile (includes HelmChart; + force for HelmRelease) · ArgoCD: suspend/resume (+ sync for App) · CronJobs: trigger/suspend/resume · pods: file transfer | | `C` / `U` / `D` | nodes: cordon / uncordon / open drain options | | `ctrl-d` / `ctrl-k` | delete / force-delete (marked rows, or current); in confirm: `f` toggles force, `c` cycles cascade (background → foreground → orphan) | | `w` | toggle wide-only columns (kubectl `-o wide`), including node labels | diff --git a/src/app/actions.rs b/src/app/actions.rs index 984f6448..ac1982b4 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1873,7 +1873,7 @@ impl App { return; } if !self.flux_suspendable() && !self.cronjob_kind() && !self.argocd_kind() { - self.flash_warn("suspend/resume only applies to CronJobs, Flux resources (ks/hr/git-, helm-, oci-repos, buckets, image automation, alerts, receivers), and ArgoCD Applications/ApplicationSets"); + self.flash_warn("suspend/resume only applies to CronJobs, Flux resources (ks/hr/HelmCharts/git-, helm-, oci-repos, buckets, image automation, alerts, receivers), and ArgoCD Applications/ApplicationSets"); return; } if self.action_targets().is_empty() { diff --git a/src/app/mod.rs b/src/app/mod.rs index 97b26651..c4bb5b3e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -92,15 +92,13 @@ const WELCOME_FLASH: &str = /// clears it (see [`App::expire_flash`]). const FLASH_TTL: std::time::Duration = std::time::Duration::from_secs(8); -/// Flux CD resource kinds whose spec has a `suspend: bool` field — every kind -/// with a corresponding `flux suspend/resume` subcommand: kustomize- and -/// helm-controller reconcilers, source-controller fetchers, image-automation -/// controllers, and the notification-controller kinds that support it. +/// Flux CD resource kinds that support suspend and resume through `spec.suspend`. const FLUX_SUSPENDABLE_KINDS: &[&str] = &[ "kustomizations", "helmreleases", "gitrepositories", "helmrepositories", + "helmcharts", "ocirepositories", "buckets", "imagerepositories", diff --git a/src/app/tests/flux.rs b/src/app/tests/flux.rs index 3a564f05..13349dd8 100644 --- a/src/app/tests/flux.rs +++ b/src/app/tests/flux.rs @@ -9,6 +9,14 @@ fn helmrelease(name: &str) -> Value { }) } +fn helmchart(name: &str) -> Value { + json!({ + "apiVersion": "source.toolkit.fluxcd.io/v1", + "kind": "HelmChart", + "metadata": {"name": name, "namespace": "default"} + }) +} + fn choose(app: &mut App, item: &str) { app.handle_key(press(KeyCode::Char('t'))).unwrap(); assert_eq!(app.mode, Mode::FluxMenu); @@ -151,11 +159,112 @@ async fn helmrelease_reconcile_patches_selected_and_marked_releases() { } } +#[tokio::test] +async fn helmchart_actions_patch_selected_and_marked_charts() { + for action in ["Suspend", "Resume", "Reconcile now"] { + for bulk in [false, true] { + let (mut app, mut rx) = test_app(); + app.cluster + .register_kind("source.toolkit.fluxcd.io", "HelmChart", "helmcharts", true); + app.switch_kind("helmcharts"); + apply(&mut app, helmchart("apps")); + apply(&mut app, helmchart("infra")); + let (requests, mut received) = mpsc::unbounded_channel(); + app.cluster.client = kube::Client::new( + tower::service_fn(move |request: http::Request| { + let requests = requests.clone(); + async move { + let (parts, body) = request.into_parts(); + assert_eq!(parts.method, http::Method::PATCH); + assert_eq!( + parts.headers["content-type"], + "application/merge-patch+json" + ); + let bytes = body.collect().await.unwrap().to_bytes(); + let patch: Value = serde_json::from_slice(&bytes).unwrap(); + requests + .send((parts.uri.path().to_string(), patch)) + .unwrap(); + Ok::<_, std::convert::Infallible>(http::Response::new( + http_body_util::Full::new(hyper::body::Bytes::from( + helmchart("apps").to_string(), + )), + )) + } + }), + "default", + ); + if bulk { + app.handle_key(press(KeyCode::Char(' '))).unwrap(); + app.handle_key(press(KeyCode::Char(' '))).unwrap(); + assert_eq!(app.marked.len(), 2); + } + choose(&mut app, action); + assert_eq!(app.mode, Mode::Table); + assert!(app.marked.is_empty()); + let mut paths = Vec::new(); + for _ in 0..if bulk { 2 } else { 1 } { + let (path, patch) = tokio::time::timeout(Duration::from_secs(2), received.recv()) + .await + .unwrap() + .unwrap(); + paths.push(path); + match action { + "Suspend" => assert_eq!(patch, json!({"spec": {"suspend": true}})), + "Resume" => assert_eq!(patch, json!({"spec": {"suspend": false}})), + _ => { + let requested = + patch["metadata"]["annotations"]["reconcile.fluxcd.io/requestedAt"] + .as_str() + .unwrap(); + assert!(requested.parse::().is_ok()); + assert_eq!( + patch, + json!({"metadata": {"annotations": { + "reconcile.fluxcd.io/requestedAt": requested + }}}) + ); + } + } + } + paths.sort(); + let prefix = "/apis/source.toolkit.fluxcd.io/v1/namespaces/default/helmcharts"; + let expected = if bulk { + vec![format!("{prefix}/apps"), format!("{prefix}/infra")] + } else { + vec![format!("{prefix}/apps")] + }; + assert_eq!(paths, expected); + let verb = match action { + "Suspend" => "suspended", + "Resume" => "resumed", + _ => "reconcile requested:", + }; + let reply = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let msg = rx.recv().await.unwrap(); + if matches!(&msg, Msg::Flash { message, .. } if message.starts_with(verb)) { + break msg; + } + } + }) + .await + .unwrap(); + app.handle_msg(reply); + assert!(!app.flash_err); + let target = if bulk { "2 helmcharts" } else { "apps" }; + assert_eq!(app.flash, format!("{verb} {target}")); + assert!(received.try_recv().is_err()); + } + } +} + #[tokio::test] async fn force_reconcile_menu_is_limited_to_flux_helmreleases() { for (plural, group, kind) in [ ("helmreleases", "helm.toolkit.fluxcd.io", "HelmRelease"), ("helmreleases", "example.com", "HelmRelease"), + ("helmcharts", "source.toolkit.fluxcd.io", "HelmChart"), ( "kustomizations", "kustomize.toolkit.fluxcd.io", @@ -189,29 +298,45 @@ async fn force_reconcile_menu_is_limited_to_flux_helmreleases() { } #[tokio::test] -async fn helmrelease_menu_cancel_and_readonly_do_not_start_an_action() { - for cancel in [KeyCode::Esc, KeyCode::Enter] { - let (mut app, _rx) = test_app(); - app.switch_kind("helmreleases"); - apply(&mut app, helmrelease("apps")); - app.handle_key(press(KeyCode::Char(' '))).unwrap(); - let flash = app.flash.clone(); - if cancel == KeyCode::Enter { - choose(&mut app, "Cancel"); - } else { - app.handle_key(press(KeyCode::Char('t'))).unwrap(); - for _ in 0..3 { - app.handle_key(press(KeyCode::Char('j'))).unwrap(); +async fn flux_menu_cancel_and_readonly_do_not_start_an_action() { + for (plural, group, kind, object) in [ + ( + "helmreleases", + "helm.toolkit.fluxcd.io", + "HelmRelease", + helmrelease("apps"), + ), + ( + "helmcharts", + "source.toolkit.fluxcd.io", + "HelmChart", + helmchart("apps"), + ), + ] { + for cancel in [KeyCode::Esc, KeyCode::Enter] { + let (mut app, _rx) = test_app(); + app.cluster.register_kind(group, kind, plural, true); + app.switch_kind(plural); + apply(&mut app, object.clone()); + app.handle_key(press(KeyCode::Char(' '))).unwrap(); + let flash = app.flash.clone(); + if cancel == KeyCode::Enter { + choose(&mut app, "Cancel"); + } else { + app.handle_key(press(KeyCode::Char('t'))).unwrap(); + for _ in 0..3 { + app.handle_key(press(KeyCode::Char('j'))).unwrap(); + } + app.handle_key(press(cancel)).unwrap(); } - app.handle_key(press(cancel)).unwrap(); + assert_eq!(app.mode, Mode::Table); + assert_eq!(app.flash, flash); + assert_eq!(app.marked.len(), 1); + app.readonly = true; + app.handle_key(press(KeyCode::Char('t'))).unwrap(); + assert_eq!(app.mode, Mode::Table); + assert!(app.flash_err); + assert_eq!(app.marked.len(), 1); } - assert_eq!(app.mode, Mode::Table); - assert_eq!(app.flash, flash); - assert_eq!(app.marked.len(), 1); - app.readonly = true; - app.handle_key(press(KeyCode::Char('t'))).unwrap(); - assert_eq!(app.mode, Mode::Table); - assert!(app.flash_err); - assert_eq!(app.marked.len(), 1); } } diff --git a/src/ui.rs b/src/ui.rs index e36bd4d5..3194bb9e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -2716,7 +2716,7 @@ fn build_help(app: &App, width: usize) -> (Vec>, String) { } else if scope == "table" && action == Action::Logs { "logs (marked pods, or current row)" } else if scope == "table" && action == Action::ActionMenu { - "action menu: Flux suspend/resume/reconcile (HelmRelease: + force reconcile); Argo CD suspend/resume (Application: + sync); CronJobs trigger/suspend/resume; pods file transfer" + "action menu: Flux suspend/resume/reconcile (includes HelmChart; HelmRelease: + force reconcile); Argo CD suspend/resume (Application: + sync); CronJobs trigger/suspend/resume; pods file transfer" } else if scope == "port_forward_picker" && action == Action::Edit { "edit local port of the selected mapping" } else if action == Action::LogMarker { From 8d358e8b77e723a6c94e483ea7531e896955ef45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikola=20Milojevi=C4=87?= Date: Wed, 16 Sep 2026 14:35:00 +0200 Subject: [PATCH 2/2] fix: check API groups before enabling Flux actions Resource plurals can also belong to unrelated CRDs. Match each supported Flux resource to its API group before opening the action menu, and test valid and conflicting groups through keyboard input. --- src/app/helpers.rs | 21 +++++++++++- src/app/mod.rs | 15 --------- src/app/tests/flux.rs | 75 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 94 insertions(+), 17 deletions(-) diff --git a/src/app/helpers.rs b/src/app/helpers.rs index e8861e82..1559e145 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -407,7 +407,26 @@ pub(super) fn xray_pool_plurals(root_kind: &str) -> &'static [&'static str] { impl App { /// Whether the current kind supports the Flux suspend/resume menu (`t`). pub fn flux_suspendable(&self) -> bool { - FLUX_SUSPENDABLE_KINDS.contains(&self.kind_plural.as_str()) + self.kind.as_ref().is_some_and(|kind| { + matches!( + (kind.ar.group.as_str(), kind.ar.plural.as_str()), + ("kustomize.toolkit.fluxcd.io", "kustomizations") + | ("helm.toolkit.fluxcd.io", "helmreleases") + | ( + "source.toolkit.fluxcd.io", + "gitrepositories" + | "helmrepositories" + | "helmcharts" + | "ocirepositories" + | "buckets" + ) + | ( + "image.toolkit.fluxcd.io", + "imagerepositories" | "imageupdateautomations" + ) + | ("notification.toolkit.fluxcd.io", "alerts" | "receivers") + ) + }) } /// Whether the current kind is an ArgoCD CRD (Application or diff --git a/src/app/mod.rs b/src/app/mod.rs index c4bb5b3e..28bda1fb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -92,21 +92,6 @@ const WELCOME_FLASH: &str = /// clears it (see [`App::expire_flash`]). const FLASH_TTL: std::time::Duration = std::time::Duration::from_secs(8); -/// Flux CD resource kinds that support suspend and resume through `spec.suspend`. -const FLUX_SUSPENDABLE_KINDS: &[&str] = &[ - "kustomizations", - "helmreleases", - "gitrepositories", - "helmrepositories", - "helmcharts", - "ocirepositories", - "buckets", - "imagerepositories", - "imageupdateautomations", - "alerts", - "receivers", -]; - /// The ArgoCD CRD group. Used to disambiguate the very generic `applications` /// and `applicationsets` plurals — only `argoproj.io` kinds get the `t` menu. const ARGOCD_GROUP: &str = "argoproj.io"; diff --git a/src/app/tests/flux.rs b/src/app/tests/flux.rs index 13349dd8..298711a8 100644 --- a/src/app/tests/flux.rs +++ b/src/app/tests/flux.rs @@ -259,11 +259,84 @@ async fn helmchart_actions_patch_selected_and_marked_charts() { } } +#[tokio::test] +async fn flux_menu_requires_the_resource_api_group() { + for (plural, kind, group) in [ + ( + "kustomizations", + "Kustomization", + "kustomize.toolkit.fluxcd.io", + ), + ("helmreleases", "HelmRelease", "helm.toolkit.fluxcd.io"), + ( + "gitrepositories", + "GitRepository", + "source.toolkit.fluxcd.io", + ), + ( + "helmrepositories", + "HelmRepository", + "source.toolkit.fluxcd.io", + ), + ("helmcharts", "HelmChart", "source.toolkit.fluxcd.io"), + ( + "ocirepositories", + "OCIRepository", + "source.toolkit.fluxcd.io", + ), + ("buckets", "Bucket", "source.toolkit.fluxcd.io"), + ( + "imagerepositories", + "ImageRepository", + "image.toolkit.fluxcd.io", + ), + ( + "imageupdateautomations", + "ImageUpdateAutomation", + "image.toolkit.fluxcd.io", + ), + ("alerts", "Alert", "notification.toolkit.fluxcd.io"), + ("receivers", "Receiver", "notification.toolkit.fluxcd.io"), + ] { + let other_flux_group = if group == "source.toolkit.fluxcd.io" { + "helm.toolkit.fluxcd.io" + } else { + "source.toolkit.fluxcd.io" + }; + for candidate in [group, "example.com", other_flux_group] { + let (mut app, _rx) = test_app(); + app.cluster.register_kind(candidate, kind, plural, true); + app.switch_kind(plural); + apply( + &mut app, + json!({ + "apiVersion": format!("{candidate}/v1"), "kind": kind, + "metadata": {"name": "apps", "namespace": "default"} + }), + ); + app.handle_key(press(KeyCode::Char(' '))).unwrap(); + app.handle_key(press(KeyCode::Char('t'))).unwrap(); + assert_eq!( + app.mode, + if candidate == group { + Mode::FluxMenu + } else { + Mode::Table + }, + "{plural}.{candidate}", + ); + assert_eq!(app.marked.len(), 1); + if candidate != group { + assert!(app.flash.starts_with("suspend/resume only applies to")); + } + } + } +} + #[tokio::test] async fn force_reconcile_menu_is_limited_to_flux_helmreleases() { for (plural, group, kind) in [ ("helmreleases", "helm.toolkit.fluxcd.io", "HelmRelease"), - ("helmreleases", "example.com", "HelmRelease"), ("helmcharts", "source.toolkit.fluxcd.io", "HelmChart"), ( "kustomizations",