• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdash-rs / kdash / 24133291995

08 Apr 2026 11:36AM UTC coverage: 65.009% (+0.2%) from 64.777%
24133291995

push

github

deepu105
fix: tests

7972 of 12263 relevant lines covered (65.01%)

155.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.48
/src/network/mod.rs
1
pub(crate) mod stream;
2

3
use core::convert::TryFrom;
4
use std::{
5
  env, fmt,
6
  io::ErrorKind,
7
  path::{Path, PathBuf},
8
  process::Stdio,
9
  sync::Arc,
10
  time::Duration,
11
};
12

13
use anyhow::{anyhow, Context, Result};
14
use k8s_openapi::{
15
  api::core::v1::Pod, apimachinery::pkg::apis::meta::v1::APIGroup as DiscoveryApiGroup,
16
  NamespaceResourceScope,
17
};
18
use kube::{
19
  api::ListParams,
20
  config::{KubeConfigOptions, Kubeconfig},
21
  core::{DynamicObject, GroupVersion},
22
  discovery::{pinned_group, verbs, Scope},
23
  Api, Client, Resource as ApiResource,
24
};
25
use log::{debug, error, info, warn};
26
use serde::de::DeserializeOwned;
27
use tokio::{process::Command, sync::Mutex, time::timeout};
28

29
use crate::app::{
30
  configmaps::ConfigMapResource,
31
  contexts,
32
  cronjobs::CronJobResource,
33
  daemonsets::DaemonSetResource,
34
  deployments::DeploymentResource,
35
  dynamic::{DynamicResource, KubeDynamicKind},
36
  ingress::IngressResource,
37
  jobs::JobResource,
38
  metrics::UtilizationResource,
39
  models::{AppResource, StatefulList},
40
  network_policies::NetworkPolicyResource,
41
  nodes::NodeResource,
42
  ns::NamespaceResource,
43
  pods::{KubePod, PodResource},
44
  pvcs::PvcResource,
45
  pvs::PvResource,
46
  replicasets::ReplicaSetResource,
47
  replication_controllers::ReplicationControllerResource,
48
  roles::{ClusterRoleBindingResource, ClusterRoleResource, RoleBindingResource, RoleResource},
49
  secrets::SecretResource,
50
  serviceaccounts::SvcAcctResource,
51
  statefulsets::StatefulSetResource,
52
  storageclass::StorageClassResource,
53
  svcs::SvcResource,
54
  troubleshoot::TroubleshootResource,
55
  ActiveBlock, App,
56
};
57

58
#[derive(Clone, Debug, Eq, PartialEq)]
59
pub enum IoEvent {
60
  GetKubeConfig,
61
  GetNodes,
62
  GetNamespaces,
63
  GetPods,
64
  GetServices,
65
  GetConfigMaps,
66
  GetStatefulSets,
67
  GetReplicaSets,
68
  GetDeployments,
69
  GetJobs,
70
  GetDaemonSets,
71
  GetCronJobs,
72
  GetSecrets,
73
  GetReplicationControllers,
74
  GetStorageClasses,
75
  GetRoles,
76
  GetRoleBindings,
77
  GetClusterRoles,
78
  GetClusterRoleBinding,
79
  GetIngress,
80
  GetPvcs,
81
  GetPvs,
82
  GetServiceAccounts,
83
  GetMetrics,
84
  GetTroubleshootFindings,
85
  RefreshClient,
86
  DiscoverDynamicRes,
87
  GetDynamicRes,
88
  GetNetworkPolicies,
89
  GetPodsBySelector { namespace: String, selector: String },
90
  GetPodsByNode { node_name: String },
91
}
92

93
async fn refresh_kube_config(context: &Option<String>) -> Result<kube::Client> {
1✔
94
  match get_client(context.clone()).await {
1✔
95
    Ok(client) => Ok(client),
1✔
96
    Err(err) if should_retry_kubectl_refresh(&err) => {
×
97
      warn!(
×
98
        "Initial client refresh failed with auth-related error, retrying after `kubectl cluster-info`: {:#}",
99
        err
100
      );
101
      run_kubectl_cluster_info(context, Duration::from_secs(3)).await?;
×
102
      get_client(context.clone()).await
×
103
    }
104
    Err(err) => Err(err),
×
105
  }
106
}
1✔
107

108
fn should_retry_kubectl_refresh(error: &anyhow::Error) -> bool {
3✔
109
  error.chain().any(|cause| {
3✔
110
    cause
3✔
111
      .downcast_ref::<kube::Error>()
3✔
112
      .is_some_and(|error| match error {
3✔
113
        kube::Error::Auth(_) => true,
1✔
114
        kube::Error::Api(status) => status.code == 401 || status.reason == "Unauthorized",
1✔
115
        _ => false,
×
116
      })
2✔
117
      || {
118
        let message = cause.to_string().to_lowercase();
1✔
119
        message.contains("auth exec")
1✔
120
          || message.contains("failed exec auth")
1✔
121
          || message.contains("exec-plugin")
1✔
122
          || message.contains("oidc")
1✔
123
          || message.contains("oauth")
1✔
124
          || message.contains("unauthorized")
1✔
125
      }
126
  })
3✔
127
}
3✔
128

129
async fn run_kubectl_cluster_info(context: &Option<String>, max_wait: Duration) -> Result<()> {
×
130
  let mut command = Command::new("kubectl");
×
131
  command
×
132
    .arg("cluster-info")
×
133
    .stderr(Stdio::null())
×
134
    .stdout(Stdio::null())
×
135
    .kill_on_drop(true);
×
136

137
  if let Some(context) = context {
×
138
    command.arg("--context").arg(context);
×
139
  }
×
140

141
  let mut child = command
×
142
    .spawn()
×
143
    .context("Failed to start `kubectl cluster-info`")?;
×
144
  match timeout(max_wait, child.wait()).await {
×
145
    Ok(Ok(status)) if status.success() => Ok(()),
×
146
    Ok(Ok(status)) => Err(anyhow!(
×
147
      "`kubectl cluster-info` exited with status {}",
×
148
      status
×
149
    )),
×
150
    Ok(Err(err)) => Err(anyhow!(
×
151
      "Failed to wait for `kubectl cluster-info`. {}",
×
152
      err
×
153
    )),
×
154
    Err(_) => {
155
      let _ = child.kill().await;
×
156
      let _ = child.wait().await;
×
157
      Err(anyhow!(
×
158
        "`kubectl cluster-info` timed out after {} seconds",
×
159
        max_wait.as_secs()
×
160
      ))
×
161
    }
162
  }
163
}
×
164

165
fn is_blank_kubeconfig(config: &Kubeconfig) -> bool {
12✔
166
  config.current_context.is_none()
12✔
167
    && config.clusters.is_empty()
5✔
168
    && config.auth_infos.is_empty()
4✔
169
    && config.contexts.is_empty()
4✔
170
}
12✔
171

172
fn format_invalid_kubeconfig_error(source: &str, problems: &[String]) -> anyhow::Error {
1✔
173
  anyhow!(
1✔
174
    "Failed to load Kubernetes config from {}: {}",
175
    source,
176
    problems.join("; ")
1✔
177
  )
178
}
1✔
179

180
fn load_kubeconfig_path(path: &Path) -> std::result::Result<Kubeconfig, String> {
13✔
181
  match Kubeconfig::read_from(path) {
13✔
182
    Ok(config) => {
10✔
183
      if is_blank_kubeconfig(&config) {
10✔
184
        Err(format!("ignored blank kubeconfig {:?}", path))
3✔
185
      } else {
186
        Ok(config)
7✔
187
      }
188
    }
189
    Err(kube::config::KubeconfigError::ReadConfig(err, path))
3✔
190
      if err.kind() == ErrorKind::NotFound =>
3✔
191
    {
192
      Err(format!("ignored missing kubeconfig {:?}", path))
3✔
193
    }
194
    Err(e) => Err(format!("ignored invalid kubeconfig {:?}: {}", path, e)),
×
195
  }
196
}
13✔
197

198
fn load_kubeconfig_from_paths(paths: &[PathBuf]) -> Result<Kubeconfig> {
6✔
199
  let mut config = Kubeconfig::default();
6✔
200
  let mut loaded = false;
6✔
201
  let mut problems = vec![];
6✔
202

203
  for path in paths {
10✔
204
    match load_kubeconfig_path(path) {
10✔
205
      Ok(next) => {
6✔
206
        config = config.merge(next)?;
6✔
207
        loaded = true;
6✔
208
      }
209
      Err(problem) => {
4✔
210
        warn!("{}", problem);
4✔
211
        problems.push(problem);
4✔
212
      }
213
    }
214
  }
215

216
  if loaded {
6✔
217
    Ok(config)
5✔
218
  } else {
219
    Err(format_invalid_kubeconfig_error("KUBECONFIG", &problems))
1✔
220
  }
221
}
6✔
222

223
fn load_local_kubeconfig() -> Result<Option<Kubeconfig>> {
2✔
224
  match env::var_os("KUBECONFIG") {
2✔
225
    Some(value) => {
2✔
226
      let paths = env::split_paths(&value)
2✔
227
        .filter(|path| !path.as_os_str().is_empty())
2✔
228
        .collect::<Vec<_>>();
2✔
229

230
      if paths.is_empty() {
2✔
231
        return Ok(None);
×
232
      }
2✔
233

234
      load_kubeconfig_from_paths(&paths).map(Some)
2✔
235
    }
236
    None => match Kubeconfig::read() {
×
237
      Ok(config) => {
×
238
        if is_blank_kubeconfig(&config) {
×
239
          Err(anyhow!(
×
240
            "Failed to load Kubernetes config from default kubeconfig: kubeconfig file is blank"
×
241
          ))
×
242
        } else {
243
          Ok(Some(config))
×
244
        }
245
      }
246
      Err(kube::config::KubeconfigError::FindPath) => Ok(None),
×
247
      Err(kube::config::KubeconfigError::ReadConfig(err, _))
×
248
        if err.kind() == ErrorKind::NotFound =>
×
249
      {
250
        Ok(None)
×
251
      }
252
      Err(e) => Err(anyhow!("Failed to load Kubernetes config. {}", e)),
×
253
    },
254
  }
255
}
2✔
256

257
async fn load_client_config(context: Option<String>) -> Result<kube::Config> {
2✔
258
  let options = KubeConfigOptions {
2✔
259
    context: context.clone(),
2✔
260
    ..Default::default()
2✔
261
  };
2✔
262

263
  if let Some(kubeconfig) = load_local_kubeconfig()? {
2✔
264
    return load_client_config_from_kubeconfig(kubeconfig, options).await;
2✔
265
  }
×
266

267
  if let Some(context) = context {
×
268
    Err(anyhow!(
×
269
      "Failed to load Kubernetes config: no valid kubeconfig was found for context {}",
×
270
      context
×
271
    ))
×
272
  } else {
273
    kube::Config::incluster().context("Failed to load Kubernetes config")
×
274
  }
275
}
2✔
276

277
async fn load_client_config_from_kubeconfig(
7✔
278
  kubeconfig: Kubeconfig,
7✔
279
  options: KubeConfigOptions,
7✔
280
) -> Result<kube::Config> {
7✔
281
  let mut config = kube::Config::from_custom_kubeconfig(kubeconfig, &options)
7✔
282
    .await
7✔
283
    .context("Failed to load Kubernetes config")?;
7✔
284

285
  if config.proxy_url.is_none() {
7✔
286
    if let Some(proxy_url) = env_https_proxy_url() {
3✔
287
      match proxy_url.parse() {
1✔
288
        Ok(parsed) => config.proxy_url = Some(parsed),
1✔
289
        Err(error) => warn!(
×
290
          "Ignoring invalid HTTPS proxy URL {:?}: {}",
291
          proxy_url, error
292
        ),
293
      }
294
    }
2✔
295
  }
4✔
296

297
  Ok(config)
7✔
298
}
7✔
299

300
fn env_https_proxy_url() -> Option<String> {
3✔
301
  env::vars_os().find_map(|(key, value)| {
429✔
302
    let normalized = key.to_string_lossy();
429✔
303
    if normalized.eq_ignore_ascii_case("HTTPS_PROXY") {
429✔
304
      Some(value.to_string_lossy().into_owned())
1✔
305
    } else {
306
      None
428✔
307
    }
308
  })
429✔
309
}
3✔
310

311
pub async fn get_client(context: Option<String>) -> Result<kube::Client> {
2✔
312
  debug!("env KUBECONFIG: {:?}", env::var_os("KUBECONFIG"));
2✔
313
  let client_config = match context.as_ref() {
2✔
314
    Some(context) => {
1✔
315
      info!("Getting kubernetes client. Context: {}", context);
1✔
316
      load_client_config(Some(context.to_owned())).await?
1✔
317
    }
318
    None => {
319
      warn!("Getting kubernetes client by inference. No context given");
1✔
320
      load_client_config(None).await?
1✔
321
    }
322
  };
323
  debug!("Kubernetes client config: {:?}", client_config);
2✔
324
  info!("Kubernetes client connected");
2✔
325
  kube::Client::try_from(client_config).context("Failed to create Kubernetes client")
2✔
326
}
2✔
327

328
#[derive(Clone)]
329
pub struct Network<'a> {
330
  pub client: Client,
331
  pub app: &'a Arc<Mutex<App>>,
332
}
333

334
impl<'a> Network<'a> {
335
  pub fn new(client: Client, app: &'a Arc<Mutex<App>>) -> Self {
1✔
336
    Network { client, app }
1✔
337
  }
1✔
338

339
  pub async fn refresh_client(&mut self) {
1✔
340
    let (context, ns, main_tab_index, context_tab_index, route) = {
1✔
341
      let mut app = self.app.lock().await;
1✔
342
      let context = app.data.selected.context.clone();
1✔
343
      let ns = app.data.selected.ns.clone();
1✔
344
      let main_tab_index = app.main_tabs.index;
1✔
345
      let context_tab_index = app.context_tabs.index;
1✔
346
      let route = app.refresh_restore_route();
1✔
347
      // so that if refresh fails we dont see mixed results
348
      app.data.selected.context = None;
1✔
349
      (context, ns, main_tab_index, context_tab_index, route)
1✔
350
    };
351

352
    match refresh_kube_config(&context).await {
1✔
353
      Ok(client) => {
1✔
354
        self.client = client;
1✔
355
        let mut app = self.app.lock().await;
1✔
356
        app.reset();
1✔
357
        app.data.selected.context = context;
1✔
358
        app.data.selected.ns = ns;
1✔
359
        app.restore_route_state(main_tab_index, context_tab_index, route);
1✔
360
        app.status_message.show("Refresh complete!");
1✔
361
      }
362
      Err(e) => {
×
363
        self
×
364
          .handle_error(anyhow!(
×
365
            "Failed to refresh client. {}. Loading default context.",
×
366
            e
×
367
          ))
×
368
          .await;
×
369
      }
370
    }
371
  }
1✔
372

373
  #[allow(clippy::cognitive_complexity)]
374
  pub async fn handle_network_event(&mut self, io_event: IoEvent) {
×
375
    match io_event {
×
376
      IoEvent::RefreshClient => {
377
        self.refresh_client().await;
×
378
      }
379
      IoEvent::GetKubeConfig => {
380
        self.get_kube_config().await;
×
381
      }
382
      IoEvent::GetNodes => {
383
        NodeResource::get_resource(self).await;
×
384
      }
385
      IoEvent::GetNamespaces => {
386
        NamespaceResource::get_resource(self).await;
×
387
      }
388
      IoEvent::GetPods => {
389
        PodResource::get_resource(self).await;
×
390
      }
391
      IoEvent::GetServices => {
392
        SvcResource::get_resource(self).await;
×
393
      }
394
      IoEvent::GetConfigMaps => {
395
        ConfigMapResource::get_resource(self).await;
×
396
      }
397
      IoEvent::GetStatefulSets => {
398
        StatefulSetResource::get_resource(self).await;
×
399
      }
400
      IoEvent::GetReplicaSets => {
401
        ReplicaSetResource::get_resource(self).await;
×
402
      }
403
      IoEvent::GetJobs => {
404
        JobResource::get_resource(self).await;
×
405
      }
406
      IoEvent::GetDaemonSets => {
407
        DaemonSetResource::get_resource(self).await;
×
408
      }
409
      IoEvent::GetCronJobs => {
410
        CronJobResource::get_resource(self).await;
×
411
      }
412
      IoEvent::GetSecrets => {
413
        SecretResource::get_resource(self).await;
×
414
      }
415
      IoEvent::GetDeployments => {
416
        DeploymentResource::get_resource(self).await;
×
417
      }
418
      IoEvent::GetReplicationControllers => {
419
        ReplicationControllerResource::get_resource(self).await;
×
420
      }
421
      IoEvent::GetMetrics => {
422
        UtilizationResource::get_resource(self).await;
×
423
      }
424
      IoEvent::GetTroubleshootFindings => {
425
        TroubleshootResource::get_resource(self).await;
×
426
      }
427
      IoEvent::GetStorageClasses => {
428
        StorageClassResource::get_resource(self).await;
×
429
      }
430
      IoEvent::GetRoles => {
431
        RoleResource::get_resource(self).await;
×
432
      }
433
      IoEvent::GetRoleBindings => {
434
        RoleBindingResource::get_resource(self).await;
×
435
      }
436
      IoEvent::GetClusterRoles => {
437
        ClusterRoleResource::get_resource(self).await;
×
438
      }
439
      IoEvent::GetClusterRoleBinding => {
440
        ClusterRoleBindingResource::get_resource(self).await;
×
441
      }
442
      IoEvent::GetIngress => {
443
        IngressResource::get_resource(self).await;
×
444
      }
445
      IoEvent::GetPvcs => {
446
        PvcResource::get_resource(self).await;
×
447
      }
448
      IoEvent::GetPvs => {
449
        PvResource::get_resource(self).await;
×
450
      }
451
      IoEvent::GetServiceAccounts => {
452
        SvcAcctResource::get_resource(self).await;
×
453
      }
454
      IoEvent::GetNetworkPolicies => {
455
        NetworkPolicyResource::get_resource(self).await;
×
456
      }
457
      IoEvent::DiscoverDynamicRes => {
458
        self.discover_dynamic_resources().await;
×
459
      }
460
      IoEvent::GetDynamicRes => {
461
        DynamicResource::get_resource(self).await;
×
462
      }
463
      IoEvent::GetPodsBySelector {
464
        namespace,
×
465
        selector,
×
466
      } => {
467
        self.get_pods_by_selector(&namespace, &selector).await;
×
468
      }
469
      IoEvent::GetPodsByNode { node_name } => {
×
470
        self.get_pods_by_node(&node_name).await;
×
471
      }
472
    };
473

474
    let mut app = self.app.lock().await;
×
475
    app.loading_complete();
×
476
  }
×
477

478
  pub async fn handle_error(&self, e: anyhow::Error) {
×
479
    error!("{:?}", e);
×
480
    let mut app = self.app.lock().await;
×
481
    app.handle_error(e);
×
482
  }
×
483

484
  pub async fn get_kube_config(&self) {
×
485
    match load_local_kubeconfig() {
×
486
      Ok(Some(config)) => {
×
487
        info!("Using Kubeconfig");
×
488
        debug!("Kubeconfig: {:?}", config);
×
489
        let mut app = self.app.lock().await;
×
490
        let selected_ctx = app.data.selected.context.to_owned();
×
491

492
        // Detect external context change (#315): if the user hasn't manually
493
        // selected a context (selected.context is None) and the kubeconfig's
494
        // current_context differs from what we had, trigger a refresh.
495
        if selected_ctx.is_none() {
×
496
          let prev_ctx = app.data.active_context.as_ref().map(|c| c.name.clone());
×
497
          if prev_ctx.is_some() && prev_ctx != config.current_context {
×
498
            info!(
×
499
              "External context change detected: {:?} -> {:?}",
500
              prev_ctx, config.current_context
501
            );
502
            app.set_contexts(contexts::get_contexts(&config, None));
×
503
            app.data.kubeconfig = Some(config);
×
504
            app.refresh();
×
505
            return;
×
506
          }
×
507
        }
×
508

509
        app.set_contexts(contexts::get_contexts(&config, selected_ctx));
×
510
        app.data.kubeconfig = Some(config);
×
511
      }
512
      Ok(None) => {
513
        self
×
514
          .handle_error(anyhow!(
×
515
            "Failed to load Kubernetes config. No kubeconfig was found"
×
516
          ))
×
517
          .await;
×
518
      }
519
      Err(e) => {
×
520
        self.handle_error(e).await;
×
521
      }
522
    }
523
  }
×
524

525
  /// calls the kubernetes API to list the given resource for either selected namespace or all namespaces
526
  pub async fn get_namespaced_resources<K, T, F>(&self, map_fn: F) -> Vec<T>
×
527
  where
×
528
    <K as ApiResource>::DynamicType: Default,
×
529
    K: kube::Resource<Scope = NamespaceResourceScope>,
×
530
    K: Clone + DeserializeOwned + fmt::Debug,
×
531
    F: Fn(K) -> T,
×
532
  {
×
533
    let api: Api<K> = self.get_namespaced_api().await;
×
534
    let lp = ListParams::default();
×
535
    match api.list(&lp).await {
×
536
      Ok(list) => list.into_iter().map(map_fn).collect::<Vec<_>>(),
×
537
      Err(e) => {
×
538
        self
×
539
          .handle_error(anyhow!(
×
540
            "Failed to get namespaced resource {}. {}",
×
541
            crate::app::utils::friendly_type_name::<T>(),
×
542
            e
×
543
          ))
×
544
          .await;
×
545
        vec![]
×
546
      }
547
    }
548
  }
×
549

550
  /// calls the kubernetes API to list the given resource for all namespaces
551
  pub async fn get_resources<K, T, F>(&self, map_fn: F) -> Vec<T>
×
552
  where
×
553
    <K as ApiResource>::DynamicType: Default,
×
554
    K: ApiResource + Clone + DeserializeOwned + fmt::Debug,
×
555
    F: Fn(K) -> T,
×
556
  {
×
557
    let api: Api<K> = Api::all(self.client.clone());
×
558
    let lp = ListParams::default();
×
559
    match api.list(&lp).await {
×
560
      Ok(list) => list.into_iter().map(map_fn).collect::<Vec<_>>(),
×
561
      Err(e) => {
×
562
        self
×
563
          .handle_error(anyhow!(
×
564
            "Failed to get resource {}. {}",
×
565
            crate::app::utils::friendly_type_name::<T>(),
×
566
            e
×
567
          ))
×
568
          .await;
×
569
        vec![]
×
570
      }
571
    }
572
  }
×
573

574
  pub async fn get_namespaced_api<K>(&self) -> Api<K>
×
575
  where
×
576
    <K as ApiResource>::DynamicType: Default,
×
577
    K: ApiResource + kube::Resource<Scope = NamespaceResourceScope>,
×
578
  {
×
579
    let app = self.app.lock().await;
×
580
    match &app.data.selected.ns {
×
581
      Some(ns) => Api::namespaced(self.client.clone(), ns),
×
582
      None => Api::all(self.client.clone()),
×
583
    }
584
  }
×
585

586
  /// Fetch pods matching a label selector in a specific namespace.
587
  /// Results are stored in `app.data.pods` for the drill-down flow.
588
  pub async fn get_pods_by_selector(&self, namespace: &str, selector: &str) {
×
589
    let api: Api<Pod> = Api::namespaced(self.client.clone(), namespace);
×
590
    let lp = ListParams::default().labels(selector);
×
591
    match api.list(&lp).await {
×
592
      Ok(list) => {
×
593
        let items: Vec<KubePod> = list.into_iter().map(Pod::into).collect();
×
594
        let mut app = self.app.lock().await;
×
595
        app.data.pods.set_items(items);
×
596
      }
597
      Err(e) => {
×
598
        self
×
599
          .handle_error(anyhow!(
×
600
            "Failed to get pods for selector '{}'. {}",
×
601
            selector,
×
602
            e
×
603
          ))
×
604
          .await;
×
605
      }
606
    }
607
  }
×
608

609
  pub async fn get_pods_by_node(&self, node_name: &str) {
×
610
    let api: Api<Pod> = Api::all(self.client.clone());
×
611
    let lp = ListParams::default().fields(&format!("spec.nodeName={}", node_name));
×
612
    match api.list(&lp).await {
×
613
      Ok(list) => {
×
614
        let items: Vec<KubePod> = list.into_iter().map(Pod::into).collect();
×
615
        let mut app = self.app.lock().await;
×
616
        app.data.pods.set_items(items);
×
617
      }
618
      Err(e) => {
×
619
        self
×
620
          .handle_error(anyhow!(
×
621
            "Failed to get pods for node '{}'. {}",
×
622
            node_name,
×
623
            e
×
624
          ))
×
625
          .await;
×
626
      }
627
    }
628
  }
×
629

630
  pub async fn get_dynamic_resources(
×
631
    &self,
×
632
    drs: &KubeDynamicKind,
×
633
    namespace: Option<&str>,
×
634
  ) -> Result<Vec<crate::app::dynamic::KubeDynamicResource>> {
×
635
    let api: Api<DynamicObject> = if drs.scope == Scope::Cluster {
×
636
      Api::all_with(self.client.clone(), &drs.api_resource)
×
637
    } else {
638
      match namespace {
×
639
        Some(ns) => Api::namespaced_with(self.client.clone(), ns, &drs.api_resource),
×
640
        None => Api::all_with(self.client.clone(), &drs.api_resource),
×
641
      }
642
    };
643

644
    let list = api.list(&Default::default()).await?;
×
645
    Ok(
×
646
      list
×
647
        .items
×
648
        .into_iter()
×
649
        .map(crate::app::dynamic::KubeDynamicResource::from)
×
650
        .collect(),
×
651
    )
×
652
  }
×
653

654
  /// Discover and cache custom resources on the cluster
655
  pub async fn discover_dynamic_resources(&self) {
×
656
    let api_groups = match self.client.list_api_groups().await {
×
657
      Ok(groups) => groups.groups,
×
658
      Err(e) => {
×
659
        self
×
660
          .handle_error(anyhow!("Failed to get dynamic resources. {}", e))
×
661
          .await;
×
662
        return;
×
663
      }
664
    };
665

666
    let mut dynamic_resources = vec![];
×
667
    let mut dynamic_menu = vec![];
×
668

669
    let excluded = [
×
670
      "Namespace",
×
671
      "Pod",
×
672
      "Service",
×
673
      "Node",
×
674
      "ConfigMap",
×
675
      "StatefulSet",
×
676
      "ReplicaSet",
×
677
      "Deployment",
×
678
      "Job",
×
679
      "DaemonSet",
×
680
      "CronJob",
×
681
      "Secret",
×
682
      "ReplicationController",
×
683
      "PersistentVolumeClaim",
×
684
      "PersistentVolume",
×
685
      "StorageClass",
×
686
      "Role",
×
687
      "RoleBinding",
×
688
      "ClusterRole",
×
689
      "ClusterRoleBinding",
×
690
      "ServiceAccount",
×
691
      "Ingress",
×
692
      "NetworkPolicy",
×
693
    ];
×
694

695
    for api_group in api_groups {
×
696
      let group_name = api_group.name.clone();
×
697
      let Some(group_version) = preferred_group_version(&api_group) else {
×
698
        warn!(
×
699
          "Skipping dynamic API group '{}' because it has no preferred or parseable version",
700
          group_name
701
        );
702
        continue;
×
703
      };
704

705
      match pinned_group(&self.client, &group_version).await {
×
706
        Ok(group) => {
×
707
          for (ar, caps) in group.recommended_resources() {
×
708
            if !caps.supports_operation(verbs::LIST) || excluded.contains(&ar.kind.as_str()) {
×
709
              continue;
×
710
            }
×
711

712
            dynamic_menu.push((ar.kind.to_string(), ActiveBlock::DynamicResource));
×
713
            dynamic_resources.push(KubeDynamicKind::new(ar, caps.scope));
×
714
          }
715
        }
716
        Err(e) => {
×
717
          warn!(
×
718
            "Skipping dynamic API group '{}' at '{}' due to discovery error: {}",
719
            group_name,
720
            group_version.api_version(),
×
721
            e
722
          );
723
        }
724
      }
725
    }
726
    let mut app = self.app.lock().await;
×
727
    // sort dynamic_menu alphabetically using the first element of the tuple
728
    dynamic_menu.sort_by(|a, b| a.0.cmp(&b.0));
×
729
    app.dynamic_resources_menu = StatefulList::with_items(dynamic_menu);
×
730
    app.data.dynamic_kinds = dynamic_resources.clone();
×
731
  }
×
732
}
733

734
fn preferred_group_version(api_group: &DiscoveryApiGroup) -> Option<GroupVersion> {
3✔
735
  api_group
3✔
736
    .preferred_version
3✔
737
    .as_ref()
3✔
738
    .map(|version| version.group_version.as_str())
3✔
739
    .or_else(|| {
3✔
740
      api_group
1✔
741
        .versions
1✔
742
        .first()
1✔
743
        .map(|version| version.group_version.as_str())
1✔
744
    })
1✔
745
    .and_then(|group_version| {
3✔
746
      let parsed: GroupVersion = group_version.parse().ok()?;
3✔
747
      (parsed.group == api_group.name && !parsed.version.is_empty()).then_some(parsed)
3✔
748
    })
3✔
749
}
3✔
750

751
#[cfg(test)]
752
mod tests {
753
  use super::*;
754
  use crate::app::{ActiveBlock, App, RouteId};
755
  use k8s_openapi::apimachinery::pkg::apis::meta::v1::GroupVersionForDiscovery;
756
  use kube::{client::AuthError, core::Status};
757
  use std::{
758
    ffi::OsString,
759
    fs,
760
    sync::{Mutex as StdMutex, OnceLock},
761
    time::{SystemTime, UNIX_EPOCH},
762
  };
763

764
  fn temp_test_dir(name: &str) -> PathBuf {
8✔
765
    let suffix = SystemTime::now()
8✔
766
      .duration_since(UNIX_EPOCH)
8✔
767
      .expect("system time should be after epoch")
8✔
768
      .as_nanos();
8✔
769
    let path = env::temp_dir().join(format!(
8✔
770
      "kdash-network-tests-{}-{}-{}",
8✔
771
      name,
8✔
772
      std::process::id(),
8✔
773
      suffix
8✔
774
    ));
8✔
775
    fs::create_dir_all(&path).expect("temp test dir should be created");
8✔
776
    path
8✔
777
  }
8✔
778

779
  fn write_kubeconfig(path: &Path, contents: &str) {
9✔
780
    fs::write(path, contents).expect("kubeconfig fixture should be written");
9✔
781
  }
9✔
782

783
  fn env_lock() -> std::sync::MutexGuard<'static, ()> {
6✔
784
    static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
785
    LOCK
6✔
786
      .get_or_init(|| StdMutex::new(()))
6✔
787
      .lock()
6✔
788
      .unwrap_or_else(|poisoned| poisoned.into_inner())
6✔
789
  }
6✔
790

791
  struct ProxyEnvGuard {
792
    https_proxy: Option<OsString>,
793
    https_proxy_lower: Option<OsString>,
794
  }
795

796
  impl ProxyEnvGuard {
797
    fn capture() -> Self {
5✔
798
      Self {
5✔
799
        https_proxy: env::var_os("HTTPS_PROXY"),
5✔
800
        https_proxy_lower: env::var_os("https_proxy"),
5✔
801
      }
5✔
802
    }
5✔
803
  }
804

805
  impl Drop for ProxyEnvGuard {
806
    fn drop(&mut self) {
5✔
807
      match &self.https_proxy {
5✔
808
        Some(value) => env::set_var("HTTPS_PROXY", value),
×
809
        None => env::remove_var("HTTPS_PROXY"),
5✔
810
      }
811

812
      match &self.https_proxy_lower {
5✔
813
        Some(value) => env::set_var("https_proxy", value),
×
814
        None => env::remove_var("https_proxy"),
5✔
815
      }
816
    }
5✔
817
  }
818

819
  fn kubeconfig_with_proxy(proxy_url: Option<&str>) -> String {
5✔
820
    let proxy_yaml = proxy_url
5✔
821
      .map(|proxy| format!("      proxy-url: {}\n", proxy))
5✔
822
      .unwrap_or_default();
5✔
823

824
    format!(
5✔
825
      r#"apiVersion: v1
826
kind: Config
827
clusters:
828
  - name: test-cluster
829
    cluster:
830
      server: https://127.0.0.1:6443
831
{proxy_yaml}contexts:
832
  - name: test-context
833
    context:
834
      cluster: test-cluster
835
      user: test-user
836
current-context: test-context
837
users:
838
  - name: test-user
839
    user:
840
      token: test-token
841
"#
842
    )
843
  }
5✔
844

845
  fn valid_kubeconfig() -> &'static str {
4✔
846
    r#"apiVersion: v1
4✔
847
kind: Config
4✔
848
clusters:
4✔
849
  - name: test-cluster
4✔
850
    cluster:
4✔
851
      server: https://127.0.0.1:6443
4✔
852
contexts:
4✔
853
  - name: test-context
4✔
854
    context:
4✔
855
      cluster: test-cluster
4✔
856
      user: test-user
4✔
857
current-context: test-context
4✔
858
users:
4✔
859
  - name: test-user
4✔
860
    user:
4✔
861
      token: test-token
4✔
862
"#
4✔
863
  }
4✔
864

865
  #[test]
866
  fn test_load_client_config_from_kubeconfig_uses_cluster_proxy_url() {
1✔
867
    let _env_lock = env_lock();
1✔
868
    let _proxy_env = ProxyEnvGuard::capture();
1✔
869
    env::remove_var("HTTPS_PROXY");
1✔
870
    env::remove_var("https_proxy");
1✔
871

872
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
873
      "http://cluster-proxy.internal:8443",
1✔
874
    )))
1✔
875
    .expect("proxy kubeconfig should deserialize");
1✔
876

877
    let config = tokio::runtime::Runtime::new()
1✔
878
      .expect("runtime should build")
1✔
879
      .block_on(load_client_config_from_kubeconfig(
1✔
880
        kubeconfig,
1✔
881
        KubeConfigOptions::default(),
1✔
882
      ))
883
      .expect("config should load");
1✔
884

885
    assert_eq!(
1✔
886
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
887
      Some("http://cluster-proxy.internal:8443/".to_string())
1✔
888
    );
889
  }
1✔
890

891
  #[test]
892
  fn test_load_client_config_from_kubeconfig_uses_https_proxy_env_var() {
1✔
893
    let _env_lock = env_lock();
1✔
894
    let _proxy_env = ProxyEnvGuard::capture();
1✔
895
    env::set_var("HTTPS_PROXY", "http://env-proxy.internal:8080");
1✔
896
    env::remove_var("https_proxy");
1✔
897

898
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(None))
1✔
899
      .expect("base kubeconfig should deserialize");
1✔
900

901
    let config = tokio::runtime::Runtime::new()
1✔
902
      .expect("runtime should build")
1✔
903
      .block_on(load_client_config_from_kubeconfig(
1✔
904
        kubeconfig,
1✔
905
        KubeConfigOptions::default(),
1✔
906
      ))
907
      .expect("config should load");
1✔
908

909
    assert_eq!(
1✔
910
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
911
      Some("http://env-proxy.internal:8080/".to_string())
1✔
912
    );
913
  }
1✔
914

915
  #[test]
916
  fn test_load_client_config_from_kubeconfig_uses_https_proxy_env_var_case_insensitively() {
1✔
917
    let _env_lock = env_lock();
1✔
918
    let _proxy_env = ProxyEnvGuard::capture();
1✔
919
    env::remove_var("HTTPS_PROXY");
1✔
920
    env::remove_var("https_proxy");
1✔
921
    env::set_var("Https_PrOxY", "http://env-proxy.internal:8080");
1✔
922

923
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(None))
1✔
924
      .expect("base kubeconfig should deserialize");
1✔
925

926
    let config = tokio::runtime::Runtime::new()
1✔
927
      .expect("runtime should build")
1✔
928
      .block_on(load_client_config_from_kubeconfig(
1✔
929
        kubeconfig,
1✔
930
        KubeConfigOptions::default(),
1✔
931
      ))
932
      .expect("config should load");
1✔
933

934
    env::remove_var("Https_PrOxY");
1✔
935

936
    assert_eq!(
1✔
937
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
938
      Some("http://env-proxy.internal:8080/".to_string())
1✔
939
    );
940
  }
1✔
941

942
  #[test]
943
  fn test_load_client_config_from_kubeconfig_prefers_cluster_proxy_over_env_var() {
1✔
944
    let _env_lock = env_lock();
1✔
945
    let _proxy_env = ProxyEnvGuard::capture();
1✔
946
    env::set_var("HTTPS_PROXY", "http://env-proxy.internal:8080");
1✔
947
    env::remove_var("https_proxy");
1✔
948

949
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
950
      "http://cluster-proxy.internal:8443",
1✔
951
    )))
1✔
952
    .expect("proxy kubeconfig should deserialize");
1✔
953

954
    let config = tokio::runtime::Runtime::new()
1✔
955
      .expect("runtime should build")
1✔
956
      .block_on(load_client_config_from_kubeconfig(
1✔
957
        kubeconfig,
1✔
958
        KubeConfigOptions::default(),
1✔
959
      ))
960
      .expect("config should load");
1✔
961

962
    assert_eq!(
1✔
963
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
964
      Some("http://cluster-proxy.internal:8443/".to_string())
1✔
965
    );
966
  }
1✔
967

968
  #[test]
969
  fn test_get_client_supports_http_proxy_configuration() {
1✔
970
    let _env_lock = env_lock();
1✔
971
    let _proxy_env = ProxyEnvGuard::capture();
1✔
972
    env::remove_var("HTTPS_PROXY");
1✔
973
    env::remove_var("https_proxy");
1✔
974

975
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
976
      "http://cluster-proxy.internal:8443",
1✔
977
    )))
1✔
978
    .expect("proxy kubeconfig should deserialize");
1✔
979

980
    let config = tokio::runtime::Runtime::new()
1✔
981
      .expect("runtime should build")
1✔
982
      .block_on(load_client_config_from_kubeconfig(
1✔
983
        kubeconfig,
1✔
984
        KubeConfigOptions::default(),
1✔
985
      ))
986
      .expect("config should load");
1✔
987
    let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
1✔
988
    let _enter = runtime.enter();
1✔
989

990
    Client::try_from(config).expect("http proxy support should be enabled");
1✔
991
  }
1✔
992

993
  #[test]
994
  fn test_load_kubeconfig_from_paths_ignores_missing_entries_when_valid_config_exists() {
1✔
995
    let dir = temp_test_dir("missing-valid");
1✔
996
    let missing = dir.join("missing-config");
1✔
997
    let valid = dir.join("valid-config");
1✔
998
    write_kubeconfig(&valid, valid_kubeconfig());
1✔
999

1000
    let kubeconfig =
1✔
1001
      load_kubeconfig_from_paths(&[missing, valid]).expect("valid kubeconfig should load");
1✔
1002

1003
    assert_eq!(kubeconfig.current_context.as_deref(), Some("test-context"));
1✔
1004
    assert_eq!(kubeconfig.clusters.len(), 1);
1✔
1005
    assert_eq!(kubeconfig.contexts.len(), 1);
1✔
1006
    assert_eq!(kubeconfig.auth_infos.len(), 1);
1✔
1007

1008
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1009
  }
1✔
1010

1011
  #[test]
1012
  fn test_load_kubeconfig_from_paths_ignores_blank_entries_when_valid_config_exists() {
1✔
1013
    let dir = temp_test_dir("blank-valid");
1✔
1014
    let blank = dir.join("blank-config");
1✔
1015
    let valid = dir.join("valid-config");
1✔
1016
    write_kubeconfig(&blank, "");
1✔
1017
    write_kubeconfig(&valid, valid_kubeconfig());
1✔
1018

1019
    let kubeconfig =
1✔
1020
      load_kubeconfig_from_paths(&[blank, valid]).expect("valid kubeconfig should load");
1✔
1021

1022
    assert_eq!(kubeconfig.current_context.as_deref(), Some("test-context"));
1✔
1023
    assert_eq!(kubeconfig.clusters.len(), 1);
1✔
1024
    assert_eq!(kubeconfig.contexts.len(), 1);
1✔
1025
    assert_eq!(kubeconfig.auth_infos.len(), 1);
1✔
1026

1027
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1028
  }
1✔
1029

1030
  #[test]
1031
  fn test_load_kubeconfig_from_paths_returns_clean_error_when_all_entries_are_invalid() {
1✔
1032
    let dir = temp_test_dir("all-invalid");
1✔
1033
    let missing = dir.join("missing-config");
1✔
1034
    let blank = dir.join("blank-config");
1✔
1035
    write_kubeconfig(&blank, "");
1✔
1036

1037
    let error = load_kubeconfig_from_paths(&[missing, blank])
1✔
1038
      .expect_err("all invalid kubeconfigs should fail")
1✔
1039
      .to_string();
1✔
1040

1041
    assert!(error.contains("Failed to load Kubernetes config from KUBECONFIG"));
1✔
1042
    assert!(error.contains("ignored missing kubeconfig"));
1✔
1043
    assert!(error.contains("ignored blank kubeconfig"));
1✔
1044

1045
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1046
  }
1✔
1047

1048
  #[test]
1049
  fn test_is_blank_kubeconfig_detects_empty_config() {
1✔
1050
    let config = Kubeconfig::default();
1✔
1051
    assert!(is_blank_kubeconfig(&config));
1✔
1052
  }
1✔
1053

1054
  #[test]
1055
  fn test_is_blank_kubeconfig_returns_false_for_populated_config() {
1✔
1056
    let config = Kubeconfig {
1✔
1057
      current_context: Some("ctx".to_string()),
1✔
1058
      ..Default::default()
1✔
1059
    };
1✔
1060
    assert!(!is_blank_kubeconfig(&config));
1✔
1061
  }
1✔
1062

1063
  #[test]
1064
  fn test_load_kubeconfig_path_ok_for_valid_file() {
1✔
1065
    let dir = temp_test_dir("path-valid");
1✔
1066
    let file = dir.join("config");
1✔
1067
    write_kubeconfig(&file, valid_kubeconfig());
1✔
1068

1069
    let config = load_kubeconfig_path(&file).expect("valid config should load");
1✔
1070
    assert_eq!(config.current_context.as_deref(), Some("test-context"));
1✔
1071

1072
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1073
  }
1✔
1074

1075
  #[test]
1076
  fn test_load_kubeconfig_path_err_for_missing_file() {
1✔
1077
    let dir = temp_test_dir("path-missing");
1✔
1078
    let missing = dir.join("does-not-exist");
1✔
1079

1080
    let err = load_kubeconfig_path(&missing).expect_err("missing file should return Err");
1✔
1081
    assert!(err.contains("ignored missing kubeconfig"));
1✔
1082

1083
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1084
  }
1✔
1085

1086
  #[test]
1087
  fn test_load_kubeconfig_path_err_for_blank_file() {
1✔
1088
    let dir = temp_test_dir("path-blank");
1✔
1089
    let blank = dir.join("blank");
1✔
1090
    write_kubeconfig(&blank, "");
1✔
1091

1092
    let err = load_kubeconfig_path(&blank).expect_err("blank file should return Err");
1✔
1093
    assert!(err.contains("ignored blank kubeconfig"));
1✔
1094

1095
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1096
  }
1✔
1097

1098
  #[test]
1099
  fn test_load_kubeconfig_from_paths_merges_multiple_valid_configs() {
1✔
1100
    let dir = temp_test_dir("merge");
1✔
1101
    let file_a = dir.join("config-a");
1✔
1102
    let file_b = dir.join("config-b");
1✔
1103

1104
    write_kubeconfig(
1✔
1105
      &file_a,
1✔
1106
      r#"apiVersion: v1
1✔
1107
kind: Config
1✔
1108
clusters:
1✔
1109
  - name: cluster-a
1✔
1110
    cluster:
1✔
1111
      server: https://a:6443
1✔
1112
contexts:
1✔
1113
  - name: ctx-a
1✔
1114
    context:
1✔
1115
      cluster: cluster-a
1✔
1116
      user: user-a
1✔
1117
current-context: ctx-a
1✔
1118
users:
1✔
1119
  - name: user-a
1✔
1120
    user:
1✔
1121
      token: token-a
1✔
1122
"#,
1✔
1123
    );
1124

1125
    write_kubeconfig(
1✔
1126
      &file_b,
1✔
1127
      r#"apiVersion: v1
1✔
1128
kind: Config
1✔
1129
clusters:
1✔
1130
  - name: cluster-b
1✔
1131
    cluster:
1✔
1132
      server: https://b:6443
1✔
1133
contexts:
1✔
1134
  - name: ctx-b
1✔
1135
    context:
1✔
1136
      cluster: cluster-b
1✔
1137
      user: user-b
1✔
1138
users:
1✔
1139
  - name: user-b
1✔
1140
    user:
1✔
1141
      token: token-b
1✔
1142
"#,
1✔
1143
    );
1144

1145
    let config =
1✔
1146
      load_kubeconfig_from_paths(&[file_a, file_b]).expect("multiple valid configs should merge");
1✔
1147

1148
    // First file's current_context wins
1149
    assert_eq!(config.current_context.as_deref(), Some("ctx-a"));
1✔
1150
    assert_eq!(config.clusters.len(), 2);
1✔
1151
    assert_eq!(config.contexts.len(), 2);
1✔
1152
    assert_eq!(config.auth_infos.len(), 2);
1✔
1153

1154
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1155
  }
1✔
1156

1157
  #[test]
1158
  fn test_preferred_group_version_uses_preferred_version() {
1✔
1159
    let api_group = DiscoveryApiGroup {
1✔
1160
      name: "example.io".into(),
1✔
1161
      preferred_version: Some(GroupVersionForDiscovery {
1✔
1162
        group_version: "example.io/v1".into(),
1✔
1163
        version: "v1".into(),
1✔
1164
      }),
1✔
1165
      server_address_by_client_cidrs: None,
1✔
1166
      versions: vec![GroupVersionForDiscovery {
1✔
1167
        group_version: "example.io/v1beta1".into(),
1✔
1168
        version: "v1beta1".into(),
1✔
1169
      }],
1✔
1170
    };
1✔
1171

1172
    let group_version =
1✔
1173
      preferred_group_version(&api_group).expect("preferred version should parse");
1✔
1174
    assert_eq!(group_version.group, "example.io");
1✔
1175
    assert_eq!(group_version.version, "v1");
1✔
1176
  }
1✔
1177

1178
  #[test]
1179
  fn test_preferred_group_version_falls_back_to_first_served_version() {
1✔
1180
    let api_group = DiscoveryApiGroup {
1✔
1181
      name: "example.io".into(),
1✔
1182
      preferred_version: None,
1✔
1183
      server_address_by_client_cidrs: None,
1✔
1184
      versions: vec![GroupVersionForDiscovery {
1✔
1185
        group_version: "example.io/v1beta1".into(),
1✔
1186
        version: "v1beta1".into(),
1✔
1187
      }],
1✔
1188
    };
1✔
1189

1190
    let group_version = preferred_group_version(&api_group).expect("served version should parse");
1✔
1191
    assert_eq!(group_version.group, "example.io");
1✔
1192
    assert_eq!(group_version.version, "v1beta1");
1✔
1193
  }
1✔
1194

1195
  #[test]
1196
  fn test_preferred_group_version_returns_none_for_invalid_version_string() {
1✔
1197
    let api_group = DiscoveryApiGroup {
1✔
1198
      name: "example.io".into(),
1✔
1199
      preferred_version: Some(GroupVersionForDiscovery {
1✔
1200
        group_version: "too/many/slashes".into(),
1✔
1201
        version: "v1".into(),
1✔
1202
      }),
1✔
1203
      server_address_by_client_cidrs: None,
1✔
1204
      versions: vec![],
1✔
1205
    };
1✔
1206

1207
    assert!(preferred_group_version(&api_group).is_none());
1✔
1208
  }
1✔
1209

1210
  #[test]
1211
  fn test_should_retry_kubectl_refresh_for_auth_error() {
1✔
1212
    let error = anyhow!(kube::Error::Auth(AuthError::AuthExec(
1✔
1213
      "refresh failed".into()
1✔
1214
    )));
1✔
1215

1216
    assert!(should_retry_kubectl_refresh(&error));
1✔
1217
  }
1✔
1218

1219
  #[test]
1220
  fn test_should_retry_kubectl_refresh_for_unauthorized_api_error() {
1✔
1221
    let error = anyhow!(kube::Error::Api(
1✔
1222
      Status::failure("unauthorized", "Unauthorized")
1✔
1223
        .with_code(401)
1✔
1224
        .boxed()
1✔
1225
    ));
1✔
1226

1227
    assert!(should_retry_kubectl_refresh(&error));
1✔
1228
  }
1✔
1229

1230
  #[test]
1231
  fn test_should_not_retry_kubectl_refresh_for_non_auth_error() {
1✔
1232
    let error = anyhow!("Failed to load Kubernetes config");
1✔
1233

1234
    assert!(!should_retry_kubectl_refresh(&error));
1✔
1235
  }
1✔
1236

1237
  #[allow(clippy::await_holding_lock)]
1238
  #[tokio::test]
1239
  async fn test_refresh_client_restores_home_route_and_resource_tab_state() {
1✔
1240
    let _env_lock = env_lock();
1✔
1241
    let previous_kubeconfig = env::var_os("KUBECONFIG");
1✔
1242
    let dir = temp_test_dir("refresh-route-state");
1✔
1243
    let kubeconfig_path = dir.join("config");
1✔
1244
    write_kubeconfig(&kubeconfig_path, valid_kubeconfig());
1✔
1245
    env::set_var("KUBECONFIG", &kubeconfig_path);
1✔
1246

1247
    let client = get_client(None)
1✔
1248
      .await
1✔
1249
      .expect("test kubeconfig should produce a client");
1✔
1250
    let app = Arc::new(Mutex::new(App::default()));
1✔
1251

1252
    {
1253
      let mut app = app.lock().await;
1✔
1254
      app.data.selected.context = Some("test-context".into());
1✔
1255
      app.data.selected.ns = Some("team-a".into());
1✔
1256
      let route = app.context_tabs.set_index(1).route.clone();
1✔
1257
      app.push_navigation_route(route);
1✔
1258
    }
1259

1260
    let mut network = Network::new(client, &app);
1✔
1261
    network.refresh_client().await;
1✔
1262

1263
    let app = app.lock().await;
1✔
1264
    assert_eq!(app.main_tabs.index, 0);
1✔
1265
    assert_eq!(app.context_tabs.index, 1);
1✔
1266
    assert_eq!(app.get_current_route().id, RouteId::Home);
1✔
1267
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
1✔
1268
    assert_eq!(app.data.selected.context.as_deref(), Some("test-context"));
1✔
1269
    assert_eq!(app.data.selected.ns.as_deref(), Some("team-a"));
1✔
1270

1271
    match previous_kubeconfig {
1✔
1272
      Some(value) => env::set_var("KUBECONFIG", value),
1✔
1273
      None => env::remove_var("KUBECONFIG"),
1✔
1274
    }
1✔
1275
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1276
  }
1✔
1277
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc