Skip to main content

kftray_portforward/kube/
shared_client.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::sync::atomic::{
5    AtomicU64,
6    AtomicUsize,
7    Ordering,
8};
9use std::time::{
10    Duration,
11    Instant,
12};
13
14use dashmap::DashMap;
15use kftray_commons::models::config_model::Config;
16use log::warn;
17use once_cell::sync::Lazy;
18use tokio::sync::Mutex;
19
20use crate::kube::client::{
21    KubeConnection,
22    create_client_with_specific_context,
23    get_kubeconfig_paths_from_option,
24    merge_kubeconfigs,
25};
26
27#[derive(Debug, Clone, Hash, PartialEq, Eq)]
28pub struct ServiceClientKey {
29    pub context_name: Option<String>,
30    pub kubeconfig_path: Option<String>,
31}
32
33impl ServiceClientKey {
34    /// `Config::DEFAULT_CONTEXT` is the placeholder the config migration
35    /// writes for a config saved without a context, so it resolves to the
36    /// kubeconfig's current-context like a missing one.
37    pub fn new(context_name: Option<String>, kubeconfig_path: Option<String>) -> Self {
38        Self {
39            context_name: context_name.filter(|name| name != Config::DEFAULT_CONTEXT),
40            kubeconfig_path,
41        }
42    }
43}
44
45struct CachedClient {
46    connection: Arc<KubeConnection>,
47    created_at: Instant,
48    signature: KubeconfigSignature,
49}
50
51impl CachedClient {
52    fn new(connection: KubeConnection, signature: KubeconfigSignature) -> Self {
53        Self {
54            connection: Arc::new(connection),
55            created_at: Instant::now(),
56            signature,
57        }
58    }
59
60    fn is_expired(&self, ttl: Duration) -> bool {
61        self.created_at.elapsed() > ttl
62    }
63
64    fn is_fresh(&self, ttl: Duration, signature: &KubeconfigSignature) -> bool {
65        !self.is_expired(ttl) && self.signature == *signature
66    }
67}
68
69/// A creation lock shared by every caller racing to build a client for one
70/// context. The count tracks callers currently interested (registered but
71/// not yet released), so the lock entry can be reclaimed exactly when the
72/// last interested caller releases it, instead of inferring "unshared" from
73/// `Arc::strong_count`, which a stray clone elsewhere could perturb.
74type CreationLock = Arc<Creation>;
75
76struct Creation {
77    failures: Mutex<Vec<SharedFailure>>,
78    interest: AtomicUsize,
79    sequence: AtomicU64,
80}
81
82fn new_creation_lock() -> CreationLock {
83    Arc::new(Creation {
84        failures: Mutex::new(Vec::new()),
85        interest: AtomicUsize::new(0),
86        sequence: AtomicU64::new(0),
87    })
88}
89
90struct SharedFailure {
91    sequence: u64,
92    signature: KubeconfigSignature,
93    error: anyhow::Error,
94}
95
96fn clone_error_text(error: &anyhow::Error) -> anyhow::Error {
97    let mut causes = error
98        .chain()
99        .rev()
100        .map(|cause| anyhow::Error::msg(cause.to_string()));
101    let root = causes
102        .next()
103        .unwrap_or_else(|| anyhow::Error::msg(error.to_string()));
104    causes.fold(root, |copy, cause| copy.context(cause.to_string()))
105}
106
107/// RAII guard for creation-lock interest. Incrementing the counter and
108/// registering its release used to be two separate steps performed at every
109/// exit point of `get_connection`; a task cancelled between the increment
110/// and one of those manual release calls (e.g. dropped while awaiting the
111/// mutex or the client-creation future) left the counter incremented
112/// forever, pinning the entry so `cleanup_expired` could never reclaim it.
113/// Tying the release to `Drop` instead makes it run exactly once no matter
114/// how the caller's future exits, cancellation included.
115///
116/// The counter must already be incremented by the caller before
117/// constructing this guard (see `SharedClientManager::get_connection`,
118/// which increments it while still holding the `creation_locks` shard
119/// guard so a concurrent `release_creation_lock` cannot observe a
120/// zero count and reclaim the entry out from under a fresh interest).
121/// Incrementing here instead would reopen that race, and incrementing
122/// while still holding the shard guard from inside `Drop` would deadlock
123/// against it on a subsequent release.
124struct CreationLockInterest<'a> {
125    manager: &'a SharedClientManager,
126    key: ServiceClientKey,
127    lock: CreationLock,
128}
129
130impl<'a> CreationLockInterest<'a> {
131    /// Wraps a lock whose interest the caller already registered
132    /// (`lock.interest.fetch_add`) so `Drop` releases it exactly once.
133    fn already_registered(
134        manager: &'a SharedClientManager, key: ServiceClientKey, lock: CreationLock,
135    ) -> Self {
136        Self { manager, key, lock }
137    }
138}
139
140impl Drop for CreationLockInterest<'_> {
141    fn drop(&mut self) {
142        self.manager.release_creation_lock(&self.key, &self.lock);
143    }
144}
145
146/// A kubeconfig resolution's file state at the time it was resolved: each
147/// path considered paired with its last-modified time (`None` if the path
148/// did not exist). Compared against a fresh stat on a cache hit so a
149/// current-context resolution is not replayed after the file it came from
150/// changed underneath it, even while still within the TTL.
151type KubeconfigSignature = Vec<(PathBuf, Option<std::time::SystemTime>)>;
152
153fn kubeconfig_signature(paths: &[PathBuf]) -> KubeconfigSignature {
154    paths
155        .iter()
156        .map(|path| {
157            let mtime = std::fs::metadata(path)
158                .and_then(|meta| meta.modified())
159                .ok();
160            (path.clone(), mtime)
161        })
162        .collect()
163}
164
165pub struct SharedClientManager {
166    clients: DashMap<ServiceClientKey, CachedClient>,
167    client_ttl: Duration,
168    creation_locks: DashMap<ServiceClientKey, CreationLock>,
169    /// Resolved current-context per kubeconfig path, so a `None`-context
170    /// `get_connection`/`resolve_key` call only reads and merges the
171    /// kubeconfig on the first miss; later calls reuse the recorded
172    /// context instead of touching disk again.
173    resolved_contexts: DashMap<Option<String>, (String, Instant, KubeconfigSignature)>,
174}
175
176impl SharedClientManager {
177    pub fn new() -> Self {
178        Self::with_ttl(Duration::from_secs(3600))
179    }
180
181    fn with_ttl(client_ttl: Duration) -> Self {
182        Self {
183            clients: DashMap::new(),
184            client_ttl,
185            creation_locks: DashMap::new(),
186            resolved_contexts: DashMap::new(),
187        }
188    }
189
190    pub async fn get_connection(
191        &self, key: ServiceClientKey,
192    ) -> anyhow::Result<Arc<KubeConnection>> {
193        let (key, signature) = self.resolve_key(key).await?;
194        let context_name = key
195            .context_name
196            .as_deref()
197            .expect("resolve_key always returns a key carrying a context");
198
199        if let Some(cached) = self.clients.get(&key)
200            && cached.is_fresh(self.client_ttl, &signature)
201        {
202            return Ok(cached.connection.clone());
203        }
204
205        let (lock, failures_seen) = {
206            let entry = self
207                .creation_locks
208                .entry(key.clone())
209                .or_insert_with(new_creation_lock);
210            let lock = entry.clone();
211            let failures_seen = lock.sequence.load(Ordering::SeqCst);
212            lock.interest.fetch_add(1, Ordering::SeqCst);
213            (lock, failures_seen)
214        };
215        let _interest = CreationLockInterest::already_registered(self, key.clone(), lock.clone());
216
217        let mut guard = lock.failures.lock().await;
218
219        if let Some(cached) = self.clients.get(&key)
220            && cached.is_fresh(self.client_ttl, &signature)
221        {
222            let client_arc = cached.connection.clone();
223            drop(cached);
224            drop(guard);
225            return Ok(client_arc);
226        }
227        if let Some(failure) = guard
228            .iter()
229            .find(|failure| failure.sequence > failures_seen && failure.signature == signature)
230        {
231            let error = clone_error_text(&failure.error);
232            drop(guard);
233            return Err(error);
234        }
235        self.clients.remove(&key);
236
237        match create_client_with_specific_context(key.kubeconfig_path.clone(), context_name).await {
238            Ok(connection) => {
239                let cached_client = CachedClient::new(connection, signature);
240                let client_arc = cached_client.connection.clone();
241                self.clients.insert(key.clone(), cached_client);
242                drop(guard);
243                Ok(client_arc)
244            }
245            Err(error) => {
246                guard.push(SharedFailure {
247                    sequence: lock.sequence.fetch_add(1, Ordering::SeqCst) + 1,
248                    signature,
249                    error: clone_error_text(&error),
250                });
251                drop(guard);
252                Err(error)
253            }
254        }
255    }
256
257    /// Resolves a key with no explicit context to the kubeconfig's
258    /// current-context, so legacy configs and bulk actions that never set an
259    /// explicit context keep working through whatever context `kubectl`
260    /// would use. The resolved name is folded into the key so the client
261    /// cache and creation lock are keyed by the actual context, never by the
262    /// absence of one.
263    ///
264    /// The kubeconfig read + merge only happens on a cache miss: the
265    /// resolved context is cached per kubeconfig path in
266    /// `resolved_contexts`, so a repeated cache-hit call never touches disk
267    /// more than once. Resolving the paths and stating them (env var read
268    /// plus one stat per file) is redone on every call so a still-fresh TTL
269    /// entry can be treated as stale once the kubeconfig it came from
270    /// changed on disk, instead of only when the TTL expires; it runs on a
271    /// blocking thread like the merge below, since a kubeconfig on a
272    /// network/remote mount can make a stat take far longer than the
273    /// syscall normally implies, and that must not stall a runtime worker.
274    /// The signature is returned for explicit contexts too, so a cached
275    /// client is rebuilt after its kubeconfig changes.
276    async fn resolve_key(
277        &self, key: ServiceClientKey,
278    ) -> anyhow::Result<(ServiceClientKey, KubeconfigSignature)> {
279        let path_option = key.kubeconfig_path.clone();
280        let (current_paths, current_signature) = tokio::task::spawn_blocking(move || {
281            let current_paths = get_kubeconfig_paths_from_option(path_option)?;
282            let current_signature = kubeconfig_signature(&current_paths);
283            Ok::<_, anyhow::Error>((current_paths, current_signature))
284        })
285        .await
286        .map_err(|e| anyhow::anyhow!("kubeconfig path resolution task panicked: {e}"))??;
287
288        if key.context_name.is_some() {
289            return Ok((key, current_signature));
290        }
291
292        if let Some(resolved) = self.resolved_contexts.get(&key.kubeconfig_path) {
293            let (context_name, resolved_at, signature) = resolved.value();
294            if resolved_at.elapsed() <= self.client_ttl && *signature == current_signature {
295                return Ok((
296                    ServiceClientKey {
297                        context_name: Some(context_name.clone()),
298                        kubeconfig_path: key.kubeconfig_path,
299                    },
300                    current_signature,
301                ));
302            }
303        }
304
305        let kubeconfig_path = key.kubeconfig_path.clone();
306        let current_context = tokio::task::spawn_blocking(move || {
307            let (kubeconfig, errors) = merge_kubeconfigs(&current_paths)?;
308            match kubeconfig.current_context {
309                Some(current_context) => {
310                    if !errors.is_empty() {
311                        warn!(
312                            "Resolved current-context for kubeconfig {} despite {} file(s) \
313                             failing to read or merge: {}",
314                            kubeconfig_path.as_deref().unwrap_or("default"),
315                            errors.len(),
316                            errors.join("; ")
317                        );
318                    }
319                    Ok(current_context)
320                }
321                None if errors.is_empty() => Err(anyhow::anyhow!(
322                    "Kubernetes context is required (kubeconfig: {}) and it has no \
323                     current-context",
324                    kubeconfig_path.as_deref().unwrap_or("default")
325                )),
326                None => Err(anyhow::anyhow!(
327                    "Kubernetes context is required (kubeconfig: {}) and it has no \
328                     current-context; it also failed to read: {}",
329                    kubeconfig_path.as_deref().unwrap_or("default"),
330                    errors.join("; ")
331                )),
332            }
333        })
334        .await
335        .map_err(|e| anyhow::anyhow!("kubeconfig resolution task panicked: {e}"))??;
336
337        self.resolved_contexts.insert(
338            key.kubeconfig_path.clone(),
339            (
340                current_context.clone(),
341                Instant::now(),
342                current_signature.clone(),
343            ),
344        );
345
346        Ok((
347            ServiceClientKey {
348                context_name: Some(current_context),
349                kubeconfig_path: key.kubeconfig_path,
350            },
351            current_signature,
352        ))
353    }
354
355    /// Drops a creation lock nobody else is using. Every creation attempt —
356    /// success or failure — calls this so the lock entry does not outlive the
357    /// callers racing to build a client for one context; a lock another
358    /// waiter still holds a clone of is left in place.
359    fn release_creation_lock(&self, key: &ServiceClientKey, lock: &CreationLock) {
360        if lock.interest.fetch_sub(1, Ordering::SeqCst) == 1 {
361            self.creation_locks.remove_if(key, |_, entry| {
362                Arc::ptr_eq(entry, lock) && entry.interest.load(Ordering::SeqCst) == 0
363            });
364        }
365    }
366
367    /// Invalidates the cached client(s) for `key`. A `None` context means
368    /// the caller does not know (or no longer trusts) which context is
369    /// current for this kubeconfig. If a context was already resolved for
370    /// this kubeconfig path (see `resolved_contexts`), only that exact
371    /// entry is evicted and the recorded resolution is dropped too, so a
372    /// later `resolve_key` call re-reads the current context from disk
373    /// instead of replaying the stale cached one; otherwise fall back to
374    /// dropping every entry for the kubeconfig path, since there is no
375    /// recorded context to target. An explicit context is checked against
376    /// the recorded resolution for its kubeconfig path too and evicts it
377    /// when it matches, so a later `None`-context call does not replay a
378    /// resolution an exact-context eviction just invalidated.
379    pub fn invalidate_client(&self, key: &ServiceClientKey) {
380        if key.context_name.is_none() {
381            if let Some((_, (resolved, _, _))) = self.resolved_contexts.remove(&key.kubeconfig_path)
382            {
383                let resolved_key = ServiceClientKey {
384                    context_name: Some(resolved),
385                    kubeconfig_path: key.kubeconfig_path.clone(),
386                };
387                self.clients.remove(&resolved_key);
388                return;
389            }
390            self.clients
391                .retain(|cached_key, _| cached_key.kubeconfig_path != key.kubeconfig_path);
392            return;
393        }
394
395        if let Some(ctx) = key.context_name.as_deref() {
396            self.resolved_contexts
397                .remove_if(&key.kubeconfig_path, |_, (resolved, _, _)| resolved == ctx);
398        }
399        self.clients.remove(key);
400    }
401
402    /// Drops every cached client no key in `in_use` still needs, so the
403    /// connections it pooled close now rather than when the TTL expires. A
404    /// key without a context keeps every client for its kubeconfig, since
405    /// the forward using it resolved through `resolved_contexts`.
406    pub fn release_unused(&self, in_use: &HashSet<ServiceClientKey>) {
407        self.clients.retain(|key, _| {
408            in_use.contains(key)
409                || in_use.contains(&ServiceClientKey::new(None, key.kubeconfig_path.clone()))
410        });
411    }
412
413    pub fn cleanup_expired(&self) {
414        self.clients
415            .retain(|_, cached| !cached.is_expired(self.client_ttl));
416        self.creation_locks.retain(|key, lock| {
417            self.clients.contains_key(key) || lock.interest.load(Ordering::SeqCst) > 0
418        });
419        self.resolved_contexts
420            .retain(|kubeconfig_path, (context_name, resolved_at, _)| {
421                if resolved_at.elapsed() > self.client_ttl {
422                    return false;
423                }
424                let resolved_key = ServiceClientKey {
425                    context_name: Some(context_name.clone()),
426                    kubeconfig_path: kubeconfig_path.clone(),
427                };
428                self.clients.contains_key(&resolved_key)
429            });
430    }
431}
432
433impl Default for SharedClientManager {
434    fn default() -> Self {
435        Self::new()
436    }
437}
438
439pub static SHARED_CLIENT_MANAGER: Lazy<SharedClientManager> = Lazy::new(SharedClientManager::new);
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    /// Polls until the creation lock's interest counter reaches `expected`,
446    /// instead of sleeping a fixed duration and hoping the other task got
447    /// scheduled in time.
448    async fn wait_for_interest_count(lock: &CreationLock, expected: usize) {
449        for _ in 0..500 {
450            if lock.interest.load(Ordering::SeqCst) == expected {
451                return;
452            }
453            tokio::time::sleep(Duration::from_millis(1)).await;
454        }
455        panic!(
456            "creation lock interest count did not reach {expected} in time (last seen {})",
457            lock.interest.load(Ordering::SeqCst)
458        );
459    }
460
461    #[tokio::test]
462    async fn get_connection_errors_when_kubeconfig_has_no_current_context() {
463        let manager = SharedClientManager::new();
464        let key = ServiceClientKey::new(
465            None,
466            Some("/nonexistent/kftray-test-kubeconfig-no-current-context".to_string()),
467        );
468
469        let err = manager.get_connection(key).await.expect_err(
470            "a key without a context and without a resolvable current-context must not resolve",
471        );
472        assert!(
473            err.to_string().contains("current-context"),
474            "error should name the missing current-context: {err}"
475        );
476        assert!(
477            err.to_string().contains("failed to read"),
478            "error should surface the underlying kubeconfig read failure instead of \
479             discarding it: {err}"
480        );
481    }
482
483    #[tokio::test]
484    async fn get_connection_resolves_none_context_to_kubeconfig_current_context() {
485        use std::io::Write;
486
487        crate::ssl::ensure_crypto_provider_installed();
488
489        let mut kubeconfig_file = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
490        write!(
491            kubeconfig_file,
492            "apiVersion: v1\n\
493             kind: Config\n\
494             current-context: kftray-test-current-context\n\
495             contexts:\n\
496             - name: kftray-test-current-context\n  \
497               context:\n    \
498                 cluster: kftray-test-cluster\n    \
499                 user: kftray-test-user\n\
500             clusters:\n\
501             - name: kftray-test-cluster\n  \
502               cluster:\n    \
503                 server: https://127.0.0.1:1\n\
504             users:\n\
505             - name: kftray-test-user\n  \
506               user: {{}}\n"
507        )
508        .expect("write temp kubeconfig");
509
510        let manager = SharedClientManager::new();
511        let key = ServiceClientKey::new(
512            None,
513            Some(kubeconfig_file.path().to_string_lossy().to_string()),
514        );
515
516        let err = manager
517            .get_connection(key)
518            .await
519            .expect_err("an unreachable resolved cluster must not produce a client");
520        assert!(
521            err.to_string().contains("kftray-test-current-context"),
522            "resolving a None context must use the kubeconfig's current-context name so the \
523             connection attempt (and its error) names it: {err}"
524        );
525        assert!(
526            !err.to_string().contains("Kubernetes context is required"),
527            "resolving a None context via the kubeconfig's current-context must not fail with \
528             a missing-context error: {err}"
529        );
530    }
531
532    #[tokio::test]
533    async fn a_config_imported_without_a_context_connects_with_the_current_context() {
534        use kftray_commons::utils::config::{
535            import_configs_with_mode,
536            read_configs_with_mode,
537        };
538        use kftray_commons::utils::db_mode::DatabaseMode;
539
540        crate::ssl::ensure_crypto_provider_installed();
541        let _db = kftray_commons::test_utils::test_db().await;
542        let kubeconfig_file = tempfile::NamedTempFile::new().unwrap();
543        std::fs::write(
544            kubeconfig_file.path(),
545            kubeconfig_yaml("kftray-test-current-context"),
546        )
547        .unwrap();
548        import_configs_with_mode(
549            r#"[{"service":"web","namespace":"default","workload_type":"service",
550                "protocol":"tcp","remote_port":80,"local_port":18080}]"#
551                .to_string(),
552            DatabaseMode::Memory,
553        )
554        .await
555        .unwrap();
556        let config = read_configs_with_mode(DatabaseMode::Memory)
557            .await
558            .unwrap()
559            .remove(0);
560
561        let key = ServiceClientKey::new(
562            config.context,
563            Some(kubeconfig_file.path().to_string_lossy().to_string()),
564        );
565        let err = SharedClientManager::new()
566            .get_connection(key)
567            .await
568            .expect_err("an unreachable cluster must not produce a client");
569
570        assert!(
571            err.to_string().contains("kftray-test-current-context"),
572            "{err}"
573        );
574    }
575
576    fn kubeconfig_yaml(context_name: &str) -> String {
577        format!(
578            "apiVersion: v1\n\
579             kind: Config\n\
580             current-context: {context_name}\n\
581             contexts:\n\
582             - name: {context_name}\n  \
583               context:\n    \
584                 cluster: kftray-test-cluster\n    \
585                 user: kftray-test-user\n\
586             clusters:\n\
587             - name: kftray-test-cluster\n  \
588               cluster:\n    \
589                 server: https://127.0.0.1:1\n\
590             users:\n\
591             - name: kftray-test-user\n  \
592               user: {{}}\n"
593        )
594    }
595
596    /// Rewrites `path` and restores its mtime to `mtime`, so the write is
597    /// invisible to a signature comparison keyed on the file's timestamp.
598    fn rewrite_kubeconfig_preserving_mtime(
599        path: &std::path::Path, contents: &str, mtime: std::time::SystemTime,
600    ) {
601        std::fs::write(path, contents).expect("rewrite temp kubeconfig");
602        std::fs::OpenOptions::new()
603            .write(true)
604            .open(path)
605            .and_then(|file| file.set_times(std::fs::FileTimes::new().set_modified(mtime)))
606            .expect("restore the kubeconfig's mtime");
607    }
608
609    #[tokio::test]
610    async fn resolve_key_with_none_context_is_cached_on_a_hit_within_ttl() {
611        use std::io::Write;
612
613        let mut kubeconfig_file = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
614        write!(
615            kubeconfig_file,
616            "{}",
617            kubeconfig_yaml("kftray-test-cache-hit-context")
618        )
619        .expect("write temp kubeconfig");
620
621        let manager = SharedClientManager::new();
622        let key = ServiceClientKey::new(
623            None,
624            Some(kubeconfig_file.path().to_string_lossy().to_string()),
625        );
626
627        let (first_resolved, _) = manager
628            .resolve_key(key.clone())
629            .await
630            .expect("a kubeconfig with a current-context must resolve");
631        assert_eq!(
632            first_resolved.context_name.as_deref(),
633            Some("kftray-test-cache-hit-context")
634        );
635
636        let original_mtime = std::fs::metadata(kubeconfig_file.path())
637            .and_then(|meta| meta.modified())
638            .expect("read the kubeconfig's mtime");
639        // Rewritten with a different current-context but restored to its
640        // original mtime: a cache hit must reuse the recorded resolution
641        // since nothing it checks (TTL, mtime) says the file changed.
642        rewrite_kubeconfig_preserving_mtime(
643            kubeconfig_file.path(),
644            &kubeconfig_yaml("kftray-test-changed-context"),
645            original_mtime,
646        );
647
648        let (second_resolved, _) = manager
649            .resolve_key(key)
650            .await
651            .expect("a cache hit must resolve without needing the rewritten kubeconfig to parse");
652        assert_eq!(
653            second_resolved.context_name.as_deref(),
654            Some("kftray-test-cache-hit-context"),
655            "a cache hit with an unchanged mtime must reuse the recorded resolved context"
656        );
657    }
658
659    #[tokio::test]
660    async fn resolve_key_re_resolves_when_the_kubeconfig_mtime_changes() {
661        use std::io::Write;
662
663        let mut kubeconfig_file = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
664        write!(
665            kubeconfig_file,
666            "{}",
667            kubeconfig_yaml("kftray-test-original-context")
668        )
669        .expect("write temp kubeconfig");
670
671        let manager = SharedClientManager::new();
672        let key = ServiceClientKey::new(
673            None,
674            Some(kubeconfig_file.path().to_string_lossy().to_string()),
675        );
676
677        let (first_resolved, _) = manager
678            .resolve_key(key.clone())
679            .await
680            .expect("a kubeconfig with a current-context must resolve");
681        assert_eq!(
682            first_resolved.context_name.as_deref(),
683            Some("kftray-test-original-context")
684        );
685
686        // A different current-context, with the mtime moved well past
687        // filesystem timestamp resolution, simulates the user switching
688        // contexts (e.g. `kubectl config use-context`) while the TTL is
689        // still fresh.
690        let advanced_mtime = std::time::SystemTime::now() + Duration::from_secs(60);
691        rewrite_kubeconfig_preserving_mtime(
692            kubeconfig_file.path(),
693            &kubeconfig_yaml("kftray-test-switched-context"),
694            advanced_mtime,
695        );
696
697        let (second_resolved, _) = manager
698            .resolve_key(key)
699            .await
700            .expect("the rewritten kubeconfig must still resolve");
701        assert_eq!(
702            second_resolved.context_name.as_deref(),
703            Some("kftray-test-switched-context"),
704            "a changed mtime must force a re-resolution even though the TTL has not expired"
705        );
706    }
707
708    #[tokio::test]
709    async fn invalidate_client_with_none_context_uses_recorded_resolved_context_only() {
710        use http::{
711            Request,
712            Response,
713        };
714        use kube::client::Body;
715        use tower_test::mock;
716
717        let manager = SharedClientManager::new();
718        let kubeconfig_path = Some("kftray-test-recorded-context-kubeconfig".to_string());
719
720        let make_cached_client = || {
721            let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
722            CachedClient::new(
723                KubeConnection {
724                    client: kube::Client::new(mock_service, "default"),
725                    cluster_url: "https://example.invalid".parse().unwrap(),
726                },
727                Vec::new(),
728            )
729        };
730
731        let recorded_key =
732            ServiceClientKey::new(Some("recorded-ctx".to_string()), kubeconfig_path.clone());
733        let other_key =
734            ServiceClientKey::new(Some("other-ctx".to_string()), kubeconfig_path.clone());
735
736        manager
737            .clients
738            .insert(recorded_key.clone(), make_cached_client());
739        manager
740            .clients
741            .insert(other_key.clone(), make_cached_client());
742        manager.resolved_contexts.insert(
743            kubeconfig_path.clone(),
744            ("recorded-ctx".to_string(), Instant::now(), Vec::new()),
745        );
746
747        manager.invalidate_client(&ServiceClientKey::new(None, kubeconfig_path.clone()));
748
749        assert!(
750            !manager.clients.contains_key(&recorded_key),
751            "the recorded resolved-context entry must be evicted"
752        );
753        assert!(
754            manager.clients.contains_key(&other_key),
755            "invalidation with a recorded resolved context must hit only that exact key, not \
756             sweep every entry for the kubeconfig path"
757        );
758        assert!(
759            !manager.resolved_contexts.contains_key(&kubeconfig_path),
760            "invalidation must drop the recorded resolved context so a later resolve re-reads \
761             the current context from disk"
762        );
763    }
764
765    #[tokio::test]
766    async fn get_connection_releases_lock_after_failed_creation() {
767        let manager = SharedClientManager::new();
768        let key = ServiceClientKey::new(
769            Some("kftray-test-nonexistent-context".to_string()),
770            Some("/nonexistent/kftray-test-kubeconfig".to_string()),
771        );
772
773        manager.get_connection(key.clone()).await.expect_err(
774            "a context absent from the (empty) kubeconfig must fail to create a client",
775        );
776
777        assert!(
778            !manager.creation_locks.contains_key(&key),
779            "a failed creation with no other waiter must not leak its lock entry"
780        );
781    }
782
783    #[tokio::test(flavor = "multi_thread", worker_threads = 8)]
784    async fn concurrent_get_connection_calls_never_leak_a_fresh_creation_lock_entry() {
785        let manager = Arc::new(SharedClientManager::new());
786        let key = ServiceClientKey::new(
787            Some("kftray-test-race-context".to_string()),
788            Some("/nonexistent/kftray-test-race-kubeconfig".to_string()),
789        );
790
791        // Many concurrent callers race the same never-cached key, each
792        // failing fast (nonexistent kubeconfig), so the creation-lock entry
793        // for this key is repeatedly created, contended, and reclaimed many
794        // times over real multi-core parallelism. Interest must be
795        // registered while the shard guard is still held: if it were
796        // registered afterwards (the pre-fix ordering), a concurrent
797        // `release_creation_lock` racing the gap between guard release and
798        // registration could remove the entry a caller just cloned, and the
799        // next caller would insert a second, different lock for the same
800        // key — surfacing here as a leaked/duplicated entry once every task
801        // has finished, or as a panic if the interest guard's `Drop` ever
802        // ran while the shard guard was still held.
803        for _ in 0..25 {
804            let mut handles = Vec::with_capacity(32);
805            for _ in 0..32 {
806                let manager = Arc::clone(&manager);
807                let key = key.clone();
808                handles.push(tokio::spawn(
809                    async move { manager.get_connection(key).await },
810                ));
811            }
812            for handle in handles {
813                let _ = handle
814                    .await
815                    .expect("a racing get_connection call must not panic");
816            }
817
818            assert!(
819                !manager.creation_locks.contains_key(&key),
820                "every caller failed and released; the creation-lock entry must not be leaked"
821            );
822        }
823    }
824
825    #[tokio::test]
826    async fn invalidate_client_with_none_context_removes_resolved_entry() {
827        use std::io::Write;
828
829        use http::{
830            Request,
831            Response,
832        };
833        use kube::client::Body;
834        use tower_test::mock;
835
836        let mut kubeconfig_file = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
837        write!(
838            kubeconfig_file,
839            "apiVersion: v1\n\
840             kind: Config\n\
841             current-context: kftray-test-invalidate-context\n\
842             contexts:\n\
843             - name: kftray-test-invalidate-context\n  \
844               context:\n    \
845                 cluster: kftray-test-cluster\n    \
846                 user: kftray-test-user\n\
847             clusters:\n\
848             - name: kftray-test-cluster\n  \
849               cluster:\n    \
850                 server: https://127.0.0.1:1\n\
851             users:\n\
852             - name: kftray-test-user\n  \
853               user: {{}}\n"
854        )
855        .expect("write temp kubeconfig");
856
857        let manager = SharedClientManager::new();
858        let kubeconfig_path = kubeconfig_file.path().to_string_lossy().to_string();
859        let raw_key = ServiceClientKey::new(None, Some(kubeconfig_path.clone()));
860        let resolved_key = ServiceClientKey::new(
861            Some("kftray-test-invalidate-context".to_string()),
862            Some(kubeconfig_path.clone()),
863        );
864
865        let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
866        let connection = KubeConnection {
867            client: kube::Client::new(mock_service, "default"),
868            cluster_url: "https://example.invalid".parse().unwrap(),
869        };
870        manager.clients.insert(
871            resolved_key.clone(),
872            CachedClient::new(connection, Vec::new()),
873        );
874        manager.resolved_contexts.insert(
875            Some(kubeconfig_path.clone()),
876            (
877                "kftray-test-invalidate-context".to_string(),
878                Instant::now(),
879                Vec::new(),
880            ),
881        );
882
883        manager.invalidate_client(&raw_key);
884
885        assert!(
886            !manager.clients.contains_key(&resolved_key),
887            "invalidate_client with a None-context key must remove the entry cached under \
888             the resolved current-context key, not just the never-cached raw key"
889        );
890        assert!(
891            !manager
892                .resolved_contexts
893                .contains_key(&Some(kubeconfig_path)),
894            "invalidation must drop the recorded resolved context so a later resolve re-reads \
895             the current context from disk"
896        );
897    }
898
899    #[tokio::test]
900    async fn invalidate_client_with_none_context_clears_every_entry_for_kubeconfig_path() {
901        use http::{
902            Request,
903            Response,
904        };
905        use kube::client::Body;
906        use tower_test::mock;
907
908        let manager = SharedClientManager::new();
909        let kubeconfig_path = Some("kftray-test-shared-kubeconfig".to_string());
910
911        let make_cached_client = || {
912            let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
913            CachedClient::new(
914                KubeConnection {
915                    client: kube::Client::new(mock_service, "default"),
916                    cluster_url: "https://example.invalid".parse().unwrap(),
917                },
918                Vec::new(),
919            )
920        };
921
922        let key_a = ServiceClientKey::new(Some("context-a".to_string()), kubeconfig_path.clone());
923        let key_b = ServiceClientKey::new(Some("context-b".to_string()), kubeconfig_path.clone());
924        let other_kubeconfig_key = ServiceClientKey::new(
925            Some("context-a".to_string()),
926            Some("kftray-test-other-kubeconfig".to_string()),
927        );
928
929        manager.clients.insert(key_a.clone(), make_cached_client());
930        manager.clients.insert(key_b.clone(), make_cached_client());
931        manager
932            .clients
933            .insert(other_kubeconfig_key.clone(), make_cached_client());
934
935        manager.invalidate_client(&ServiceClientKey::new(None, kubeconfig_path));
936
937        assert!(
938            !manager.clients.contains_key(&key_a),
939            "every cached context for the kubeconfig path must be dropped"
940        );
941        assert!(
942            !manager.clients.contains_key(&key_b),
943            "every cached context for the kubeconfig path must be dropped, not just one \
944             re-resolved current-context entry"
945        );
946        assert!(
947            manager.clients.contains_key(&other_kubeconfig_key),
948            "entries for a different kubeconfig path must be left untouched"
949        );
950    }
951
952    #[tokio::test]
953    async fn release_unused_keeps_clients_a_forward_still_needs() {
954        use http::{
955            Request,
956            Response,
957        };
958        use kube::client::Body;
959        use tower_test::mock;
960
961        let manager = SharedClientManager::new();
962        let kubeconfig_path = Some("kftray-test-shared-kubeconfig".to_string());
963
964        let make_cached_client = || {
965            let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
966            CachedClient::new(
967                KubeConnection {
968                    client: kube::Client::new(mock_service, "default"),
969                    cluster_url: "https://example.invalid".parse().unwrap(),
970                },
971                Vec::new(),
972            )
973        };
974
975        let key_a = ServiceClientKey::new(Some("context-a".to_string()), kubeconfig_path.clone());
976        let key_b = ServiceClientKey::new(Some("context-b".to_string()), kubeconfig_path.clone());
977        let other_kubeconfig_key = ServiceClientKey::new(
978            Some("context-a".to_string()),
979            Some("kftray-test-other-kubeconfig".to_string()),
980        );
981
982        manager.clients.insert(key_a.clone(), make_cached_client());
983        manager.clients.insert(key_b.clone(), make_cached_client());
984        manager
985            .clients
986            .insert(other_kubeconfig_key.clone(), make_cached_client());
987
988        manager.release_unused(&HashSet::from([
989            key_b.clone(),
990            ServiceClientKey::new(None, other_kubeconfig_key.kubeconfig_path.clone()),
991        ]));
992
993        assert!(
994            !manager.clients.contains_key(&key_a),
995            "a client no forward uses must be dropped"
996        );
997        assert!(
998            manager.clients.contains_key(&key_b),
999            "a client a forward names by context must stay"
1000        );
1001        assert!(
1002            manager.clients.contains_key(&other_kubeconfig_key),
1003            "a forward without a context keeps every client for its kubeconfig"
1004        );
1005
1006        manager.release_unused(&HashSet::new());
1007        assert!(
1008            manager.clients.is_empty(),
1009            "with nothing forwarding, no client is kept"
1010        );
1011    }
1012
1013    #[tokio::test]
1014    async fn cancelling_get_connection_releases_creation_lock_interest() {
1015        let manager = Arc::new(SharedClientManager::new());
1016        let key = ServiceClientKey::new(
1017            Some("ctx".to_string()),
1018            Some("kftray-test-cancel-kubeconfig".to_string()),
1019        );
1020
1021        // Hold the creation lock so the spawned get_connection call
1022        // registers interest and then blocks awaiting the mutex, mirroring
1023        // a real second caller arriving while a first creation is in
1024        // flight.
1025        let lock = manager
1026            .creation_locks
1027            .entry(key.clone())
1028            .or_insert_with(new_creation_lock)
1029            .clone();
1030        let guard = lock.failures.lock().await;
1031
1032        let waiter_manager = manager.clone();
1033        let waiter_key = key.clone();
1034        let handle = tokio::spawn(async move { waiter_manager.get_connection(waiter_key).await });
1035
1036        // Let the waiter register interest and block on the held mutex.
1037        wait_for_interest_count(&lock, 1).await;
1038        assert_eq!(
1039            lock.interest.load(Ordering::SeqCst),
1040            1,
1041            "waiter must have registered interest before being cancelled"
1042        );
1043
1044        // Cancel the waiter mid-await by dropping its future, the way
1045        // task cancellation does.
1046        handle.abort();
1047        let _ = handle.await;
1048
1049        assert_eq!(
1050            lock.interest.load(Ordering::SeqCst),
1051            0,
1052            "a cancelled get_connection must release its creation-lock interest"
1053        );
1054
1055        drop(guard);
1056    }
1057
1058    #[test]
1059    fn release_creation_lock_keeps_entry_when_another_waiter_holds_it() {
1060        let manager = SharedClientManager::new();
1061        let key = ServiceClientKey::new(Some("ctx".to_string()), None);
1062        let lock = manager
1063            .creation_locks
1064            .entry(key.clone())
1065            .or_insert_with(new_creation_lock)
1066            .clone();
1067        // Two callers registered interest; only one releases.
1068        lock.interest.fetch_add(2, Ordering::SeqCst);
1069
1070        manager.release_creation_lock(&key, &lock);
1071
1072        assert!(
1073            manager.creation_locks.contains_key(&key),
1074            "a lock another waiter still references must not be dropped"
1075        );
1076    }
1077
1078    #[test]
1079    fn cleanup_expired_retains_lock_entries_still_in_use() {
1080        let manager = SharedClientManager::new();
1081        let key = ServiceClientKey::new(Some("ctx".to_string()), None);
1082        let lock = manager
1083            .creation_locks
1084            .entry(key.clone())
1085            .or_insert_with(new_creation_lock)
1086            .clone();
1087        lock.interest.fetch_add(1, Ordering::SeqCst);
1088
1089        manager.cleanup_expired();
1090        assert!(
1091            manager.creation_locks.contains_key(&key),
1092            "cleanup_expired must not drop a lock entry an in-flight creation still holds"
1093        );
1094
1095        lock.interest.fetch_sub(1, Ordering::SeqCst);
1096        manager.cleanup_expired();
1097        assert!(
1098            !manager.creation_locks.contains_key(&key),
1099            "cleanup_expired should drop an unheld lock entry once no client is cached for it"
1100        );
1101    }
1102
1103    #[tokio::test]
1104    async fn get_connection_releases_lock_on_post_lock_cache_hit() {
1105        use http::{
1106            Request,
1107            Response,
1108        };
1109        use kube::client::Body;
1110        use tower_test::mock;
1111
1112        let manager = Arc::new(SharedClientManager::new());
1113        let kubeconfig_path = "kftray-test-post-lock-kubeconfig";
1114        let key = ServiceClientKey::new(Some("ctx".to_string()), Some(kubeconfig_path.to_string()));
1115
1116        // Stand in for a concurrent first caller that already holds the
1117        // creation lock, about to finish populating the cache.
1118        let lock = manager
1119            .creation_locks
1120            .entry(key.clone())
1121            .or_insert_with(new_creation_lock)
1122            .clone();
1123        lock.interest.fetch_add(1, Ordering::SeqCst);
1124        let guard = lock.failures.lock().await;
1125
1126        let waiter_manager = manager.clone();
1127        let waiter_key = key.clone();
1128        let waiter = tokio::spawn(async move { waiter_manager.get_connection(waiter_key).await });
1129
1130        // Let the waiter's pre-lock cache check (a miss) run and block it
1131        // on the still-held creation lock, mirroring a real race where a
1132        // second caller arrives while the first is still creating a client.
1133        wait_for_interest_count(&lock, 2).await;
1134
1135        let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
1136        let connection = KubeConnection {
1137            client: kube::Client::new(mock_service, "default"),
1138            cluster_url: "https://example.invalid".parse().unwrap(),
1139        };
1140        manager.clients.insert(
1141            key.clone(),
1142            CachedClient::new(
1143                connection,
1144                kubeconfig_signature(&[PathBuf::from(kubeconfig_path)]),
1145            ),
1146        );
1147
1148        // Release the lock the way a real creator finishing its own attempt
1149        // would: drop the guard, then release this caller's interest. The
1150        // waiter's post-lock check now sees the cache entry just inserted
1151        // and must take the cache-hit path.
1152        drop(guard);
1153        manager.release_creation_lock(&key, &lock);
1154
1155        let result = tokio::time::timeout(Duration::from_secs(2), waiter)
1156            .await
1157            .expect("post-lock cache hit must resolve, not hang")
1158            .expect("waiter task must not panic");
1159        assert!(
1160            result.is_ok(),
1161            "post-lock cache hit must resolve to the cached connection"
1162        );
1163
1164        assert!(
1165            !manager.creation_locks.contains_key(&key),
1166            "a post-lock cache hit must release its clone of the creation lock"
1167        );
1168    }
1169
1170    const FAILING_KUBECONFIG: &str = "/nonexistent/kftray-test-shared-failure-kubeconfig";
1171
1172    fn failing_signature() -> KubeconfigSignature {
1173        kubeconfig_signature(&[PathBuf::from(FAILING_KUBECONFIG)])
1174    }
1175
1176    fn stale_signature() -> KubeconfigSignature {
1177        vec![(
1178            PathBuf::from(FAILING_KUBECONFIG),
1179            Some(std::time::SystemTime::UNIX_EPOCH),
1180        )]
1181    }
1182
1183    fn record_failure(
1184        lock: &CreationLock, failures: &mut Vec<SharedFailure>, signature: KubeconfigSignature,
1185        message: &str,
1186    ) {
1187        failures.push(SharedFailure {
1188            sequence: lock.sequence.fetch_add(1, Ordering::SeqCst) + 1,
1189            signature,
1190            error: anyhow::anyhow!("{message}").context("Failed to create client"),
1191        });
1192    }
1193
1194    async fn waiter_behind_failed_creations(
1195        stored: Vec<(KubeconfigSignature, &str)>,
1196    ) -> (ServiceClientKey, Arc<SharedClientManager>, anyhow::Error) {
1197        let manager = Arc::new(SharedClientManager::new());
1198        let key = ServiceClientKey::new(
1199            Some("ctx".to_string()),
1200            Some(FAILING_KUBECONFIG.to_string()),
1201        );
1202
1203        let lock = manager
1204            .creation_locks
1205            .entry(key.clone())
1206            .or_insert_with(new_creation_lock)
1207            .clone();
1208        lock.interest.fetch_add(1, Ordering::SeqCst);
1209        let mut guard = lock.failures.lock().await;
1210
1211        let waiter_manager = manager.clone();
1212        let waiter_key = key.clone();
1213        let waiter = tokio::spawn(async move { waiter_manager.get_connection(waiter_key).await });
1214        wait_for_interest_count(&lock, 2).await;
1215
1216        for (signature, message) in stored {
1217            record_failure(&lock, &mut guard, signature, message);
1218        }
1219        drop(guard);
1220        manager.release_creation_lock(&key, &lock);
1221
1222        let error = tokio::time::timeout(Duration::from_secs(5), waiter)
1223            .await
1224            .expect("a waiter behind a failed creation must not hang")
1225            .expect("waiter task must not panic")
1226            .expect_err("no client can be created from a nonexistent kubeconfig");
1227        (key, manager, error)
1228    }
1229
1230    #[tokio::test]
1231    async fn a_waiter_queued_behind_a_failed_creation_gets_that_failure() {
1232        let (key, manager, error) =
1233            waiter_behind_failed_creations(vec![(failing_signature(), "cluster unreachable")])
1234                .await;
1235
1236        assert_eq!(
1237            format!("{error:#}"),
1238            "Failed to create client: cluster unreachable"
1239        );
1240        assert!(
1241            !manager.creation_locks.contains_key(&key),
1242            "the shared failure must leave with the last queued caller"
1243        );
1244    }
1245
1246    #[tokio::test]
1247    async fn a_failure_of_another_kubeconfig_state_is_not_shared() {
1248        let (_, _, error) =
1249            waiter_behind_failed_creations(vec![(stale_signature(), "cluster unreachable")]).await;
1250
1251        assert!(
1252            !error.to_string().contains("cluster unreachable"),
1253            "a waiter whose kubeconfig differs from the failed attempt must try again: {error}"
1254        );
1255    }
1256
1257    #[tokio::test]
1258    async fn a_later_failure_of_another_kubeconfig_state_keeps_the_earlier_one() {
1259        let (_, _, error) = waiter_behind_failed_creations(vec![
1260            (failing_signature(), "cluster unreachable"),
1261            (stale_signature(), "edited kubeconfig rejected"),
1262        ])
1263        .await;
1264
1265        assert_eq!(
1266            format!("{error:#}"),
1267            "Failed to create client: cluster unreachable"
1268        );
1269    }
1270
1271    #[tokio::test]
1272    async fn a_failure_shared_past_a_later_success_of_another_state_leaves_that_success_cached() {
1273        crate::ssl::ensure_crypto_provider_installed();
1274        let port = fake_api_server().await;
1275        let kubeconfig = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
1276        std::fs::write(kubeconfig.path(), kubeconfig_yaml("ctx")).expect("write temp kubeconfig");
1277        let kubeconfig_path = kubeconfig.path().to_string_lossy().to_string();
1278        let signature = || kubeconfig_signature(&[kubeconfig.path().to_path_buf()]);
1279        let manager = Arc::new(SharedClientManager::new());
1280        let key = ServiceClientKey::new(Some("ctx".to_string()), Some(kubeconfig_path.clone()));
1281
1282        let lock = manager
1283            .creation_locks
1284            .entry(key.clone())
1285            .or_insert_with(new_creation_lock)
1286            .clone();
1287        lock.interest.fetch_add(1, Ordering::SeqCst);
1288        let mut guard = lock.failures.lock().await;
1289
1290        let waiter_manager = manager.clone();
1291        let waiter_key = key.clone();
1292        let waiter = tokio::spawn(async move { waiter_manager.get_connection(waiter_key).await });
1293        wait_for_interest_count(&lock, 2).await;
1294
1295        record_failure(&lock, &mut guard, signature(), "cluster unreachable");
1296        rewrite_kubeconfig_preserving_mtime(
1297            kubeconfig.path(),
1298            &kubeconfig_for_server(port),
1299            std::time::SystemTime::now() + Duration::from_secs(60),
1300        );
1301        let created = create_client_with_specific_context(Some(kubeconfig_path), "ctx")
1302            .await
1303            .expect("the edited kubeconfig reaches the fake API server");
1304        let cached = CachedClient::new(created, signature());
1305        let newer_connection = cached.connection.clone();
1306        manager.clients.insert(key.clone(), cached);
1307        drop(guard);
1308        manager.release_creation_lock(&key, &lock);
1309
1310        let error = tokio::time::timeout(Duration::from_secs(5), waiter)
1311            .await
1312            .expect("a waiter given a shared failure must not hang")
1313            .expect("waiter task must not panic")
1314            .expect_err("the older caller is given the failure of its own kubeconfig state");
1315        assert_eq!(
1316            format!("{error:#}"),
1317            "Failed to create client: cluster unreachable"
1318        );
1319
1320        let retained = manager
1321            .clients
1322            .get(&key)
1323            .expect("the newer client must stay cached")
1324            .connection
1325            .clone();
1326        assert!(
1327            Arc::ptr_eq(&retained, &newer_connection),
1328            "the older caller's failure must not evict the newer client"
1329        );
1330
1331        let reused = manager
1332            .get_connection(key)
1333            .await
1334            .expect("a caller on the newer kubeconfig state reuses the cached client");
1335        assert!(
1336            Arc::ptr_eq(&reused, &newer_connection),
1337            "a caller on the newer kubeconfig state must not recreate the client"
1338        );
1339        let version = reused
1340            .client
1341            .apiserver_version()
1342            .await
1343            .expect("the retained client still reaches the cluster");
1344        assert_eq!(version.git_version, "v1.30.0");
1345    }
1346
1347    #[tokio::test]
1348    async fn a_caller_still_waiting_for_the_lock_leaves_a_stale_client_cached() {
1349        use http::{
1350            Request,
1351            Response,
1352        };
1353        use kube::client::Body;
1354        use tower_test::mock;
1355
1356        let manager = Arc::new(SharedClientManager::new());
1357        let key = ServiceClientKey::new(
1358            Some("ctx".to_string()),
1359            Some(FAILING_KUBECONFIG.to_string()),
1360        );
1361        let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
1362        let connection = KubeConnection {
1363            client: kube::Client::new(mock_service, "default"),
1364            cluster_url: "https://example.invalid".parse().unwrap(),
1365        };
1366        manager.clients.insert(
1367            key.clone(),
1368            CachedClient::new(connection, stale_signature()),
1369        );
1370
1371        let lock = manager
1372            .creation_locks
1373            .entry(key.clone())
1374            .or_insert_with(new_creation_lock)
1375            .clone();
1376        lock.interest.fetch_add(1, Ordering::SeqCst);
1377        let guard = lock.failures.lock().await;
1378
1379        let waiter_manager = manager.clone();
1380        let waiter_key = key.clone();
1381        let waiter = tokio::spawn(async move { waiter_manager.get_connection(waiter_key).await });
1382        wait_for_interest_count(&lock, 2).await;
1383
1384        assert!(
1385            manager.clients.contains_key(&key),
1386            "a caller that does not hold the creation lock must not evict the cached client"
1387        );
1388
1389        drop(guard);
1390        manager.release_creation_lock(&key, &lock);
1391        tokio::time::timeout(Duration::from_secs(5), waiter)
1392            .await
1393            .expect("the waiter must not hang")
1394            .expect("waiter task must not panic")
1395            .expect_err("no client can be created from a nonexistent kubeconfig");
1396
1397        assert!(
1398            !manager.clients.contains_key(&key),
1399            "the caller that replaces a client built for another kubeconfig state must evict it"
1400        );
1401    }
1402
1403    #[tokio::test]
1404    async fn a_caller_arriving_after_the_failure_tries_again() {
1405        let manager = SharedClientManager::new();
1406        let key = ServiceClientKey::new(
1407            Some("ctx".to_string()),
1408            Some(FAILING_KUBECONFIG.to_string()),
1409        );
1410        let lock = manager
1411            .creation_locks
1412            .entry(key.clone())
1413            .or_insert_with(new_creation_lock)
1414            .clone();
1415        lock.interest.fetch_add(1, Ordering::SeqCst);
1416        let mut failures = lock.failures.lock().await;
1417        record_failure(
1418            &lock,
1419            &mut failures,
1420            failing_signature(),
1421            "cluster unreachable",
1422        );
1423        drop(failures);
1424
1425        let error = manager
1426            .get_connection(key)
1427            .await
1428            .expect_err("no client can be created from a nonexistent kubeconfig");
1429
1430        assert!(
1431            !error.to_string().contains("cluster unreachable"),
1432            "a caller that registered after the failure must try again: {error}"
1433        );
1434    }
1435
1436    #[test]
1437    fn clone_error_text_keeps_the_message_and_its_causes() {
1438        let error = anyhow::anyhow!("root cause")
1439            .context("middle")
1440            .context("outer");
1441
1442        let copy = clone_error_text(&error);
1443
1444        assert_eq!(format!("{copy:#}"), format!("{error:#}"));
1445        assert_eq!(copy.to_string(), "outer");
1446    }
1447
1448    #[tokio::test]
1449    async fn invalidate_client_with_explicit_context_drops_matching_resolved_context() {
1450        use http::{
1451            Request,
1452            Response,
1453        };
1454        use kube::client::Body;
1455        use tower_test::mock;
1456
1457        let manager = SharedClientManager::new();
1458        let kubeconfig_path = Some("kftray-test-explicit-invalidate-kubeconfig".to_string());
1459
1460        let (mock_service, _handle) = mock::pair::<Request<Body>, Response<Body>>();
1461        let connection = KubeConnection {
1462            client: kube::Client::new(mock_service, "default"),
1463            cluster_url: "https://example.invalid".parse().unwrap(),
1464        };
1465
1466        let key = ServiceClientKey::new(Some("explicit-ctx".to_string()), kubeconfig_path.clone());
1467        manager
1468            .clients
1469            .insert(key.clone(), CachedClient::new(connection, Vec::new()));
1470        manager.resolved_contexts.insert(
1471            kubeconfig_path.clone(),
1472            ("explicit-ctx".to_string(), Instant::now(), Vec::new()),
1473        );
1474
1475        manager.invalidate_client(&key);
1476
1477        assert!(
1478            !manager.clients.contains_key(&key),
1479            "the invalidated client entry must be removed"
1480        );
1481        assert!(
1482            !manager.resolved_contexts.contains_key(&kubeconfig_path),
1483            "invalidating the context currently recorded as resolved must drop the recorded \
1484             resolution too, so a later None-context call re-reads the kubeconfig instead of \
1485             replaying the now-invalid context"
1486        );
1487    }
1488
1489    #[tokio::test]
1490    async fn invalidate_client_with_explicit_context_leaves_unrelated_resolved_context() {
1491        let manager = SharedClientManager::new();
1492        let kubeconfig_path = Some("kftray-test-unrelated-kubeconfig".to_string());
1493
1494        manager.resolved_contexts.insert(
1495            kubeconfig_path.clone(),
1496            ("current-ctx".to_string(), Instant::now(), Vec::new()),
1497        );
1498
1499        manager.invalidate_client(&ServiceClientKey::new(
1500            Some("some-other-ctx".to_string()),
1501            kubeconfig_path.clone(),
1502        ));
1503
1504        assert!(
1505            manager.resolved_contexts.contains_key(&kubeconfig_path),
1506            "invalidating a context that is not the currently recorded resolution must not \
1507             touch the recorded resolution"
1508        );
1509    }
1510
1511    #[tokio::test]
1512    async fn resolve_key_re_resolves_after_ttl_expires() {
1513        use std::io::Write;
1514
1515        let mut kubeconfig_file = tempfile::NamedTempFile::new().expect("create temp kubeconfig");
1516        write!(
1517            kubeconfig_file,
1518            "apiVersion: v1\n\
1519             kind: Config\n\
1520             current-context: kftray-test-ttl-context\n\
1521             contexts:\n\
1522             - name: kftray-test-ttl-context\n  \
1523               context:\n    \
1524                 cluster: kftray-test-cluster\n    \
1525                 user: kftray-test-user\n\
1526             clusters:\n\
1527             - name: kftray-test-cluster\n  \
1528               cluster:\n    \
1529                 server: https://127.0.0.1:1\n\
1530             users:\n\
1531             - name: kftray-test-user\n  \
1532               user: {{}}\n"
1533        )
1534        .expect("write temp kubeconfig");
1535
1536        let manager = SharedClientManager::with_ttl(Duration::from_millis(20));
1537        let key = ServiceClientKey::new(
1538            None,
1539            Some(kubeconfig_file.path().to_string_lossy().to_string()),
1540        );
1541
1542        let (first, _) = manager
1543            .resolve_key(key.clone())
1544            .await
1545            .expect("initial resolution must succeed");
1546        assert_eq!(
1547            first.context_name.as_deref(),
1548            Some("kftray-test-ttl-context")
1549        );
1550
1551        // Stamp the recorded resolution as older than the ttl and corrupt its
1552        // value; a fresh disk read is the only way the next resolve_key call
1553        // can recover the real context name.
1554        manager.resolved_contexts.insert(
1555            key.kubeconfig_path.clone(),
1556            (
1557                "stale-context".to_string(),
1558                Instant::now() - Duration::from_millis(21),
1559                Vec::new(),
1560            ),
1561        );
1562
1563        let (second, _) = manager
1564            .resolve_key(key)
1565            .await
1566            .expect("expired cache entry must fall back to a fresh kubeconfig read");
1567        assert_eq!(
1568            second.context_name.as_deref(),
1569            Some("kftray-test-ttl-context"),
1570            "an expired resolved-context cache entry must not be reused; resolve_key must \
1571             re-read the kubeconfig instead of returning the stale recorded context"
1572        );
1573    }
1574
1575    async fn fake_api_server() -> u16 {
1576        use tokio::io::{
1577            AsyncReadExt,
1578            AsyncWriteExt,
1579        };
1580        use tokio::net::TcpListener;
1581
1582        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1583        let port = listener.local_addr().unwrap().port();
1584        tokio::spawn(async move {
1585            loop {
1586                let Ok((mut socket, _)) = listener.accept().await else {
1587                    return;
1588                };
1589                tokio::spawn(async move {
1590                    let mut buf = [0u8; 4096];
1591                    let _ = socket.read(&mut buf).await;
1592                    let body = r#"{"major":"1","minor":"30","gitVersion":"v1.30.0","gitCommit":"x","gitTreeState":"clean","buildDate":"x","goVersion":"x","compiler":"gc","platform":"linux/amd64"}"#;
1593                    let response = format!(
1594                        "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: \
1595                         {}\r\nconnection: close\r\n\r\n{}",
1596                        body.len(),
1597                        body
1598                    );
1599                    let _ = socket.write_all(response.as_bytes()).await;
1600                });
1601            }
1602        });
1603        port
1604    }
1605
1606    fn kubeconfig_for_server(port: u16) -> String {
1607        format!(
1608            "apiVersion: v1\nkind: Config\ncurrent-context: ctx\ncontexts:\n- name: ctx\n  \
1609             context:\n    cluster: c\n    user: u\nclusters:\n- name: c\n  cluster:\n    \
1610             server: http://127.0.0.1:{port}\nusers:\n- name: u\n  user: {{}}\n"
1611        )
1612    }
1613
1614    #[tokio::test]
1615    async fn explicit_context_client_follows_kubeconfig_edit() {
1616        crate::ssl::ensure_crypto_provider_installed();
1617        let a = fake_api_server().await;
1618        let b = fake_api_server().await;
1619        let file = tempfile::NamedTempFile::new().unwrap();
1620        std::fs::write(file.path(), kubeconfig_for_server(a)).unwrap();
1621        let manager = SharedClientManager::new();
1622        let key = ServiceClientKey::new(
1623            Some("ctx".to_string()),
1624            Some(file.path().to_string_lossy().to_string()),
1625        );
1626        let first = manager.get_connection(key.clone()).await.unwrap();
1627        assert_eq!(first.cluster_url.port_u16(), Some(a));
1628        // Coarse-mtime file systems need the rewrite to land in a later second.
1629        tokio::time::sleep(Duration::from_millis(1100)).await;
1630        std::fs::write(file.path(), kubeconfig_for_server(b)).unwrap();
1631        let second = manager.get_connection(key).await.unwrap();
1632        assert_eq!(
1633            second.cluster_url.port_u16(),
1634            Some(b),
1635            "served the cached cluster {} after the kubeconfig moved to port {b}",
1636            second.cluster_url
1637        );
1638    }
1639}