use std::sync::Arc;
use std::time::Duration;
use kube::{
Client,
api::{Api, DynamicObject, ListParams},
core::GroupVersionKind,
discovery::{Discovery, Scope},
};
use serde_json::Value as JsonValue;
use sherpack_engine::cluster_reader::ClusterReader;
pub const DEFAULT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5);
pub struct KubeClusterReader {
client: Client,
discovery: Discovery,
timeout: Duration,
}
impl KubeClusterReader {
pub async fn new(client: Client) -> Result<Self, kube::Error> {
let discovery = Discovery::new(client.clone()).run().await?;
Ok(Self {
client,
discovery,
timeout: DEFAULT_LOOKUP_TIMEOUT,
})
}
pub fn with_discovery(client: Client, discovery: Discovery) -> Self {
Self {
client,
discovery,
timeout: DEFAULT_LOOKUP_TIMEOUT,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn timeout(&self) -> Duration {
self.timeout
}
pub fn into_arc(self) -> Arc<dyn ClusterReader> {
Arc::new(self)
}
}
impl ClusterReader for KubeClusterReader {
fn lookup_one(
&self,
api_version: &str,
kind: &str,
namespace: &str,
name: &str,
) -> Option<JsonValue> {
let timeout = self.timeout;
let kind_for_log = kind.to_string();
let name_for_log = name.to_string();
block_on_current(async move {
let (api_resource, capabilities) = resolve_or_none(&self.discovery, api_version, kind)?;
let api: Api<DynamicObject> = match capabilities.scope {
Scope::Cluster => Api::all_with(self.client.clone(), &api_resource),
Scope::Namespaced => {
if namespace.is_empty() {
Api::all_with(self.client.clone(), &api_resource)
} else {
Api::namespaced_with(self.client.clone(), namespace, &api_resource)
}
}
};
let result = tokio::time::timeout(timeout, api.get_opt(name)).await;
let obj = match result {
Ok(Ok(opt)) => opt?,
Ok(Err(_)) => return None,
Err(_) => {
tracing::warn!(
"lookup {}/{} timed out after {:?}",
kind_for_log,
name_for_log,
timeout
);
return None;
}
};
serde_json::to_value(obj).ok()
})
.flatten()
}
fn lookup_list(&self, api_version: &str, kind: &str, namespace: &str) -> Vec<JsonValue> {
let timeout = self.timeout;
let kind_for_log = kind.to_string();
block_on_current(async move {
let Some((api_resource, capabilities)) =
resolve_or_none(&self.discovery, api_version, kind)
else {
return Vec::new();
};
let api: Api<DynamicObject> = match capabilities.scope {
Scope::Cluster => Api::all_with(self.client.clone(), &api_resource),
Scope::Namespaced => {
if namespace.is_empty() {
Api::all_with(self.client.clone(), &api_resource)
} else {
Api::namespaced_with(self.client.clone(), namespace, &api_resource)
}
}
};
match tokio::time::timeout(timeout, api.list(&ListParams::default())).await {
Ok(Ok(list)) => list
.items
.into_iter()
.filter_map(|o| serde_json::to_value(o).ok())
.collect(),
Ok(Err(_)) => Vec::new(),
Err(_) => {
tracing::warn!("lookup list {} timed out after {:?}", kind_for_log, timeout);
Vec::new()
}
}
})
.unwrap_or_default()
}
}
fn block_on_current<F, T>(fut: F) -> Option<T>
where
F: std::future::Future<Output = T>,
{
let handle = tokio::runtime::Handle::try_current().ok()?;
Some(tokio::task::block_in_place(|| handle.block_on(fut)))
}
fn resolve_or_none(
discovery: &Discovery,
api_version: &str,
kind: &str,
) -> Option<(
kube::discovery::ApiResource,
kube::discovery::ApiCapabilities,
)> {
let (group, version) = match api_version.rsplit_once('/') {
Some((g, v)) => (g.to_string(), v.to_string()),
None => (String::new(), api_version.to_string()),
};
let gvk = GroupVersionKind {
group,
version,
kind: kind.to_string(),
};
discovery.resolve_gvk(&gvk)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_or_none_handles_core_api() {
let (group, version) = match "v1".rsplit_once('/') {
Some((g, v)) => (g.to_string(), v.to_string()),
None => (String::new(), "v1".to_string()),
};
assert_eq!(group, "");
assert_eq!(version, "v1");
}
#[test]
fn test_resolve_or_none_handles_grouped_api() {
let (group, version) = match "apps/v1".rsplit_once('/') {
Some((g, v)) => (g.to_string(), v.to_string()),
None => (String::new(), "apps/v1".to_string()),
};
assert_eq!(group, "apps");
assert_eq!(version, "v1");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_block_on_current_works_inside_runtime() {
let result: Option<i32> = block_on_current(async { 42 });
assert_eq!(result, Some(42));
}
#[test]
fn test_block_on_current_returns_none_outside_runtime() {
let result: Option<i32> = block_on_current(async { 42 });
assert_eq!(result, None);
}
#[tokio::test(flavor = "multi_thread")]
async fn test_timeout_wraps_slow_future() {
let timeout = Duration::from_millis(50);
let result =
tokio::time::timeout(timeout, tokio::time::sleep(Duration::from_millis(500))).await;
assert!(result.is_err(), "expected timeout error");
}
#[tokio::test(flavor = "multi_thread")]
async fn test_timeout_passes_through_fast_future() {
let timeout = Duration::from_secs(5);
let result = tokio::time::timeout(timeout, async { 42 }).await;
assert_eq!(result.unwrap(), 42);
}
}