#[cfg(feature = "kubernetes")]
use blueprint_core::info;
use blueprint_std::path::PathBuf;
#[cfg(feature = "kubernetes")]
use blueprint_std::{collections::HashMap, sync::Arc};
#[cfg(feature = "kubernetes")]
use kube::config::Kubeconfig;
#[cfg(feature = "kubernetes")]
use kube::{Client, Config};
use serde::{Deserialize, Serialize};
#[cfg(feature = "kubernetes")]
use tokio::sync::RwLock;
#[cfg(feature = "kubernetes")]
use crate::core::error::{Error, Result};
#[cfg(feature = "kubernetes")]
pub struct RemoteClusterManager {
clusters: Arc<RwLock<HashMap<String, RemoteCluster>>>,
active_cluster: Arc<RwLock<Option<String>>>,
}
#[cfg(not(feature = "kubernetes"))]
pub struct RemoteClusterManager {
_private: (),
}
#[cfg(feature = "kubernetes")]
impl RemoteClusterManager {
pub fn new() -> Self {
Self {
clusters: Arc::new(RwLock::new(HashMap::new())),
active_cluster: Arc::new(RwLock::new(None)),
}
}
pub async fn add_cluster(&self, name: String, config: KubernetesClusterConfig) -> Result<()> {
info!("Adding remote cluster: {}", name);
let kube_config = if let Some(ref path) = config.kubeconfig_path {
let kubeconfig_yaml = tokio::fs::read_to_string(path).await.map_err(|e| {
Error::ConfigurationError(format!("Failed to read kubeconfig file: {}", e))
})?;
let kubeconfig: kube::config::Kubeconfig = serde_yaml::from_str(&kubeconfig_yaml)
.map_err(|e| Error::ConfigurationError(format!("Invalid kubeconfig: {}", e)))?;
Config::from_custom_kubeconfig(kubeconfig, &Default::default()).await?
} else {
Config::infer().await?
};
let kube_config = if let Some(ref context_name) = config.context {
let kubeconfig_yaml = if let Some(ref path) = config.kubeconfig_path {
std::fs::read_to_string(path)
.map_err(|e| Error::Other(format!("Failed to read kubeconfig: {}", e)))?
} else {
let home =
std::env::var("HOME").map_err(|_| Error::Other("HOME not set".into()))?;
let default_path = format!("{}/.kube/config", home);
std::fs::read_to_string(&default_path)
.map_err(|e| Error::Other(format!("Failed to read kubeconfig: {}", e)))?
};
let mut kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_yaml)
.map_err(|e| Error::Other(format!("Failed to parse kubeconfig: {}", e)))?;
if !kubeconfig.contexts.iter().any(|c| c.name == *context_name) {
return Err(Error::Other(format!(
"Context '{}' not found in kubeconfig",
context_name
)));
}
kubeconfig.current_context = Some(context_name.clone());
Config::from_custom_kubeconfig(kubeconfig, &Default::default()).await?
} else {
kube_config
};
let client = Client::try_from(kube_config)?;
let cluster = RemoteCluster { config, client };
self.clusters.write().await.insert(name.clone(), cluster);
let mut active = self.active_cluster.write().await;
if active.is_none() {
*active = Some(name);
}
Ok(())
}
pub async fn set_active_cluster(&self, name: &str) -> Result<()> {
let clusters = self.clusters.read().await;
if !clusters.contains_key(name) {
return Err(Error::ConfigurationError(format!(
"Cluster {} not found",
name
)));
}
let mut active = self.active_cluster.write().await;
*active = Some(name.to_string());
info!("Switched active cluster to: {}", name);
Ok(())
}
pub async fn list_clusters(&self) -> Vec<(String, CloudProvider)> {
let clusters = self.clusters.read().await;
clusters
.iter()
.map(|(name, cluster)| (name.clone(), cluster.config.provider.clone()))
.collect()
}
pub async fn get_cluster_endpoint(&self, name: &str) -> Result<String> {
let clusters = self.clusters.read().await;
let cluster = clusters
.get(name)
.ok_or_else(|| Error::ConfigurationError(format!("Cluster {} not found", name)))?;
Ok(cluster.client.default_namespace().to_string())
}
}
impl Default for RemoteClusterManager {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(feature = "kubernetes"))]
impl RemoteClusterManager {
pub fn new() -> Self {
Self { _private: () }
}
}
#[cfg(feature = "kubernetes")]
struct RemoteCluster {
config: KubernetesClusterConfig,
client: Client,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KubernetesClusterConfig {
pub kubeconfig_path: Option<PathBuf>,
pub context: Option<String>,
pub namespace: String,
pub provider: CloudProvider,
pub region: Option<String>,
}
impl Default for KubernetesClusterConfig {
fn default() -> Self {
Self {
kubeconfig_path: None,
context: None,
namespace: "blueprint-remote".to_string(),
provider: CloudProvider::Generic,
region: None,
}
}
}
pub use blueprint_pricing_engine_lib::types::CloudProvider;
pub trait CloudProviderExt {
fn to_service_type(&self) -> &str;
fn requires_tunnel(&self) -> bool;
}
impl CloudProviderExt for CloudProvider {
fn to_service_type(&self) -> &str {
match self {
CloudProvider::AWS | CloudProvider::Azure => "LoadBalancer",
CloudProvider::GCP => "ClusterIP", CloudProvider::DigitalOcean | CloudProvider::Vultr | CloudProvider::Linode => {
"LoadBalancer"
}
_ => "ClusterIP",
}
}
fn requires_tunnel(&self) -> bool {
matches!(
self,
CloudProvider::Generic | CloudProvider::BareMetal(_) | CloudProvider::DockerLocal
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_cluster_management() {
#[cfg(feature = "kubernetes")]
{
let _ = rustls::crypto::ring::default_provider().install_default();
let manager = RemoteClusterManager::new();
let config = KubernetesClusterConfig {
namespace: "test-namespace".to_string(),
provider: CloudProvider::AWS,
..Default::default()
};
let result = manager.add_cluster("test-aws".to_string(), config).await;
let clusters = manager.list_clusters().await;
if result.is_ok() {
assert_eq!(clusters.len(), 1);
} else {
assert_eq!(clusters.len(), 0);
}
}
#[cfg(not(feature = "kubernetes"))]
{
let _manager = RemoteClusterManager::new();
}
}
#[test]
fn test_provider_service_type() {
use super::CloudProviderExt;
assert_eq!(CloudProvider::AWS.to_service_type(), "LoadBalancer");
assert_eq!(CloudProvider::GCP.to_service_type(), "ClusterIP");
assert_eq!(CloudProvider::Generic.to_service_type(), "ClusterIP");
}
#[test]
fn test_provider_tunnel_requirement() {
use super::CloudProviderExt;
assert!(!CloudProvider::AWS.requires_tunnel());
assert!(!CloudProvider::GCP.requires_tunnel());
assert!(CloudProvider::Generic.requires_tunnel());
assert!(CloudProvider::BareMetal(vec!["host".to_string()]).requires_tunnel());
}
}