diff --git a/docs/features.md b/docs/features.md index 5f40f99..7f33202 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 0364bc6..571de90 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 984f644..ac1982b 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/helpers.rs b/src/app/helpers.rs index e8861e8..1559e14 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 97b2665..28bda1f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -92,23 +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 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. -const FLUX_SUSPENDABLE_KINDS: &[&str] = &[ - "kustomizations", - "helmreleases", - "gitrepositories", - "helmrepositories", - "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 3a564f0..298711a 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,185 @@ 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 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", "kustomize.toolkit.fluxcd.io", @@ -189,29 +371,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 e36bd4d..3194bb9 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 {