pub mod kubernetes;
pub mod models;
pub mod templates;
pub mod websocket_client;
use kftray_commons::models::{
config_model::Config,
config_state_model::ConfigState,
response::CustomResponse,
};
use kftray_commons::utils::config_state::update_config_state_with_mode;
use kftray_commons::utils::db_mode::DatabaseMode;
use log::{
error,
info,
};
use tokio_util::sync::CancellationToken;
use crate::kube::shared_client::{
SHARED_CLIENT_MANAGER,
ServiceClientKey,
};
pub async fn start_expose(
configs: Vec<Config>, mode: DatabaseMode,
) -> Result<Vec<CustomResponse>, String> {
let configs = configs
.into_iter()
.map(|mut config| {
config.workload_type = Some("expose".to_string());
config
})
.collect();
crate::kube::start_port_forward_with_mode(configs, "tcp", mode, false).await
}
async fn roll_back_exposure<T>(
client: &kube::Client, config: &Config, resources: &models::ExposeResources,
location: &kubernetes::ExposeLocation, mode: DatabaseMode,
guard: crate::kube::stop::ClusterResourceGuard, reason: String,
) -> Result<T, String> {
let config_id_str = config
.id
.map_or_else(|| "default".to_string(), |id| id.to_string());
let (cleaned, message) = kubernetes::rollback_created_resources(
client,
&config.namespace,
&resources.owned,
&config_id_str,
location,
mode,
reason,
)
.await;
if cleaned {
guard.disarm().await;
}
Err(message)
}
pub(crate) async fn start_single_expose(
config: Config, mode: DatabaseMode, cancellation: Option<&CancellationToken>,
) -> Result<CustomResponse, String> {
use self::kubernetes::create_expose_resources;
use self::websocket_client::WebSocketTunnelClient;
use crate::kube::models::{
NameSpace,
Port,
PortForward,
Target,
TargetSelector,
};
use crate::port_forward::CHILD_PROCESSES;
let config_id = config.id.ok_or("Config has no ID")?;
let cancelled = || cancellation.is_some_and(CancellationToken::is_cancelled);
if cancelled() {
return Err(format!("Expose startup cancelled for config {config_id}"));
}
let client_key = ServiceClientKey::new(config.context.clone(), config.kubeconfig.clone());
let connection = SHARED_CLIENT_MANAGER
.get_connection(client_key)
.await
.map_err(|e| format!("Failed to get K8s client: {}", e))?;
let client = connection.client.clone();
let location = kubernetes::ExposeLocation::resolve(&connection.cluster_url, &config.namespace);
let destination = crate::kube::client::cluster_identity(&connection.cluster_url);
info!("Creating expose resources for config {}", config_id);
let mut guard = crate::kube::stop::ClusterResourceGuard::arm(
config_id,
config.clone(),
Some(destination.clone()),
mode,
)
.await?;
let created = create_expose_resources(&connection, &config, mode, cancellation).await;
let resources = match created {
Ok(resources) => {
guard.confirm();
if cancelled() {
let reason = format!("Expose startup cancelled for config {config_id}");
return roll_back_exposure(
&client, &config, &resources, &location, mode, guard, reason,
)
.await;
}
resources
}
Err(error) => {
if !error.ambiguous && error.rolled_back {
guard.disarm().await;
} else if !error.ambiguous {
guard.confirm();
}
return Err(error.message);
}
};
info!(
"Resources created: deployment={}, service={}, pod={}",
resources.deployment_name, resources.service_name, resources.pod_name
);
let label_selector = match kubernetes::expose_owner_selector(&config_id.to_string(), mode).await
{
Ok(selector) => selector,
Err(error) => {
return roll_back_exposure(&client, &config, &resources, &location, mode, guard, error)
.await;
}
};
let target = Target {
selector: TargetSelector::PodLabel(label_selector),
port: Port::Number(i32::from(resources.websocket_port)),
namespace: NameSpace(Some(config.namespace.clone())),
};
let port_forward = PortForward::new(
target,
Some(0),
None,
config.context.clone(),
config.kubeconfig.clone(),
config_id,
"expose".to_string(),
)
.expecting_destination(Some(destination.clone()));
let started = port_forward.port_forward_tcp(None).await;
let (websocket_port, mut pf_process) = match started {
Ok(started) => started,
Err(error) => {
let reason = format!("Failed to start port-forward: {error}");
return roll_back_exposure(
&client, &config, &resources, &location, mode, guard, reason,
)
.await;
}
};
if cancelled() {
let reason = format!("Expose startup cancelled for config {config_id}");
pf_process.cleanup_and_abort().await;
return roll_back_exposure(&client, &config, &resources, &location, mode, guard, reason)
.await;
}
info!(
"Port-forward established: localhost:{} → pod:{}",
websocket_port, resources.websocket_port
);
let local_service_port = config.local_port.unwrap_or(8080);
let local_service_address = config
.local_address
.clone()
.unwrap_or_else(|| "127.0.0.1".to_string());
let ws_client = WebSocketTunnelClient::new(
websocket_port,
local_service_address.clone(),
local_service_port,
);
info!(
"Starting WebSocket tunnel: pod → localhost:{} → {}:{}",
websocket_port, local_service_address, local_service_port
);
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel();
let ws_handle = tokio::spawn(async move {
if let Err(e) = ws_client.start(ready_tx).await {
error!("WebSocket client error: {}", e);
tokio::spawn(async move {
if let Err(stop_error) =
crate::kube::stop_port_forward_with_mode(config_id.to_string(), mode).await
{
error!(
"Failed to clean up config {config_id} after its reverse tunnel ended: \
{stop_error}"
);
}
});
}
});
pf_process.set_ws_client_handle(ws_handle);
let ready = async {
match tokio::time::timeout(std::time::Duration::from_secs(30), ready_rx).await {
Ok(Ok(result)) => result,
Ok(Err(error)) => Err(format!(
"Expose startup task ended before connecting: {error}"
)),
Err(_) => Err("Timed out connecting the reverse WebSocket tunnel".to_owned()),
}
};
let startup = match cancellation {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => Err(format!("Expose startup cancelled for config {config_id}")),
startup = ready => startup,
},
None => ready.await,
};
if let Err(error) = startup {
pf_process.cleanup_and_abort().await;
return roll_back_exposure(&client, &config, &resources, &location, mode, guard, error)
.await;
}
let config_state = ConfigState {
id: None,
config_id,
is_running: true,
process_id: Some(std::process::id()),
is_retrying: false,
retry_count: None,
last_error: None,
};
let recorded =
kftray_commons::utils::config_state::set_running_snapshot(config_id, &config, mode).await;
if let Err(error) = match recorded {
Ok(()) => update_config_state_with_mode(&config_state, mode).await,
Err(error) => Err(error),
} {
pf_process.cleanup_and_abort().await;
kftray_commons::utils::config_state::clear_running_snapshot(config_id, mode).await;
return roll_back_exposure(&client, &config, &resources, &location, mode, guard, error)
.await;
}
pf_process.set_config(config.clone());
CHILD_PROCESSES.insert(config_id, pf_process);
guard.confirm();
drop(guard);
info!("Expose tunnel fully established for config {}", config_id);
Ok(CustomResponse {
id: Some(config_id),
service: config.service.unwrap_or(resources.service_name),
namespace: config.namespace.clone(),
local_port: local_service_port,
remote_port: resources.websocket_port,
context: config.context.unwrap_or_default(),
stdout: String::new(),
stderr: String::new(),
status: 0,
protocol: "tcp".to_string(),
})
}
pub async fn stop_expose(config_id: i64, mode: DatabaseMode) -> Result<CustomResponse, String> {
crate::kube::stop_port_forward_with_mode(config_id.to_string(), mode).await
}