use std::collections::HashMap;
use std::time::{
Duration,
SystemTime,
UNIX_EPOCH,
};
use futures::StreamExt;
use k8s_openapi::api::{
apps::v1::Deployment,
core::v1::{
Container,
Pod,
Probe,
Service,
TCPSocketAction,
},
networking::v1::Ingress,
};
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
use kftray_commons::models::config_model::Config;
use kftray_commons::utils::db_mode::DatabaseMode;
use kube::api::{
DeleteParams,
ListParams,
PostParams,
};
use kube::{
Api,
Client,
};
use kube_runtime::WatchStreamExt;
use log::{
debug,
info,
warn,
};
use tokio_util::sync::CancellationToken;
use crate::expose::{
models::ExposeResources,
templates,
};
fn extract_subdomain(domain: &str) -> String {
domain.split('.').next().unwrap_or(domain).to_string()
}
pub fn expose_resource_prefix() -> String {
let username: String = whoami::username()
.unwrap_or_else(|_| "unknown".to_string())
.to_lowercase()
.chars()
.filter(char::is_ascii_alphanumeric)
.take(16)
.collect();
format!("kftray-expose-{username}-")
}
pub async fn create_expose_resources(
connection: &crate::kube::client::KubeConnection, config: &Config, mode: DatabaseMode,
cancellation: Option<&CancellationToken>,
) -> Result<ExposeResources, ExposeCreateError> {
let client = connection.client.clone();
let location = ExposeLocation::resolve(&connection.cluster_url, &config.namespace);
let config_id_str = config
.id
.map_or_else(|| "default".to_string(), |id| id.to_string());
const PRE_START_CLEANUP_TIMEOUT: Duration = Duration::from_secs(60);
let cleanup_timeout = async {
tokio::time::timeout(
PRE_START_CLEANUP_TIMEOUT,
delete_expose_resources(
client.clone(),
&config.namespace,
&config_id_str,
config.exposure_type.as_deref() == Some("public"),
&location,
mode,
),
)
.await
.unwrap_or_else(|_| {
Err(format!(
"Timed out after {PRE_START_CLEANUP_TIMEOUT:?} cleaning up the earlier exposure \
for config {config_id_str}"
))
})
};
match cancellation {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => Err(format!(
"Expose startup cancelled for config {config_id_str} before cleaning up the \
earlier exposure"
)),
result = cleanup_timeout => result,
},
None => cleanup_timeout.await,
}
.map_err(ExposeCreateError::from)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs();
let random_string: String = {
use rand::RngExt;
let mut rng = rand::rng();
(0..6)
.map(|_| {
let idx = rng.random_range(0..26);
(b'a' + idx) as char
})
.collect()
};
let deployment_name = format!(
"{}{}-{}",
expose_resource_prefix(),
timestamp,
random_string
);
let service_name = if config.exposure_type.as_deref() == Some("public") {
config
.alias
.as_ref()
.map(|alias| extract_subdomain(alias))
.unwrap_or_else(|| deployment_name.clone())
} else {
config
.alias
.clone()
.unwrap_or_else(|| deployment_name.clone())
};
let ingress_name = if config.exposure_type.as_deref() == Some("public") {
config
.alias
.as_ref()
.map(|alias| extract_subdomain(alias))
.unwrap_or_else(|| deployment_name.clone())
} else {
config
.alias
.clone()
.unwrap_or_else(|| deployment_name.clone())
};
let mut created: Vec<CreatedResource> = Vec::new();
let result: Result<ExposeResources, ExposeCreateError> = async {
let (deployment, relay) = create_deployment(
&client,
&config.namespace,
&deployment_name,
&config_id_str,
config,
mode,
)
.await?;
created.push(deployment);
let pod_name = wait_for_pod_ready(
&client,
&config.namespace,
&config_id_str,
mode,
&relay,
cancellation,
)
.await?;
let pod_ip = get_pod_ip(&client, &config.namespace, &pod_name).await?;
let local_port = config.local_port.unwrap_or(8080);
created.push(
create_service(
&client,
&config.namespace,
&service_name,
&config_id_str,
local_port,
mode,
)
.await?,
);
let ingress_created = if config.exposure_type.as_deref() == Some("public") {
created.push(
create_ingress(
&client,
&config.namespace,
&ingress_name,
&service_name,
config,
&location,
mode,
)
.await?,
);
true
} else {
false
};
Ok(ExposeResources {
deployment_name: deployment_name.clone(),
service_name: service_name.clone(),
ingress_name: if ingress_created {
Some(ingress_name)
} else {
None
},
pod_ip,
pod_name,
websocket_port: relay.websocket_port,
owned: std::mem::take(&mut created),
})
}
.await;
if let Err(error) = result {
let (cleaned, message) = rollback_created_resources(
&client,
&config.namespace,
&created,
&config_id_str,
&location,
mode,
error.message,
)
.await;
return Err(ExposeCreateError {
message,
ambiguous: error.ambiguous,
rolled_back: cleaned,
});
}
result
}
async fn tag_expose_ownership(
labels: &mut Option<std::collections::BTreeMap<String, String>>, config_id: &str,
mode: DatabaseMode,
) -> Result<(), String> {
let labels = labels.get_or_insert_with(std::collections::BTreeMap::new);
labels.insert("app".to_owned(), "kftray-expose".to_owned());
labels.insert("config_id".to_owned(), config_id.to_owned());
labels.insert(
crate::kube::proxy::INSTALLATION_LABEL.to_owned(),
kftray_commons::utils::config_dir::owner_identity(mode).await?,
);
Ok(())
}
fn is_ownership_label(key: &str) -> bool {
matches!(key, "app" | "config_id") || key == crate::kube::proxy::INSTALLATION_LABEL
}
pub async fn expose_owner_selector(config_id: &str, mode: DatabaseMode) -> Result<String, String> {
Ok(format!(
"app=kftray-expose,config_id={config_id},{}={}",
crate::kube::proxy::INSTALLATION_LABEL,
kftray_commons::utils::config_dir::owner_identity(mode).await?
))
}
pub struct ExposeCreateError {
pub message: String,
pub ambiguous: bool,
pub rolled_back: bool,
}
impl From<String> for ExposeCreateError {
fn from(message: String) -> Self {
Self {
message,
ambiguous: false,
rolled_back: false,
}
}
}
impl From<&str> for ExposeCreateError {
fn from(message: &str) -> Self {
Self::from(message.to_owned())
}
}
const CREATE_REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
async fn create_bounded<K>(
api: &Api<K>, kind: ResourceKind, object: &K,
) -> Result<CreatedResource, ExposeCreateError>
where
K: Clone + serde::de::DeserializeOwned + std::fmt::Debug + serde::Serialize + kube::Resource,
{
match tokio::time::timeout(
CREATE_REQUEST_TIMEOUT,
api.create(&PostParams::default(), object),
)
.await
{
Ok(Ok(created)) => Ok(CreatedResource {
kind,
name: created.meta().name.clone().unwrap_or_default(),
uid: created.meta().uid.clone(),
}),
Ok(Err(error)) => Err(classify_create_error(kind, &error)),
Err(_) => Err(ExposeCreateError {
message: format!(
"Timed out after {CREATE_REQUEST_TIMEOUT:?} creating the {}; the server may still \
be applying it",
kind.label()
),
ambiguous: true,
rolled_back: false,
}),
}
}
fn classify_create_error(kind: ResourceKind, error: &kube::Error) -> ExposeCreateError {
let ambiguous = match error {
kube::Error::Api(response) => {
matches!(response.code, 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
_ => true,
};
ExposeCreateError {
message: format!("Failed to create {}: {error}", kind.label()),
ambiguous,
rolled_back: false,
}
}
#[derive(Debug)]
pub struct CreatedResource {
kind: ResourceKind,
name: String,
uid: Option<String>,
}
#[derive(Debug, Clone, Copy)]
pub enum ResourceKind {
Deployment,
Service,
Ingress,
}
impl ResourceKind {
fn label(self) -> &'static str {
match self {
Self::Deployment => "deployment",
Self::Service => "service",
Self::Ingress => "ingress",
}
}
}
pub async fn delete_created_resources(
client: &Client, namespace: &str, created: &[CreatedResource],
) -> Result<(), String> {
let mut errors = Vec::new();
for resource in created.iter().rev() {
let dp = DeleteParams {
preconditions: resource.uid.clone().map(|uid| kube::api::Preconditions {
uid: Some(uid),
resource_version: None,
}),
propagation_policy: matches!(resource.kind, ResourceKind::Deployment)
.then_some(kube::api::PropagationPolicy::Foreground),
..DeleteParams::default()
};
let name = &resource.name;
let deleted = match resource.kind {
ResourceKind::Deployment => {
let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
api.delete(name, &dp).await.map(|_| ())
}
ResourceKind::Service => {
let api: Api<Service> = Api::namespaced(client.clone(), namespace);
api.delete(name, &dp).await.map(|_| ())
}
ResourceKind::Ingress => {
let api: Api<Ingress> = Api::namespaced(client.clone(), namespace);
api.delete(name, &dp).await.map(|_| ())
}
};
if let Err(error) = deleted
&& !matches!(&error, kube::Error::Api(response) if response.code == 404)
{
let conflict = matches!(&error, kube::Error::Api(response) if response.code == 409);
let still_owned = !conflict
|| match still_present(client, namespace, resource).await {
Ok(present) => present,
Err(read_error) => {
errors.push(format!(
"could not confirm whether {} still exists after a conflicting \
delete: {read_error}",
resource.name
));
true
}
};
if still_owned {
errors.push(error.to_string());
}
}
}
if !errors.is_empty() {
return Err(errors.join("; "));
}
let deadline = tokio::time::Instant::now() + ROLLBACK_DELETION_TIMEOUT;
loop {
let mut remaining = Vec::new();
let mut read_errors: Vec<String> = Vec::new();
for resource in created {
match still_present(client, namespace, resource).await {
Ok(true) => remaining.push(resource.name.clone()),
Ok(false) => {}
Err(error) => {
remaining.push(resource.name.clone());
read_errors.push(error);
}
}
}
if remaining.is_empty() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
let mut message = format!(
"Resources are still terminating after {ROLLBACK_DELETION_TIMEOUT:?}: {}",
remaining.join(", ")
);
if !read_errors.is_empty() {
message.push_str(&format!(
"; also failed to confirm: {}",
read_errors.join("; ")
));
}
return Err(message);
}
tokio::time::sleep(ROLLBACK_DELETION_POLL).await;
}
}
const ROLLBACK_DELETION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(45);
const ROLLBACK_DELETION_POLL: std::time::Duration = std::time::Duration::from_millis(250);
pub(crate) async fn rollback_created_resources(
client: &Client, namespace: &str, created: &[CreatedResource], config_id: &str,
location: &ExposeLocation, mode: DatabaseMode, reason: String,
) -> (bool, String) {
const ROLLBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
let cleaned = tokio::time::timeout(
ROLLBACK_TIMEOUT,
delete_created_resources(client, namespace, created),
)
.await;
if matches!(cleaned, Ok(Ok(())))
&& created
.iter()
.any(|r| matches!(r.kind, ResourceKind::Ingress))
{
forget_ingress_history(config_id, location, mode).await;
}
match cleaned {
Ok(Ok(())) => (true, reason),
Ok(Err(cleanup_error)) => (false, format!("{reason}; cleanup failed: {cleanup_error}")),
Err(_) => (
false,
format!("{reason}; cleanup timed out after {ROLLBACK_TIMEOUT:?} and will be retried"),
),
}
}
async fn still_present(
client: &Client, namespace: &str, resource: &CreatedResource,
) -> Result<bool, String> {
let uid = match resource.kind {
ResourceKind::Deployment => {
let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
api.get_opt(&resource.name)
.await
.map(|found| found.and_then(|item| item.metadata.uid))
}
ResourceKind::Service => {
let api: Api<Service> = Api::namespaced(client.clone(), namespace);
api.get_opt(&resource.name)
.await
.map(|found| found.and_then(|item| item.metadata.uid))
}
ResourceKind::Ingress => {
let api: Api<Ingress> = Api::namespaced(client.clone(), namespace);
api.get_opt(&resource.name)
.await
.map(|found| found.and_then(|item| item.metadata.uid))
}
}
.map_err(|error| error.to_string())?;
Ok(match (uid, resource.uid.as_deref()) {
(None, _) => false,
(Some(found), Some(created)) => found == created,
(Some(_), None) => true,
})
}
#[derive(Debug)]
struct RelayProbe {
container_name: Option<String>,
readiness_probe_present: bool,
websocket_port: u16,
}
async fn create_deployment(
client: &Client, namespace: &str, deployment_name: &str, config_id: &str, config: &Config,
mode: DatabaseMode,
) -> Result<(CreatedResource, RelayProbe), ExposeCreateError> {
let deployments: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let local_port = config.local_port.unwrap_or(8080).to_string();
let mut values = HashMap::new();
values.insert("deployment_name", deployment_name.to_string());
values.insert("namespace", namespace.to_string());
values.insert("config_id", config_id.to_string());
values.insert("local_port", local_port);
let template = templates::load_deployment_template()?;
let rendered = templates::render_template(&template, &values);
let mut deployment: Deployment = serde_json::from_str(&rendered)
.map_err(|e| format!("Failed to parse deployment: {}", e))?;
tag_expose_ownership(&mut deployment.metadata.labels, config_id, mode).await?;
if let Some(spec) = deployment.spec.as_mut() {
tag_expose_ownership(
&mut spec.template.metadata.get_or_insert_default().labels,
config_id,
mode,
)
.await?;
let ownership: Vec<(String, String)> = spec
.template
.metadata
.as_ref()
.and_then(|metadata| metadata.labels.clone())
.unwrap_or_default()
.into_iter()
.filter(|(key, _)| is_ownership_label(key))
.collect();
spec.selector
.match_labels
.get_or_insert_with(std::collections::BTreeMap::new)
.extend(ownership);
if let Some(expressions) = spec.selector.match_expressions.as_mut() {
expressions.retain(|expression| !is_ownership_label(&expression.key));
}
}
let spec = deployment
.spec
.as_mut()
.and_then(|deployment| deployment.template.spec.as_mut())
.ok_or("Expose deployment must contain a pod specification")?;
let relay = spec.containers.iter_mut().find(|container| {
container.name == "kftray-server"
|| container.env.as_ref().is_some_and(|env| {
env.iter().any(|value| {
value.name == "PROXY_TYPE" && value.value.as_deref() == Some("reverse_http")
})
})
});
let Some(container) = relay else {
return Err(
"Expose deployment has no container named kftray-server and none with a literal \
PROXY_TYPE=reverse_http env entry, so the relay cannot be identified"
.into(),
);
};
let websocket_port =
container_env_port(container, "WEBSOCKET_PORT", 9999).ok_or_else(|| {
"WEBSOCKET_PORT is set from valueFrom/envFrom, which cannot be resolved before the \
relay pod exists; the tunnel's target port cannot be determined"
.to_string()
})?;
let websocket_port = u16::try_from(websocket_port)
.ok()
.filter(|port| *port != 0)
.ok_or_else(|| {
format!(
"WEBSOCKET_PORT {websocket_port} is not a valid port (must be between 1 and \
65535)"
)
})?;
let mut relay_probe = RelayProbe {
container_name: Some(container.name.clone()),
readiness_probe_present: false,
websocket_port,
};
container.startup_probe.get_or_insert_with(|| Probe {
tcp_socket: Some(TCPSocketAction {
port: IntOrString::Int(websocket_port as i32),
..Default::default()
}),
period_seconds: Some(1),
timeout_seconds: Some(1),
failure_threshold: Some(30),
..Default::default()
});
if !config.exposes_tcp()
&& let Some(http_port) = container_env_port(container, "HTTP_PORT", 8080)
{
let http_port = u16::try_from(http_port)
.ok()
.filter(|port| *port != 0)
.ok_or_else(|| {
format!("HTTP_PORT {http_port} is not a valid port (must be between 1 and 65535)")
})?;
container.readiness_probe.get_or_insert_with(|| Probe {
tcp_socket: Some(TCPSocketAction {
port: IntOrString::Int(http_port as i32),
..Default::default()
}),
period_seconds: Some(1),
timeout_seconds: Some(1),
failure_threshold: Some(3),
..Default::default()
});
}
relay_probe.readiness_probe_present = container.readiness_probe.is_some();
let created = create_bounded(&deployments, ResourceKind::Deployment, &deployment).await?;
info!("Deployment created successfully");
Ok((created, relay_probe))
}
fn container_env_port(container: &Container, name: &str, default: i32) -> Option<i32> {
if let Some(variable) = container
.env
.as_ref()
.and_then(|env| env.iter().find(|variable| variable.name == name))
{
return variable
.value
.as_deref()
.and_then(|value| value.parse().ok());
}
if container
.env_from
.as_ref()
.is_some_and(|sources| !sources.is_empty())
{
return None;
}
Some(default)
}
fn relay_pod_ready(pod: &Pod, relay: &RelayProbe) -> bool {
if pod.metadata.deletion_timestamp.is_some() {
return false;
}
let Some(status) = pod.status.as_ref() else {
return false;
};
if status.phase.as_deref() != Some("Running") {
return false;
}
match (&relay.container_name, relay.readiness_probe_present) {
(Some(container_name), false) => {
status
.container_statuses
.as_ref()
.is_some_and(|containers| {
containers.iter().any(|container| {
&container.name == container_name
&& (container.ready || container.started == Some(true))
})
})
}
_ => status.conditions.as_ref().is_some_and(|conditions| {
conditions
.iter()
.any(|condition| condition.type_ == "Ready" && condition.status == "True")
}),
}
}
async fn wait_for_pod_ready(
client: &Client, namespace: &str, config_id: &str, mode: DatabaseMode, relay: &RelayProbe,
cancellation: Option<&CancellationToken>,
) -> Result<String, String> {
let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
let watcher = kube_runtime::watcher(
pods,
kube_runtime::watcher::Config::default()
.labels(&expose_owner_selector(config_id, mode).await?),
)
.default_backoff()
.applied_objects();
futures::pin_mut!(watcher);
let watch = async {
while let Some(event) = watcher.next().await {
let pod = match event {
Ok(pod) => pod,
Err(error) => {
debug!("Retrying the relay pod watch: {error}");
continue;
}
};
if relay_pod_ready(&pod, relay) {
return pod
.metadata
.name
.ok_or_else(|| "Pod has no name".to_string());
}
}
Err("Expose pod watch ended before readiness".to_string())
};
match cancellation {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => Err(format!("Expose startup cancelled for config {config_id}")),
result = tokio::time::timeout(Duration::from_secs(120), watch) => result
.map_err(|_| "Timed out waiting for expose pod readiness".to_string())?,
},
None => tokio::time::timeout(Duration::from_secs(120), watch)
.await
.map_err(|_| "Timed out waiting for expose pod readiness".to_string())?,
}
}
async fn get_pod_ip(client: &Client, namespace: &str, pod_name: &str) -> Result<String, String> {
let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
let pod = tokio::time::timeout(CREATE_REQUEST_TIMEOUT, pods.get(pod_name))
.await
.map_err(|_| format!("Timed out after {CREATE_REQUEST_TIMEOUT:?} reading the relay pod"))?
.map_err(|e| format!("Failed to get pod: {}", e))?;
let pod_ip = pod.status.and_then(|s| s.pod_ip).ok_or("Pod has no IP")?;
Ok(pod_ip)
}
async fn create_service(
client: &Client, namespace: &str, service_name: &str, config_id: &str, local_port: u16,
mode: DatabaseMode,
) -> Result<CreatedResource, ExposeCreateError> {
let services: Api<Service> = Api::namespaced(client.clone(), namespace);
let mut values = HashMap::new();
values.insert("service_name", service_name.to_string());
values.insert("namespace", namespace.to_string());
values.insert("config_id", config_id.to_string());
values.insert("local_port", local_port.to_string());
let template = templates::load_service_template()?;
let rendered = templates::render_template(&template, &values);
let mut service: Service =
serde_json::from_str(&rendered).map_err(|e| format!("Failed to parse service: {}", e))?;
tag_expose_ownership(&mut service.metadata.labels, config_id, mode).await?;
if let Some(selector) = service
.spec
.as_mut()
.and_then(|spec| spec.selector.as_mut())
.filter(|selector| !selector.is_empty())
{
let mut ownership = None;
tag_expose_ownership(&mut ownership, config_id, mode).await?;
selector.extend(ownership.unwrap_or_default());
}
let created = create_bounded(&services, ResourceKind::Service, &service).await?;
info!("Service created successfully");
Ok(created)
}
async fn create_ingress(
client: &Client, namespace: &str, ingress_name: &str, service_name: &str, config: &Config,
location: &ExposeLocation, mode: DatabaseMode,
) -> Result<CreatedResource, ExposeCreateError> {
let ingresses: Api<Ingress> = Api::namespaced(client.clone(), namespace);
let domain = config
.alias
.as_ref()
.ok_or("Domain not configured for public exposure (set alias field)")?;
let config_id_str = config
.id
.map_or_else(|| "default".to_string(), |id| id.to_string());
let local_port = config.local_port.unwrap_or(8080);
let cert_manager_enabled = config.cert_manager_enabled.unwrap_or(false);
let annotations = templates::build_ingress_annotations(
cert_manager_enabled,
config.cert_issuer.as_deref(),
config.cert_issuer_kind.as_deref(),
config.ingress_annotations.as_deref(),
);
let tls = templates::build_tls_section(cert_manager_enabled, domain, &config_id_str);
let ingress_class_name = templates::build_ingress_class_name(config.ingress_class.as_deref());
let mut values = HashMap::new();
values.insert("ingress_name", ingress_name.to_string());
values.insert("namespace", namespace.to_string());
values.insert("service_name", service_name.to_string());
values.insert("domain", domain.clone());
values.insert("config_id", config_id_str.clone());
values.insert("local_port", local_port.to_string());
values.insert("annotations", annotations);
values.insert("tls", tls);
values.insert("ingress_class_name", ingress_class_name);
let template = templates::load_ingress_template()?;
let rendered = templates::render_template(&template, &values);
let mut ingress: Ingress =
serde_json::from_str(&rendered).map_err(|e| format!("Failed to parse ingress: {}", e))?;
tag_expose_ownership(&mut ingress.metadata.labels, &config_id_str, mode).await?;
remember_ingress_created(&config_id_str, location, mode).await?;
match create_bounded(&ingresses, ResourceKind::Ingress, &ingress).await {
Ok(created) => {
info!("Created ingress");
Ok(created)
}
Err(error) => {
if !error.ambiguous {
forget_ingress_history(&config_id_str, location, mode).await;
return Err(error);
}
match resolve_ambiguous_ingress_create(
&ingresses,
ingress_name,
&config_id_str,
location,
mode,
)
.await
{
Ok(IngressCreateResolution::Adopted(adopted)) => Ok(adopted),
Ok(IngressCreateResolution::StillAmbiguous) => Err(error),
Ok(IngressCreateResolution::OwnedByOther) => Err(ExposeCreateError {
message: error.message,
ambiguous: false,
rolled_back: error.rolled_back,
}),
Err(verify_error) => {
warn!(
"Failed to verify ambiguous ingress create for config \
{config_id_str}: {verify_error}"
);
Err(error)
}
}
}
}
}
enum IngressCreateResolution {
Adopted(CreatedResource),
OwnedByOther,
StillAmbiguous,
}
async fn resolve_ambiguous_ingress_create(
ingresses: &Api<Ingress>, ingress_name: &str, config_id: &str, location: &ExposeLocation,
mode: DatabaseMode,
) -> Result<IngressCreateResolution, String> {
let found = ingresses
.get_opt(ingress_name)
.await
.map_err(|error| error.to_string())?;
let Some(ingress) = found else {
return Ok(IngressCreateResolution::StillAmbiguous);
};
let owner_identity = kftray_commons::utils::config_dir::owner_identity(mode).await?;
let owned = ingress.metadata.labels.as_ref().is_some_and(|labels| {
labels.get("config_id").map(String::as_str) == Some(config_id)
&& labels
.get(crate::kube::proxy::INSTALLATION_LABEL)
.map(String::as_str)
== Some(owner_identity.as_str())
});
if !owned {
forget_ingress_history(config_id, location, mode).await;
return Ok(IngressCreateResolution::OwnedByOther);
}
Ok(IngressCreateResolution::Adopted(CreatedResource {
kind: ResourceKind::Ingress,
name: ingress.metadata.name.clone().unwrap_or_default(),
uid: ingress.metadata.uid,
}))
}
fn ingress_history_key(config_id: &str, location: &ExposeLocation) -> String {
let scope = crate::kube::stop::stable_digest(&[
Some(location.cluster.as_str()),
Some(location.namespace.as_str()),
]);
format!("expose_ingress_created:{config_id}:{scope:016x}")
}
#[derive(Clone)]
pub struct ExposeLocation {
pub cluster: String,
pub namespace: String,
}
impl ExposeLocation {
pub fn resolve(cluster_url: &http::Uri, namespace: &str) -> Self {
Self {
cluster: crate::kube::client::cluster_identity(cluster_url),
namespace: namespace.to_owned(),
}
}
}
async fn remember_ingress_created(
config_id: &str, location: &ExposeLocation, mode: DatabaseMode,
) -> Result<(), String> {
kftray_commons::utils::settings::set_setting_with_mode(
&ingress_history_key(config_id, location),
"1",
mode,
)
.await
.map_err(|error| {
format!("Failed to record the ingress history for config {config_id}: {error}")
})
}
pub async fn ingress_was_created(
config_id: &str, location: &ExposeLocation, mode: DatabaseMode,
) -> bool {
match kftray_commons::utils::settings::get_setting_with_mode(
&ingress_history_key(config_id, location),
mode,
)
.await
{
Ok(value) => value.is_some(),
Err(error) => {
log::warn!("Could not read the ingress history for config {config_id}: {error}");
true
}
}
}
async fn forget_ingress_history(config_id: &str, location: &ExposeLocation, mode: DatabaseMode) {
if let Err(error) = kftray_commons::utils::settings::delete_setting_with_mode(
&ingress_history_key(config_id, location),
mode,
)
.await
{
log::debug!("Failed to clear the ingress history for config {config_id}: {error}");
}
}
fn legacy_exposure_key(config_id: &str, mode: DatabaseMode) -> String {
kftray_commons::utils::settings::expose_legacy_key(config_id, mode)
}
fn mode_scope(mode: DatabaseMode) -> &'static str {
kftray_commons::utils::settings::mode_scope(mode)
}
async fn ensure_expose_history_baseline(mode: DatabaseMode) -> Result<(), String> {
let context = kftray_commons::utils::db_mode::DatabaseManager::get_context(mode).await?;
kftray_commons::utils::settings::establish_expose_history_baseline(&context.pool, mode)
.await
.map_err(|error| error.to_string())
}
fn legacy_verified_key(config_id: &str, location: &ExposeLocation, mode: DatabaseMode) -> String {
let scope = crate::kube::stop::stable_digest(&[
Some(location.cluster.as_str()),
Some(location.namespace.as_str()),
]);
format!(
"expose_legacy_verified:{}:{config_id}:{scope:016x}",
mode_scope(mode)
)
}
async fn legacy_exposure_possible(
config_id: &str, location: &ExposeLocation, mode: DatabaseMode,
) -> bool {
use kftray_commons::utils::settings::get_setting_with_mode;
let marked = match get_setting_with_mode(&legacy_exposure_key(config_id, mode), mode).await {
Ok(value) => value.is_some(),
Err(error) => {
log::warn!("Could not read the exposure baseline for config {config_id}: {error}");
return true;
}
};
if !marked {
return false;
}
match get_setting_with_mode(&legacy_verified_key(config_id, location, mode), mode).await {
Ok(value) => value.is_none(),
Err(error) => {
log::warn!("Could not read the exposure verification for config {config_id}: {error}");
true
}
}
}
async fn record_legacy_verified(config_id: &str, location: &ExposeLocation, mode: DatabaseMode) {
if let Err(error) = kftray_commons::utils::settings::set_setting_with_mode(
&legacy_verified_key(config_id, location, mode),
"1",
mode,
)
.await
{
log::debug!("Failed to record the exposure verification for config {config_id}: {error}");
}
}
fn named_items<T: kube::Resource + Clone>(
list: &kube::core::ObjectList<T>, kind: &str,
) -> Vec<String> {
list.items
.iter()
.filter_map(|item| item.meta().name.clone())
.map(|name| format!("{kind}/{name}"))
.collect()
}
pub async fn delete_expose_resources(
client: Client, namespace: &str, config_id_label: &str, ingress_possible: bool,
location: &ExposeLocation, mode: DatabaseMode,
) -> Result<(), String> {
if let Err(error) = ensure_expose_history_baseline(mode).await {
warn!("Could not establish the exposure history baseline: {error}");
}
let mut legacy_possible = false;
let ingress_possible =
ingress_possible || ingress_was_created(config_id_label, location, mode).await || {
legacy_possible = legacy_exposure_possible(config_id_label, location, mode).await;
legacy_possible
};
let lp = ListParams::default().labels(&expose_owner_selector(config_id_label, mode).await?);
info!(
"Deleting expose resources for config_id label '{}'",
config_id_label
);
let (ingresses, services, deployments) = tokio::join!(
delete_ingresses(&client, namespace, &lp, ingress_possible),
delete_services(&client, namespace, &lp),
delete_deployments(&client, namespace, &lp)
);
let mut errors: Vec<String> = [ingresses, services, deployments]
.into_iter()
.filter_map(Result::err)
.collect();
if errors.is_empty()
&& let Err(error) = wait_until_gone(&client, namespace, &lp, ingress_possible).await
{
errors.push(error);
}
let unlabelled = ListParams::default().labels(&format!(
"app=kftray-expose,config_id={config_id_label},!{}",
crate::kube::proxy::INSTALLATION_LABEL
));
let deployments: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let services: Api<Service> = Api::namespaced(client.clone(), namespace);
let ingresses: Api<Ingress> = Api::namespaced(client.clone(), namespace);
let mut leftovers: Vec<String> = Vec::new();
match deployments.list(&unlabelled).await {
Ok(list) => leftovers.extend(named_items(&list, "deployment")),
Err(error) => errors.push(format!(
"Failed to list earlier exposure deployments: {error}"
)),
}
match services.list(&unlabelled).await {
Ok(list) => leftovers.extend(named_items(&list, "service")),
Err(error) => errors.push(format!("Failed to list earlier exposure services: {error}")),
}
match ingresses.list(&unlabelled).await {
Ok(list) => leftovers.extend(named_items(&list, "ingress")),
Err(kube::Error::Api(response))
if matches!(response.code, 403 | 404) && !ingress_possible => {}
Err(error) => errors.push(format!(
"Failed to list earlier exposure ingresses: {error}"
)),
}
if !leftovers.is_empty() {
errors.push(format!(
"Exposure resources from an earlier version are still running and cannot be \
attributed to this installation: {}. Remove them from the server resources screen.",
leftovers.join(", ")
));
}
if !errors.is_empty() {
return Err(errors.join("; "));
}
forget_ingress_history(config_id_label, location, mode).await;
if legacy_possible {
record_legacy_verified(config_id_label, location, mode).await;
}
info!(
"Successfully deleted expose resources for config_id label '{}'",
config_id_label
);
Ok(())
}
fn owned_delete_params(uid: Option<String>) -> DeleteParams {
DeleteParams {
preconditions: uid.map(|uid| kube::api::Preconditions {
uid: Some(uid),
resource_version: None,
}),
..DeleteParams::default()
}
}
async fn delete_ingresses(
client: &Client, namespace: &str, lp: &ListParams, ingress_possible: bool,
) -> Result<(), String> {
let api: Api<Ingress> = Api::namespaced(client.clone(), namespace);
let items = match api.list(lp).await {
Ok(list) => list,
Err(kube::Error::Api(response))
if !ingress_possible && (response.code == 403 || response.code == 404) =>
{
debug!("Skipping ingress cleanup: {}", response.message);
return Ok(());
}
Err(kube::Error::Api(response)) if response.code == 403 => {
return Err(format!(
"Cannot verify that no public ingress is left for this configuration: listing \
ingresses is not allowed ({}). Grant list access on ingresses once, or remove \
any ingress this configuration created from the server resources screen",
response.message
));
}
Err(e) => {
return Err(format!("Failed to list expose ingresses: {e}"));
}
};
if items.items.is_empty() {
debug!("No ingresses found to delete");
return Ok(());
}
let mut errors = Vec::new();
for ingress in items.items {
if let Some(name) = &ingress.metadata.name {
info!("Deleting ingress: {}", name);
match api
.delete(name, &owned_delete_params(ingress.metadata.uid.clone()))
.await
{
Ok(_) => info!("Ingress {} deleted successfully", name),
Err(e) if matches!(&e, kube::Error::Api(response) if response.code == 404) => {}
Err(e) => errors.push(format!("Failed to delete ingress {name}: {e}")),
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
async fn delete_services(client: &Client, namespace: &str, lp: &ListParams) -> Result<(), String> {
let api: Api<Service> = Api::namespaced(client.clone(), namespace);
let items = match api.list(lp).await {
Ok(list) => list,
Err(e) => {
return Err(format!("Failed to list expose services: {e}"));
}
};
if items.items.is_empty() {
debug!("No services found to delete");
return Ok(());
}
let mut errors = Vec::new();
for service in items.items {
if let Some(name) = &service.metadata.name {
info!("Deleting service: {}", name);
match api
.delete(name, &owned_delete_params(service.metadata.uid.clone()))
.await
{
Ok(_) => info!("Service {} deleted successfully", name),
Err(e) if matches!(&e, kube::Error::Api(response) if response.code == 404) => {}
Err(e) => errors.push(format!("Failed to delete service {name}: {e}")),
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
async fn wait_until_gone(
client: &Client, namespace: &str, lp: &ListParams, ingress_possible: bool,
) -> Result<(), String> {
let deadline = tokio::time::Instant::now() + ROLLBACK_DELETION_TIMEOUT;
let mut poll_interval = ROLLBACK_DELETION_POLL;
loop {
let deployments: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let services: Api<Service> = Api::namespaced(client.clone(), namespace);
let ingresses: Api<Ingress> = Api::namespaced(client.clone(), namespace);
let mut remaining = names_matching(&deployments, lp)
.await
.map_err(|error| error.to_string())?;
remaining.extend(
names_matching(&services, lp)
.await
.map_err(|error| error.to_string())?,
);
match names_matching(&ingresses, lp).await {
Ok(names) => remaining.extend(names),
Err(kube::Error::Api(response))
if !ingress_possible && matches!(response.code, 403 | 404) =>
{
debug!("Skipping ingress verification: {}", response.message);
}
Err(error) => return Err(error.to_string()),
}
if remaining.is_empty() {
return Ok(());
}
if tokio::time::Instant::now() >= deadline {
return Err(format!(
"Exposure resources are still terminating after {ROLLBACK_DELETION_TIMEOUT:?}: {}",
remaining.join(", ")
));
}
tokio::time::sleep(poll_interval).await;
poll_interval = (poll_interval * 2).min(std::time::Duration::from_secs(2));
}
}
async fn names_matching<K>(api: &Api<K>, lp: &ListParams) -> Result<Vec<String>, kube::Error>
where
K: Clone + serde::de::DeserializeOwned + std::fmt::Debug + kube::Resource,
{
Ok(api
.list(lp)
.await?
.items
.iter()
.filter_map(|item| item.meta().name.clone())
.collect())
}
async fn delete_deployments(
client: &Client, namespace: &str, lp: &ListParams,
) -> Result<(), String> {
let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
let items = match api.list(lp).await {
Ok(list) => list,
Err(e) => {
return Err(format!("Failed to list expose deployments: {e}"));
}
};
if items.items.is_empty() {
debug!("No deployments found to delete");
return Ok(());
}
let mut errors = Vec::new();
for deployment in items.items {
if let Some(name) = &deployment.metadata.name {
info!("Deleting deployment: {}", name);
let dp = DeleteParams {
propagation_policy: Some(kube::api::PropagationPolicy::Foreground),
..owned_delete_params(deployment.metadata.uid.clone())
};
match api.delete(name, &dp).await {
Ok(_) => info!("Deployment {} deleted successfully", name),
Err(e) if matches!(&e, kube::Error::Api(response) if response.code == 404) => {}
Err(e) => errors.push(format!("Failed to delete deployment {name}: {e}")),
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
#[cfg(test)]
mod tests {
use http::{
Method,
Request,
Response,
};
use kube::client::Body;
use tower_test::mock;
use super::*;
fn relay_container(env: Vec<k8s_openapi::api::core::v1::EnvVar>) -> Container {
Container {
name: "kftray-server".to_owned(),
env: Some(env),
..Default::default()
}
}
#[test]
fn probe_ports_follow_a_customized_literal_value() {
let container = relay_container(vec![k8s_openapi::api::core::v1::EnvVar {
name: "HTTP_PORT".to_owned(),
value: Some("9100".to_owned()),
..Default::default()
}]);
assert_eq!(
container_env_port(&container, "HTTP_PORT", 8080),
Some(9100)
);
assert_eq!(
container_env_port(&container, "WEBSOCKET_PORT", 9999),
Some(9999),
"an absent variable still means the template default"
);
}
#[test]
fn no_probe_is_injected_for_a_dynamically_supplied_port() {
let from_config_map = relay_container(vec![k8s_openapi::api::core::v1::EnvVar {
name: "HTTP_PORT".to_owned(),
value_from: Some(k8s_openapi::api::core::v1::EnvVarSource {
config_map_key_ref: Some(k8s_openapi::api::core::v1::ConfigMapKeySelector {
name: "ports".to_owned(),
key: "http".to_owned(),
..Default::default()
}),
..Default::default()
}),
..Default::default()
}]);
assert_eq!(
container_env_port(&from_config_map, "HTTP_PORT", 8080),
None
);
let mut from_env_from = relay_container(Vec::new());
from_env_from.env_from = Some(vec![k8s_openapi::api::core::v1::EnvFromSource {
config_map_ref: Some(k8s_openapi::api::core::v1::ConfigMapEnvSource {
name: "ports".to_owned(),
..Default::default()
}),
..Default::default()
}]);
assert_eq!(container_env_port(&from_env_from, "HTTP_PORT", 8080), None);
let mut explicit_alongside_env_from =
relay_container(vec![k8s_openapi::api::core::v1::EnvVar {
name: "HTTP_PORT".to_owned(),
value: Some("9100".to_owned()),
..Default::default()
}]);
explicit_alongside_env_from.env_from = from_env_from.env_from.clone();
assert_eq!(
container_env_port(&explicit_alongside_env_from, "HTTP_PORT", 8080),
Some(9100),
"an explicit env entry takes precedence over envFrom, as in Kubernetes"
);
}
#[tokio::test]
async fn an_out_of_range_websocket_port_fails_the_deployment_create() {
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
let manifest_path =
kftray_commons::utils::config_dir::get_expose_deployment_manifest_path().unwrap();
let manifest = r#"{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "{deployment_name}",
"namespace": "{namespace}",
"labels": {"app": "kftray-expose", "config_id": "{config_id}"}
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"template": {
"metadata": {"labels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"spec": {
"terminationGracePeriodSeconds": 10,
"containers": [{
"name": "kftray-server",
"image": "ghcr.io/hcavarsan/kftray-server:latest",
"env": [
{"name": "PROXY_TYPE", "value": "reverse_http"},
{"name": "HTTP_PORT", "value": "8080"},
{"name": "WEBSOCKET_PORT", "value": "70000"},
{"name": "REMOTE_ADDRESS", "value": "localhost"},
{"name": "REMOTE_PORT", "value": "{local_port}"},
{"name": "LOCAL_PORT", "value": "{local_port}"},
{"name": "RUST_LOG", "value": "DEBUG"}
],
"ports": [
{"containerPort": 8080, "name": "http"},
{"containerPort": 9999, "name": "websocket"}
]
}]
}
}
}
}"#;
std::fs::write(&manifest_path, manifest).unwrap();
let config = Config {
id: Some(4001),
namespace: "default".to_owned(),
local_port: Some(8080),
..Config::default()
};
let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let result =
create_deployment(&client, "default", "myapp-deploy", "4001", &config, mode).await;
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error = result.expect_err(
"a WEBSOCKET_PORT outside u16 range must fail the create before any request is sent",
);
assert!(
error.message.contains("WEBSOCKET_PORT"),
"the error must name the offending variable: {}",
error.message
);
assert!(
!error.ambiguous,
"a template validation failure happens before any request, so it is a definitive \
rejection"
);
}
#[tokio::test]
async fn an_unresolved_websocket_port_fails_the_deployment_create() {
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
let manifest_path =
kftray_commons::utils::config_dir::get_expose_deployment_manifest_path().unwrap();
let manifest = r#"{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "{deployment_name}",
"namespace": "{namespace}",
"labels": {"app": "kftray-expose", "config_id": "{config_id}"}
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"template": {
"metadata": {"labels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"spec": {
"terminationGracePeriodSeconds": 10,
"containers": [{
"name": "kftray-server",
"image": "ghcr.io/hcavarsan/kftray-server:latest",
"env": [
{"name": "PROXY_TYPE", "value": "reverse_http"},
{"name": "HTTP_PORT", "value": "8080"},
{"name": "WEBSOCKET_PORT", "valueFrom": {"configMapKeyRef": {"name": "ports", "key": "ws"}}},
{"name": "REMOTE_ADDRESS", "value": "localhost"},
{"name": "REMOTE_PORT", "value": "{local_port}"},
{"name": "LOCAL_PORT", "value": "{local_port}"},
{"name": "RUST_LOG", "value": "DEBUG"}
],
"ports": [
{"containerPort": 8080, "name": "http"},
{"containerPort": 9999, "name": "websocket"}
]
}]
}
}
}
}"#;
std::fs::write(&manifest_path, manifest).unwrap();
let config = Config {
id: Some(4002),
namespace: "default".to_owned(),
local_port: Some(8080),
..Config::default()
};
let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let result =
create_deployment(&client, "default", "myapp-deploy", "4002", &config, mode).await;
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error = result.expect_err(
"a WEBSOCKET_PORT this cannot resolve must fail the create instead of keeping 9999 \
as the tunnel target",
);
assert!(
error.message.contains("WEBSOCKET_PORT"),
"the error must name the offending variable: {}",
error.message
);
assert!(
!error.ambiguous,
"a template validation failure happens before any request, so it is a definitive \
rejection"
);
}
#[tokio::test]
async fn a_zero_websocket_port_fails_the_deployment_create() {
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
let manifest_path =
kftray_commons::utils::config_dir::get_expose_deployment_manifest_path().unwrap();
let manifest = r#"{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "{deployment_name}",
"namespace": "{namespace}",
"labels": {"app": "kftray-expose", "config_id": "{config_id}"}
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"template": {
"metadata": {"labels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"spec": {
"terminationGracePeriodSeconds": 10,
"containers": [{
"name": "kftray-server",
"image": "ghcr.io/hcavarsan/kftray-server:latest",
"env": [
{"name": "PROXY_TYPE", "value": "reverse_http"},
{"name": "HTTP_PORT", "value": "8080"},
{"name": "WEBSOCKET_PORT", "value": "0"},
{"name": "REMOTE_ADDRESS", "value": "localhost"},
{"name": "REMOTE_PORT", "value": "{local_port}"},
{"name": "LOCAL_PORT", "value": "{local_port}"},
{"name": "RUST_LOG", "value": "DEBUG"}
],
"ports": [
{"containerPort": 8080, "name": "http"},
{"containerPort": 9999, "name": "websocket"}
]
}]
}
}
}
}"#;
std::fs::write(&manifest_path, manifest).unwrap();
let config = Config {
id: Some(4003),
namespace: "default".to_owned(),
local_port: Some(8080),
..Config::default()
};
let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let result =
create_deployment(&client, "default", "myapp-deploy", "4003", &config, mode).await;
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error =
result.expect_err("WEBSOCKET_PORT 0 is not a usable port and must fail the create");
assert!(
error.message.contains("WEBSOCKET_PORT"),
"the error must name the offending variable: {}",
error.message
);
}
#[tokio::test]
async fn no_identifiable_relay_container_fails_the_deployment_create() {
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
let manifest_path =
kftray_commons::utils::config_dir::get_expose_deployment_manifest_path().unwrap();
let manifest = r#"{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "{deployment_name}",
"namespace": "{namespace}",
"labels": {"app": "kftray-expose", "config_id": "{config_id}"}
},
"spec": {
"replicas": 1,
"selector": {"matchLabels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"template": {
"metadata": {"labels": {"app": "kftray-expose", "config_id": "{config_id}"}},
"spec": {
"terminationGracePeriodSeconds": 10,
"containers": [{
"name": "custom-relay",
"image": "ghcr.io/hcavarsan/kftray-server:latest",
"env": [
{"name": "REMOTE_ADDRESS", "value": "localhost"},
{"name": "REMOTE_PORT", "value": "{local_port}"},
{"name": "LOCAL_PORT", "value": "{local_port}"}
]
}]
}
}
}
}"#;
std::fs::write(&manifest_path, manifest).unwrap();
let config = Config {
id: Some(4004),
namespace: "default".to_owned(),
local_port: Some(8080),
..Config::default()
};
let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let result =
create_deployment(&client, "default", "myapp-deploy", "4004", &config, mode).await;
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
result.expect_err("a deployment with no identifiable relay container must fail the create");
}
async fn created_relay_container(config: &Config) -> (Container, RelayProbe) {
use http_body_util::BodyExt;
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
kftray_commons::utils::manifests::create_expose_deployment_manifest().unwrap();
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::POST);
let body = request.into_body().collect().await.unwrap().to_bytes();
let posted: Deployment = serde_json::from_slice(&body).unwrap();
let created = serde_json::json!({
"apiVersion": "apps/v1", "kind": "Deployment",
"metadata": {"name": "relay-1", "namespace": "default", "uid": "deploy-uid"}
});
send.send_response(
Response::builder()
.status(201)
.body(Body::from(serde_json::to_vec(&created).unwrap()))
.unwrap(),
);
posted
}));
let result = create_deployment(
&client,
"default",
"relay-1",
"4005",
config,
DatabaseMode::Memory,
)
.await;
let posted = server.await.unwrap();
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let (_, relay) = result.unwrap_or_else(|error| panic!("{}", error.message));
let container = posted
.spec
.unwrap()
.template
.spec
.unwrap()
.containers
.remove(0);
(container, relay)
}
#[tokio::test]
async fn a_tcp_expose_relay_is_not_readiness_probed_on_its_client_port() {
let mut config = Config {
id: Some(4005),
workload_type: Some("expose".to_owned()),
exposure_type: Some("private".to_owned()),
namespace: "default".to_owned(),
local_port: Some(5432),
..Config::default()
};
let (http_relay, http_probe) = created_relay_container(&config).await;
assert_eq!(
http_relay
.readiness_probe
.and_then(|probe| probe.tcp_socket)
.map(|socket| socket.port),
Some(IntOrString::Int(8080))
);
assert!(http_probe.readiness_probe_present);
config.exposure_protocol = Some("tcp".to_owned());
let (tcp_relay, tcp_probe) = created_relay_container(&config).await;
assert!(
tcp_relay.readiness_probe.is_none(),
"every probe connection on HTTP_PORT opens a connection to the local service: {:?}",
tcp_relay.readiness_probe
);
assert!(!tcp_probe.readiness_probe_present);
assert!(tcp_relay.startup_probe.is_some());
}
#[test]
fn expose_location_stores_the_canonical_cluster_identity() {
let with_default_port: http::Uri = "https://host:443/".parse().unwrap();
let without_port: http::Uri = "https://host".parse().unwrap();
assert_eq!(
ExposeLocation::resolve(&with_default_port, "default").cluster,
ExposeLocation::resolve(&without_port, "default").cluster,
);
assert_eq!(
ExposeLocation::resolve(&with_default_port, "default").cluster,
"https://host"
);
let ipv6: http::Uri = "https://[2001:db8::1]:6443/".parse().unwrap();
assert_eq!(
ExposeLocation::resolve(&ipv6, "default").cluster,
"https://[2001:db8::1]:6443"
);
}
#[test]
fn pod_readiness_falls_back_to_the_relay_container_without_a_probe() {
let relay = RelayProbe {
container_name: Some("kftray-server".to_owned()),
readiness_probe_present: false,
websocket_port: 9999,
};
let ready: Pod = serde_json::from_value(serde_json::json!({
"metadata": {"name": "relay"},
"status": {
"phase": "Running",
"containerStatuses": [{
"name": "kftray-server", "ready": true, "started": false,
"restartCount": 0, "image": "relay", "imageID": "relay"
}]
}
}))
.unwrap();
assert!(
relay_pod_ready(&ready, &relay),
"no readiness probe means Kubernetes marks the container ready as soon as it runs"
);
let not_ready: Pod = serde_json::from_value(serde_json::json!({
"metadata": {"name": "relay"},
"status": {
"phase": "Running",
"containerStatuses": [{
"name": "kftray-server", "ready": false, "started": false,
"restartCount": 0, "image": "relay", "imageID": "relay"
}]
}
}))
.unwrap();
assert!(!relay_pod_ready(¬_ready, &relay));
}
#[test]
fn pod_readiness_requires_the_aggregate_condition_when_a_probe_is_present() {
let relay = RelayProbe {
container_name: Some("kftray-server".to_owned()),
readiness_probe_present: true,
websocket_port: 9999,
};
let sidecar_not_ready: Pod = serde_json::from_value(serde_json::json!({
"metadata": {"name": "relay"},
"status": {
"phase": "Running",
"conditions": [{"type": "Ready", "status": "False"}],
"containerStatuses": [
{"name": "kftray-server", "ready": true, "started": true,
"restartCount": 0, "image": "relay", "imageID": "relay"},
{"name": "sidecar", "ready": false, "started": true,
"restartCount": 0, "image": "sidecar", "imageID": "sidecar"}
]
}
}))
.unwrap();
assert!(!relay_pod_ready(&sidecar_not_ready, &relay));
let all_ready: Pod = serde_json::from_value(serde_json::json!({
"metadata": {"name": "relay"},
"status": {
"phase": "Running",
"conditions": [{"type": "Ready", "status": "True"}],
"containerStatuses": [
{"name": "kftray-server", "ready": true, "started": true,
"restartCount": 0, "image": "relay", "imageID": "relay"}
]
}
}))
.unwrap();
assert!(relay_pod_ready(&all_ready, &relay));
}
#[tokio::test]
async fn cleanup_attempts_all_resources_and_ignores_not_found() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let mut deleted = std::collections::HashSet::new();
while deleted.len() < 9 {
let (request, send) = handle.next_request().await.unwrap();
let name = request.uri().path().rsplit('/').next().unwrap();
let (status, body) = if request.method() == Method::GET {
let items: Vec<_> = ["missing", "broken", "ok"].into_iter()
.map(|suffix| serde_json::json!({"metadata":{"name":format!("{name}-{suffix}")}}))
.collect();
(200, serde_json::json!({"items":items}))
} else {
assert_eq!(request.method(), Method::DELETE);
assert!(deleted.insert(name.to_string()));
let status = if name.ends_with("-missing") {
404
} else if name.ends_with("-broken") {
500
} else {
200
};
(
status,
serde_json::json!({
"apiVersion":"v1", "kind":"Status", "status":if status==200 {"Success"} else {"Failure"},
"reason":if status==404 {"NotFound"} else {"InternalError"},
"message":name, "code":status
}),
)
};
send.send_response(
Response::builder()
.status(status)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
);
}
deleted
}));
let (result, deleted) = tokio::time::timeout(std::time::Duration::from_secs(2), async {
let location =
ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
let result = delete_expose_resources(
client,
"default",
"42",
true,
&location,
DatabaseMode::Memory,
)
.await;
(result, server.await.unwrap())
})
.await
.unwrap();
let error = result.expect_err("failed deletions must be reported");
for kind in ["ingresses", "services", "deployments"] {
for suffix in ["missing", "broken", "ok"] {
assert!(deleted.contains(&format!("{kind}-{suffix}")));
}
assert!(error.contains(&format!("{kind}-broken")), "{error}");
assert!(!error.contains(&format!("{kind}-missing")), "{error}");
assert!(!error.contains(&format!("{kind}-ok")), "{error}");
}
}
fn deny_ingress_listing(
mut handle: mock::Handle<Request<Body>, Response<Body>>,
) -> tokio_util::task::AbortOnDropHandle<()> {
tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
while let Some((request, send)) = handle.next_request().await {
assert_eq!(request.method(), Method::GET);
let (status, body) = if request.uri().path().contains("/ingresses") {
(
403,
serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"Forbidden","message":"ingresses is forbidden","code":403
}),
)
} else {
(200, serde_json::json!({"items":[]}))
};
send.send_response(
Response::builder()
.status(status)
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
);
}
}))
}
#[tokio::test]
async fn a_pre_history_exposure_cannot_start_privately_without_verifying_its_ingress() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let legacy = kftray_commons::utils::config::insert_config_with_mode(
Config {
workload_type: Some("expose".to_owned()),
exposure_type: Some("private".to_owned()),
alias: Some("myapp".to_owned()),
namespace: "default".to_owned(),
..Config::default()
},
mode,
)
.await
.unwrap();
kftray_commons::utils::settings::set_setting_with_mode(
&legacy_exposure_key(&legacy.to_string(), mode),
"1",
mode,
)
.await
.unwrap();
let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let _server = deny_ingress_listing(handle);
let location = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
let error = tokio::time::timeout(
std::time::Duration::from_secs(2),
delete_expose_resources(
client.clone(),
"default",
&legacy.to_string(),
false,
&location,
mode,
),
)
.await
.unwrap()
.expect_err("a refusal to list ingresses cannot be read as absence for this exposure");
assert!(error.contains("Cannot verify"), "{error}");
let (allowing, handle) = mock::pair::<Request<Body>, Response<Body>>();
let allowing = kube::Client::new(allowing, "default");
let _permissive = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let mut handle = handle;
while let Some((_, send)) = handle.next_request().await {
send.send_response(
Response::builder()
.status(200)
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"items":[]})).unwrap(),
))
.unwrap(),
);
}
}));
let verified_ns =
ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "verified");
tokio::time::timeout(
std::time::Duration::from_secs(2),
delete_expose_resources(
allowing,
"verified",
&legacy.to_string(),
false,
&verified_ns,
mode,
),
)
.await
.unwrap()
.expect("a successful listing verifies this destination");
tokio::time::timeout(
std::time::Duration::from_secs(2),
delete_expose_resources(
client.clone(),
"verified",
&legacy.to_string(),
false,
&verified_ns,
mode,
),
)
.await
.unwrap()
.expect("a verified destination tolerates the refusal from then on");
let other_ns = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "other");
tokio::time::timeout(
std::time::Duration::from_secs(2),
delete_expose_resources(
client.clone(),
"other",
&legacy.to_string(),
false,
&other_ns,
mode,
),
)
.await
.unwrap()
.expect_err("verification in one namespace says nothing about another");
let fresh = kftray_commons::utils::config::insert_config_with_mode(
Config {
workload_type: Some("expose".to_owned()),
exposure_type: Some("private".to_owned()),
alias: Some("other".to_owned()),
namespace: "default".to_owned(),
..Config::default()
},
mode,
)
.await
.unwrap();
tokio::time::timeout(
std::time::Duration::from_secs(2),
delete_expose_resources(
client,
"default",
&fresh.to_string(),
false,
&location,
mode,
),
)
.await
.unwrap()
.expect("a private exposure with a complete history tolerates the refusal");
for id in [legacy, fresh] {
let _ = kftray_commons::utils::config::delete_config_with_mode(id, mode).await;
}
}
#[tokio::test]
async fn a_confirmed_ingress_rollback_clears_its_history() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_id = "2002";
let location = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
remember_ingress_created(config_id, &location, mode)
.await
.unwrap();
assert!(ingress_was_created(config_id, &location, mode).await);
let created = vec![CreatedResource {
kind: ResourceKind::Ingress,
name: "myapp".to_owned(),
uid: Some("abc-uid".to_owned()),
}];
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::DELETE);
let status = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Success","code":200
});
send.send_response(
Response::builder()
.status(200)
.body(Body::from(serde_json::to_vec(&status).unwrap()))
.unwrap(),
);
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::GET);
let not_found = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"NotFound","message":"myapp","code":404
});
send.send_response(
Response::builder()
.status(404)
.body(Body::from(serde_json::to_vec(¬_found).unwrap()))
.unwrap(),
);
}));
let (cleaned, _message) = tokio::time::timeout(
std::time::Duration::from_secs(2),
rollback_created_resources(
&client,
"default",
&created,
config_id,
&location,
mode,
"reason".to_owned(),
),
)
.await
.unwrap();
server.await.unwrap();
assert!(cleaned, "the mocked delete and confirmation both succeeded");
assert!(
!ingress_was_created(config_id, &location, mode).await,
"a confirmed UID-scoped delete proves no ingress is left, so a later private start \
must not need ingress-list rights just to rule this exposure out"
);
}
#[tokio::test]
async fn a_409_on_ingress_create_keeps_its_history() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
kftray_commons::utils::manifests::create_expose_ingress_manifest().unwrap();
let config_id = "3003";
let location = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
let config = Config {
id: Some(3003),
namespace: "default".to_owned(),
alias: Some("myapp.example.com".to_owned()),
local_port: Some(8080),
..Config::default()
};
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::POST);
let conflict = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"AlreadyExists","message":"ingresses.networking.k8s.io \"myapp\" \
already exists","code":409
});
send.send_response(
Response::builder()
.status(409)
.body(Body::from(serde_json::to_vec(&conflict).unwrap()))
.unwrap(),
);
}));
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
create_ingress(
&client,
"default",
"myapp",
"myapp-svc",
&config,
&location,
mode,
),
)
.await
.unwrap();
server.await.unwrap();
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error = result.expect_err("a 409 must not be reported as a created ingress");
assert!(
error.ambiguous,
"an AlreadyExists response can be this attempt's own request landing late; it must \
not be read as a definitive rejection"
);
assert!(
ingress_was_created(config_id, &location, mode).await,
"a 409 must not wipe the ingress history: the object it names may be the one this \
attempt itself is responsible for"
);
}
#[tokio::test]
async fn an_ambiguous_ingress_create_keeps_history_after_a_notfound() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
kftray_commons::utils::manifests::create_expose_ingress_manifest().unwrap();
let config_id = "3004";
let location = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
let config = Config {
id: Some(3004),
namespace: "default".to_owned(),
alias: Some("myapp2.example.com".to_owned()),
local_port: Some(8080),
..Config::default()
};
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::POST);
let unavailable = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"ServiceUnavailable","message":"etcd timeout","code":503
});
send.send_response(
Response::builder()
.status(503)
.body(Body::from(serde_json::to_vec(&unavailable).unwrap()))
.unwrap(),
);
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::GET);
let not_found = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"NotFound","message":"myapp2","code":404
});
send.send_response(
Response::builder()
.status(404)
.body(Body::from(serde_json::to_vec(¬_found).unwrap()))
.unwrap(),
);
}));
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
create_ingress(
&client,
"default",
"myapp2",
"myapp2-svc",
&config,
&location,
mode,
),
)
.await
.unwrap();
server.await.unwrap();
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error =
result.expect_err("nothing was adopted, so this must still be reported as a failure");
assert!(
error.ambiguous,
"the create itself was never definitively rejected"
);
assert!(
ingress_was_created(config_id, &location, mode).await,
"a NotFound after an ambiguous create is not proof nothing was created (eventual \
consistency, a stale read), so the history must stay for a later check to resolve"
);
}
#[tokio::test]
async fn an_ambiguous_ingress_create_owned_by_another_installation_is_definitive() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
kftray_commons::utils::manifests::create_expose_ingress_manifest().unwrap();
let config_id = "3005";
let location = ExposeLocation::resolve(&"http://127.0.0.1:1".parse().unwrap(), "default");
let config = Config {
id: Some(3005),
namespace: "default".to_owned(),
alias: Some("myapp3.example.com".to_owned()),
local_port: Some(8080),
..Config::default()
};
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::POST);
let unavailable = serde_json::json!({
"apiVersion":"v1","kind":"Status","status":"Failure",
"reason":"ServiceUnavailable","message":"etcd timeout","code":503
});
send.send_response(
Response::builder()
.status(503)
.body(Body::from(serde_json::to_vec(&unavailable).unwrap()))
.unwrap(),
);
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::GET);
let owned_by_other = serde_json::json!({
"apiVersion":"networking.k8s.io/v1","kind":"Ingress",
"metadata":{
"name":"myapp3",
"uid":"other-owner-uid",
"labels":{
"app":"kftray-expose",
"config_id":"9999",
"installation_id":"someone-else"
}
}
});
send.send_response(
Response::builder()
.status(200)
.body(Body::from(serde_json::to_vec(&owned_by_other).unwrap()))
.unwrap(),
);
}));
let result = tokio::time::timeout(
std::time::Duration::from_secs(2),
create_ingress(
&client,
"default",
"myapp3",
"myapp3-svc",
&config,
&location,
mode,
),
)
.await
.unwrap();
server.await.unwrap();
if let Some(original) = original_config_dir {
unsafe { std::env::set_var("KFTRAY_CONFIG", original) };
} else {
unsafe { std::env::remove_var("KFTRAY_CONFIG") };
}
let error = result.expect_err("an object owned by another installation was not adopted");
assert!(
!error.ambiguous,
"an object that exists and belongs to someone else is proof this attempt's create \
did not land for it; the guard must be able to settle instead of staying uncertain \
forever"
);
assert!(
!ingress_was_created(config_id, &location, mode).await,
"the history is cleared once ownership is definitively resolved against someone else"
);
}
#[tokio::test]
async fn a_cancelled_readiness_wait_rolls_back_the_created_deployment() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mode = DatabaseMode::Memory;
let config_dir = tempfile::tempdir().unwrap();
let original_config_dir = std::env::var("KFTRAY_CONFIG").ok();
unsafe { std::env::set_var("KFTRAY_CONFIG", config_dir.path()) };
kftray_commons::utils::manifests::create_expose_deployment_manifest().unwrap();
let config = Config {
id: Some(88_801),
workload_type: Some("expose".to_owned()),
exposure_type: Some("private".to_owned()),
alias: Some("myapp".to_owned()),
namespace: "default".to_owned(),
local_port: Some(8080),
..Config::default()
};
let (mock_service, mut handle) = mock::pair::<Request<Body>, Response<Body>>();
let client = kube::Client::new(mock_service, "default");
let connection = crate::kube::client::KubeConnection {
client: client.clone(),
cluster_url: "http://127.0.0.1:1".parse().unwrap(),
};
let cancellation = CancellationToken::new();
let server_cancellation = cancellation.clone();
let server = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
let (_request, send) = loop {
let (request, send) = handle.next_request().await.unwrap();
if request.method() == Method::POST {
break (request, send);
}
assert_eq!(request.method(), Method::GET);
send.send_response(
Response::builder()
.status(200)
.body(Body::from(
serde_json::to_vec(&serde_json::json!({"items": []})).unwrap(),
))
.unwrap(),
);
};
let deployment = serde_json::json!({
"apiVersion": "apps/v1", "kind": "Deployment",
"metadata": {"name": "relay-1", "namespace": "default", "uid": "deploy-uid"}
});
server_cancellation.cancel();
send.send_response(
Response::builder()
.status(201)
.body(Body::from(serde_json::to_vec(&deployment).unwrap()))
.unwrap(),
);
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::DELETE);
assert!(
request.uri().path().ends_with("/relay-1"),
"{}",
request.uri()
);
let status = serde_json::json!({
"apiVersion": "v1", "kind": "Status", "status": "Success", "code": 200
});
send.send_response(
Response::builder()
.status(200)
.body(Body::from(serde_json::to_vec(&status).unwrap()))
.unwrap(),
);
let (request, send) = handle.next_request().await.unwrap();
assert_eq!(request.method(), Method::GET);
let not_found = serde_json::json!({
"apiVersion": "v1", "kind": "Status", "status": "Failure",
"reason": "NotFound", "message": "relay-1", "code": 404
});
send.send_response(
Response::builder()
.status(404)
.body(Body::from(serde_json::to_vec(¬_found).unwrap()))
.unwrap(),
);
}));
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
create_expose_resources(&connection, &config, mode, Some(&cancellation)),
)
.await
.unwrap();
server.await.unwrap();
match original_config_dir {
Some(val) => unsafe { std::env::set_var("KFTRAY_CONFIG", val) },
None => unsafe { std::env::remove_var("KFTRAY_CONFIG") },
}
let error = result.expect_err("a cancelled readiness wait must not report success");
assert!(
error.rolled_back,
"the deployment created before cancellation must be rolled back: {}",
error.message
);
assert!(error.message.contains("cancelled"), "{}", error.message);
}
}