Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
115 lines
3.6 KiB
Rust
115 lines
3.6 KiB
Rust
//! 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<Vec<crate::monitoring::PodInfo>> {
|
|
let client = Client::try_default().await?;
|
|
let pods: Api<Pod> = 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::<Vec<_>>()
|
|
.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)
|
|
}
|
|
}
|