Files
foxhunt/services/api/src/grpc/pods_handler.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
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>
2026-03-04 23:32:46 +01:00

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)
}
}