diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a4595c9..a091608 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,6 @@ flowchart TD UI --> Relm[Relm4 App Component] Relm --> Commands[Async Commands and Streams] Commands --> KubeCrate[aetheris-kube] - Commands -. optional VPN .-> OpenConnectCrate[aetheris-openconnect] KubeCrate --> Kubeconfig[Kubeconfig] KubeCrate --> Api[Kubernetes / OpenShift API] Relm --> Store[ProjectStore] @@ -19,7 +18,7 @@ flowchart TD VTE --> Commands ``` -`aetheris-app` owns windows, widgets, user state, and persistence of Aetheris projects. `aetheris-kube` owns kubeconfig parsing, Kubernetes clients, discovery, list/watch, mutations, logs, exec, port-forwarding, metrics, and resource details. `aetheris-openconnect` is an optional native integration crate for libopenconnect; it isolates C FFI and exposes a Rust API for future VPN workflows. +`aetheris-app` owns windows, widgets, user state, and persistence of Aetheris projects. `aetheris-kube` owns kubeconfig parsing, Kubernetes clients, discovery, list/watch, mutations, logs, exec, port-forwarding, metrics, and resource details. ## Crate Boundaries @@ -41,17 +40,10 @@ flowchart LR Ops[Logs exec port-forward mutations] end - subgraph OpenConnect["aetheris-openconnect"] - SafeApi[Safe Rust API] - Ffi[libopenconnect FFI] - end - Widgets --> State State --> Commands State --> Streams Commands --> Manager - Commands -. optional .-> SafeApi - SafeApi --> Ffi Streams --> Manager Manager --> Session Session --> Resources @@ -60,7 +52,7 @@ flowchart LR Projects --> State ``` -The backend crate must not import GTK, Adwaita, Relm4, VTE, or application widgets. Shared data crosses the boundary through DTOs exported from `aetheris-kube::types`. Native C integrations are kept out of both UI widgets and `aetheris-kube`; each integration gets a focused crate such as `aetheris-openconnect`, with `unsafe` declarations confined to its FFI module. +The backend crate must not import GTK, Adwaita, Relm4, VTE, or application widgets. Shared data crosses the boundary through DTOs exported from `aetheris-kube::types`. ## Application Lifecycle diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92b53ad..ca6548a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,16 +13,6 @@ sudo dnf install -y \ openssl-devel ``` -OpenConnect integration is optional while the VPN UI is under development. To -build it locally, install the libopenconnect development files and enable the -feature: - -```sh -sudo dnf install -y openconnect-devel -cargo check -p aetheris-openconnect --features system-libopenconnect -cargo check -p aetheris-app --features openconnect -``` - Run the app: ```sh @@ -66,7 +56,6 @@ Aetheris has two Rust crates: - `aetheris-kube` — pure Kubernetes backend. It must not depend on GTK, Relm4, Libadwaita, or VTE. - `aetheris-app` — Relm4/GTK4 application. It owns UI state, widgets, project persistence, and command wiring. -- `aetheris-openconnect` — optional libopenconnect integration. It owns C FFI and exposes only safe Rust types to the app. See [ARCHITECTURE.md](ARCHITECTURE.md) for diagrams and module responsibilities. diff --git a/Cargo.lock b/Cargo.lock index 621127d..552562b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,7 +7,6 @@ name = "aetheris-app" version = "1.3.1" dependencies = [ "aetheris-kube", - "aetheris-openconnect", "anyhow", "dirs", "futures", @@ -43,14 +42,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "aetheris-openconnect" -version = "1.3.1" -dependencies = [ - "pkg-config", - "thiserror", -] - [[package]] name = "ahash" version = "0.8.12" diff --git a/Cargo.toml b/Cargo.toml index 1a254c5..aa16bbb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,6 @@ resolver = "2" members = [ "crates/aetheris-app", "crates/aetheris-kube", - "crates/aetheris-openconnect", ] [workspace.package] diff --git a/LICENSE b/LICENSE index 261eeb9..18f3d70 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2026 LuminusOS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/crates/aetheris-app/Cargo.toml b/crates/aetheris-app/Cargo.toml index dd79240..5c76f70 100644 --- a/crates/aetheris-app/Cargo.toml +++ b/crates/aetheris-app/Cargo.toml @@ -55,14 +55,9 @@ resources = [ [features] default = [] -openconnect = [ - "dep:aetheris-openconnect", - "aetheris-openconnect/system-libopenconnect", -] [dependencies] aetheris-kube = { path = "../aetheris-kube" } -aetheris-openconnect = { path = "../aetheris-openconnect", optional = true } anyhow = "1" dirs = "6" futures = "0.3" diff --git a/crates/aetheris-app/src/app.rs b/crates/aetheris-app/src/app.rs index fd3be38..2851a86 100644 --- a/crates/aetheris-app/src/app.rs +++ b/crates/aetheris-app/src/app.rs @@ -5,9 +5,10 @@ use std::{ }; use aetheris_kube::{ - AddClusterRequest, ClusterSummary, ContainerUsage, ContextInfo, KubeManager, ObjectCondition, - ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, PodExecRequest, - PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, ResourceKind, ResourceUsage, + AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo, + KubeManager, ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, + PodExecEvent, PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, + ResourceKind, ResourceUsage, }; use futures::FutureExt; use futures::future::{AbortHandle, Abortable}; @@ -178,6 +179,7 @@ pub struct App { object_columns: Vec<(ObjectTableColumn, gtk::ColumnViewColumn)>, object_list_stack: gtk::Stack, detail: DetailPane, + object_load_token: u64, object_watch_token: u64, object_watch_abort_handle: Option, log_streaming: bool, @@ -308,7 +310,7 @@ pub enum AppMsg { CaFileLoaded(Result), ShowImportFile, Refresh, - ObjectsLoaded(Result, String>), + ObjectsLoaded(u64, Result, String>), AddCluster, ClusterAdded(Result<(String, String), String>), StateLoadedForCluster(String, Result), diff --git a/crates/aetheris-app/src/app/commands.rs b/crates/aetheris-app/src/app/commands.rs index ddcf6f0..c426289 100644 --- a/crates/aetheris-app/src/app/commands.rs +++ b/crates/aetheris-app/src/app/commands.rs @@ -1,6 +1,10 @@ +use std::time::Duration; + use super::utils::*; use super::*; +const CLUSTER_LOAD_TIMEOUT: Duration = Duration::from_secs(30); + pub(super) async fn load_state() -> AppMsg { let result = async { // A missing kubeconfig is the normal first-run state — the user @@ -34,29 +38,39 @@ pub(super) async fn load_state() -> AppMsg { } pub(super) async fn load_cluster(context: String) -> AppMsg { - let result = async { - let manager = KubeManager::load()?; - let session = manager.connect_context(&context).await?; - let (namespaces, namespace_warning) = match session.list_namespaces().await { - Ok(namespaces) => (namespaces, None), - Err(error) => ( - manager.namespace_for_context(&context), - Some(format_error(error)), - ), - }; - let resources = session.discover_resources().await?; - Ok::<_, anyhow::Error>(ClusterState { - namespaces, - resources, - namespace_warning, - }) - } + let result = match tokio::time::timeout(CLUSTER_LOAD_TIMEOUT, async { + load_cluster_state(context).await + }) .await + { + Ok(result) => result, + Err(_) => Err(anyhow::anyhow!( + "Timed out while discovering Kubernetes resources for this cluster. Check that the API server is reachable and try again." + )), + } .map_err(format_error); AppMsg::ClusterLoaded(result) } +async fn load_cluster_state(context: String) -> anyhow::Result { + let manager = KubeManager::load()?; + let session = manager.connect_context(&context).await?; + let (namespaces, namespace_warning) = match session.list_namespaces().await { + Ok(namespaces) => (namespaces, None), + Err(error) => ( + manager.namespace_for_context(&context), + Some(format_error(error)), + ), + }; + let resources = session.discover_resources().await?; + Ok(ClusterState { + namespaces, + resources, + namespace_warning, + }) +} + pub(super) async fn load_cluster_summary(context: String) -> AppMsg { let result = async { let manager = KubeManager::load()?; @@ -70,13 +84,14 @@ pub(super) async fn load_cluster_summary(context: String) -> AppMsg { } pub(super) async fn list_objects( + token: u64, context: String, resource: ResourceKind, namespace: Option, ) -> AppMsg { let result = list_objects_snapshot(context, resource, namespace).await; - AppMsg::ObjectsLoaded(result) + AppMsg::ObjectsLoaded(token, result) } pub(super) async fn list_objects_snapshot( diff --git a/crates/aetheris-app/src/app/component.rs b/crates/aetheris-app/src/app/component.rs index 71123a8..68d8f19 100644 --- a/crates/aetheris-app/src/app/component.rs +++ b/crates/aetheris-app/src/app/component.rs @@ -971,6 +971,7 @@ impl Component for App { node_unschedulable: None, request_token: 0, }, + object_load_token: 0, object_watch_token: 0, object_watch_abort_handle: None, log_streaming: false, diff --git a/crates/aetheris-app/src/app/handler.rs b/crates/aetheris-app/src/app/handler.rs index bde1f00..9edf3ea 100644 --- a/crates/aetheris-app/src/app/handler.rs +++ b/crates/aetheris-app/src/app/handler.rs @@ -1127,7 +1127,10 @@ impl App { self.refresh_objects(sender); } } - AppMsg::ObjectsLoaded(Ok(objects)) => { + AppMsg::ObjectsLoaded(token, Ok(objects)) => { + if token != self.object_load_token { + return; + } self.loading = false; let count = objects.len(); self.objects = objects; @@ -1136,7 +1139,10 @@ impl App { self.rebuild_object_list(); self.start_object_watch(sender); } - AppMsg::ObjectsLoaded(Err(error)) => { + AppMsg::ObjectsLoaded(token, Err(error)) => { + if token != self.object_load_token { + return; + } self.loading = false; self.stop_object_watch(); self.objects.clear(); diff --git a/crates/aetheris-app/src/app/methods.rs b/crates/aetheris-app/src/app/methods.rs index dd30020..a71e199 100644 --- a/crates/aetheris-app/src/app/methods.rs +++ b/crates/aetheris-app/src/app/methods.rs @@ -270,10 +270,16 @@ impl App { None }; self.stop_object_watch(); + self.object_load_token = self.object_load_token.saturating_add(1); + let token = self.object_load_token; self.loading = true; + self.objects.clear(); self.status = tr_format("Loading {resource}...", &[("{resource}", resource.label())]); self.sync_status(); - sender.oneshot_command(async move { list_objects(context, resource, namespace).await }); + self.rebuild_object_list(); + sender.oneshot_command( + async move { list_objects(token, context, resource, namespace).await }, + ); } pub(super) fn selected_resource_kind(&self) -> Option<&ResourceKind> { @@ -446,9 +452,9 @@ impl App { .set_sensitive(self.selected_context.is_some() && !self.loading); self.column_filter_list .set_sensitive(self.selected_context.is_some() && !self.loading); - self.cluster_back_button.set_sensitive(!self.loading); + self.cluster_back_button.set_sensitive(true); self.cluster_menu_button - .set_sensitive(self.selected_context.is_some() && !self.loading); + .set_sensitive(self.selected_context.is_some()); self.cluster_refresh_button .set_sensitive(!self.loading && !self.visible_contexts().is_empty()); self.add_cluster_button.set_sensitive(!self.loading); diff --git a/crates/aetheris-app/src/app/object_detail.rs b/crates/aetheris-app/src/app/object_detail.rs index 49d7d73..1b5e0e6 100644 --- a/crates/aetheris-app/src/app/object_detail.rs +++ b/crates/aetheris-app/src/app/object_detail.rs @@ -392,20 +392,23 @@ pub(super) fn rebuild_container_metrics(list: >k::ListBox, detail: &ObjectDeta return; } - for container in &detail.containers { + for container in &detail.container_resources { let usage = detail .container_metrics .iter() - .find(|usage| usage.name == *container); + .find(|usage| usage.name == container.name); list.append(&container_metric_row(container, usage)); } } -pub(super) fn container_metric_row(name: &str, usage: Option<&ContainerUsage>) -> gtk::ListBoxRow { - let row = gtk::ListBoxRow::new(); - row.set_selectable(false); - let action = adw::ActionRow::builder().title(name).build(); - action.add_prefix(&status_prefix_chip(&tr("Running"))); +pub(super) fn container_metric_row( + resources: &ContainerResources, + usage: Option<&ContainerUsage>, +) -> adw::ExpanderRow { + let action = adw::ExpanderRow::builder() + .title(resources.name.as_str()) + .expanded(false) + .build(); if let Some(usage) = usage { let metrics = gtk::Box::new(gtk::Orientation::Horizontal, 12); metrics.set_valign(gtk::Align::Center); @@ -426,10 +429,75 @@ pub(super) fn container_metric_row(name: &str, usage: Option<&ContainerUsage>) - } else { action.set_subtitle(&tr("Metrics unavailable")); } - row.set_child(Some(&action)); + action.add_row(&container_resource_row( + &tr("CPU"), + usage.map(|usage| usage.cpu.as_str()), + &resources.cpu_request, + &resources.cpu_limit, + "applications-engineering-symbolic", + format_cpu_quantity, + )); + action.add_row(&container_resource_row( + &tr("Memory"), + usage.map(|usage| usage.memory.as_str()), + &resources.memory_request, + &resources.memory_limit, + "drive-harddisk-symbolic", + format_memory_quantity, + )); + action +} + +fn container_resource_row( + title: &str, + current: Option<&str>, + request: &str, + limit: &str, + icon_name: &str, + formatter: fn(&str) -> String, +) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(title).build(); + row.add_prefix(>k::Image::from_icon_name(available_icon_name( + icon_name, + "applications-system-symbolic", + ))); + row.add_suffix(&resource_text_pair( + &tr("Current"), + current.unwrap_or("-"), + formatter, + )); + row.add_suffix(&resource_text_pair(&tr("Request"), request, formatter)); + row.add_suffix(&resource_text_pair(&tr("Limit"), limit, formatter)); row } +fn resource_text_pair(label: &str, value: &str, formatter: fn(&str) -> String) -> gtk::Box { + let pair = gtk::Box::new(gtk::Orientation::Vertical, 2); + pair.set_valign(gtk::Align::Center); + pair.set_margin_start(10); + + let title = gtk::Label::new(Some(label)); + title.add_css_class("caption"); + title.add_css_class("dim-label"); + title.set_xalign(1.0); + pair.append(&title); + + let value_label = gtk::Label::new(Some(&format_resource_value(value, formatter))); + value_label.add_css_class("caption"); + value_label.set_xalign(1.0); + value_label.set_tooltip_text(Some(value)); + pair.append(&value_label); + pair +} + +fn format_resource_value(value: &str, formatter: fn(&str) -> String) -> String { + if value.is_empty() || value == "-" { + String::from("-") + } else { + formatter(value) + } +} + fn format_cpu_quantity(value: &str) -> String { let Some((amount, suffix)) = split_quantity(value) else { return value.to_owned(); diff --git a/crates/aetheris-kube/src/lib.rs b/crates/aetheris-kube/src/lib.rs index 8309b9b..c40ae83 100644 --- a/crates/aetheris-kube/src/lib.rs +++ b/crates/aetheris-kube/src/lib.rs @@ -18,8 +18,8 @@ pub(crate) use resources::{api_resource, namespace_scope, resource_scope}; pub use manager::KubeManager; pub use session::KubeSession; pub use types::{ - AddClusterRequest, ClusterSummary, ContainerUsage, ContextInfo, ObjectCondition, ObjectDetail, - ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, PodExecRequest, PodLogRequest, - PodPortForwardEvent, PodPortForwardRequest, PodSummary, ResourceKind, ResourceRatio, - ResourceScope, ResourceUsage, + AddClusterRequest, ClusterSummary, ContainerResources, ContainerUsage, ContextInfo, + ObjectCondition, ObjectDetail, ObjectEvent, ObjectSummary, ObjectWatchEvent, PodExecEvent, + PodExecRequest, PodLogRequest, PodPortForwardEvent, PodPortForwardRequest, PodSummary, + ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, }; diff --git a/crates/aetheris-kube/src/metrics.rs b/crates/aetheris-kube/src/metrics.rs index 3122861..10d2fe4 100644 --- a/crates/aetheris-kube/src/metrics.rs +++ b/crates/aetheris-kube/src/metrics.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::time::Duration; use anyhow::Result; use kube::api::{ApiResource, DynamicObject, ListParams}; @@ -7,11 +8,28 @@ use serde_json::Value; use crate::{ContainerUsage, KubeSession, ResourceKind, ResourceUsage}; +const METRICS_TIMEOUT: Duration = Duration::from_secs(1); + impl KubeSession { pub(crate) async fn resource_metrics( &self, resource: &ResourceKind, namespace: Option<&str>, + ) -> Result> { + match tokio::time::timeout(METRICS_TIMEOUT, async { + self.resource_metrics_inner(resource, namespace).await + }) + .await + { + Ok(result) => result, + Err(_) => anyhow::bail!("timed out while loading Kubernetes metrics"), + } + } + + async fn resource_metrics_inner( + &self, + resource: &ResourceKind, + namespace: Option<&str>, ) -> Result> { let namespace = namespace.filter(|namespace| !namespace.is_empty() && *namespace != "all"); let metrics_resource = match (resource.group.as_str(), resource.kind.as_str()) { @@ -42,6 +60,21 @@ impl KubeSession { &self, namespace: &str, pod: &str, + ) -> Result> { + match tokio::time::timeout(METRICS_TIMEOUT, async { + self.pod_container_metrics_inner(namespace, pod).await + }) + .await + { + Ok(result) => result, + Err(_) => anyhow::bail!("timed out while loading container metrics"), + } + } + + async fn pod_container_metrics_inner( + &self, + namespace: &str, + pod: &str, ) -> Result> { if namespace == "-" { return Ok(Vec::new()); diff --git a/crates/aetheris-kube/src/objects.rs b/crates/aetheris-kube/src/objects.rs index b2f38db..6dd0811 100644 --- a/crates/aetheris-kube/src/objects.rs +++ b/crates/aetheris-kube/src/objects.rs @@ -10,9 +10,9 @@ use serde_json::Value; use crate::status::{age_label, status_label}; use crate::{ - KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, ObjectWatchEvent, PodSummary, - ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, api_resource, namespace_scope, - resource_scope, + ContainerResources, KubeSession, ObjectCondition, ObjectDetail, ObjectSummary, + ObjectWatchEvent, PodSummary, ResourceKind, ResourceRatio, ResourceScope, ResourceUsage, + api_resource, namespace_scope, resource_scope, }; impl KubeSession { @@ -198,22 +198,35 @@ impl KubeSession { self.context ) })?; - let containers = object_containers(&object, resource); + let container_resources = object_container_resources(&object, resource); + let containers = container_resources + .iter() + .map(|container| container.name.clone()) + .collect(); let replicas = object_replicas(&object, resource); let node_unschedulable = object_node_unschedulable(&object, resource); let conditions = object_conditions(&object); - let metrics = self - .resource_metrics(resource, namespace) - .await - .unwrap_or_default(); - let summary = object_summary(object.clone(), resource, &metrics); - let container_metrics = if resource.kind == "Pod" && resource.group.is_empty() { - self.pod_container_metrics(&summary.namespace, &summary.name) - .await - .unwrap_or_default() + let object_namespace = object.namespace().unwrap_or_else(|| String::from("-")); + let object_name = object.name_any(); + let is_pod = resource.kind == "Pod" && resource.group.is_empty(); + let (metrics, container_metrics) = if is_pod { + let (metrics, container_metrics) = tokio::join!( + self.resource_metrics(resource, namespace), + self.pod_container_metrics(&object_namespace, &object_name), + ); + ( + metrics.unwrap_or_default(), + container_metrics.unwrap_or_default(), + ) } else { - Vec::new() + ( + self.resource_metrics(resource, namespace) + .await + .unwrap_or_default(), + Vec::new(), + ) }; + let summary = object_summary(object.clone(), resource, &metrics); let yaml = serde_yaml::to_string(&object).context("failed to serialize object YAML")?; let related_pods = self .deployment_pods(resource, &summary.namespace, &object) @@ -237,6 +250,7 @@ impl KubeSession { age: summary.age, metrics: summary.metrics, container_metrics, + container_resources, yaml, containers, related_pods, @@ -498,7 +512,10 @@ pub(crate) fn quantity_as_f64(value: &str) -> Option { value.parse::().ok() } -fn object_containers(object: &DynamicObject, resource: &ResourceKind) -> Vec { +fn object_container_resources( + object: &DynamicObject, + resource: &ResourceKind, +) -> Vec { if resource.kind != "Pod" || !resource.group.is_empty() { return Vec::new(); } @@ -510,18 +527,36 @@ fn object_containers(object: &DynamicObject, resource: &ResourceKind) -> Vec Option { + let name = container.get("name")?.as_str()?.to_owned(); + let resources = container.get("resources"); + + Some(ContainerResources { + name, + cpu_request: resource_quantity(resources, "requests", "cpu"), + cpu_limit: resource_quantity(resources, "limits", "cpu"), + memory_request: resource_quantity(resources, "requests", "memory"), + memory_limit: resource_quantity(resources, "limits", "memory"), + }) +} + +fn resource_quantity(resources: Option<&Value>, section: &str, name: &str) -> String { + resources + .and_then(|resources| resources.get(section)) + .and_then(|section| section.get(name)) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .unwrap_or("-") + .to_owned() +} + fn object_images(object: &DynamicObject, resource: &ResourceKind) -> Vec { if resource.kind != "Pod" || !resource.group.is_empty() { return Vec::new(); @@ -616,8 +651,8 @@ fn deployment_label_selector(object: &DynamicObject) -> Option { #[cfg(test)] mod tests { - use super::{object_images, quantity_as_f64, resource_ratio}; - use crate::{ResourceKind, ResourceScope}; + use super::{object_container_resources, object_images, quantity_as_f64, resource_ratio}; + use crate::{ContainerResources, ResourceKind, ResourceScope}; use kube::api::DynamicObject; use serde_json::json; @@ -674,4 +709,77 @@ mod tests { ] ); } + + #[test] + fn object_container_resources_reads_pod_container_requests_and_limits() { + let pod: DynamicObject = serde_json::from_value(json!({ + "apiVersion": "v1", + "kind": "Pod", + "metadata": { + "name": "sample", + "namespace": "my-namespace" + }, + "spec": { + "containers": [ + { + "name": "app", + "resources": { + "requests": {"cpu": "250m", "memory": "128Mi"}, + "limits": {"cpu": "500m", "memory": "256Mi"} + } + }, + { + "name": "sidecar", + "resources": { + "requests": {"cpu": "100m"} + } + } + ], + "initContainers": [ + { + "name": "init", + "resources": { + "limits": {"memory": "64Mi"} + } + } + ] + } + })) + .expect("pod should deserialize"); + let resource = ResourceKind { + group: String::new(), + version: String::from("v1"), + api_version: String::from("v1"), + kind: String::from("Pod"), + plural: String::from("pods"), + scope: ResourceScope::Namespaced, + }; + + assert_eq!( + object_container_resources(&pod, &resource), + vec![ + ContainerResources { + name: String::from("app"), + cpu_request: String::from("250m"), + cpu_limit: String::from("500m"), + memory_request: String::from("128Mi"), + memory_limit: String::from("256Mi"), + }, + ContainerResources { + name: String::from("sidecar"), + cpu_request: String::from("100m"), + cpu_limit: String::from("-"), + memory_request: String::from("-"), + memory_limit: String::from("-"), + }, + ContainerResources { + name: String::from("init"), + cpu_request: String::from("-"), + cpu_limit: String::from("-"), + memory_request: String::from("-"), + memory_limit: String::from("64Mi"), + }, + ] + ); + } } diff --git a/crates/aetheris-kube/src/resources.rs b/crates/aetheris-kube/src/resources.rs index 937572c..01a7ad2 100644 --- a/crates/aetheris-kube/src/resources.rs +++ b/crates/aetheris-kube/src/resources.rs @@ -8,15 +8,18 @@ use crate::{KubeSession, ResourceKind, ResourceScope}; impl KubeSession { pub async fn discover_resources(&self) -> Result> { - let discovery = Discovery::new(self.client.clone()) - .run() - .await - .with_context(|| { - format!( - "Could not discover Kubernetes resource types using context {}.", - self.context - ) - })?; + let discovery = match Discovery::new(self.client.clone()).run_aggregated().await { + Ok(discovery) => discovery, + Err(aggregated_error) => Discovery::new(self.client.clone()) + .run() + .await + .with_context(|| { + format!( + "Could not discover Kubernetes resource types using context {}. Aggregated discovery also failed: {aggregated_error}", + self.context + ) + })?, + }; let mut resources = Vec::new(); for group in discovery.groups_alphabetical() { diff --git a/crates/aetheris-kube/src/status.rs b/crates/aetheris-kube/src/status.rs index 71c38ad..797adeb 100644 --- a/crates/aetheris-kube/src/status.rs +++ b/crates/aetheris-kube/src/status.rs @@ -77,6 +77,9 @@ fn deployment_status_label(object: &DynamicObject) -> (String, Option<(i64, i64) if updated < desired { return (format!("Updating {ready}/{desired}"), ratio); } + if available > 0 { + return (format!("Available {available}/{desired}"), ratio); + } (format!("Unavailable {ready}/{desired}"), ratio) } @@ -330,6 +333,27 @@ mod tests { assert_eq!(ratio, Some((3, 10))); } + #[test] + fn deployment_status_label_reports_partial_availability() { + let object: DynamicObject = serde_json::from_value(serde_json::json!({ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { "name": "web" }, + "spec": { "replicas": 10 }, + "status": { + "readyReplicas": 7, + "updatedReplicas": 10, + "availableReplicas": 7 + } + })) + .unwrap(); + + let (label, ratio) = deployment_status_label(&object); + + assert_eq!(label, "Available 7/10"); + assert_eq!(ratio, Some((7, 10))); + } + #[test] fn job_status_label_reports_completions_ratio() { let object: DynamicObject = serde_json::from_value(serde_json::json!({ diff --git a/crates/aetheris-kube/src/types.rs b/crates/aetheris-kube/src/types.rs index 6a0d334..b43e7dd 100644 --- a/crates/aetheris-kube/src/types.rs +++ b/crates/aetheris-kube/src/types.rs @@ -76,6 +76,8 @@ pub struct ObjectDetail { pub age: String, pub metrics: Option, pub container_metrics: Vec, + #[serde(default)] + pub container_resources: Vec, pub yaml: String, pub containers: Vec, pub related_pods: Vec, @@ -125,6 +127,15 @@ pub struct ContainerUsage { pub memory: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ContainerResources { + pub name: String, + pub cpu_request: String, + pub cpu_limit: String, + pub memory_request: String, + pub memory_limit: String, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ClusterSummary { pub version: Option, diff --git a/crates/aetheris-openconnect/Cargo.toml b/crates/aetheris-openconnect/Cargo.toml deleted file mode 100644 index fd799bd..0000000 --- a/crates/aetheris-openconnect/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "aetheris-openconnect" -description = "OpenConnect integration layer for Aetheris" -version.workspace = true -edition.workspace = true -license.workspace = true -authors.workspace = true -links = "openconnect" - -[features] -default = [] -system-libopenconnect = [] - -[dependencies] -thiserror.workspace = true - -[build-dependencies] -pkg-config = "0.3" diff --git a/crates/aetheris-openconnect/build.rs b/crates/aetheris-openconnect/build.rs deleted file mode 100644 index 3395621..0000000 --- a/crates/aetheris-openconnect/build.rs +++ /dev/null @@ -1,18 +0,0 @@ -fn main() { - println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); - println!("cargo:rerun-if-env-changed=OPENCONNECT_NO_PKG_CONFIG"); - - if std::env::var_os("CARGO_FEATURE_SYSTEM_LIBOPENCONNECT").is_none() { - return; - } - - if std::env::var_os("OPENCONNECT_NO_PKG_CONFIG").is_some() { - println!("cargo:rustc-link-lib=openconnect"); - return; - } - - pkg_config::Config::new() - .atleast_version("8.20") - .probe("openconnect") - .expect("system-libopenconnect requires libopenconnect development files"); -} diff --git a/crates/aetheris-openconnect/src/error.rs b/crates/aetheris-openconnect/src/error.rs deleted file mode 100644 index 1db3330..0000000 --- a/crates/aetheris-openconnect/src/error.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::ffi::NulError; - -#[derive(Debug, thiserror::Error)] -pub enum OpenConnectError { - #[error("OpenConnect support was built without libopenconnect")] - NotLinked, - #[error("{field} contains an interior NUL byte")] - InteriorNul { - field: &'static str, - #[source] - source: NulError, - }, - #[error("libopenconnect initialization failed with code {0}")] - InitFailed(i32), - #[error("unable to allocate OpenConnect session")] - AllocationFailed, - #[error("{operation} failed with code {code}")] - OperationFailed { operation: &'static str, code: i32 }, -} - -pub type Result = std::result::Result; diff --git a/crates/aetheris-openconnect/src/events.rs b/crates/aetheris-openconnect/src/events.rs deleted file mode 100644 index 98512b2..0000000 --- a/crates/aetheris-openconnect/src/events.rs +++ /dev/null @@ -1,35 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum OpenConnectEvent { - Progress { - level: ProgressLevel, - message: String, - }, - CertificateValidationRequired { - reason: String, - }, - AuthenticationFormRequired, - Connected, - Disconnected, - Error(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProgressLevel { - Error, - Info, - Debug, - Trace, - Unknown(i32), -} - -impl From for ProgressLevel { - fn from(value: i32) -> Self { - match value { - 0 => Self::Error, - 1 => Self::Info, - 2 => Self::Debug, - 3 => Self::Trace, - other => Self::Unknown(other), - } - } -} diff --git a/crates/aetheris-openconnect/src/ffi.rs b/crates/aetheris-openconnect/src/ffi.rs deleted file mode 100644 index 77da78a..0000000 --- a/crates/aetheris-openconnect/src/ffi.rs +++ /dev/null @@ -1,43 +0,0 @@ -#![cfg(feature = "system-libopenconnect")] - -use std::ffi::{c_char, c_int, c_void}; - -#[repr(C)] -pub struct OpenconnectInfo { - _private: [u8; 0], -} - -pub type ValidatePeerCertCallback = - unsafe extern "C" fn(privdata: *mut c_void, reason: *const c_char) -> c_int; -pub type WriteNewConfigCallback = - unsafe extern "C" fn(privdata: *mut c_void, buf: *const c_char, buflen: c_int) -> c_int; -pub type ProcessAuthFormCallback = - unsafe extern "C" fn(privdata: *mut c_void, form: *mut c_void) -> c_int; - -unsafe extern "C" { - pub fn openconnect_init_ssl() -> c_int; - pub fn openconnect_get_version() -> *const c_char; - - pub fn openconnect_vpninfo_new( - useragent: *const c_char, - validate_peer_cert: Option, - write_new_config: Option, - process_auth_form: Option, - progress: *mut c_void, - privdata: *mut c_void, - ) -> *mut OpenconnectInfo; - - pub fn openconnect_vpninfo_free(vpninfo: *mut OpenconnectInfo); - pub fn openconnect_parse_url(vpninfo: *mut OpenconnectInfo, url: *const c_char) -> c_int; - pub fn openconnect_set_protocol( - vpninfo: *mut OpenconnectInfo, - protocol: *const c_char, - ) -> c_int; - pub fn openconnect_set_cafile(vpninfo: *mut OpenconnectInfo, cafile: *const c_char) -> c_int; - pub fn openconnect_set_loglevel(vpninfo: *mut OpenconnectInfo, level: c_int); - pub fn openconnect_set_system_trust(vpninfo: *mut OpenconnectInfo, val: u32); - pub fn openconnect_disable_ipv6(vpninfo: *mut OpenconnectInfo) -> c_int; - pub fn openconnect_disable_dtls(vpninfo: *mut OpenconnectInfo) -> c_int; - pub fn openconnect_get_protocol(vpninfo: *mut OpenconnectInfo) -> *const c_char; - pub fn openconnect_get_connect_url(vpninfo: *mut OpenconnectInfo) -> *const c_char; -} diff --git a/crates/aetheris-openconnect/src/lib.rs b/crates/aetheris-openconnect/src/lib.rs deleted file mode 100644 index 9f76a78..0000000 --- a/crates/aetheris-openconnect/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -mod error; -pub mod events; -mod session; - -#[cfg(feature = "system-libopenconnect")] -mod ffi; - -pub use error::{OpenConnectError, Result}; -pub use events::{OpenConnectEvent, ProgressLevel}; -pub use session::{LogLevel, OpenConnectConfig, OpenConnectSession}; - -pub fn is_linked() -> bool { - cfg!(feature = "system-libopenconnect") -} - -pub fn library_version() -> Result> { - session::library_version() -} diff --git a/crates/aetheris-openconnect/src/session.rs b/crates/aetheris-openconnect/src/session.rs deleted file mode 100644 index 8b95053..0000000 --- a/crates/aetheris-openconnect/src/session.rs +++ /dev/null @@ -1,317 +0,0 @@ -#[cfg(any(feature = "system-libopenconnect", test))] -use std::ffi::CString; - -use crate::Result; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OpenConnectConfig { - pub server_url: String, - pub protocol: Option, - pub ca_file: Option, - pub user_agent: Option, - pub log_level: LogLevel, - pub use_system_trust: bool, - pub disable_ipv6: bool, - pub disable_dtls: bool, -} - -impl OpenConnectConfig { - pub fn new(server_url: impl Into) -> Self { - Self { - server_url: server_url.into(), - protocol: None, - ca_file: None, - user_agent: Some(default_user_agent()), - log_level: LogLevel::Info, - use_system_trust: true, - disable_ipv6: false, - disable_dtls: false, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LogLevel { - Error, - Info, - Debug, - Trace, -} - -impl LogLevel { - #[cfg(feature = "system-libopenconnect")] - fn as_openconnect_level(self) -> i32 { - match self { - Self::Error => 0, - Self::Info => 1, - Self::Debug => 2, - Self::Trace => 3, - } - } -} - -pub fn default_user_agent() -> String { - format!("Aetheris/{}", env!("CARGO_PKG_VERSION")) -} - -#[cfg(any(feature = "system-libopenconnect", test))] -fn c_string(field: &'static str, value: impl Into>) -> Result { - CString::new(value).map_err(|source| crate::OpenConnectError::InteriorNul { field, source }) -} - -#[cfg(feature = "system-libopenconnect")] -mod linked { - use std::{ - ffi::{CStr, CString}, - ptr, - sync::OnceLock, - }; - - use super::{OpenConnectConfig, c_string}; - use crate::{OpenConnectError, Result, ffi}; - - static INIT_RESULT: OnceLock = OnceLock::new(); - - pub struct OpenConnectSession { - vpninfo: *mut ffi::OpenconnectInfo, - strings: Vec, - } - - impl OpenConnectSession { - pub fn new(config: OpenConnectConfig) -> Result { - initialize_ssl()?; - - let mut strings = Vec::new(); - let user_agent = c_string( - "user_agent", - config - .user_agent - .clone() - .unwrap_or_else(super::default_user_agent), - )?; - - let vpninfo = unsafe { - // SAFETY: libopenconnect receives a valid, NUL-terminated user-agent string. - // All callbacks are NULL for this initial non-interactive session wrapper. - ffi::openconnect_vpninfo_new( - user_agent.as_ptr(), - None, - None, - None, - ptr::null_mut(), - ptr::null_mut(), - ) - }; - if vpninfo.is_null() { - return Err(OpenConnectError::AllocationFailed); - } - strings.push(user_agent); - - let mut session = Self { vpninfo, strings }; - session.configure(config)?; - Ok(session) - } - - pub fn protocol(&self) -> Option { - unsafe { - // SAFETY: vpninfo is owned by self and valid until Drop. - optional_string(ffi::openconnect_get_protocol(self.vpninfo)) - } - } - - pub fn connect_url(&self) -> Option { - unsafe { - // SAFETY: vpninfo is owned by self and valid until Drop. - optional_string(ffi::openconnect_get_connect_url(self.vpninfo)) - } - } - - fn configure(&mut self, config: OpenConnectConfig) -> Result<()> { - self.set_log_level(config.log_level); - self.set_system_trust(config.use_system_trust); - - let server_url = c_string("server_url", config.server_url)?; - let code = unsafe { - // SAFETY: vpninfo is valid and server_url is a valid C string. - ffi::openconnect_parse_url(self.vpninfo, server_url.as_ptr()) - }; - self.strings.push(server_url); - check_code("parse OpenConnect URL", code)?; - - if let Some(protocol) = config.protocol { - let protocol = c_string("protocol", protocol)?; - let code = unsafe { - // SAFETY: vpninfo is valid and protocol is a valid C string. - ffi::openconnect_set_protocol(self.vpninfo, protocol.as_ptr()) - }; - self.strings.push(protocol); - check_code("set OpenConnect protocol", code)?; - } - - if let Some(ca_file) = config.ca_file { - let ca_file = c_string("ca_file", ca_file)?; - let code = unsafe { - // SAFETY: vpninfo is valid and ca_file is a valid C string. - ffi::openconnect_set_cafile(self.vpninfo, ca_file.as_ptr()) - }; - self.strings.push(ca_file); - check_code("set OpenConnect CA file", code)?; - } - - if config.disable_ipv6 { - let code = unsafe { - // SAFETY: vpninfo is valid for the lifetime of self. - ffi::openconnect_disable_ipv6(self.vpninfo) - }; - check_code("disable OpenConnect IPv6", code)?; - } - - if config.disable_dtls { - let code = unsafe { - // SAFETY: vpninfo is valid for the lifetime of self. - ffi::openconnect_disable_dtls(self.vpninfo) - }; - check_code("disable OpenConnect DTLS", code)?; - } - - Ok(()) - } - - fn set_log_level(&self, log_level: super::LogLevel) { - unsafe { - // SAFETY: vpninfo is valid for the lifetime of self. - ffi::openconnect_set_loglevel(self.vpninfo, log_level.as_openconnect_level()); - } - } - - fn set_system_trust(&self, enabled: bool) { - unsafe { - // SAFETY: vpninfo is valid for the lifetime of self. - ffi::openconnect_set_system_trust(self.vpninfo, u32::from(enabled)); - } - } - } - - impl Drop for OpenConnectSession { - fn drop(&mut self) { - unsafe { - // SAFETY: vpninfo was returned by openconnect_vpninfo_new and is freed once here. - ffi::openconnect_vpninfo_free(self.vpninfo); - } - } - } - - pub fn library_version() -> Result> { - initialize_ssl()?; - unsafe { - // SAFETY: openconnect_get_version returns a static string or NULL. - Ok(optional_string(ffi::openconnect_get_version())) - } - } - - fn initialize_ssl() -> Result<()> { - let code = *INIT_RESULT.get_or_init(|| unsafe { - // SAFETY: libopenconnect documents openconnect_init_ssl as process-global init. - ffi::openconnect_init_ssl() - }); - if code == 0 { - Ok(()) - } else { - Err(OpenConnectError::InitFailed(code)) - } - } - - fn check_code(operation: &'static str, code: i32) -> Result<()> { - if code == 0 { - Ok(()) - } else { - Err(OpenConnectError::OperationFailed { operation, code }) - } - } - - unsafe fn optional_string(value: *const std::ffi::c_char) -> Option { - if value.is_null() { - None - } else { - Some( - unsafe { CStr::from_ptr(value) } - .to_string_lossy() - .into_owned(), - ) - } - } -} - -#[cfg(not(feature = "system-libopenconnect"))] -mod linked { - use super::OpenConnectConfig; - use crate::{OpenConnectError, Result}; - - #[derive(Debug)] - pub struct OpenConnectSession; - - impl OpenConnectSession { - pub fn new(_config: OpenConnectConfig) -> Result { - Err(OpenConnectError::NotLinked) - } - - pub fn protocol(&self) -> Option { - None - } - - pub fn connect_url(&self) -> Option { - None - } - } - - pub fn library_version() -> Result> { - Err(OpenConnectError::NotLinked) - } -} - -pub use linked::OpenConnectSession; - -pub fn library_version() -> Result> { - linked::library_version() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::OpenConnectError; - - #[test] - fn default_config_sets_aetheris_user_agent() { - let config = OpenConnectConfig::new("https://vpn.example.com"); - - assert_eq!(config.server_url, "https://vpn.example.com"); - assert_eq!( - config.user_agent.as_deref(), - Some(default_user_agent().as_str()) - ); - assert!(config.use_system_trust); - } - - #[test] - fn interior_nul_is_reported_with_field_name() { - let error = c_string("server_url", b"https://vpn.example.com\0bad".to_vec()) - .expect_err("interior NUL should fail"); - - assert!(matches!( - error, - OpenConnectError::InteriorNul { - field: "server_url", - .. - } - )); - } - - #[cfg(not(feature = "system-libopenconnect"))] - #[test] - fn session_reports_not_linked_without_system_feature() { - let error = OpenConnectSession::new(OpenConnectConfig::new("https://vpn.example.com")) - .expect_err("session creation should require libopenconnect feature"); - - assert!(matches!(error, OpenConnectError::NotLinked)); - } -}