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 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
69type 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
107struct CreationLockInterest<'a> {
125 manager: &'a SharedClientManager,
126 key: ServiceClientKey,
127 lock: CreationLock,
128}
129
130impl<'a> CreationLockInterest<'a> {
131 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
146type 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_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 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(¤t_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(¤t_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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}