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
2 changes: 1 addition & 1 deletion docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
21 changes: 20 additions & 1 deletion src/app/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 0 additions & 17 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
244 changes: 221 additions & 23 deletions src/app/tests/flux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<kube::client::Body>| {
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::<Timestamp>().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",
Expand Down Expand Up @@ -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);
}
}
2 changes: 1 addition & 1 deletion src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2716,7 +2716,7 @@ fn build_help(app: &App, width: usize) -> (Vec<Line<'static>>, 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 {
Expand Down