From c559f8e317520726673decb925865edf5d9405ad Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Thu, 9 Jul 2026 14:50:53 -0300 Subject: [PATCH 1/2] chore: migrate workspace lint cleanup to Rust 2024 Set the workspace and app crate edition to Rust 2024, update rustfmt configuration, and apply the resulting formatting changes across the app and kube crates. Collapse Clippy-reported nested conditionals and remove a redundant let binding so cargo clippy --workspace --all-targets -- -D warnings passes on the current toolchain. --- Cargo.toml | 2 +- crates/aetheris-app/Cargo.toml | 2 +- crates/aetheris-app/src/app.rs | 2 +- crates/aetheris-app/src/app/commands.rs | 6 +-- crates/aetheris-app/src/app/handler.rs | 20 +++++----- crates/aetheris-app/src/app/methods.rs | 11 +++--- crates/aetheris-app/src/app/projects.rs | 16 +++++--- crates/aetheris-app/src/app/style.rs | 12 +++--- crates/aetheris-app/src/app/utils.rs | 4 +- crates/aetheris-app/src/app/widgets.rs | 50 ++++++++++++------------- crates/aetheris-app/src/app/yaml.rs | 28 +++++++++----- crates/aetheris-kube/src/events.rs | 2 +- crates/aetheris-kube/src/exec.rs | 40 ++++++++++---------- crates/aetheris-kube/src/kubeconfig.rs | 28 +++++++------- crates/aetheris-kube/src/logs.rs | 2 +- crates/aetheris-kube/src/manager.rs | 10 ++--- crates/aetheris-kube/src/mutations.rs | 4 +- crates/aetheris-kube/src/objects.rs | 8 ++-- crates/aetheris-kube/src/portforward.rs | 2 +- crates/aetheris-kube/src/resources.rs | 10 ++--- crates/aetheris-kube/src/status.rs | 22 +++++------ rustfmt.toml | 2 +- 22 files changed, 145 insertions(+), 138 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 620eb6d..4ff40b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/aetheris-app", "crates/aetheris-kube"] [workspace.package] version = "1.3.0" -edition = "2021" +edition = "2024" license = "GPL-3.0-or-later" authors = ["Leandro Rodrigues "] diff --git a/crates/aetheris-app/Cargo.toml b/crates/aetheris-app/Cargo.toml index 66b1bbd..fc088b5 100644 --- a/crates/aetheris-app/Cargo.toml +++ b/crates/aetheris-app/Cargo.toml @@ -2,7 +2,7 @@ name = "aetheris-app" description = "Aetheris native Kubernetes client for GNOME" version = "1.3.0" -edition = "2021" +edition = "2024" license = "GPL-3.0-or-later" authors = ["Leandro Rodrigues "] diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 97ab0d2..52b1c79 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -9,8 +9,8 @@ use aetheris_kube::{ ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, ResourceKind, ResourceUsage, }; -use futures::future::{AbortHandle, Abortable}; use futures::FutureExt; +use futures::future::{AbortHandle, Abortable}; use relm4::adw::prelude::*; use relm4::prelude::*; use relm4::{adw, gtk}; diff --git a/crates/aetheris-app/src/app/commands.rs b/crates/aetheris-app/src/app/commands.rs index e9d3fe5..ddcf6f0 100644 --- a/crates/aetheris-app/src/app/commands.rs +++ b/crates/aetheris-app/src/app/commands.rs @@ -84,15 +84,13 @@ pub(super) async fn list_objects_snapshot( resource: ResourceKind, namespace: Option, ) -> Result, String> { - let result = async { + async { let manager = KubeManager::load()?; let session = manager.connect_context(&context).await?; session.list_objects(&resource, namespace.as_deref()).await } .await - .map_err(format_error); - - result + .map_err(format_error) } pub(super) async fn stream_object_watch( diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index f82909d..f592619 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -363,16 +363,16 @@ impl App { self.show_custom_namespace_dialog(root); return; } - if let Some(namespace) = choices.get(index as usize) { - if self.selected_namespace != *namespace { - self.selected_namespace.clone_from(namespace); - self.remember_selected_namespace(); - self.sync_dropdowns(Some(sender.clone())); - self.show_object_list(); - self.stop_log_stream(); - self.stop_port_forward(); - self.refresh_objects(sender); - } + if let Some(namespace) = choices.get(index as usize) + && self.selected_namespace != *namespace + { + self.selected_namespace.clone_from(namespace); + self.remember_selected_namespace(); + self.sync_dropdowns(Some(sender.clone())); + self.show_object_list(); + self.stop_log_stream(); + self.stop_port_forward(); + self.refresh_objects(sender); } } AppMsg::CustomNamespaceEntered => { diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index b5b688f..8c67919 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -341,12 +341,11 @@ impl App { self.namespaces.push(namespace.to_owned()); } - if let Some(context) = self.selected_context.clone() { - if let Some(project) = self.projects.selected_project_mut() { - if project.add_custom_namespace(&context, namespace) { - self.save_projects_or_toast(); - } - } + if let Some(context) = self.selected_context.clone() + && let Some(project) = self.projects.selected_project_mut() + && project.add_custom_namespace(&context, namespace) + { + self.save_projects_or_toast(); } } diff --git a/crates/aetheris-app/src/app/projects.rs b/crates/aetheris-app/src/app/projects.rs index d6b9d42..b3e3bdb 100644 --- a/crates/aetheris-app/src/app/projects.rs +++ b/crates/aetheris-app/src/app/projects.rs @@ -860,9 +860,11 @@ mod tests { project.custom_namespaces_for_context(Some("prod")), vec![String::from("billing")] ); - assert!(project - .custom_namespaces_for_context(Some("stage")) - .is_empty()); + assert!( + project + .custom_namespaces_for_context(Some("stage")) + .is_empty() + ); assert!(!project.has_custom_namespace(Some("stage"), "billing")); } @@ -876,9 +878,11 @@ mod tests { project.add_custom_namespace("prod", "billing"); assert!(project.remove_custom_namespace("prod", "billing")); - assert!(project - .custom_namespaces_for_context(Some("prod")) - .is_empty()); + assert!( + project + .custom_namespaces_for_context(Some("prod")) + .is_empty() + ); assert!(project.custom_namespaces_by_context.is_empty()); } diff --git a/crates/aetheris-app/src/app/style.rs b/crates/aetheris-app/src/app/style.rs index 6cda1ae..1a71eca 100644 --- a/crates/aetheris-app/src/app/style.rs +++ b/crates/aetheris-app/src/app/style.rs @@ -47,12 +47,12 @@ fn app_style_candidates() -> Vec { paths.extend(std::env::split_paths(&data_dirs).map(|path| path.join(APP_STYLE_RESOURCE))); } - if let Ok(exe) = std::env::current_exe() { - if let Some(bin_dir) = exe.parent() { - paths.push(bin_dir.join("../share").join(APP_STYLE_RESOURCE)); - paths.push(bin_dir.join("share").join(APP_STYLE_RESOURCE)); - paths.push(bin_dir.join("../style.css")); - } + if let Ok(exe) = std::env::current_exe() + && let Some(bin_dir) = exe.parent() + { + paths.push(bin_dir.join("../share").join(APP_STYLE_RESOURCE)); + paths.push(bin_dir.join("share").join(APP_STYLE_RESOURCE)); + paths.push(bin_dir.join("../style.css")); } if let Some(app_dir) = std::env::var_os("APPDIR") { diff --git a/crates/aetheris-app/src/app/utils.rs b/crates/aetheris-app/src/app/utils.rs index 420cf83..8dc17e9 100644 --- a/crates/aetheris-app/src/app/utils.rs +++ b/crates/aetheris-app/src/app/utils.rs @@ -42,7 +42,9 @@ pub(super) fn terminal_error_message(error: &str) -> String { || lower.contains("pods/exec") || (lower.contains("cannot create") && lower.contains("exec")) { - return tr("You do not have permission to open a terminal for this Pod. Your Kubernetes user needs create access to pods/exec in this namespace."); + return tr( + "You do not have permission to open a terminal for this Pod. Your Kubernetes user needs create access to pods/exec in this namespace.", + ); } tr_format( diff --git a/crates/aetheris-app/src/app/widgets.rs b/crates/aetheris-app/src/app/widgets.rs index 2fd2dde..ce5cd5c 100644 --- a/crates/aetheris-app/src/app/widgets.rs +++ b/crates/aetheris-app/src/app/widgets.rs @@ -107,30 +107,28 @@ pub(super) fn namespace_selector_row( .build(); action.add_prefix(>k::Image::from_icon_name("folder-symbolic")); - if is_custom { - if let Some(sender) = sender { - let edit_button = gtk::Button::from_icon_name("document-edit-symbolic"); - edit_button.add_css_class("flat"); - edit_button.set_valign(gtk::Align::Center); - edit_button.set_tooltip_text(Some(&tr("Rename"))); - edit_button.connect_clicked({ - let sender = sender.clone(); - let namespace = namespace.to_owned(); - move |_| sender.input(AppMsg::OpenRenameNamespaceDialog(namespace.clone())) - }); - action.add_suffix(&edit_button); - - let delete_button = gtk::Button::from_icon_name("user-trash-symbolic"); - delete_button.add_css_class("flat"); - delete_button.add_css_class("destructive-action"); - delete_button.set_valign(gtk::Align::Center); - delete_button.set_tooltip_text(Some(&tr("Remove"))); - delete_button.connect_clicked({ - let namespace = namespace.to_owned(); - move |_| sender.input(AppMsg::RemoveCustomNamespace(namespace.clone())) - }); - action.add_suffix(&delete_button); - } + if is_custom && let Some(sender) = sender { + let edit_button = gtk::Button::from_icon_name("document-edit-symbolic"); + edit_button.add_css_class("flat"); + edit_button.set_valign(gtk::Align::Center); + edit_button.set_tooltip_text(Some(&tr("Rename"))); + edit_button.connect_clicked({ + let sender = sender.clone(); + let namespace = namespace.to_owned(); + move |_| sender.input(AppMsg::OpenRenameNamespaceDialog(namespace.clone())) + }); + action.add_suffix(&edit_button); + + let delete_button = gtk::Button::from_icon_name("user-trash-symbolic"); + delete_button.add_css_class("flat"); + delete_button.add_css_class("destructive-action"); + delete_button.set_valign(gtk::Align::Center); + delete_button.set_tooltip_text(Some(&tr("Remove"))); + delete_button.connect_clicked({ + let namespace = namespace.to_owned(); + move |_| sender.input(AppMsg::RemoveCustomNamespace(namespace.clone())) + }); + action.add_suffix(&delete_button); } if selected { @@ -602,8 +600,8 @@ const RELATED_POD_COLUMNS: [ObjectColumn; 3] = [ /// same cell factories as the main object table, default widths, no /// persistence. Returns the sorted model too — activation positions are /// indices into it, not into the unsorted store. -pub(super) fn related_pods_column_view( -) -> (gtk::ColumnView, gtk::gio::ListStore, gtk::SortListModel) { +pub(super) fn related_pods_column_view() +-> (gtk::ColumnView, gtk::gio::ListStore, gtk::SortListModel) { let store = gtk::gio::ListStore::new::(); let view = gtk::ColumnView::builder() .single_click_activate(true) diff --git a/crates/aetheris-app/src/app/yaml.rs b/crates/aetheris-app/src/app/yaml.rs index 7bb6f25..846e549 100644 --- a/crates/aetheris-app/src/app/yaml.rs +++ b/crates/aetheris-app/src/app/yaml.rs @@ -205,12 +205,12 @@ fn update_yaml_error_state(buffer: &sourceview5::Buffer, error_label: >k::Labe return; }; - if let Some(tag) = text_buffer.tag_table().lookup("yaml-error-line") { - if let Some(start) = text_buffer.iter_at_line((line - 1) as i32) { - let mut end = start; - end.forward_to_line_end(); - text_buffer.apply_tag(&tag, &start, &end); - } + if let Some(tag) = text_buffer.tag_table().lookup("yaml-error-line") + && let Some(start) = text_buffer.iter_at_line((line - 1) as i32) + { + let mut end = start; + end.forward_to_line_end(); + text_buffer.apply_tag(&tag, &start, &end); } error_label.set_label(&tr_format( "Line {line}: {message}", @@ -579,7 +579,9 @@ pub(super) fn desired_state_explanation(kind: &str, mapping: &serde_yaml::Mappin "ConfigMap" => { tr("ConfigMaps usually express desired configuration through data instead of spec.") } - "Secret" => tr("Secrets usually express desired sensitive data through data or stringData instead of spec."), + "Secret" => tr( + "Secrets usually express desired sensitive data through data or stringData instead of spec.", + ), _ => tr("This manifest does not include a spec section."), }; } @@ -863,14 +865,20 @@ pub(super) fn kind_explanation(kind: &str) -> String { pub(super) fn spec_explanation(kind: &str) -> String { match kind { - "Pod" => tr("Desired Pod configuration, including containers, volumes, restart policy and scheduling hints."), - "Deployment" => tr("Desired Deployment state, usually replicas, selector and the Pod template."), + "Pod" => tr( + "Desired Pod configuration, including containers, volumes, restart policy and scheduling hints.", + ), + "Deployment" => { + tr("Desired Deployment state, usually replicas, selector and the Pod template.") + } "Service" => tr("Desired Service routing, including selector, type and exposed ports."), "Ingress" => tr("Desired routing rules, TLS configuration and backend Services."), "Job" => tr("Desired one-shot workload, including completion policy and Pod template."), "CronJob" => tr("Desired schedule and Job template."), "Node" => tr("Node spec is mostly managed by Kubernetes and should be edited carefully."), - _ => tr("Desired state for this resource. Controllers reconcile the live object toward this section."), + _ => tr( + "Desired state for this resource. Controllers reconcile the live object toward this section.", + ), } } diff --git a/crates/aetheris-kube/src/events.rs b/crates/aetheris-kube/src/events.rs index 2e506c9..1c08866 100644 --- a/crates/aetheris-kube/src/events.rs +++ b/crates/aetheris-kube/src/events.rs @@ -5,7 +5,7 @@ use kube::api::ListParams; use kube::{Api, ResourceExt}; use crate::status::age_label; -use crate::{resource_scope, KubeSession, ObjectEvent, ResourceKind}; +use crate::{KubeSession, ObjectEvent, ResourceKind, resource_scope}; impl KubeSession { pub(crate) async fn object_events( diff --git a/crates/aetheris-kube/src/exec.rs b/crates/aetheris-kube/src/exec.rs index 6b220b0..cd6b025 100644 --- a/crates/aetheris-kube/src/exec.rs +++ b/crates/aetheris-kube/src/exec.rs @@ -1,7 +1,7 @@ -use anyhow::{bail, Context as AnyhowContext, Result}; +use anyhow::{Context as AnyhowContext, Result, bail}; use k8s_openapi::api::core::v1::Pod; -use kube::api::AttachParams; use kube::Api; +use kube::api::AttachParams; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use crate::{KubeSession, PodExecEvent, PodExecRequest}; @@ -70,15 +70,15 @@ impl KubeSession { .await .context("failed to finish exec session")?; - if let Some(status) = status { - if status.status.as_deref() == Some("Failure") { - bail!( - "{}", - status - .message - .unwrap_or_else(|| String::from("command failed")) - ); - } + if let Some(status) = status + && status.status.as_deref() == Some("Failure") + { + bail!( + "{}", + status + .message + .unwrap_or_else(|| String::from("command failed")) + ); } Ok(()) @@ -155,15 +155,15 @@ impl KubeSession { .await .context("failed to finish terminal session")?; - if let Some(status) = status { - if status.status.as_deref() == Some("Failure") { - bail!( - "{}", - status - .message - .unwrap_or_else(|| String::from("terminal session failed")) - ); - } + if let Some(status) = status + && status.status.as_deref() == Some("Failure") + { + bail!( + "{}", + status + .message + .unwrap_or_else(|| String::from("terminal session failed")) + ); } Ok(()) diff --git a/crates/aetheris-kube/src/kubeconfig.rs b/crates/aetheris-kube/src/kubeconfig.rs index ed2056d..05e74a5 100644 --- a/crates/aetheris-kube/src/kubeconfig.rs +++ b/crates/aetheris-kube/src/kubeconfig.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::PathBuf; -use anyhow::{bail, Context as AnyhowContext, Result}; +use anyhow::{Context as AnyhowContext, Result, bail}; use kube::config::{ AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext, @@ -96,15 +96,15 @@ impl KubeManager { // Renaming a cluster creates fresh entries under the new name above; // drop the stale ones left behind under the old name so it doesn't // linger as a duplicate in the cluster list. - if let Some(original_name) = request.original_context_name.as_deref() { - if original_name != request.context_name { - kubeconfig - .contexts - .retain(|context| context.name != original_name); - kubeconfig - .clusters - .retain(|cluster| cluster.name != original_name); - } + if let Some(original_name) = request.original_context_name.as_deref() + && original_name != request.context_name + { + kubeconfig + .contexts + .retain(|context| context.name != original_name); + kubeconfig + .clusters + .retain(|cluster| cluster.name != original_name); } kubeconfig.current_context = Some(request.context_name); @@ -144,10 +144,10 @@ impl KubeManager { kubeconfig = Kubeconfig::merge(kubeconfig, imported) .context("failed to merge imported kubeconfig")?; - if kubeconfig.current_context.is_none() { - if let Some(context) = kubeconfig.contexts.first() { - kubeconfig.current_context = Some(context.name.clone()); - } + if kubeconfig.current_context.is_none() + && let Some(context) = kubeconfig.contexts.first() + { + kubeconfig.current_context = Some(context.name.clone()); } kubeconfig.api_version = Some(String::from("v1")); kubeconfig.kind = Some(String::from("Config")); diff --git a/crates/aetheris-kube/src/logs.rs b/crates/aetheris-kube/src/logs.rs index fed42a6..26994b3 100644 --- a/crates/aetheris-kube/src/logs.rs +++ b/crates/aetheris-kube/src/logs.rs @@ -1,8 +1,8 @@ use anyhow::{Context as AnyhowContext, Result}; use futures::{AsyncBufReadExt, TryStreamExt}; use k8s_openapi::api::core::v1::Pod; -use kube::api::LogParams; use kube::Api; +use kube::api::LogParams; use crate::{KubeSession, PodLogRequest}; diff --git a/crates/aetheris-kube/src/manager.rs b/crates/aetheris-kube/src/manager.rs index 5b70853..3478a9a 100644 --- a/crates/aetheris-kube/src/manager.rs +++ b/crates/aetheris-kube/src/manager.rs @@ -1,8 +1,8 @@ use std::collections::BTreeSet; use anyhow::{Context as AnyhowContext, Result}; -use kube::config::{Config, KubeConfigOptions, Kubeconfig}; use kube::Client; +use kube::config::{Config, KubeConfigOptions, Kubeconfig}; use crate::kubeconfig::{cluster_server, cluster_skips_tls_verify, server_host}; use crate::{ContextInfo, KubeSession}; @@ -74,12 +74,10 @@ impl KubeManager { .contexts .iter() .find(|named_context| named_context.name == context) + && let Some(context) = &named_context.context + && let Some(namespace) = &context.namespace { - if let Some(context) = &named_context.context { - if let Some(namespace) = &context.namespace { - namespaces.insert(namespace.clone()); - } - } + namespaces.insert(namespace.clone()); } namespaces.into_iter().collect() diff --git a/crates/aetheris-kube/src/mutations.rs b/crates/aetheris-kube/src/mutations.rs index f26f57d..b1c82e3 100644 --- a/crates/aetheris-kube/src/mutations.rs +++ b/crates/aetheris-kube/src/mutations.rs @@ -1,11 +1,11 @@ -use anyhow::{bail, Context as AnyhowContext, Result}; +use anyhow::{Context as AnyhowContext, Result, bail}; use k8s_openapi::api::apps::v1::Deployment; use k8s_openapi::api::core::v1::{Node, Pod}; use kube::api::{DeleteParams, DynamicObject, ListParams, Patch, PatchParams}; use kube::{Api, ResourceExt}; use serde_json::Value; -use crate::{api_resource, resource_scope, KubeSession, ObjectDetail, ResourceKind}; +use crate::{KubeSession, ObjectDetail, ResourceKind, api_resource, resource_scope}; impl KubeSession { pub async fn apply_object_yaml( diff --git a/crates/aetheris-kube/src/objects.rs b/crates/aetheris-kube/src/objects.rs index 679c92c..71954a2 100644 --- a/crates/aetheris-kube/src/objects.rs +++ b/crates/aetheris-kube/src/objects.rs @@ -4,15 +4,15 @@ use anyhow::{Context as AnyhowContext, Result}; use futures::StreamExt; use k8s_openapi::api::core::v1::Pod; use kube::api::{DynamicObject, ListParams}; -use kube::runtime::watcher::{watcher, Event as WatcherEvent}; +use kube::runtime::watcher::{Event as WatcherEvent, watcher}; use kube::{Api, ResourceExt}; use serde_json::Value; use crate::status::{age_label, status_label}; use crate::{ - api_resource, namespace_scope, resource_scope, KubeSession, ObjectCondition, ObjectDetail, - ObjectSummary, ObjectWatchEvent, PodSummary, ResourceKind, ResourceRatio, ResourceScope, - ResourceUsage, + KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, ObjectWatchEvent, PodSummary, + ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, api_resource, namespace_scope, + resource_scope, }; impl KubeSession { diff --git a/crates/aetheris-kube/src/portforward.rs b/crates/aetheris-kube/src/portforward.rs index a5e8ebd..78c5201 100644 --- a/crates/aetheris-kube/src/portforward.rs +++ b/crates/aetheris-kube/src/portforward.rs @@ -1,4 +1,4 @@ -use anyhow::{bail, Context as AnyhowContext, Result}; +use anyhow::{Context as AnyhowContext, Result, bail}; use k8s_openapi::api::core::v1::Pod; use kube::Api; use tokio::io::copy_bidirectional; diff --git a/crates/aetheris-kube/src/resources.rs b/crates/aetheris-kube/src/resources.rs index 9a221f3..937572c 100644 --- a/crates/aetheris-kube/src/resources.rs +++ b/crates/aetheris-kube/src/resources.rs @@ -1,7 +1,7 @@ use anyhow::{Context as AnyhowContext, Result}; use k8s_openapi::api::core::v1::Namespace; use kube::api::{ApiResource, DynamicObject, ListParams}; -use kube::discovery::{verbs, Discovery, Scope}; +use kube::discovery::{Discovery, Scope, verbs}; use kube::{Api, ResourceExt}; use crate::{KubeSession, ResourceKind, ResourceScope}; @@ -64,10 +64,10 @@ impl KubeSession { if let Ok(names) = self.list_openshift_projects().await { return Ok(normalized_namespace_names(names)); } - if self.server.contains("/k8s/clusters/") { - if let Ok(names) = self.list_rancher_namespaces().await { - return Ok(normalized_namespace_names(names)); - } + if self.server.contains("/k8s/clusters/") + && let Ok(names) = self.list_rancher_namespaces().await + { + return Ok(normalized_namespace_names(names)); } Err(native_error) diff --git a/crates/aetheris-kube/src/status.rs b/crates/aetheris-kube/src/status.rs index d75b67c..71c38ad 100644 --- a/crates/aetheris-kube/src/status.rs +++ b/crates/aetheris-kube/src/status.rs @@ -21,7 +21,7 @@ pub(crate) fn status_label( return ( spec_string(object, "type").unwrap_or_else(|| String::from("ClusterIP")), None, - ) + ); } ("networking.k8s.io", "Ingress") => return (ingress_status_label(object), None), ("", "ConfigMap") => return (data_entries_status_label(object), None), @@ -37,17 +37,17 @@ pub(crate) fn status_label( return (phase.to_owned(), None); } - if let Some(conditions) = status.get("conditions").and_then(|value| value.as_array()) { - if let Some(ready) = conditions.iter().find(|condition| { + if let Some(conditions) = status.get("conditions").and_then(|value| value.as_array()) + && let Some(ready) = conditions.iter().find(|condition| { condition.get("type").and_then(|value| value.as_str()) == Some("Ready") - }) { - let label = ready - .get("status") - .and_then(|value| value.as_str()) - .map(|status| format!("Ready={status}")) - .unwrap_or_else(|| String::from("Ready")); - return (label, None); - } + }) + { + let label = ready + .get("status") + .and_then(|value| value.as_str()) + .map(|status| format!("Ready={status}")) + .unwrap_or_else(|| String::from("Ready")); + return (label, None); } let ready_replicas = status.get("readyReplicas").and_then(|value| value.as_i64()); diff --git a/rustfmt.toml b/rustfmt.toml index d114434..7b8d1d9 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,4 +1,4 @@ -edition = "2021" +edition = "2024" fn_params_layout = "Tall" match_arm_leading_pipes = "Preserve" merge_derives = true From 82285089825b5fdb5461e52c1a4c02ff126d0e7c Mon Sep 17 00:00:00 2001 From: Leandro Rodrigues Date: Thu, 9 Jul 2026 15:05:56 -0300 Subject: [PATCH 2/2] feat(clusters): load CA certificate data Accept PEM or base64-encoded PEM CA data for token-based cluster entries, encode PEM data into kubeconfig certificate-authority-data, and reject CA data when TLS verification is skipped. Add a CA certificate file picker to the token cluster dialog and cover the new validation and encoding behavior with kubeconfig tests. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/aetheris-app/src/app.rs | 2 + crates/aetheris-app/src/app/component.rs | 10 +- crates/aetheris-app/src/app/dialogs.rs | 7 ++ crates/aetheris-app/src/app/handler.rs | 46 +++++++++ crates/aetheris-kube/Cargo.toml | 1 + crates/aetheris-kube/src/kubeconfig.rs | 116 +++++++++++++++++++++++ 8 files changed, 183 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 10cbbe0..eedf889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,6 +27,7 @@ name = "aetheris-kube" version = "1.3.0" dependencies = [ "anyhow", + "base64", "dirs", "futures", "http", diff --git a/Cargo.toml b/Cargo.toml index 4ff40b7..cfd994b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ authors = ["Leandro Rodrigues "] [workspace.dependencies] anyhow = "1" +base64 = "0.22" dirs = "6" futures = "0.3" http = "1" diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index 52b1c79..6cf87d2 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -304,7 +304,9 @@ pub enum AppMsg { PodPortForwardEvent(u64, PodPortForwardEvent), PodPortForwardFinished(u64, Result<(), String>), ShowAddClusterDialog, + ShowCaFile, ShowTokenForm, + CaFileLoaded(Result), ShowImportFile, Refresh, ObjectsLoaded(Result, String>), diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 88df98f..71123a8 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -498,9 +498,12 @@ impl Component for App { .hexpand(true) .build(); let setup_ca_entry = adw::EntryRow::builder() - .title(tr("CA Data")) + .title(tr("CA Certificate")) .hexpand(true) .build(); + let setup_ca_file_button = gtk::Button::builder() + .icon_name("document-open-symbolic") + .build(); let setup_insecure_check = adw::SwitchRow::builder() .title(tr("Skip TLS Verification")) .build(); @@ -541,6 +544,7 @@ impl Component for App { server_entry: &setup_server_entry, token_entry: &setup_token_entry, ca_entry: &setup_ca_entry, + ca_file_button: &setup_ca_file_button, insecure_check: &setup_insecure_check, add_button: &setup_button, title_label: &cluster_token_title_label, @@ -861,6 +865,10 @@ impl Component for App { let sender = sender.clone(); move |_| sender.input(AppMsg::AddCluster) }); + setup_ca_file_button.connect_clicked({ + let sender = sender.clone(); + move |_| sender.input(AppMsg::ShowCaFile) + }); let model = App { projects: ProjectStore::default(), diff --git a/crates/aetheris-app/src/app/dialogs.rs b/crates/aetheris-app/src/app/dialogs.rs index aa9f698..62cb7bd 100644 --- a/crates/aetheris-app/src/app/dialogs.rs +++ b/crates/aetheris-app/src/app/dialogs.rs @@ -162,6 +162,7 @@ pub(super) struct ClusterDialogWidgets<'a> { pub(super) server_entry: &'a adw::EntryRow, pub(super) token_entry: &'a adw::PasswordEntryRow, pub(super) ca_entry: &'a adw::EntryRow, + pub(super) ca_file_button: &'a gtk::Button, pub(super) insecure_check: &'a adw::SwitchRow, pub(super) add_button: &'a gtk::Button, pub(super) title_label: &'a gtk::Label, @@ -245,6 +246,12 @@ pub(super) fn token_cluster_page( heading.append(title); container.append(&heading); + widgets.ca_file_button.add_css_class("flat"); + widgets + .ca_file_button + .set_tooltip_text(Some(&tr("Choose CA certificate file"))); + widgets.ca_entry.add_suffix(widgets.ca_file_button); + container.append(&entry_list(&[ widgets.name_entry.upcast_ref(), widgets.server_entry.upcast_ref(), diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index f592619..bde1f00 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -1065,6 +1065,26 @@ impl App { self.set_cluster_dialog_editing(false); self.cluster_dialog_stack.set_visible_child_name("token"); } + AppMsg::ShowCaFile => { + let dialog = gtk::FileDialog::builder() + .title(tr("Choose CA Certificate")) + .accept_label(tr("Choose")) + .modal(true) + .build(); + let sender = sender.clone(); + dialog.open( + Some(root), + gtk::gio::Cancellable::NONE, + move |result| match result { + Ok(file) => sender.input(read_ca_file(file)), + Err(error) => { + if !error.matches(gtk::gio::IOErrorEnum::Cancelled) { + sender.input(AppMsg::Toast(error.to_string())); + } + } + }, + ); + } AppMsg::ShowImportFile => { let dialog = gtk::FileDialog::builder() .title(tr("Import Kubeconfig")) @@ -1093,6 +1113,13 @@ impl App { }, ); } + AppMsg::CaFileLoaded(Ok(data)) => { + self.setup_ca_entry.set_text(data.trim()); + self.setup_insecure_check.set_active(false); + } + AppMsg::CaFileLoaded(Err(error)) => { + self.toaster.add_toast(adw::Toast::new(&error)); + } AppMsg::Refresh => { if self.resources.is_empty() { self.load_cluster(sender); @@ -1252,3 +1279,22 @@ impl App { } } } + +fn read_ca_file(file: gtk::gio::File) -> AppMsg { + let Some(path) = file.path() else { + return AppMsg::Toast(tr( + "Selected file is not available on the local filesystem.", + )); + }; + + let result = fs::read_to_string(&path).map_err(|error| { + tr_format( + "Unable to read {path}: {error}", + &[ + ("{path}", path.display().to_string()), + ("{error}", error.to_string()), + ], + ) + }); + AppMsg::CaFileLoaded(result) +} diff --git a/crates/aetheris-kube/Cargo.toml b/crates/aetheris-kube/Cargo.toml index cfdb6c6..cb7e3ed 100644 --- a/crates/aetheris-kube/Cargo.toml +++ b/crates/aetheris-kube/Cargo.toml @@ -8,6 +8,7 @@ authors.workspace = true [dependencies] anyhow.workspace = true +base64.workspace = true dirs.workspace = true futures.workspace = true http.workspace = true diff --git a/crates/aetheris-kube/src/kubeconfig.rs b/crates/aetheris-kube/src/kubeconfig.rs index 05e74a5..10f22a9 100644 --- a/crates/aetheris-kube/src/kubeconfig.rs +++ b/crates/aetheris-kube/src/kubeconfig.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::PathBuf; use anyhow::{Context as AnyhowContext, Result, bail}; +use base64::prelude::*; use kube::config::{ AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext, @@ -183,10 +184,39 @@ fn normalize_add_cluster_request(mut request: AddClusterRequest) -> Result Result { + if looks_like_pem_certificate(&value) { + return Ok(BASE64_STANDARD.encode(value.as_bytes())); + } + + let compact = value.split_whitespace().collect::(); + let decoded = BASE64_STANDARD + .decode(compact.as_bytes()) + .context("CA data must be a PEM certificate or base64-encoded PEM certificate")?; + let decoded = std::str::from_utf8(&decoded) + .context("CA data must decode to a PEM certificate, not raw DER bytes")?; + if !looks_like_pem_certificate(decoded) { + bail!("CA data must decode to a PEM certificate"); + } + + Ok(compact) +} + +fn looks_like_pem_certificate(value: &str) -> bool { + value.contains("-----BEGIN CERTIFICATE-----") && value.contains("-----END CERTIFICATE-----") +} + fn kubeconfig_write_path() -> Result { if let Some(value) = std::env::var_os("KUBECONFIG") { let paths = std::env::split_paths(&value) @@ -271,6 +301,7 @@ pub(crate) fn server_host(server: &str) -> String { mod tests { use std::sync::Mutex; + use base64::prelude::*; use kube::config::{ AuthInfo, Cluster, Context as KubeContext, Kubeconfig, NamedAuthInfo, NamedCluster, NamedContext, @@ -340,6 +371,75 @@ mod tests { let _ = std::fs::remove_file(&path); } + #[test] + fn add_token_cluster_rejects_ca_when_tls_verification_is_skipped() { + let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); + let path = test_kubeconfig_path("aetheris-kube-test-insecure-ca"); + unsafe { + std::env::set_var("KUBECONFIG", &path); + } + + let request = AddClusterRequest { + context_name: String::from("invalid-tls"), + server: String::from("https://api.example.com:6443"), + bearer_token: String::from("sha256~token"), + certificate_authority_data: Some(test_ca_pem()), + insecure_skip_tls_verify: true, + original_context_name: None, + }; + let error = KubeManager::add_token_cluster(request).expect_err("request must be rejected"); + + assert!( + error + .to_string() + .contains("CA data cannot be used when TLS verification is skipped") + ); + + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let _ = std::fs::remove_file(&path); + } + + #[test] + fn add_token_cluster_encodes_pem_ca_data() { + let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); + let path = test_kubeconfig_path("aetheris-kube-test-ca-pem"); + unsafe { + std::env::set_var("KUBECONFIG", &path); + } + + let ca = test_ca_pem(); + let request = AddClusterRequest { + context_name: String::from("pem-ca"), + server: String::from("https://api.example.com:6443"), + bearer_token: String::from("sha256~token"), + certificate_authority_data: Some(ca.clone()), + insecure_skip_tls_verify: false, + original_context_name: None, + }; + KubeManager::add_token_cluster(request).expect("PEM CA should be accepted"); + + let kubeconfig = Kubeconfig::read_from(&path).expect("kubeconfig should be readable"); + let stored_ca = kubeconfig + .clusters + .iter() + .find(|cluster| cluster.name == "pem-ca") + .and_then(|cluster| cluster.cluster.as_ref()) + .and_then(|cluster| cluster.certificate_authority_data.as_ref()) + .expect("CA data should be stored"); + let decoded = BASE64_STANDARD + .decode(stored_ca) + .expect("stored CA should be valid base64"); + + assert_eq!(decoded, ca.trim().as_bytes()); + + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let _ = std::fs::remove_file(&path); + } + #[test] fn add_token_cluster_reuses_token_for_non_conventional_user_name() { let _guard = KUBECONFIG_ENV_LOCK.lock().unwrap(); @@ -641,4 +741,20 @@ mod tests { .as_nanos() )) } + + fn test_ca_pem() -> String { + String::from( + "-----BEGIN CERTIFICATE-----\n\ + MIIBszCCAVmgAwIBAgIUeH9mTSZQm2u9uU2xK4w6cmzNmjcwCgYIKoZIzj0EAwIw\n\ + FzEVMBMGA1UEAwwMYWV0aGVyaXMtdGVzdDAeFw0yNjAxMDEwMDAwMDBaFw0yNzAx\n\ + MDEwMDAwMDBaMBcxFTATBgNVBAMMDGFldGhlcmlzLXRlc3QwWTATBgcqhkjOPQIB\n\ + BggqhkjOPQMBBwNCAAQtsnR+S4p0lDNoe0JvLqR0ZGFxiiq7mU0u5KFLMZzU2l+O\n\ + fZgLr9LkqJYTVX9BYRZL2uY5pKiBmqnH6r8jo1MwUTAdBgNVHQ4EFgQUX0T5hZlP\n\ + Q0GYovLUG6CmH0iP2JwwHwYDVR0jBBgwFoAUX0T5hZlPQ0GYovLUG6CmH0iP2Jww\n\ + DwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNIADBFAiEA7Lc8EX0GkL+5TMWX\n\ + B4JrLuN94ZG6wGB5SYrYd7Lj5P8CIBtMn+5Y0rQkgC0yQZdTx95k7iU01AOhKy4s\n\ + Qd6UpELK\n\ + -----END CERTIFICATE-----\n", + ) + } }