use std::collections::{
HashMap,
HashSet,
};
use std::sync::Arc;
use std::time::{
Duration,
Instant,
};
use futures::stream::{
self,
StreamExt,
};
use k8s_openapi::api::apps::v1::Deployment;
use k8s_openapi::api::core::v1::Pod;
use kftray_commons::config_model::Config;
use kftray_commons::{
config::get_config_with_mode,
models::{
config_state_model::ConfigState,
response::CustomResponse,
},
utils::{
config::read_configs_with_mode,
config_state::{
get_configs_state_with_mode,
running_snapshot,
try_running_snapshot,
update_config_state_with_mode,
},
db_mode::DatabaseMode,
timeout_manager::cancel_timeout_for_forward,
},
};
use kftray_telemetry::Phase;
use kube::Client;
use kube::api::{
Api,
DeleteParams,
ListParams,
};
use tokio::task::spawn_blocking;
use tokio::time::timeout;
use tracing::{
info,
warn,
};
use crate::kube::shared_client::{
SHARED_CLIENT_MANAGER,
ServiceClientKey,
};
use crate::port_forward::CHILD_PROCESSES;
#[cfg(test)]
use crate::port_forward::PortForwardProcess;
#[derive(Clone, Debug)]
struct PendingTarget {
config: Config,
uncertain_until: Option<Instant>,
cluster: bool,
local: bool,
destination: Option<String>,
}
impl PendingTarget {
fn is_uncertain(&self, now: Instant) -> bool {
self.uncertain_until.is_some_and(|until| now < until)
}
fn describes(&self, config: &Config, destination: Option<&str>) -> bool {
same_resources(&self.config, config)
&& match (self.destination.as_deref(), destination) {
(Some(left), Some(right)) => left == right,
_ => true,
}
}
fn is_exactly(&self, config: &Config, destination: Option<&str>) -> bool {
same_resources(&self.config, config) && self.destination.as_deref() == destination
}
}
lazy_static::lazy_static! {
static ref PENDING_CLEANUP: dashmap::DashMap<i64, Vec<PendingTarget>> = dashmap::DashMap::new();
}
lazy_static::lazy_static! {
static ref STOP_GENERATIONS: dashmap::DashMap<i64, u64> = dashmap::DashMap::new();
}
pub fn stop_generation(id: i64) -> u64 {
STOP_GENERATIONS.get(&id).map_or(0, |count| *count)
}
pub(crate) const SHARED_LOCK_WAIT: Duration = Duration::from_secs(30);
pub const UNCERTAIN_CREATE_WINDOW: Duration = Duration::from_secs(30);
fn same_resources(left: &Config, right: &Config) -> bool {
left.namespace == right.namespace
&& left.context == right.context
&& left.kubeconfig == right.kubeconfig
&& left.workload_type == right.workload_type
&& left.protocol == right.protocol
&& left.service == right.service
&& left.local_address == right.local_address
&& left.domain_enabled == right.domain_enabled
&& left.exposure_type == right.exposure_type
&& left.alias == right.alias
}
pub(crate) fn stable_digest(fields: &[Option<&str>]) -> u64 {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
let mut feed = |bytes: &[u8]| {
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(PRIME);
}
};
for field in fields {
match field {
Some(value) => {
feed(b"s");
feed(&(value.len() as u64).to_le_bytes());
feed(value.as_bytes());
}
None => feed(b"n"),
}
}
hash
}
const UNCERTAIN_CREATE_PREFIX: &str = "uncertain_create:";
#[derive(serde::Serialize, serde::Deserialize)]
struct PersistedTarget {
config: Config,
#[serde(default)]
destination: Option<String>,
}
fn parse_persisted_target(value: &str) -> Option<(Config, Option<String>)> {
let value: serde_json::Value = serde_json::from_str(value).ok()?;
if value.get("config").is_some() {
let persisted: PersistedTarget = serde_json::from_value(value).ok()?;
return Some((persisted.config, persisted.destination));
}
Some((serde_json::from_value(value).ok()?, None))
}
async fn persist_uncertain_target(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> Result<(), String> {
let serialized = serde_json::to_string(&PersistedTarget {
config: config.clone(),
destination: destination.map(ToOwned::to_owned),
})
.map_err(|error| format!("Failed to describe the cleanup metadata for config {id}: {error}"))?;
let key = uncertain_create_key(id, config, destination, mode);
if let Err(error) = kftray_commons::utils::settings::delete_setting_with_mode(
&confirmation_key(id, config, destination, mode),
mode,
)
.await
{
log::debug!("Failed to reset the confirmation count for config {id}: {error}");
}
kftray_commons::utils::settings::set_setting_with_mode(&key, &serialized, mode)
.await
.map_err(|error| format!("Failed to persist an unsettled create for config {id}: {error}"))
}
async fn confirm_uncertain_target(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> bool {
const CONFIRMATIONS_REQUIRED: u32 = 2;
let key = confirmation_key(id, config, destination, mode);
let seen = match kftray_commons::utils::settings::increment_setting_with_mode(&key, mode).await
{
Ok(seen) => seen,
Err(error) => {
warn!("Failed to record a cleanup confirmation for config {id}: {error}");
return false;
}
};
if seen >= CONFIRMATIONS_REQUIRED {
if let Err(error) =
kftray_commons::utils::settings::delete_setting_with_mode(&key, mode).await
{
log::debug!("Failed to clear the confirmation count for config {id}: {error}");
}
return true;
}
false
}
async fn forget_uncertain_target(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> Result<(), String> {
for key in [
uncertain_create_key(id, config, destination, mode),
confirmation_key(id, config, destination, mode),
] {
kftray_commons::utils::settings::delete_setting_with_mode(&key, mode)
.await
.map_err(|error| {
format!("Failed to clear the durable cleanup record for config {id}: {error}")
})?;
}
Ok(())
}
fn confirmation_key(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> String {
format!(
"uncertain_confirmations:{}",
uncertain_create_key(id, config, destination, mode)
.strip_prefix(UNCERTAIN_CREATE_PREFIX)
.unwrap_or_default()
)
}
fn uncertain_create_key(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> String {
let digest = stable_digest(&[
destination,
Some(config.namespace.as_str()),
config.context.as_deref(),
config.kubeconfig.as_deref(),
config.workload_type.as_deref(),
Some(config.protocol.as_str()),
config.service.as_deref(),
config.local_address.as_deref(),
config.domain_enabled.map(|on| if on { "1" } else { "0" }),
config.alias.as_deref(),
config.exposure_type.as_deref(),
]);
let scope = match mode {
DatabaseMode::File => "file",
DatabaseMode::Memory => "memory",
};
format!("{UNCERTAIN_CREATE_PREFIX}{scope}:{id}:{digest:016x}")
}
async fn restore_uncertain_targets(mode: DatabaseMode) {
let stored = match kftray_commons::utils::settings::get_settings_with_prefix_and_mode(
UNCERTAIN_CREATE_PREFIX,
mode,
)
.await
{
Ok(stored) => stored,
Err(error) => {
warn!("Failed to read unsettled creates: {error}");
return;
}
};
let scope = match mode {
DatabaseMode::File => "file:",
DatabaseMode::Memory => "memory:",
};
for (key, value) in stored {
let Some(id) = key
.strip_prefix(UNCERTAIN_CREATE_PREFIX)
.and_then(|rest| rest.strip_prefix(scope))
.and_then(|rest| rest.split(':').next())
.and_then(|id| id.parse::<i64>().ok())
else {
continue;
};
let Some((config, destination)) = parse_persisted_target(&value) else {
continue;
};
if PENDING_CLEANUP.get(&id).is_some_and(|entries| {
entries
.iter()
.any(|entry| entry.cluster && entry.is_exactly(&config, destination.as_deref()))
}) {
continue;
}
record_target(
id,
config,
Some(Instant::now() + UNCERTAIN_CREATE_WINDOW),
true,
false,
destination,
);
}
restore_local_cleanup_targets(mode).await;
}
const LOCAL_CLEANUP_PREFIX: &str = "local_cleanup:";
fn local_cleanup_key(id: i64, mode: DatabaseMode) -> String {
let scope = match mode {
DatabaseMode::File => "file",
DatabaseMode::Memory => "memory",
};
format!("{LOCAL_CLEANUP_PREFIX}{scope}:{id}")
}
async fn restore_local_cleanup_targets(mode: DatabaseMode) {
let stored = match kftray_commons::utils::settings::get_settings_with_prefix_and_mode(
LOCAL_CLEANUP_PREFIX,
mode,
)
.await
{
Ok(stored) => stored,
Err(error) => {
warn!("Failed to read unsettled local cleanup obligations: {error}");
return;
}
};
let scope = match mode {
DatabaseMode::File => "file:",
DatabaseMode::Memory => "memory:",
};
for (key, value) in stored {
let Some(id) = key
.strip_prefix(LOCAL_CLEANUP_PREFIX)
.and_then(|rest| rest.strip_prefix(scope))
.and_then(|id| id.parse::<i64>().ok())
else {
continue;
};
let Ok(config) = serde_json::from_str::<Config>(&value) else {
continue;
};
if PENDING_CLEANUP.get(&id).is_some_and(|entries| {
entries
.iter()
.any(|entry| entry.local && entry.is_exactly(&config, None))
}) {
continue;
}
record_target(id, config, None, false, true, None);
}
}
pub(crate) async fn running_in_another_process(id: i64, mode: DatabaseMode) -> Option<u32> {
if mode != DatabaseMode::File {
return None;
}
let states = get_configs_state_with_mode(mode).await.ok()?;
states
.into_iter()
.find(|state| state.config_id == id)
.filter(kftray_commons::utils::config_state::is_owned_by_other_live_process)
.and_then(|state| state.process_id)
}
pub(crate) async fn forwarding_configs(mode: DatabaseMode) -> Vec<Config> {
let mut configs: Vec<Config> = CHILD_PROCESSES
.iter()
.filter_map(|entry| entry.value().config().cloned())
.collect();
if mode != DatabaseMode::File {
return configs;
}
let elsewhere =
match kftray_commons::utils::config_state::get_configs_state_with_mode(mode).await {
Ok(states) => states,
Err(error) => {
warn!("Failed to read which configurations other processes are running: {error}");
return configs;
}
};
for state in elsewhere
.into_iter()
.filter(kftray_commons::utils::config_state::is_owned_by_other_live_process)
{
let snapshot =
kftray_commons::utils::config_state::running_snapshot(state.config_id, mode).await;
match snapshot {
Some(config) => configs.push(config),
None => {
if let Ok(config) = get_config_with_mode(state.config_id, mode).await {
configs.push(config);
}
}
}
}
configs
}
#[cfg(test)]
pub(crate) fn record_pending_cleanup(id: i64, config: Config) {
record_target(id, config, None, true, true, None);
}
pub(crate) async fn record_local_cleanup(id: i64, config: Config, mode: DatabaseMode) {
record_target(id, config.clone(), None, false, true, None);
match serde_json::to_string(&config) {
Ok(serialized) => {
if let Err(error) = kftray_commons::utils::settings::set_setting_with_mode(
&local_cleanup_key(id, mode),
&serialized,
mode,
)
.await
{
warn!("Failed to persist a local cleanup obligation for config {id}: {error}");
}
}
Err(error) => {
warn!("Failed to describe a local cleanup obligation for config {id}: {error}");
}
}
}
pub(crate) async fn settle_local_cleanup(id: i64, config: &Config, mode: DatabaseMode) {
if let Some(mut entries) = PENDING_CLEANUP.get_mut(&id) {
for entry in entries
.iter_mut()
.filter(|entry| same_resources(&entry.config, config))
{
entry.local = false;
}
entries.retain(|entry| entry.cluster || entry.local);
}
PENDING_CLEANUP.remove_if(&id, |_, entries| entries.is_empty());
if let Err(error) = kftray_commons::utils::settings::delete_setting_with_mode(
&local_cleanup_key(id, mode),
mode,
)
.await
{
log::debug!(
"Failed to clear the persisted local cleanup obligation for config {id}: {error}"
);
}
}
fn record_target(
id: i64, config: Config, uncertain_until: Option<Instant>, cluster: bool, local: bool,
destination: Option<String>,
) {
let mut entries = PENDING_CLEANUP.entry(id).or_default();
if let Some(existing) = entries
.iter_mut()
.find(|entry| entry.describes(&config, destination.as_deref()))
{
existing.uncertain_until = match (existing.uncertain_until, uncertain_until) {
(Some(left), Some(right)) => Some(left.max(right)),
(left, right) => left.or(right),
};
existing.cluster |= cluster;
existing.local |= local;
if existing.destination.is_none() {
existing.destination = destination;
}
return;
}
entries.push(PendingTarget {
config,
uncertain_until,
cluster,
local,
destination,
});
}
pub(crate) fn forget_pending_cleanup(id: i64, config: &Config, destination: Option<&str>) {
if let Some(mut entries) = PENDING_CLEANUP.get_mut(&id) {
entries.retain(|entry| !entry.is_exactly(config, destination));
}
PENDING_CLEANUP.remove_if(&id, |_, entries| entries.is_empty());
}
pub async fn settle_cluster_obligation(
config_id: i64, destination: &str, mode: DatabaseMode,
) -> Result<(), String> {
let matching: Vec<PendingTarget> = pending_cleanup_targets(config_id)
.into_iter()
.filter(|target| target.cluster && target.destination.as_deref() == Some(destination))
.collect();
for target in matching {
forget_uncertain_target(
config_id,
&target.config,
target.destination.as_deref(),
mode,
)
.await
.map_err(|error| {
format!("Failed to clear the durable cleanup record for config {config_id}: {error}")
})?;
set_target_obligations(config_id, &target, None, false, target.local);
}
Ok(())
}
fn set_target_obligations(
id: i64, target: &PendingTarget, uncertain_until: Option<Instant>, cluster: bool, local: bool,
) {
if !cluster && !local {
forget_pending_cleanup(id, &target.config, target.destination.as_deref());
return;
}
let mut entries = PENDING_CLEANUP.entry(id).or_default();
if let Some(existing) = entries
.iter_mut()
.find(|entry| entry.is_exactly(&target.config, target.destination.as_deref()))
{
existing.uncertain_until = uncertain_until;
existing.cluster = cluster;
existing.local = local;
return;
}
entries.push(PendingTarget {
config: target.config.clone(),
uncertain_until,
cluster,
local,
destination: target.destination.clone(),
});
}
fn pending_cleanup_targets(id: i64) -> Vec<PendingTarget> {
PENDING_CLEANUP
.get(&id)
.map(|entry| entry.value().clone())
.unwrap_or_default()
}
pub(crate) async fn recorded_configs(id: i64, mode: DatabaseMode) -> Option<Vec<Config>> {
let mut configs: Vec<Config> = pending_cleanup_targets(id)
.into_iter()
.map(|target| target.config)
.collect();
let scope = kftray_commons::utils::settings::mode_scope(mode);
let uncertain = kftray_commons::utils::settings::get_settings_with_prefix_and_mode(
&format!("{UNCERTAIN_CREATE_PREFIX}{scope}:{id}:"),
mode,
)
.await
.ok()?;
for (_, value) in uncertain {
let (config, _) = parse_persisted_target(&value)?;
configs.push(config);
}
let local =
kftray_commons::utils::settings::get_setting_with_mode(&local_cleanup_key(id, mode), mode)
.await
.ok()?;
if let Some(value) = local {
configs.push(serde_json::from_str::<Config>(&value).ok()?);
}
configs.extend(try_running_snapshot(id, mode).await.ok()?);
Some(configs)
}
pub(crate) struct ClusterResourceGuard {
id: i64,
config: Option<Config>,
confirmed: bool,
mode: DatabaseMode,
destination: Option<String>,
inherited_until: Option<Instant>,
}
impl ClusterResourceGuard {
pub(crate) async fn arm(
id: i64, config: Config, destination: Option<String>, mode: DatabaseMode,
) -> Result<Self, String> {
let mut inherited_until = PENDING_CLEANUP.get(&id).and_then(|entries| {
entries
.iter()
.find(|entry| entry.is_exactly(&config, destination.as_deref()))
.and_then(|entry| entry.uncertain_until)
});
if inherited_until.is_none()
&& kftray_commons::utils::settings::get_setting_with_mode(
&uncertain_create_key(id, &config, destination.as_deref(), mode),
mode,
)
.await
.ok()
.flatten()
.is_some()
{
inherited_until = Some(Instant::now() + UNCERTAIN_CREATE_WINDOW);
}
persist_uncertain_target(id, &config, destination.as_deref(), mode).await?;
record_target(
id,
config.clone(),
Some(Instant::now() + UNCERTAIN_CREATE_WINDOW),
true,
false,
destination.clone(),
);
Ok(Self {
id,
config: Some(config),
confirmed: false,
mode,
destination,
inherited_until,
})
}
pub(crate) fn confirm(&mut self) {
self.confirmed = true;
if let Some(config) = &self.config
&& let Some(mut entries) = PENDING_CLEANUP.get_mut(&self.id)
&& let Some(entry) = entries
.iter_mut()
.find(|entry| entry.is_exactly(config, self.destination.as_deref()))
{
entry.uncertain_until = self.inherited_until;
}
}
pub(crate) async fn disarm(mut self) {
let Some(config) = self.config.take() else {
return;
};
let destination = self.destination.take();
if let Some(inherited) = self.inherited_until {
self.confirmed = true;
if let Some(mut entries) = PENDING_CLEANUP.get_mut(&self.id)
&& let Some(entry) = entries
.iter_mut()
.find(|entry| entry.is_exactly(&config, destination.as_deref()))
{
entry.uncertain_until = Some(inherited);
}
return;
}
if let Err(error) =
forget_uncertain_target(self.id, &config, destination.as_deref(), self.mode).await
{
warn!("{error}");
return;
}
if let Some(mut entries) = PENDING_CLEANUP.get_mut(&self.id) {
for entry in entries
.iter_mut()
.filter(|entry| entry.is_exactly(&config, destination.as_deref()))
{
entry.cluster = false;
entry.uncertain_until = None;
}
entries.retain(|entry| entry.cluster || entry.local);
}
PENDING_CLEANUP.remove_if(&self.id, |_, entries| entries.is_empty());
}
}
impl Drop for ClusterResourceGuard {
fn drop(&mut self) {
if let Some(config) = self.config.take() {
let uncertain_until =
(!self.confirmed).then(|| Instant::now() + UNCERTAIN_CREATE_WINDOW);
record_target(
self.id,
config,
uncertain_until,
true,
false,
self.destination.take(),
);
}
}
}
lazy_static::lazy_static! {
static ref RELEASING_ADDRESSES: dashmap::DashMap<String, AddressState> = dashmap::DashMap::new();
}
#[derive(Default)]
struct AddressState {
claims: Vec<Option<i64>>,
releases: usize,
}
struct ReleaseInFlight(String);
impl ReleaseInFlight {
fn mark(
address: &str, owner: Option<i64>, keep_for: impl Fn(&str) -> bool,
) -> Option<Arc<Self>> {
let mut state = RELEASING_ADDRESSES.entry(address.to_owned()).or_default();
if state.claims.iter().any(|claim| *claim != owner) || keep_for(address) {
drop(state);
RELEASING_ADDRESSES.remove_if_mut(address, |_, state| {
state.releases == 0 && state.claims.is_empty()
});
return None;
}
state.releases += 1;
drop(state);
Some(Arc::new(Self(address.to_owned())))
}
}
pub(crate) fn address_has_other_owner(address: &str, exclude: Option<i64>) -> bool {
CHILD_PROCESSES.iter().any(|entry| {
Some(*entry.key()) != exclude
&& entry
.value()
.config()
.and_then(|config| config.local_address.clone())
.is_some_and(|local| local == address)
})
}
impl Drop for ReleaseInFlight {
fn drop(&mut self) {
RELEASING_ADDRESSES.remove_if_mut(&self.0, |_, state| {
state.releases = state.releases.saturating_sub(1);
state.releases == 0 && state.claims.is_empty()
});
}
}
pub(crate) struct AddressClaim(String, Option<i64>);
impl AddressClaim {
pub(crate) fn take(address: &str, owner: Option<i64>) -> Option<Self> {
let mut state = RELEASING_ADDRESSES.entry(address.to_owned()).or_default();
if state.releases > 0 {
drop(state);
RELEASING_ADDRESSES.remove_if_mut(address, |_, state| {
state.releases == 0 && state.claims.is_empty()
});
return None;
}
state.claims.push(owner);
drop(state);
Some(Self(address.to_owned(), owner))
}
}
impl Drop for AddressClaim {
fn drop(&mut self) {
RELEASING_ADDRESSES.remove_if_mut(&self.0, |_, state| {
if let Some(index) = state.claims.iter().position(|claim| *claim == self.1) {
state.claims.swap_remove(index);
}
state.releases == 0 && state.claims.is_empty()
});
}
}
lazy_static::lazy_static! {
static ref HOST_ENTRY_CLAIMS: dashmap::DashMap<i64, u64> = dashmap::DashMap::new();
}
static HOST_ENTRY_CLAIM_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub(crate) fn claim_host_entries(id: i64) -> u64 {
let token = HOST_ENTRY_CLAIM_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
HOST_ENTRY_CLAIMS.insert(id, token);
token
}
pub(crate) fn take_host_entry_claim_if_current(id: i64, token: u64) -> bool {
let mut was_current = false;
HOST_ENTRY_CLAIMS.remove_if(&id, |_, current| {
was_current = *current == token;
was_current
});
was_current
}
pub(crate) fn host_entry_claim_is_current(id: i64, token: u64) -> bool {
HOST_ENTRY_CLAIMS
.get(&id)
.is_some_and(|current| *current == token)
}
pub(crate) fn host_entry_has_any_claim(id: i64) -> bool {
HOST_ENTRY_CLAIMS.contains_key(&id)
}
pub(crate) struct HostsClaimGuard {
id: i64,
token: u64,
armed: bool,
}
impl HostsClaimGuard {
pub(crate) fn new(id: i64) -> Self {
Self {
id,
token: claim_host_entries(id),
armed: true,
}
}
pub(crate) fn token(&self) -> u64 {
self.token
}
pub(crate) fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for HostsClaimGuard {
fn drop(&mut self) {
if self.armed {
take_host_entry_claim_if_current(self.id, self.token);
}
}
}
pub(crate) fn address_release_in_flight(address: &str) -> bool {
RELEASING_ADDRESSES
.get(address)
.is_some_and(|state| state.releases > 0)
}
#[derive(Default)]
struct LocalCleanup {
failures: Vec<String>,
deferred: Vec<String>,
unsatisfiable: Vec<String>,
}
impl LocalCleanup {
fn settled(&self) -> bool {
self.failures.is_empty() && self.deferred.is_empty()
}
}
async fn release_local_resources(id: i64, config: &Config, mode: DatabaseMode) -> LocalCleanup {
let mut cleanup = LocalCleanup::default();
if let Some(address) = &config.local_address
&& crate::network_utils::is_custom_loopback_address(address)
{
let phase = kftray_telemetry::phase(Phase::ReleaseAddress);
let released = release_address_with_fallback(address, Some(id), mode).await;
if released.is_err() {
phase.fail();
}
drop(phase);
if let Err(error) = released {
if error.is_unsatisfiable() {
cleanup.unsatisfiable.push(error.to_string());
} else {
cleanup.deferred.push(error.to_string());
}
}
}
let snapshot = config.clone();
let in_use = forwarding_configs(mode).await;
let phase = kftray_telemetry::phase(Phase::HostsCleanup);
let hosts =
crate::hostsfile::remove_config_host_entries(id, Some(&snapshot), &in_use, mode).await;
if hosts.is_err() {
phase.fail();
}
drop(phase);
match hosts {
Ok(()) => {}
Err(error) => cleanup.failures.push(error.to_string()),
}
cleanup
}
async fn delete_cluster_resources(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> Result<(), String> {
const CLUSTER_CLEANUP_TIMEOUT: Duration = Duration::from_secs(60);
match timeout(
CLUSTER_CLEANUP_TIMEOUT,
delete_cluster_resources_inner(id, config, destination, mode),
)
.await
{
Ok(result) => result,
Err(_) => Err(format!(
"Timed out after {CLUSTER_CLEANUP_TIMEOUT:?} deleting the cluster resources for config \
{id}"
)),
}
}
fn config_has_cluster_resources(config: &Config) -> bool {
matches!(
config.workload_type.as_deref(),
Some("expose") | Some("proxy")
) || config.protocol == "udp"
}
async fn delete_cluster_resources_inner(
id: i64, config: &Config, destination: Option<&str>, mode: DatabaseMode,
) -> Result<(), String> {
if !config_has_cluster_resources(config) {
return Ok(());
}
let key = ServiceClientKey::new(config.context.clone(), config.kubeconfig.clone());
let connection = SHARED_CLIENT_MANAGER
.get_connection(key)
.await
.map_err(|error| error.to_string())?;
let resolved = crate::kube::client::cluster_identity(&connection.cluster_url);
if let Some(recorded) = destination
&& recorded != resolved
{
return Err(format!(
"The resources for config {id} were created on {recorded}, but the context now \
resolves to {resolved}; restore that kubeconfig or remove them from the server \
resources screen"
));
}
if config.workload_type.as_deref() == Some("expose") {
crate::expose::kubernetes::delete_expose_resources(
connection.client.clone(),
&config.namespace,
&id.to_string(),
config.exposure_type.as_deref() == Some("public"),
&crate::expose::kubernetes::ExposeLocation::resolve(
&connection.cluster_url,
&config.namespace,
),
mode,
)
.await
} else {
let phase = kftray_telemetry::phase(Phase::RelayDelete);
let deleted =
delete_proxy_cluster_resources(connection.client.clone(), &config.namespace, id, mode)
.await;
drop(phase);
deleted
}
}
fn try_release_address_sync(address: &str) -> Result<(), String> {
let app_id = "com.kftray.app".to_string();
let socket_path =
kftray_helper::communication::get_default_socket_path().map_err(|e| e.to_string())?;
if !kftray_helper::client::socket_comm::is_socket_available(&socket_path) {
return Err("Helper service is not available".to_string());
}
let command = kftray_helper::messages::RequestCommand::Address(
kftray_helper::messages::AddressCommand::Release {
address: address.to_string(),
},
);
match kftray_helper::client::socket_comm::send_request(&socket_path, &app_id, command) {
Ok(response) => match response.result {
kftray_helper::messages::RequestResult::Success => Ok(()),
kftray_helper::messages::RequestResult::Error(error) => Err(error),
_ => Err("Unexpected response format".to_string()),
},
Err(e) => Err(e.to_string()),
}
}
fn address_owned_by_another_process(helper_error: &str) -> bool {
helper_error.contains("owned by another live process")
}
pub(crate) enum AddressReleaseError {
Failed(String),
PrivilegeUnavailable(String),
}
impl AddressReleaseError {
pub(crate) fn is_unsatisfiable(&self) -> bool {
matches!(self, Self::PrivilegeUnavailable(_))
}
}
impl std::fmt::Display for AddressReleaseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(message) | Self::PrivilegeUnavailable(message) => write!(f, "{message}"),
}
}
}
pub(crate) async fn release_address_with_fallback(
address: &str, owner: Option<i64>, mode: DatabaseMode,
) -> Result<(), AddressReleaseError> {
const ADDRESS_RELEASE_TIMEOUT: Duration = Duration::from_secs(3);
if mode == DatabaseMode::File
&& forwarding_configs(mode)
.await
.iter()
.any(|config| config.id != owner && config.local_address.as_deref() == Some(address))
{
log::debug!("Skipping the release of {address}: a forward elsewhere is using it");
return Ok(());
}
let Some(releasing) = ReleaseInFlight::mark(address, owner, |address| {
address_has_other_owner(address, owner)
}) else {
log::debug!("Skipping the release of {address}: another forward is using it");
return Ok(());
};
if mode == DatabaseMode::File
&& forwarding_configs(mode)
.await
.iter()
.any(|config| config.id != owner && config.local_address.as_deref() == Some(address))
{
log::debug!("Skipping the release of {address}: a forward elsewhere is using it");
return Ok(());
}
let address_owned = address.to_string();
let result = timeout(ADDRESS_RELEASE_TIMEOUT, {
let addr = address_owned.clone();
let releasing = Arc::clone(&releasing);
spawn_blocking(move || {
let _releasing = releasing;
try_release_address_sync(&addr)
})
})
.await;
let helper_error = match result {
Ok(Ok(Ok(_))) => {
info!("Successfully released address via helper: {}", address);
return Ok(());
}
Ok(Ok(Err(e))) => e.to_string(),
Ok(Err(e)) => e.to_string(),
Err(_) => format!("timed out after {ADDRESS_RELEASE_TIMEOUT:?}"),
};
if address_owned_by_another_process(&helper_error) {
log::debug!("Skipping the release of {address}: {helper_error}");
return Ok(());
}
let platform = timeout(
ADDRESS_RELEASE_TIMEOUT,
crate::network_utils::remove_loopback_address(address),
)
.await;
match platform {
Ok(Ok(
crate::network_utils::LoopbackRelease::Released
| crate::network_utils::LoopbackRelease::AlreadyAbsent,
)) => {
warn!(
"Released address {} without the helper ({}).",
address, helper_error
);
Ok(())
}
Ok(Ok(crate::network_utils::LoopbackRelease::PrivilegeUnavailable(platform_error))) => {
warn!(
"Cannot release address {}: helper: {}; platform needs elevated privileges that \
are not available right now: {}",
address, helper_error, platform_error
);
Err(AddressReleaseError::PrivilegeUnavailable(format!(
"Releasing address {address} needs elevated privileges that are not available \
right now: {platform_error}"
)))
}
Ok(Ok(crate::network_utils::LoopbackRelease::Failed(platform_error))) => {
warn!(
"Failed to release address {}: helper: {}; platform: {}",
address, helper_error, platform_error
);
Err(AddressReleaseError::Failed(format!(
"Failed to release address {address}: {helper_error}; {platform_error}"
)))
}
Ok(Err(platform_error)) => {
warn!(
"Failed to release address {}: helper: {}; platform: {}",
address, helper_error, platform_error
);
Err(AddressReleaseError::Failed(format!(
"Failed to release address {address}: {helper_error}; {platform_error}"
)))
}
Err(_) => Err(AddressReleaseError::Failed(format!(
"Address release timed out for {address} after {ADDRESS_RELEASE_TIMEOUT:?}"
))),
}
}
pub(crate) async fn delete_proxy_cluster_resources(
client: Client, namespace: &str, config_id: i64, mode: DatabaseMode,
) -> Result<(), String> {
let prefix = crate::kube::proxy::proxy_resource_prefix();
let owned = ListParams::default()
.labels(&crate::kube::proxy::proxy_owner_selector(&config_id.to_string(), mode).await?);
let dp = DeleteParams {
grace_period_seconds: Some(0),
..DeleteParams::default()
};
let deployment_dp = DeleteParams {
grace_period_seconds: Some(0),
..DeleteParams::foreground()
};
let pods: Api<Pod> = Api::namespaced(client.clone(), namespace);
let deployments: Api<Deployment> = Api::namespaced(client, namespace);
let selectors = [&owned];
let mut errors: Vec<String> =
match delete_prefixed(&deployments, &selectors, &prefix, &deployment_dp).await {
Ok(()) => Vec::new(),
Err(error) => vec![error],
};
if let Err(error) = delete_prefixed(&pods, &selectors, &prefix, &dp).await {
errors.push(error);
}
let unlabelled = format!(
"config_id={config_id},!{}",
crate::kube::proxy::INSTALLATION_LABEL
);
let leftovers = list_prefixed_names(&pods, &unlabelled, &prefix).await?
+ &list_prefixed_names(&deployments, &unlabelled, &prefix).await?;
if !leftovers.is_empty() {
errors.push(format!(
"Relay resources from an earlier version are still running and cannot be attributed to \
this installation: {}. Remove them from the server resources screen.",
leftovers.trim_end_matches(", ")
));
}
if errors.is_empty() {
Ok(())
} else {
Err(errors.join("; "))
}
}
async fn list_prefixed_names<K>(
api: &Api<K>, selector: &str, prefix: &str,
) -> Result<String, String>
where
K: Clone + serde::de::DeserializeOwned + std::fmt::Debug + kube::Resource,
{
let lp = ListParams::default().labels(selector);
let list = api.list(&lp).await.map_err(|error| error.to_string())?;
Ok(list
.items
.iter()
.filter_map(|item| item.meta().name.clone())
.filter(|name| name.starts_with(prefix))
.map(|name| format!("{name}, "))
.collect())
}
async fn delete_prefixed<K>(
api: &Api<K>, selectors: &[&ListParams], prefix: &str, dp: &DeleteParams,
) -> Result<(), String>
where
K: Clone + serde::de::DeserializeOwned + std::fmt::Debug + kube::Resource,
{
const DELETION_TIMEOUT: Duration = Duration::from_secs(20);
const POLL: Duration = Duration::from_millis(250);
let mut errors = Vec::new();
for lp in selectors {
let list = api.list(lp).await.map_err(|error| error.to_string())?;
for item in list.items {
let Some(name) = item.meta().name.clone() else {
continue;
};
if !name.starts_with(prefix) {
continue;
}
if let Err(error) = api.delete(&name, dp).await
&& !matches!(&error, kube::Error::Api(response) if response.code == 404)
{
errors.push(error.to_string());
}
}
}
if !errors.is_empty() {
return Err(errors.join("; "));
}
let deadline = Instant::now() + DELETION_TIMEOUT;
loop {
let mut remaining = Vec::new();
for lp in selectors {
let list = api.list(lp).await.map_err(|error| error.to_string())?;
remaining.extend(
list.items
.iter()
.filter_map(|item| item.meta().name.clone())
.filter(|name| name.starts_with(prefix)),
);
}
if remaining.is_empty() {
return Ok(());
}
if Instant::now() >= deadline {
return Err(format!(
"Resources are still terminating after {DELETION_TIMEOUT:?}: {}",
remaining.join(", ")
));
}
tokio::time::sleep(POLL).await;
}
}
pub async fn stop_all_port_forward() -> Result<Vec<CustomResponse>, String> {
stop_all_port_forward_with_mode(DatabaseMode::File).await
}
pub async fn delete_configs_if_idle<F, Fut>(
ids: &[i64], mode: DatabaseMode, delete: F,
) -> Result<(), String>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<(), String>>,
{
let mut ordered: Vec<i64> = ids.to_vec();
ordered.sort_unstable();
ordered.dedup();
let mut guards = Vec::with_capacity(ordered.len());
for id in &ordered {
let lock = crate::kube::proxy_recovery::acquire_recovery_lock(*id).await;
match timeout(SHARED_LOCK_WAIT, lock.lock_owned()).await {
Ok(guard) => guards.push(guard),
Err(_) => {
return Err(format!(
"Configuration {id} is still starting or stopping; stop it before deleting"
));
}
}
}
let mut shared = Vec::with_capacity(ordered.len());
for id in &ordered {
shared.push(
kftray_commons::utils::config_dir::lock_config(*id, mode, SHARED_LOCK_WAIT).await?,
);
}
restore_uncertain_targets(mode).await;
let running_elsewhere: HashSet<i64> = if mode == DatabaseMode::File {
kftray_commons::utils::config_state::get_configs_state_with_mode(mode)
.await?
.into_iter()
.filter(kftray_commons::utils::config_state::is_owned_by_other_live_process)
.map(|state| state.config_id)
.collect()
} else {
HashSet::new()
};
let active: Vec<i64> = ordered
.iter()
.copied()
.filter(|id| {
CHILD_PROCESSES.contains_key(id)
|| crate::kube::proxy::STARTING_PROXIES.contains_key(id)
|| crate::kube::proxy_recovery::recovery_in_progress(*id)
|| running_elsewhere.contains(id)
|| PENDING_CLEANUP
.get(id)
.is_some_and(|entries| entries.iter().any(|entry| entry.cluster))
})
.collect();
if !active.is_empty() {
return Err(format!(
"Stop these configurations before deleting them: {}",
active
.iter()
.map(i64::to_string)
.collect::<Vec<_>>()
.join(", ")
));
}
delete().await
}
pub fn cancel_all_startups() {
for entry in crate::kube::proxy::STARTING_PROXIES.iter() {
entry.value().cancel();
}
for entry in crate::kube::proxy_recovery::RECOVERY_MANAGERS.iter() {
entry.value().cancel();
}
for entry in CHILD_PROCESSES.iter() {
entry.value().cancel();
}
}
pub async fn reconcile_pending_cleanup(
mode: DatabaseMode, deadline: Duration, exclude: &HashSet<i64>,
) -> Vec<i64> {
const RETRY_DELAY: Duration = Duration::from_secs(2);
const MIN_RETRY_DELAY: Duration = Duration::from_millis(50);
restore_uncertain_targets(mode).await;
let until = Instant::now() + deadline;
loop {
let mut ids: Vec<i64> = PENDING_CLEANUP
.iter()
.map(|entry| *entry.key())
.filter(|id| !exclude.contains(id))
.collect();
ids.extend(
CHILD_PROCESSES
.iter()
.map(|entry| *entry.key())
.filter(|id| !PENDING_CLEANUP.contains_key(id) && !exclude.contains(id)),
);
if ids.is_empty() {
if crate::kube::start::OUTSTANDING_ALLOCATIONS.load(std::sync::atomic::Ordering::SeqCst)
== 0
{
return Vec::new();
}
let Some(remaining) = until.checked_duration_since(Instant::now()) else {
warn!("Giving up on address allocations that never finished");
return unresolved_cleanup(mode, exclude).await;
};
tokio::time::sleep(RETRY_DELAY.min(remaining).max(MIN_RETRY_DELAY)).await;
continue;
}
let Some(remaining) = until.checked_duration_since(Instant::now()) else {
warn!(
"Giving up on cleanup for {} configuration(s) that never settled: {ids:?}",
ids.len()
);
return unresolved_cleanup(mode, exclude).await;
};
tokio::time::sleep(RETRY_DELAY.min(remaining).max(MIN_RETRY_DELAY)).await;
for id in ids {
let Some(remaining) = until.checked_duration_since(Instant::now()) else {
return unresolved_cleanup(mode, exclude).await;
};
match tokio::time::timeout(remaining, stop_config(id, None, mode)).await {
Ok(Err(error)) => warn!("Cleanup for config {id} is still incomplete: {error}"),
Err(_) => {
warn!("Cleanup for config {id} did not finish within the shutdown budget");
return unresolved_cleanup(mode, exclude).await;
}
Ok(Ok(_)) => {}
}
}
}
}
async fn unresolved_cleanup(mode: DatabaseMode, exclude: &HashSet<i64>) -> Vec<i64> {
let mut ids: Vec<i64> = PENDING_CLEANUP
.iter()
.map(|entry| *entry.key())
.filter(|id| !exclude.contains(id))
.collect();
ids.extend(
CHILD_PROCESSES
.iter()
.map(|entry| *entry.key())
.filter(|id| !PENDING_CLEANUP.contains_key(id) && !exclude.contains(id)),
);
for entry in CHILD_PROCESSES.iter() {
if exclude.contains(entry.key()) {
continue;
}
if let Some(config) = entry.value().config() {
record_target(
*entry.key(),
config.clone(),
None,
config_has_cluster_resources(config),
true,
entry.value().destination(),
);
}
}
let owed: Vec<(i64, PendingTarget)> = PENDING_CLEANUP
.iter()
.filter(|entry| !exclude.contains(entry.key()))
.flat_map(|entry| {
entry
.value()
.iter()
.filter(|target| target.cluster)
.map(|target| (*entry.key(), target.clone()))
.collect::<Vec<_>>()
})
.collect();
for (id, target) in owed {
if let Err(error) =
persist_uncertain_target(id, &target.config, target.destination.as_deref(), mode).await
{
warn!("Failed to persist the outstanding cleanup for config {id}: {error}");
}
}
ids.sort_unstable();
ids.dedup();
ids
}
pub async fn stop_all_port_forward_with_mode(
mode: DatabaseMode,
) -> Result<Vec<CustomResponse>, String> {
stop_all_port_forward_with_mode_excluding(mode, &HashSet::new()).await
}
async fn collect_stop_all_targets(
mode: DatabaseMode, exclude: &HashSet<i64>,
) -> (
HashSet<i64>,
HashMap<i64, Config>,
HashSet<i64>,
Result<Vec<Config>, String>,
Result<Vec<ConfigState>, String>,
) {
restore_uncertain_targets(mode).await;
let mut ids: HashSet<i64> = CHILD_PROCESSES.iter().map(|entry| *entry.key()).collect();
for entry in crate::kube::proxy::STARTING_PROXIES.iter() {
entry.value().cancel();
ids.insert(*entry.key());
}
for entry in crate::kube::proxy_recovery::RECOVERY_MANAGERS.iter() {
entry.value().cancel();
ids.insert(*entry.key());
}
for entry in CHILD_PROCESSES.iter() {
entry.value().cancel();
}
let mut registry_only: HashSet<i64> = HashSet::new();
for entry in crate::kube::proxy_recovery::RECOVERY_LOCKS.iter() {
if Arc::strong_count(entry.value()) > 1 && !ids.contains(entry.key()) {
registry_only.insert(*entry.key());
}
}
for entry in PENDING_CLEANUP.iter() {
if !ids.contains(entry.key()) {
registry_only.insert(*entry.key());
}
}
ids.extend(registry_only.iter().copied());
if !exclude.is_empty() {
ids.retain(|id| !exclude.contains(id));
registry_only.retain(|id| !exclude.contains(id));
}
let configs_result = read_configs_with_mode(mode).await;
let states_result = get_configs_state_with_mode(mode).await;
if let Ok(states) = &states_result {
for state in states {
if !state.is_running {
continue;
}
let owned_elsewhere = mode == DatabaseMode::File
&& !CHILD_PROCESSES.contains_key(&state.config_id)
&& kftray_commons::utils::config_state::is_owned_by_other_live_process(state);
if owned_elsewhere {
log::debug!(
"Stop-all: skipping config {} forwarded by another live kftray process",
state.config_id
);
registry_only.remove(&state.config_id);
ids.remove(&state.config_id);
continue;
}
registry_only.remove(&state.config_id);
ids.insert(state.config_id);
}
}
let configs: HashMap<i64, Config> = configs_result
.as_ref()
.ok()
.into_iter()
.flatten()
.filter_map(|config| config.id.map(|id| (id, config.clone())))
.collect();
(ids, configs, registry_only, configs_result, states_result)
}
pub async fn stop_all_port_forward_with_mode_excluding(
mode: DatabaseMode, exclude: &HashSet<i64>,
) -> Result<Vec<CustomResponse>, String> {
let (ids, configs, registry_only, configs_result, states_result) =
collect_stop_all_targets(mode, exclude).await;
let mut responses: Vec<CustomResponse> = stream::iter(ids)
.map(|id| {
let config = configs.get(&id).cloned();
let suppress_missing = registry_only.contains(&id);
async move {
match stop_config(id, config.as_ref(), mode).await {
Ok(response) => Some(response),
Err(error) => {
if suppress_missing && error == nothing_forwarding_error(id) {
log::debug!(
"Stop-all: config {id} was only tracked by the recovery/cleanup \
registry and nothing remained to clean"
);
None
} else {
Some(stop_response(id, config.as_ref(), Some(error)))
}
}
}
}
})
.buffer_unordered(16)
.collect::<Vec<Option<CustomResponse>>>()
.await
.into_iter()
.flatten()
.collect();
if let Err(error) = configs_result {
warn!("Could not read configs while stopping every forward: {error}");
responses.push(enumeration_failure(format!(
"Could not read configs, so some forwards may not have been cleaned up: {error}"
)));
}
if let Err(error) = states_result {
warn!("Could not read config states while stopping every forward: {error}");
responses.push(enumeration_failure(format!(
"Could not read config states, so persisted forwards may have been missed: {error}"
)));
}
Ok(responses)
}
pub async fn stop_all_port_forward_with_deadline(
mode: DatabaseMode, exclude: &HashSet<i64>, deadline: Duration,
) -> Result<(Vec<CustomResponse>, Vec<i64>), String> {
let (ids, configs, registry_only, configs_result, states_result) =
collect_stop_all_targets(mode, exclude).await;
let handles: Vec<(i64, tokio::task::JoinHandle<Option<CustomResponse>>)> = ids
.iter()
.map(|&id| {
let config = configs.get(&id).cloned();
let suppress_missing = registry_only.contains(&id);
let handle = tokio::spawn(async move {
match stop_config(id, config.as_ref(), mode).await {
Ok(response) => Some(response),
Err(error) => {
if suppress_missing && error == nothing_forwarding_error(id) {
log::debug!(
"Stop-all: config {id} was only tracked by the recovery/cleanup \
registry and nothing remained to clean"
);
None
} else {
Some(stop_response(id, config.as_ref(), Some(error)))
}
}
}
});
(id, handle)
})
.collect();
let mut responses = Vec::new();
let mut completed: HashSet<i64> = HashSet::new();
let deadline_at = Instant::now() + deadline;
for (id, handle) in handles {
let remaining = deadline_at.saturating_duration_since(Instant::now());
match tokio::time::timeout(remaining, handle).await {
Ok(Ok(Some(response))) => {
completed.insert(id);
responses.push(response);
}
Ok(Ok(None)) => {
completed.insert(id);
}
Ok(Err(join_error)) => {
completed.insert(id);
warn!("Stop-all: stopping config {id} panicked: {join_error}");
responses.push(stop_response(
id,
configs.get(&id),
Some(format!("Stopping config {id} panicked: {join_error}")),
));
}
Err(_) => {
}
}
}
let unfinished: Vec<i64> = ids
.into_iter()
.filter(|id| !completed.contains(id))
.collect();
if let Err(error) = configs_result {
warn!("Could not read configs while stopping every forward: {error}");
responses.push(enumeration_failure(format!(
"Could not read configs, so some forwards may not have been cleaned up: {error}"
)));
}
if let Err(error) = states_result {
warn!("Could not read config states while stopping every forward: {error}");
responses.push(enumeration_failure(format!(
"Could not read config states, so persisted forwards may have been missed: {error}"
)));
}
Ok((responses, unfinished))
}
fn nothing_forwarding_error(id: i64) -> String {
format!("No port forwarding process found for config_id '{id}'")
}
fn enumeration_failure(error: String) -> CustomResponse {
CustomResponse {
id: None,
service: String::new(),
namespace: String::new(),
local_port: 0,
remote_port: 0,
context: String::new(),
protocol: String::new(),
stdout: String::new(),
status: 1,
stderr: error,
}
}
pub async fn stop_port_forward(config_id: String) -> Result<CustomResponse, String> {
stop_port_forward_with_mode(config_id, DatabaseMode::File).await
}
pub async fn stop_port_forward_with_mode(
config_id: String, mode: DatabaseMode,
) -> Result<CustomResponse, String> {
let id = config_id
.parse::<i64>()
.map_err(|_| "Invalid config ID".to_string())?;
if let Some(startup) = crate::kube::proxy::STARTING_PROXIES.get(&id) {
startup.cancel();
}
if let Some(manager) = crate::kube::proxy_recovery::RECOVERY_MANAGERS.get(&id) {
manager.cancel();
}
if let Some(process) = CHILD_PROCESSES.get(&id) {
process.cancel();
}
restore_uncertain_targets(mode).await;
let config = get_config_with_mode(id, mode).await;
let response = stop_config(id, config.as_ref().ok(), mode).await;
match (config, response) {
(Err(lookup), Err(cleanup)) => Err(format!("{cleanup}; {lookup}")),
(_, response) => response,
}
}
async fn stop_config(
id: i64, config: Option<&Config>, mode: DatabaseMode,
) -> Result<CustomResponse, String> {
if let Some(startup) = crate::kube::proxy::STARTING_PROXIES.get(&id) {
startup.cancel();
}
if let Some(manager) = crate::kube::proxy_recovery::RECOVERY_MANAGERS.get(&id) {
manager.cancel();
}
let lock = crate::kube::proxy_recovery::acquire_recovery_lock(id).await;
let (guard, was_starting) = match lock.try_lock() {
Ok(guard) => (guard, false),
Err(_) => (lock.lock().await, true),
};
let _shared =
match kftray_commons::utils::config_dir::lock_config(id, mode, SHARED_LOCK_WAIT).await {
Ok(shared) => shared,
Err(error) => {
drop(guard);
drop(lock);
crate::kube::proxy_recovery::remove_recovery_lock(id);
return Err(error);
}
};
if let Some(startup) = crate::kube::proxy::STARTING_PROXIES.get(&id) {
startup.cancel();
}
if let Some((_, manager)) = crate::kube::proxy_recovery::RECOVERY_MANAGERS.remove(&id) {
manager.cancel();
}
if !CHILD_PROCESSES.contains_key(&id)
&& let Some(owner) = running_in_another_process(id, mode).await
{
drop(guard);
drop(lock);
crate::kube::proxy_recovery::remove_recovery_lock(id);
return Err(format!(
"Config {id} is being forwarded by another kftray process ({owner}); stop it there"
));
}
*STOP_GENERATIONS.entry(id).or_insert(0) += 1;
let process = CHILD_PROCESSES.remove(&id);
let existed = process.is_some();
let mut retained = None;
let mut retained_destination = None;
let mut listener_held = None;
if let Some((_, mut process)) = process {
retained = process.config().cloned();
retained_destination = process.destination();
if let Some(config) = &retained {
record_target(
id,
config.clone(),
None,
config_has_cluster_resources(config),
true,
retained_destination.clone(),
);
}
let phase = kftray_telemetry::phase(Phase::Shutdown);
listener_held = process.cleanup_and_abort().await.err();
if listener_held.is_some() {
phase.fail();
}
drop(phase);
}
let pending = pending_cleanup_targets(id);
let refreshed = if retained.is_none() && pending.is_empty() && was_starting {
running_snapshot(id, mode).await
} else {
None
};
let evidenced = existed || !pending.is_empty() || refreshed.is_some();
let config = retained
.as_ref()
.or(pending.first().map(|target| &target.config))
.or(refreshed.as_ref())
.or(config);
cancel_timeout_for_forward(id).await;
let result = if let Some(config) = config {
let recorded = pending
.iter()
.find(|target| target.describes(config, retained_destination.as_deref()));
let mut targets = vec![PendingTarget {
config: config.clone(),
uncertain_until: recorded.and_then(|target| target.uncertain_until),
cluster: recorded.map_or_else(
|| evidenced && config_has_cluster_resources(config),
|target| target.cluster,
),
local: recorded.is_none_or(|target| target.local),
destination: recorded
.and_then(|target| target.destination.clone())
.or(retained_destination),
}];
targets.extend(
pending
.iter()
.filter(|target| !recorded.is_some_and(|primary| std::ptr::eq(primary, *target)))
.cloned(),
);
for target in &targets {
record_target(
id,
target.config.clone(),
target.uncertain_until,
target.cluster,
target.local,
target.destination.clone(),
);
}
let mut errors: Vec<String> = listener_held
.map(|held| format!("Stopped config {id}, but {held}"))
.into_iter()
.collect();
let mut settled: Vec<PendingTarget> = Vec::new();
let unanswered: Vec<PendingTarget> = targets
.iter()
.filter(|target| target.uncertain_until.is_some())
.cloned()
.collect();
let is_unanswered = |target: &PendingTarget| {
unanswered
.iter()
.any(|entry| entry.describes(&target.config, target.destination.as_deref()))
};
let now = Instant::now();
for target in &targets {
let cluster = if target.cluster {
delete_cluster_resources(id, &target.config, target.destination.as_deref(), mode)
.await
} else {
Ok(())
};
let local = if target.local {
release_local_resources(id, &target.config, mode).await
} else {
LocalCleanup::default()
};
let uncertain = target.is_uncertain(now);
if cluster.is_ok() && local.settled() && !uncertain {
settled.push(target.clone());
continue;
}
let unconfirmed = is_unanswered(target)
&& (cluster.is_err()
|| uncertain
|| !confirm_uncertain_target(
id,
&target.config,
target.destination.as_deref(),
mode,
)
.await);
let cluster_owed = target.cluster && (cluster.is_err() || uncertain || unconfirmed);
let mut cluster_owed = cluster_owed;
let mut cluster_settled = target.cluster && !cluster_owed;
if cluster_settled
&& let Err(error) =
forget_uncertain_target(id, &target.config, target.destination.as_deref(), mode)
.await
{
cluster_owed = true;
cluster_settled = false;
errors.push(error);
}
set_target_obligations(
id,
target,
if cluster_settled {
None
} else {
target.uncertain_until
},
cluster_owed,
target.local && !local.settled(),
);
if let Err(error) = cluster {
SHARED_CLIENT_MANAGER.invalidate_client(&ServiceClientKey::new(
target.config.context.clone(),
target.config.kubeconfig.clone(),
));
errors.push(error);
} else if uncertain || unconfirmed {
errors.push(format!(
"A create request for config {id} was never answered, so its cluster resources \
are still being reconciled; stop it again to complete the check"
));
}
errors.extend(local.failures);
errors.extend(local.unsatisfiable);
for deferred in local.deferred {
warn!("Config {id} stopped with cleanup still pending: {deferred}");
}
}
for target in settled {
let destination = target.destination.as_deref();
let answered = !is_unanswered(&target);
if answered || confirm_uncertain_target(id, &target.config, destination, mode).await {
match forget_uncertain_target(id, &target.config, destination, mode).await {
Ok(()) => forget_pending_cleanup(id, &target.config, destination),
Err(error) => {
set_target_obligations(id, &target, None, true, false);
errors.push(error);
}
}
continue;
}
set_target_obligations(id, &target, None, true, false);
errors.push(format!(
"A create request for config {id} was never answered, so its cluster resources are \
still being reconciled; stop it again to complete the check"
));
}
let state = ConfigState::new(id, false);
if let Err(error) = update_config_state_with_mode(&state, mode).await {
errors.push(error);
} else {
kftray_commons::utils::config_state::clear_running_snapshot(id, mode).await;
}
if errors.is_empty() {
Ok(stop_response(id, Some(config), None))
} else {
Err(errors.join("; "))
}
} else if existed {
let mut error = format!(
"Stopped the local process for config {id} but could not load its configuration, so \
cluster resources, loopback addresses and host entries were left in place"
);
if let Some(held) = listener_held {
error = format!("{error}; {held}");
}
Err(error)
} else {
Err(nothing_forwarding_error(id))
};
release_unused_clients();
drop(guard);
drop(lock);
crate::kube::proxy_recovery::remove_recovery_lock(id);
result
}
fn release_unused_clients() {
let in_use: HashSet<ServiceClientKey> = CHILD_PROCESSES
.iter()
.filter_map(|entry| {
let config = entry.value().config()?;
Some(ServiceClientKey::new(
config.context.clone(),
config.kubeconfig.clone(),
))
})
.collect();
SHARED_CLIENT_MANAGER.release_unused(&in_use);
}
fn stop_response(id: i64, config: Option<&Config>, error: Option<String>) -> CustomResponse {
CustomResponse {
id: Some(id),
service: config
.and_then(|config| config.service.clone())
.unwrap_or_default(),
namespace: config
.map(|config| config.namespace.clone())
.unwrap_or_default(),
local_port: config
.and_then(|config| config.local_port)
.unwrap_or_default(),
remote_port: config
.and_then(|config| config.remote_port)
.unwrap_or_default(),
context: config
.and_then(|config| config.context.clone())
.unwrap_or_default(),
protocol: config
.map(|config| config.protocol.clone())
.unwrap_or_default(),
stdout: if error.is_none() {
"Port forwarding has been stopped".to_string()
} else {
String::new()
},
status: i32::from(error.is_some()),
stderr: error.unwrap_or_default(),
}
}
#[cfg(test)]
mod tests {
use tokio::net::{
TcpListener,
UdpSocket,
};
use super::*;
fn local_config(id: i64) -> Config {
Config {
id: Some(id),
namespace: "default".to_string(),
service: Some("test-service".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("service".to_string()),
..Config::default()
}
}
#[tokio::test]
async fn stop_releases_listener_and_websocket_owner_before_returning() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_011;
let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap();
let tcp_address = tcp.local_addr().unwrap();
let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let udp_address = udp.local_addr().unwrap();
let task = tokio::spawn(async move {
let _tcp = tcp;
std::future::pending::<anyhow::Result<()>>().await
});
let websocket = tokio::spawn(async move {
let _udp = udp;
std::future::pending::<()>().await;
});
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_ws_client_handle(websocket);
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.unwrap();
let _tcp = TcpListener::bind(tcp_address).await.unwrap();
let _udp = UdpSocket::bind(udp_address).await.unwrap();
}
#[tokio::test]
async fn stop_reports_a_listener_that_did_not_shut_down_in_time() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = kftray_commons::utils::config::insert_config_with_mode(
Config {
id: None,
..local_config(0)
},
DatabaseMode::Memory,
)
.await
.unwrap();
update_config_state_with_mode(&ConfigState::new(id, true), DatabaseMode::Memory)
.await
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let (release, held) = std::sync::mpsc::channel::<()>();
let (started, running) = tokio::sync::oneshot::channel::<()>();
let owner = tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
let _listener = listener;
let _ = started.send(());
let _ = held.recv();
Ok(())
});
running.await.unwrap();
let mut process = PortForwardProcess::new(owner, id.to_string());
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
let error = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect_err("a stop must not report success while the listener may still be bound");
assert!(error.contains("may still be in use"), "{error}");
assert!(
std::net::TcpListener::bind(address).is_err(),
"the listener still holds the port, so success would have been false"
);
assert!(
!CHILD_PROCESSES.contains_key(&id),
"the registry must not keep an entry that no later stop can settle"
);
let states = get_configs_state_with_mode(DatabaseMode::Memory)
.await
.unwrap();
assert!(
states
.iter()
.any(|state| state.config_id == id && !state.is_running),
"the forward is cancelled, so it must not stay marked running"
);
tokio::time::timeout(
Duration::from_secs(5),
stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory),
)
.await
.expect("a later stop must not wait on the lingering listener")
.expect("with nothing registered, stopping the stopped row is a no-op");
drop(release);
let deadline = Instant::now() + Duration::from_secs(5);
while std::net::TcpListener::bind(address).is_err() {
assert!(
Instant::now() < deadline,
"the port is released once the owner returns"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
#[tokio::test]
async fn every_stop_that_acts_on_an_id_advances_its_generation() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_012;
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
let before = stop_generation(id);
stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.unwrap();
assert_eq!(
stop_generation(id),
before + 1,
"a stop that found the forward counts once"
);
let _ = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory).await;
assert_eq!(stop_generation(id), before + 2);
}
#[tokio::test]
async fn a_stop_still_completes_once_the_startup_gate_has_closed() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let _reopen = crate::kube::proxy::ReopenStartupGate;
let id = 410_083;
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
crate::kube::proxy::close_startup_gate();
let response = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.unwrap();
assert_eq!(response.status, 0);
assert!(!CHILD_PROCESSES.contains_key(&id));
}
#[tokio::test]
async fn stop_uses_the_retained_config_when_the_database_lookup_fails() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_051;
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(Config {
id: Some(id),
workload_type: Some("proxy".to_string()),
protocol: "tcp".to_string(),
namespace: "default".to_string(),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/isolated-test-kubeconfig".to_string()),
..Config::default()
});
CHILD_PROCESSES.insert(id, process);
let error = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect_err("cluster cleanup must be attempted from the retained config");
assert!(!CHILD_PROCESSES.contains_key(&id));
assert!(
!error.contains("could not load its configuration"),
"the retained snapshot should have supplied the cleanup metadata: {error}"
);
}
#[tokio::test]
async fn stop_all_releases_every_transport_in_memory_mode() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let mut addresses = Vec::new();
for id in [410_021, 410_022] {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
addresses.push(listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let _listener = listener;
std::future::pending::<anyhow::Result<()>>().await
});
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
}
let udp = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let udp_address = udp.local_addr().unwrap();
let task = tokio::spawn(async move {
let _udp = udp;
std::future::pending::<anyhow::Result<()>>().await
});
let mut process = PortForwardProcess::new(task, "410023".to_string());
process.set_config(local_config(410_023));
CHILD_PROCESSES.insert(410_023, process);
let responses = stop_all_port_forward_with_mode(DatabaseMode::Memory)
.await
.unwrap();
for id in [410_021, 410_022, 410_023] {
assert!(
responses
.iter()
.any(|response| response.id == Some(id) && response.status == 0)
);
}
for address in addresses {
let _listener = TcpListener::bind(address).await.unwrap();
}
let _udp = UdpSocket::bind(udp_address).await.unwrap();
}
#[tokio::test]
async fn failed_cluster_cleanup_clears_running_but_keeps_pending_cleanup() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
namespace: "default".to_string(),
service: Some("expose-target".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("expose".to_string()),
..Config::default()
};
let id =
kftray_commons::utils::config::insert_config_with_mode(config, DatabaseMode::Memory)
.await
.unwrap();
update_config_state_with_mode(&ConfigState::new(id, true), DatabaseMode::Memory)
.await
.unwrap();
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
CHILD_PROCESSES.insert(id, PortForwardProcess::new(task, id.to_string()));
let error = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect_err("an unreachable cluster must fail the stop");
assert!(!error.is_empty());
let states = get_configs_state_with_mode(DatabaseMode::Memory)
.await
.unwrap();
assert!(
states
.iter()
.any(|state| state.config_id == id && !state.is_running),
"the local process is already torn down, so is_running must clear even though \
cluster cleanup failed"
);
assert!(
!pending_cleanup_targets(id).is_empty(),
"the unmet cluster obligation must stay in the durable registry so a later stop-all \
retries it"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn unresolved_cleanup_does_not_record_a_cluster_obligation_for_a_plain_forward() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_071;
PENDING_CLEANUP.remove(&id);
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(local_config(id));
CHILD_PROCESSES.insert(id, process);
unresolved_cleanup(DatabaseMode::Memory, &HashSet::new()).await;
let targets = pending_cleanup_targets(id);
assert!(
targets.iter().any(|target| !target.cluster),
"a plain TCP forward never creates cluster resources, so it must not need cluster \
access to be considered idle: {targets:?}"
);
if let Some((_, mut process)) = CHILD_PROCESSES.remove(&id) {
let _ = process.cleanup_and_abort().await;
}
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn stopping_an_already_stopped_proxy_by_row_alone_does_not_need_cluster_access() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
namespace: "default".to_string(),
service: Some("relay".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("proxy".to_string()),
..Config::default()
};
let id =
kftray_commons::utils::config::insert_config_with_mode(config, DatabaseMode::Memory)
.await
.unwrap();
PENDING_CLEANUP.remove(&id);
let response = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect(
"stopping a config with nothing running and no evidence of cluster resources \
must not need cluster access",
);
assert_eq!(response.status, 0);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_settled_create_keeps_its_local_cleanup_recorded() {
let _db = kftray_commons::test_utils::test_db().await;
let id = -9_312;
PENDING_CLEANUP.remove(&id);
let config = Config {
id: Some(id),
namespace: "prod".to_string(),
service: Some("relay".to_string()),
local_address: Some("127.0.44.1".to_string()),
workload_type: Some("proxy".to_string()),
..Config::default()
};
record_pending_cleanup(id, config.clone());
ClusterResourceGuard::arm(id, config.clone(), None, DatabaseMode::Memory)
.await
.unwrap()
.disarm()
.await;
let targets = pending_cleanup_targets(id);
assert_eq!(targets.len(), 1);
assert!(
targets[0].local,
"deleting the deployment must not discard the address still bound"
);
assert!(!targets[0].cluster);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_local_cleanup_obligation_survives_a_simulated_restart() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = -9_313;
PENDING_CLEANUP.remove(&id);
let mode = DatabaseMode::Memory;
let key = local_cleanup_key(id, mode);
kftray_commons::utils::settings::delete_setting_with_mode(&key, mode)
.await
.unwrap();
let config = Config {
id: Some(id),
namespace: "prod".to_string(),
service: Some("relay".to_string()),
local_address: Some("127.0.44.2".to_string()),
workload_type: Some("proxy".to_string()),
..Config::default()
};
record_local_cleanup(id, config.clone(), mode).await;
assert!(
kftray_commons::utils::settings::get_setting_with_mode(&key, mode)
.await
.unwrap()
.is_some(),
"the obligation must be durable, not only in this process's memory"
);
PENDING_CLEANUP.remove(&id);
assert!(pending_cleanup_targets(id).is_empty());
restore_uncertain_targets(mode).await;
let targets = pending_cleanup_targets(id);
assert_eq!(
targets.len(),
1,
"the persisted local obligation must be reloaded into the registry"
);
assert!(targets[0].local);
assert!(!targets[0].cluster);
assert_eq!(targets[0].config.local_address, config.local_address);
settle_local_cleanup(id, &config, mode).await;
assert!(
kftray_commons::utils::settings::get_setting_with_mode(&key, mode)
.await
.unwrap()
.is_none(),
"settling must forget the durable record too"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn recorded_configs_include_obligations_only_a_restart_left_on_disk() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = -9_314;
PENDING_CLEANUP.remove(&id);
let mode = DatabaseMode::Memory;
let config = Config {
id: Some(id),
service: Some("relay".to_string()),
alias: Some("relay.local".to_string()),
domain_enabled: Some(true),
..Config::default()
};
assert_eq!(recorded_configs(id, mode).await.unwrap().len(), 0);
record_local_cleanup(id, config.clone(), mode).await;
PENDING_CLEANUP.remove(&id);
let recorded = recorded_configs(id, mode).await.unwrap();
assert_eq!(recorded.len(), 1);
assert_eq!(recorded[0].alias, config.alias);
settle_local_cleanup(id, &config, mode).await;
assert!(recorded_configs(id, mode).await.unwrap().is_empty());
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_malformed_cleanup_record_is_unknown_not_an_alias_free_config() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = -9_315;
PENDING_CLEANUP.remove(&id);
let mode = DatabaseMode::Memory;
let config = Config {
id: Some(id),
service: Some("relay".to_string()),
alias: Some("relay.local".to_string()),
domain_enabled: Some(true),
..Config::default()
};
let mut record = serde_json::to_value(PersistedTarget {
config: config.clone(),
destination: None,
})
.unwrap();
record["destination"] = serde_json::json!(1);
kftray_commons::utils::settings::set_setting_with_mode(
&uncertain_create_key(id, &config, None, mode),
&record.to_string(),
mode,
)
.await
.unwrap();
assert!(recorded_configs(id, mode).await.is_none());
kftray_commons::utils::settings::delete_setting_with_mode(
&uncertain_create_key(id, &config, None, mode),
mode,
)
.await
.unwrap();
PENDING_CLEANUP.remove(&id);
}
#[test]
fn owned_by_another_process_is_recognized_and_settled_not_failed() {
assert!(address_owned_by_another_process(
"Address 127.0.44.1 is owned by another live process (pid 4242)"
));
assert!(
!address_owned_by_another_process("Helper service is not available"),
"an unrelated helper failure must still be reported, not silently settled"
);
assert!(
!address_owned_by_another_process("timed out after 3s"),
"a timeout must still be reported, not silently settled"
);
}
#[test]
fn a_startup_can_release_the_address_it_claimed() {
let address = "127.0.57.1";
let claim = AddressClaim::take(address, Some(11)).expect("a free address can be claimed");
let releasing = ReleaseInFlight::mark(address, Some(11), |_| false);
assert!(
releasing.is_some(),
"a startup must be able to release what it allocated"
);
assert!(
ReleaseInFlight::mark(address, Some(12), |_| false).is_none(),
"a release for another configuration must not take a claimed address"
);
drop(releasing);
drop(claim);
}
#[test]
fn a_claimed_address_cannot_be_released_underneath_its_startup() {
let address = "127.0.56.1";
let claim = AddressClaim::take(address, Some(7)).expect("a free address can be claimed");
assert!(
ReleaseInFlight::mark(address, None, |_| false).is_none(),
"an abandoned allocation must not remove an address a startup is using"
);
drop(claim);
let releasing =
ReleaseInFlight::mark(address, None, |_| false).expect("the address is free again");
assert!(
AddressClaim::take(address, Some(8)).is_none(),
"a startup must not adopt an address mid-removal"
);
drop(releasing);
assert!(AddressClaim::take(address, Some(8)).is_some());
}
#[test]
fn an_address_stays_marked_until_every_release_finishes() {
let address = "127.0.55.1";
let first = ReleaseInFlight::mark(address, None, |_| false);
let second = ReleaseInFlight::mark(address, None, |_| false);
drop(second);
assert!(
address_release_in_flight(address),
"the first release is still executing and can remove the alias"
);
drop(first);
assert!(!address_release_in_flight(address));
}
#[tokio::test]
async fn a_retry_settling_does_not_answer_for_an_earlier_unanswered_create() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
id: Some(730_412),
namespace: "shared".to_string(),
service: Some("relay".to_string()),
workload_type: Some("expose".to_string()),
..Config::default()
};
let id = config.id.unwrap();
PENDING_CLEANUP.remove(&id);
let destination = Some("https://a".to_string());
record_target(
id,
config.clone(),
Some(Instant::now() + UNCERTAIN_CREATE_WINDOW),
true,
false,
destination.clone(),
);
ClusterResourceGuard::arm(
id,
config.clone(),
destination.clone(),
DatabaseMode::Memory,
)
.await
.unwrap()
.disarm()
.await;
let targets = pending_cleanup_targets(id);
assert!(
targets
.iter()
.any(|target| target.cluster && target.is_uncertain(Instant::now())),
"the earlier create is still unanswered: {targets:?}"
);
let mut guard =
ClusterResourceGuard::arm(id, config.clone(), destination, DatabaseMode::Memory)
.await
.unwrap();
guard.confirm();
drop(guard);
let targets = pending_cleanup_targets(id);
assert!(
targets
.iter()
.any(|target| target.cluster && target.is_uncertain(Instant::now())),
"confirming the retry must not clear inherited uncertainty: {targets:?}"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_rejected_retry_after_a_restart_keeps_the_persisted_unanswered_create() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
id: Some(730_413),
namespace: "shared".to_string(),
service: Some("relay".to_string()),
workload_type: Some("expose".to_string()),
..Config::default()
};
let id = config.id.unwrap();
let destination = Some("https://a".to_string());
PENDING_CLEANUP.remove(&id);
persist_uncertain_target(id, &config, destination.as_deref(), DatabaseMode::Memory)
.await
.unwrap();
let key = uncertain_create_key(id, &config, destination.as_deref(), DatabaseMode::Memory);
ClusterResourceGuard::arm(
id,
config.clone(),
destination.clone(),
DatabaseMode::Memory,
)
.await
.unwrap()
.disarm()
.await;
let stored =
kftray_commons::utils::settings::get_setting_with_mode(&key, DatabaseMode::Memory)
.await
.unwrap();
assert!(
stored.is_some(),
"the retry's rejection must not erase the earlier run's unanswered create"
);
assert!(
pending_cleanup_targets(id)
.iter()
.any(|target| target.cluster && target.uncertain_until.is_some()),
"the obligation is tracked in memory as unanswered"
);
forget_uncertain_target(id, &config, destination.as_deref(), DatabaseMode::Memory)
.await
.unwrap();
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn settle_clears_the_durable_record_under_the_callers_own_mode() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
id: Some(730_414),
namespace: "shared".to_string(),
service: Some("relay".to_string()),
workload_type: Some("expose".to_string()),
..Config::default()
};
let id = config.id.unwrap();
let destination = Some("https://a".to_string());
PENDING_CLEANUP.remove(&id);
persist_uncertain_target(id, &config, destination.as_deref(), DatabaseMode::Memory)
.await
.unwrap();
let key = uncertain_create_key(id, &config, destination.as_deref(), DatabaseMode::Memory);
record_target(id, config.clone(), None, true, false, destination.clone());
settle_cluster_obligation(id, "https://a", DatabaseMode::Memory)
.await
.unwrap();
let stored =
kftray_commons::utils::settings::get_setting_with_mode(&key, DatabaseMode::Memory)
.await
.unwrap();
assert!(
stored.is_none(),
"settle_cluster_obligation must clear the durable record under the caller's own \
mode, not always DatabaseMode::File: a memory-mode session's obligation would \
otherwise survive and be resurrected by the next `restore_uncertain_targets`"
);
assert!(
pending_cleanup_targets(id).is_empty(),
"the in-memory cluster obligation must be settled too"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn settle_forgets_the_record_only_for_a_matching_destination() {
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::db::init().await.unwrap();
let config = Config {
id: Some(910_501),
namespace: "settle-test".to_string(),
service: Some("relay".to_string()),
workload_type: Some("proxy".to_string()),
..Config::default()
};
let id = config.id.unwrap();
PENDING_CLEANUP.remove(&id);
record_target(
id,
config.clone(),
None,
true,
false,
Some("https://a".to_string()),
);
record_target(
id,
config.clone(),
None,
true,
false,
Some("https://b".to_string()),
);
settle_cluster_obligation(id, "https://a", DatabaseMode::File)
.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 targets = pending_cleanup_targets(id);
assert!(
!targets
.iter()
.any(|target| target.destination.as_deref() == Some("https://a")),
"the matching destination's cluster obligation must be forgotten: {targets:?}"
);
assert!(
targets
.iter()
.any(|target| target.destination.as_deref() == Some("https://b") && target.cluster),
"an obligation for another destination must stay: {targets:?}"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn the_same_rows_on_two_servers_are_two_cleanup_targets() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
id: Some(730_411),
namespace: "shared".to_string(),
service: Some("relay".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("proxy".to_string()),
..Config::default()
};
let id = config.id.unwrap();
PENDING_CLEANUP.remove(&id);
record_target(
id,
config.clone(),
None,
true,
false,
Some("https://a".into()),
);
record_target(
id,
config.clone(),
None,
true,
false,
Some("https://b".into()),
);
assert_eq!(pending_cleanup_targets(id).len(), 2);
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(config.clone());
CHILD_PROCESSES.insert(id, process);
let error = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect_err("an unreachable cluster must fail the stop");
assert_eq!(
error.matches("Failed to create Kubernetes client").count(),
2,
"one pass must attempt both servers: {error}"
);
let mut destinations: Vec<Option<String>> = pending_cleanup_targets(id)
.into_iter()
.map(|target| target.destination)
.collect();
destinations.sort();
assert_eq!(
destinations,
vec![Some("https://a".to_string()), Some("https://b".to_string())],
"both servers stay owed after a failed pass"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_failed_cleanup_keeps_its_snapshot_after_the_config_is_edited() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let config = Config {
namespace: "original-namespace".to_string(),
service: Some("proxy-target".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("proxy".to_string()),
..Config::default()
};
let id = kftray_commons::utils::config::insert_config_with_mode(
config.clone(),
DatabaseMode::Memory,
)
.await
.unwrap();
PENDING_CLEANUP.remove(&id);
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(Config {
id: Some(id),
..config
});
CHILD_PROCESSES.insert(id, process);
stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory)
.await
.expect_err("an unreachable cluster must fail the stop");
kftray_commons::utils::config::delete_config_with_mode(id, DatabaseMode::Memory)
.await
.unwrap();
let pending = pending_cleanup_targets(id);
assert_eq!(
pending.len(),
1,
"a failed cleanup must keep its snapshot for the next stop"
);
assert_eq!(pending[0].config.namespace, "original-namespace");
let responses = stop_all_port_forward_with_mode(DatabaseMode::Memory)
.await
.unwrap();
assert!(
responses.iter().any(|response| response.id == Some(id)),
"stop-all must retry a configuration whose cleanup never finished"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn an_unremovable_alias_does_not_block_deleting_a_stopped_config() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_131;
let config = Config {
id: Some(id),
namespace: "default".to_string(),
local_address: Some("127.0.0.99".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("service".to_string()),
..Config::default()
};
record_target(id, config, None, false, true, None);
let deleted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let marker = std::sync::Arc::clone(&deleted);
delete_configs_if_idle(&[id], DatabaseMode::Memory, || async move {
marker.store(true, std::sync::atomic::Ordering::Relaxed);
Ok(())
})
.await
.expect("a stopped configuration must be deletable");
assert!(
deleted.load(std::sync::atomic::Ordering::Relaxed),
"deletion must run: nothing about this configuration is still forwarding"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_dropped_startup_leaves_its_resources_for_stop_all() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_130;
let config = Config {
id: Some(id),
namespace: "default".to_string(),
service: Some("kftray-forward-abc".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("proxy".to_string()),
..Config::default()
};
record_pending_cleanup(id, config);
let responses = stop_all_port_forward_with_mode(DatabaseMode::Memory)
.await
.unwrap();
assert!(
responses.iter().any(|response| response.id == Some(id)),
"stop-all must reach resources left behind by a dropped startup"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn an_uncertain_create_survives_an_empty_cleanup_pass() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_160;
let config = Config {
id: Some(id),
namespace: "default".to_string(),
service: Some("kftray-forward-pending".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("service".to_string()),
..Config::default()
};
let guard = ClusterResourceGuard::arm(id, config.clone(), None, DatabaseMode::Memory)
.await
.unwrap();
drop(guard);
let _ = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory).await;
assert!(
pending_cleanup_targets(id)
.iter()
.any(|target| target.config.service.as_deref() == Some("kftray-forward-pending")),
"an unconfirmed create must not be forgotten on one empty pass"
);
let mut guard = ClusterResourceGuard::arm(id, config.clone(), None, DatabaseMode::Memory)
.await
.unwrap();
guard.confirm();
std::mem::forget(guard);
let _ = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory).await;
assert!(
!pending_cleanup_targets(id).is_empty(),
"a retry's confirmation does not settle the abandoned create"
);
PENDING_CLEANUP.remove(&id);
forget_uncertain_target(id, &config, None, DatabaseMode::Memory)
.await
.unwrap();
let mut guard = ClusterResourceGuard::arm(id, config.clone(), None, DatabaseMode::Memory)
.await
.unwrap();
guard.confirm();
std::mem::forget(guard);
let _ = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory).await;
assert!(
pending_cleanup_targets(id).is_empty(),
"a confirmed target is forgotten once cleanup succeeds"
);
PENDING_CLEANUP.remove(&id);
}
#[tokio::test]
async fn a_restart_after_failed_cleanup_keeps_both_resource_sets() {
let _db = kftray_commons::test_utils::test_db().await;
let _lock = crate::port_forward::PROCESS_TEST_MUTEX.lock().await;
let id = 410_140;
let orphaned = Config {
id: Some(id),
namespace: "old-namespace".to_string(),
service: Some("kftray-forward-old".to_string()),
context: Some("missing-context".to_string()),
kubeconfig: Some("/nonexistent/kubeconfig".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("proxy".to_string()),
..Config::default()
};
record_pending_cleanup(id, orphaned.clone());
let restarted = Config {
id: Some(id),
namespace: "new-namespace".to_string(),
service: Some("plain-service".to_string()),
protocol: "tcp".to_string(),
workload_type: Some("service".to_string()),
..Config::default()
};
let task = tokio::spawn(std::future::pending::<anyhow::Result<()>>());
let mut process = PortForwardProcess::new(task, id.to_string());
process.set_config(restarted);
CHILD_PROCESSES.insert(id, process);
let stopped = stop_port_forward_with_mode(id.to_string(), DatabaseMode::Memory).await;
assert!(
stopped.is_err(),
"the orphaned proxy must still be attempted, not silently skipped"
);
let still_pending = pending_cleanup_targets(id);
assert!(
still_pending
.iter()
.any(|target| target.config.namespace == "old-namespace"),
"cleaning the restarted config must not forget the earlier resources"
);
PENDING_CLEANUP.remove(&id);
}
}