//! Kubernetes pod listing for the Pods cockpit. //! //! Queries the in-cluster Kubernetes API (via `kube` crate) to list pods in a //! given namespace, converting them into proto `PodInfo` messages. The handler //! is called periodically by the `subscribe_cluster_pods` streaming RPC. use anyhow::Result; use k8s_openapi::api::core::v1::Pod; use kube::{Api, Client}; /// Fetch all pods in a namespace and convert to proto `PodInfo`. pub async fn list_pods(namespace: &str) -> Result> { let client = Client::try_default().await?; let pods: Api = Api::namespaced(client, namespace); let pod_list = pods.list(&Default::default()).await?; let mut infos = Vec::with_capacity(pod_list.items.len()); for pod in pod_list.items { let meta = &pod.metadata; let spec = pod.spec.as_ref(); let status = pod.status.as_ref(); let name = meta.name.clone().unwrap_or_default(); let labels = meta.labels.as_ref(); let service = labels .and_then(|l| l.get("app.kubernetes.io/name")) .cloned() .unwrap_or_default(); let phase = status .and_then(|s| s.phase.clone()) .unwrap_or_else(|| "Unknown".into()); let ready = status .and_then(|s| s.conditions.as_ref()) .map(|conds| { conds .iter() .any(|c| c.type_ == "Ready" && c.status == "True") }) .unwrap_or(false); let restarts: i32 = status .and_then(|s| s.container_statuses.as_ref()) .map(|cs| cs.iter().map(|c| c.restart_count).sum()) .unwrap_or(0); let node = spec .and_then(|s| s.node_name.clone()) .unwrap_or_default(); let age = meta .creation_timestamp .as_ref() .map(|t| format_age(&t.0)) .unwrap_or_else(|| "?".into()); // Try to get version from FOXHUNT_BUILD_VERSION env var in first container let version = spec .and_then(|s| s.containers.first()) .and_then(|c| c.env.as_ref()) .and_then(|envs| { envs.iter() .find(|e| e.name == "FOXHUNT_BUILD_VERSION") .and_then(|e| e.value.clone()) }) .unwrap_or_default(); infos.push(crate::monitoring::PodInfo { name, service, namespace: namespace.into(), status: phase, restarts, age, node: short_node_name(&node), ready, version, }); } infos.sort_by(|a, b| (&a.service, &a.name).cmp(&(&b.service, &b.name))); Ok(infos) } /// Shorten Scaleway node names: "scw-foxhunt-pool-default-abc123" -> "pool-default-abc123" fn short_node_name(full: &str) -> String { full.strip_prefix("scw-foxhunt-") .unwrap_or(full) .split('-') .take(3) .collect::>() .join("-") .chars() .take(20) .collect() } /// Format a duration since `created` as a human-readable age string. /// /// k8s-openapi 0.27+ uses `jiff::Timestamp` for `creation_timestamp`. fn format_age(created: &k8s_openapi::jiff::Timestamp) -> String { let now = k8s_openapi::jiff::Timestamp::now(); let secs = now.as_second() - created.as_second(); if secs < 60 { format!("{secs}s") } else if secs < 3600 { format!("{}m", secs / 60) } else if secs < 86400 { format!("{}h{}m", secs / 3600, (secs % 3600) / 60) } else { format!("{}d{}h", secs / 86400, (secs % 86400) / 3600) } }