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

kdash-rs / kdash / 24138980530

08 Apr 2026 01:50PM UTC coverage: 65.099% (-0.07%) from 65.167%
24138980530

push

github

web-flow
Merge pull request #504 from sed-i/feature/events

feat: add "Events" under the "More" menu

99 of 176 new or added lines in 5 files covered. (56.25%)

1 existing line in 1 file now uncovered.

8125 of 12481 relevant lines covered (65.1%)

137.96 hits per line

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

63.37
/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
  events::EventResource,
37
  ingress::IngressResource,
38
  jobs::JobResource,
39
  metrics::UtilizationResource,
40
  models::{AppResource, StatefulList},
41
  network_policies::NetworkPolicyResource,
42
  nodes::NodeResource,
43
  ns::NamespaceResource,
44
  pods::{KubePod, PodResource},
45
  pvcs::PvcResource,
46
  pvs::PvResource,
47
  replicasets::ReplicaSetResource,
48
  replication_controllers::ReplicationControllerResource,
49
  roles::{ClusterRoleBindingResource, ClusterRoleResource, RoleBindingResource, RoleResource},
50
  secrets::SecretResource,
51
  serviceaccounts::SvcAcctResource,
52
  statefulsets::StatefulSetResource,
53
  storageclass::StorageClassResource,
54
  svcs::SvcResource,
55
  troubleshoot::TroubleshootResource,
56
  ActiveBlock, App,
57
};
58

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

299
  Ok(config)
7✔
300
}
7✔
301

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

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

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

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

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

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

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

479
    let mut app = self.app.lock().await;
×
480
    app.loading_complete();
×
481
  }
×
482

483
  pub async fn handle_error(&self, e: anyhow::Error) {
×
484
    error!("{:?}", e);
×
485
    let mut app = self.app.lock().await;
×
486
    app.handle_error(e);
×
487
  }
×
488

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

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

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

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

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

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

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

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

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

649
    let list = api.list(&Default::default()).await?;
×
650
    Ok(
×
651
      list
×
652
        .items
×
653
        .into_iter()
×
654
        .map(crate::app::dynamic::KubeDynamicResource::from)
×
655
        .collect(),
×
656
    )
×
657
  }
×
658

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

671
    let mut dynamic_resources = vec![];
×
672
    let mut dynamic_menu = vec![];
×
673

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

701
    for api_group in api_groups {
×
702
      let group_name = api_group.name.clone();
×
703
      let Some(group_version) = preferred_group_version(&api_group) else {
×
704
        warn!(
×
705
          "Skipping dynamic API group '{}' because it has no preferred or parseable version",
706
          group_name
707
        );
708
        continue;
×
709
      };
710

711
      match pinned_group(&self.client, &group_version).await {
×
712
        Ok(group) => {
×
713
          for (ar, caps) in group.recommended_resources() {
×
714
            if !caps.supports_operation(verbs::LIST) || excluded.contains(&ar.kind.as_str()) {
×
715
              continue;
×
716
            }
×
717

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

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

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

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

785
  fn write_kubeconfig(path: &Path, contents: &str) {
9✔
786
    fs::write(path, contents).expect("kubeconfig fixture should be written");
9✔
787
  }
9✔
788

789
  fn env_lock() -> std::sync::MutexGuard<'static, ()> {
6✔
790
    static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
791
    LOCK
6✔
792
      .get_or_init(|| StdMutex::new(()))
6✔
793
      .lock()
6✔
794
      .unwrap_or_else(|poisoned| poisoned.into_inner())
6✔
795
  }
6✔
796

797
  struct ProxyEnvGuard {
798
    https_proxy: Option<OsString>,
799
    https_proxy_lower: Option<OsString>,
800
  }
801

802
  impl ProxyEnvGuard {
803
    fn capture() -> Self {
5✔
804
      Self {
5✔
805
        https_proxy: env::var_os("HTTPS_PROXY"),
5✔
806
        https_proxy_lower: env::var_os("https_proxy"),
5✔
807
      }
5✔
808
    }
5✔
809
  }
810

811
  impl Drop for ProxyEnvGuard {
812
    fn drop(&mut self) {
5✔
813
      match &self.https_proxy {
5✔
814
        Some(value) => env::set_var("HTTPS_PROXY", value),
×
815
        None => env::remove_var("HTTPS_PROXY"),
5✔
816
      }
817

818
      match &self.https_proxy_lower {
5✔
819
        Some(value) => env::set_var("https_proxy", value),
×
820
        None => env::remove_var("https_proxy"),
5✔
821
      }
822
    }
5✔
823
  }
824

825
  fn clear_https_proxy_env() {
5✔
826
    env::remove_var("HTTPS_PROXY");
5✔
827
    env::remove_var("https_proxy");
5✔
828
  }
5✔
829

830
  fn kubeconfig_with_proxy(proxy_url: Option<&str>) -> String {
5✔
831
    let proxy_yaml = proxy_url
5✔
832
      .map(|proxy| format!("      proxy-url: {}\n", proxy))
5✔
833
      .unwrap_or_default();
5✔
834

835
    format!(
5✔
836
      r#"apiVersion: v1
837
kind: Config
838
clusters:
839
  - name: test-cluster
840
    cluster:
841
      server: https://127.0.0.1:6443
842
{proxy_yaml}contexts:
843
  - name: test-context
844
    context:
845
      cluster: test-cluster
846
      user: test-user
847
current-context: test-context
848
users:
849
  - name: test-user
850
    user:
851
      token: test-token
852
"#
853
    )
854
  }
5✔
855

856
  fn valid_kubeconfig() -> &'static str {
4✔
857
    r#"apiVersion: v1
4✔
858
kind: Config
4✔
859
clusters:
4✔
860
  - name: test-cluster
4✔
861
    cluster:
4✔
862
      server: https://127.0.0.1:6443
4✔
863
contexts:
4✔
864
  - name: test-context
4✔
865
    context:
4✔
866
      cluster: test-cluster
4✔
867
      user: test-user
4✔
868
current-context: test-context
4✔
869
users:
4✔
870
  - name: test-user
4✔
871
    user:
4✔
872
      token: test-token
4✔
873
"#
4✔
874
  }
4✔
875

876
  #[test]
877
  fn test_load_client_config_from_kubeconfig_uses_cluster_proxy_url() {
1✔
878
    let _env_lock = env_lock();
1✔
879
    let _proxy_env = ProxyEnvGuard::capture();
1✔
880
    clear_https_proxy_env();
1✔
881

882
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
883
      "http://cluster-proxy.internal:8443",
1✔
884
    )))
1✔
885
    .expect("proxy kubeconfig should deserialize");
1✔
886

887
    let config = tokio::runtime::Runtime::new()
1✔
888
      .expect("runtime should build")
1✔
889
      .block_on(load_client_config_from_kubeconfig(
1✔
890
        kubeconfig,
1✔
891
        KubeConfigOptions::default(),
1✔
892
      ))
893
      .expect("config should load");
1✔
894

895
    assert_eq!(
1✔
896
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
897
      Some("http://cluster-proxy.internal:8443/".to_string())
1✔
898
    );
899
  }
1✔
900

901
  #[test]
902
  fn test_load_client_config_from_kubeconfig_uses_https_proxy_env_var() {
1✔
903
    let _env_lock = env_lock();
1✔
904
    let _proxy_env = ProxyEnvGuard::capture();
1✔
905
    clear_https_proxy_env();
1✔
906
    env::set_var("HTTPS_PROXY", "http://env-proxy.internal:8080");
1✔
907

908
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(None))
1✔
909
      .expect("base kubeconfig should deserialize");
1✔
910

911
    let config = tokio::runtime::Runtime::new()
1✔
912
      .expect("runtime should build")
1✔
913
      .block_on(load_client_config_from_kubeconfig(
1✔
914
        kubeconfig,
1✔
915
        KubeConfigOptions::default(),
1✔
916
      ))
917
      .expect("config should load");
1✔
918

919
    assert_eq!(
1✔
920
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
921
      Some("http://env-proxy.internal:8080/".to_string())
1✔
922
    );
923
  }
1✔
924

925
  #[test]
926
  fn test_load_client_config_from_kubeconfig_uses_https_proxy_env_var_case_insensitively() {
1✔
927
    let _env_lock = env_lock();
1✔
928
    let _proxy_env = ProxyEnvGuard::capture();
1✔
929
    clear_https_proxy_env();
1✔
930
    env::set_var("Https_PrOxY", "http://env-proxy.internal:8080");
1✔
931

932
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(None))
1✔
933
      .expect("base kubeconfig should deserialize");
1✔
934

935
    let config = tokio::runtime::Runtime::new()
1✔
936
      .expect("runtime should build")
1✔
937
      .block_on(load_client_config_from_kubeconfig(
1✔
938
        kubeconfig,
1✔
939
        KubeConfigOptions::default(),
1✔
940
      ))
941
      .expect("config should load");
1✔
942

943
    env::remove_var("Https_PrOxY");
1✔
944

945
    assert_eq!(
1✔
946
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
947
      Some("http://env-proxy.internal:8080/".to_string())
1✔
948
    );
949
  }
1✔
950

951
  #[test]
952
  fn test_load_client_config_from_kubeconfig_prefers_cluster_proxy_over_env_var() {
1✔
953
    let _env_lock = env_lock();
1✔
954
    let _proxy_env = ProxyEnvGuard::capture();
1✔
955
    clear_https_proxy_env();
1✔
956
    env::set_var("HTTPS_PROXY", "http://env-proxy.internal:8080");
1✔
957

958
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
959
      "http://cluster-proxy.internal:8443",
1✔
960
    )))
1✔
961
    .expect("proxy kubeconfig should deserialize");
1✔
962

963
    let config = tokio::runtime::Runtime::new()
1✔
964
      .expect("runtime should build")
1✔
965
      .block_on(load_client_config_from_kubeconfig(
1✔
966
        kubeconfig,
1✔
967
        KubeConfigOptions::default(),
1✔
968
      ))
969
      .expect("config should load");
1✔
970

971
    assert_eq!(
1✔
972
      config.proxy_url.as_ref().map(ToString::to_string),
1✔
973
      Some("http://cluster-proxy.internal:8443/".to_string())
1✔
974
    );
975
  }
1✔
976

977
  #[test]
978
  fn test_get_client_supports_http_proxy_configuration() {
1✔
979
    let _env_lock = env_lock();
1✔
980
    let _proxy_env = ProxyEnvGuard::capture();
1✔
981
    clear_https_proxy_env();
1✔
982

983
    let kubeconfig: Kubeconfig = serde_yaml::from_str(&kubeconfig_with_proxy(Some(
1✔
984
      "http://cluster-proxy.internal:8443",
1✔
985
    )))
1✔
986
    .expect("proxy kubeconfig should deserialize");
1✔
987

988
    let config = tokio::runtime::Runtime::new()
1✔
989
      .expect("runtime should build")
1✔
990
      .block_on(load_client_config_from_kubeconfig(
1✔
991
        kubeconfig,
1✔
992
        KubeConfigOptions::default(),
1✔
993
      ))
994
      .expect("config should load");
1✔
995
    let runtime = tokio::runtime::Runtime::new().expect("runtime should build");
1✔
996
    let _enter = runtime.enter();
1✔
997

998
    Client::try_from(config).expect("http proxy support should be enabled");
1✔
999
  }
1✔
1000

1001
  #[test]
1002
  fn test_load_kubeconfig_from_paths_ignores_missing_entries_when_valid_config_exists() {
1✔
1003
    let dir = temp_test_dir("missing-valid");
1✔
1004
    let missing = dir.join("missing-config");
1✔
1005
    let valid = dir.join("valid-config");
1✔
1006
    write_kubeconfig(&valid, valid_kubeconfig());
1✔
1007

1008
    let kubeconfig =
1✔
1009
      load_kubeconfig_from_paths(&[missing, valid]).expect("valid kubeconfig should load");
1✔
1010

1011
    assert_eq!(kubeconfig.current_context.as_deref(), Some("test-context"));
1✔
1012
    assert_eq!(kubeconfig.clusters.len(), 1);
1✔
1013
    assert_eq!(kubeconfig.contexts.len(), 1);
1✔
1014
    assert_eq!(kubeconfig.auth_infos.len(), 1);
1✔
1015

1016
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1017
  }
1✔
1018

1019
  #[test]
1020
  fn test_load_kubeconfig_from_paths_ignores_blank_entries_when_valid_config_exists() {
1✔
1021
    let dir = temp_test_dir("blank-valid");
1✔
1022
    let blank = dir.join("blank-config");
1✔
1023
    let valid = dir.join("valid-config");
1✔
1024
    write_kubeconfig(&blank, "");
1✔
1025
    write_kubeconfig(&valid, valid_kubeconfig());
1✔
1026

1027
    let kubeconfig =
1✔
1028
      load_kubeconfig_from_paths(&[blank, valid]).expect("valid kubeconfig should load");
1✔
1029

1030
    assert_eq!(kubeconfig.current_context.as_deref(), Some("test-context"));
1✔
1031
    assert_eq!(kubeconfig.clusters.len(), 1);
1✔
1032
    assert_eq!(kubeconfig.contexts.len(), 1);
1✔
1033
    assert_eq!(kubeconfig.auth_infos.len(), 1);
1✔
1034

1035
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1036
  }
1✔
1037

1038
  #[test]
1039
  fn test_load_kubeconfig_from_paths_returns_clean_error_when_all_entries_are_invalid() {
1✔
1040
    let dir = temp_test_dir("all-invalid");
1✔
1041
    let missing = dir.join("missing-config");
1✔
1042
    let blank = dir.join("blank-config");
1✔
1043
    write_kubeconfig(&blank, "");
1✔
1044

1045
    let error = load_kubeconfig_from_paths(&[missing, blank])
1✔
1046
      .expect_err("all invalid kubeconfigs should fail")
1✔
1047
      .to_string();
1✔
1048

1049
    assert!(error.contains("Failed to load Kubernetes config from KUBECONFIG"));
1✔
1050
    assert!(error.contains("ignored missing kubeconfig"));
1✔
1051
    assert!(error.contains("ignored blank kubeconfig"));
1✔
1052

1053
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1054
  }
1✔
1055

1056
  #[test]
1057
  fn test_is_blank_kubeconfig_detects_empty_config() {
1✔
1058
    let config = Kubeconfig::default();
1✔
1059
    assert!(is_blank_kubeconfig(&config));
1✔
1060
  }
1✔
1061

1062
  #[test]
1063
  fn test_is_blank_kubeconfig_returns_false_for_populated_config() {
1✔
1064
    let config = Kubeconfig {
1✔
1065
      current_context: Some("ctx".to_string()),
1✔
1066
      ..Default::default()
1✔
1067
    };
1✔
1068
    assert!(!is_blank_kubeconfig(&config));
1✔
1069
  }
1✔
1070

1071
  #[test]
1072
  fn test_load_kubeconfig_path_ok_for_valid_file() {
1✔
1073
    let dir = temp_test_dir("path-valid");
1✔
1074
    let file = dir.join("config");
1✔
1075
    write_kubeconfig(&file, valid_kubeconfig());
1✔
1076

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

1080
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1081
  }
1✔
1082

1083
  #[test]
1084
  fn test_load_kubeconfig_path_err_for_missing_file() {
1✔
1085
    let dir = temp_test_dir("path-missing");
1✔
1086
    let missing = dir.join("does-not-exist");
1✔
1087

1088
    let err = load_kubeconfig_path(&missing).expect_err("missing file should return Err");
1✔
1089
    assert!(err.contains("ignored missing kubeconfig"));
1✔
1090

1091
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1092
  }
1✔
1093

1094
  #[test]
1095
  fn test_load_kubeconfig_path_err_for_blank_file() {
1✔
1096
    let dir = temp_test_dir("path-blank");
1✔
1097
    let blank = dir.join("blank");
1✔
1098
    write_kubeconfig(&blank, "");
1✔
1099

1100
    let err = load_kubeconfig_path(&blank).expect_err("blank file should return Err");
1✔
1101
    assert!(err.contains("ignored blank kubeconfig"));
1✔
1102

1103
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1104
  }
1✔
1105

1106
  #[test]
1107
  fn test_load_kubeconfig_from_paths_merges_multiple_valid_configs() {
1✔
1108
    let dir = temp_test_dir("merge");
1✔
1109
    let file_a = dir.join("config-a");
1✔
1110
    let file_b = dir.join("config-b");
1✔
1111

1112
    write_kubeconfig(
1✔
1113
      &file_a,
1✔
1114
      r#"apiVersion: v1
1✔
1115
kind: Config
1✔
1116
clusters:
1✔
1117
  - name: cluster-a
1✔
1118
    cluster:
1✔
1119
      server: https://a:6443
1✔
1120
contexts:
1✔
1121
  - name: ctx-a
1✔
1122
    context:
1✔
1123
      cluster: cluster-a
1✔
1124
      user: user-a
1✔
1125
current-context: ctx-a
1✔
1126
users:
1✔
1127
  - name: user-a
1✔
1128
    user:
1✔
1129
      token: token-a
1✔
1130
"#,
1✔
1131
    );
1132

1133
    write_kubeconfig(
1✔
1134
      &file_b,
1✔
1135
      r#"apiVersion: v1
1✔
1136
kind: Config
1✔
1137
clusters:
1✔
1138
  - name: cluster-b
1✔
1139
    cluster:
1✔
1140
      server: https://b:6443
1✔
1141
contexts:
1✔
1142
  - name: ctx-b
1✔
1143
    context:
1✔
1144
      cluster: cluster-b
1✔
1145
      user: user-b
1✔
1146
users:
1✔
1147
  - name: user-b
1✔
1148
    user:
1✔
1149
      token: token-b
1✔
1150
"#,
1✔
1151
    );
1152

1153
    let config =
1✔
1154
      load_kubeconfig_from_paths(&[file_a, file_b]).expect("multiple valid configs should merge");
1✔
1155

1156
    // First file's current_context wins
1157
    assert_eq!(config.current_context.as_deref(), Some("ctx-a"));
1✔
1158
    assert_eq!(config.clusters.len(), 2);
1✔
1159
    assert_eq!(config.contexts.len(), 2);
1✔
1160
    assert_eq!(config.auth_infos.len(), 2);
1✔
1161

1162
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1163
  }
1✔
1164

1165
  #[test]
1166
  fn test_preferred_group_version_uses_preferred_version() {
1✔
1167
    let api_group = DiscoveryApiGroup {
1✔
1168
      name: "example.io".into(),
1✔
1169
      preferred_version: Some(GroupVersionForDiscovery {
1✔
1170
        group_version: "example.io/v1".into(),
1✔
1171
        version: "v1".into(),
1✔
1172
      }),
1✔
1173
      server_address_by_client_cidrs: None,
1✔
1174
      versions: vec![GroupVersionForDiscovery {
1✔
1175
        group_version: "example.io/v1beta1".into(),
1✔
1176
        version: "v1beta1".into(),
1✔
1177
      }],
1✔
1178
    };
1✔
1179

1180
    let group_version =
1✔
1181
      preferred_group_version(&api_group).expect("preferred version should parse");
1✔
1182
    assert_eq!(group_version.group, "example.io");
1✔
1183
    assert_eq!(group_version.version, "v1");
1✔
1184
  }
1✔
1185

1186
  #[test]
1187
  fn test_preferred_group_version_falls_back_to_first_served_version() {
1✔
1188
    let api_group = DiscoveryApiGroup {
1✔
1189
      name: "example.io".into(),
1✔
1190
      preferred_version: None,
1✔
1191
      server_address_by_client_cidrs: None,
1✔
1192
      versions: vec![GroupVersionForDiscovery {
1✔
1193
        group_version: "example.io/v1beta1".into(),
1✔
1194
        version: "v1beta1".into(),
1✔
1195
      }],
1✔
1196
    };
1✔
1197

1198
    let group_version = preferred_group_version(&api_group).expect("served version should parse");
1✔
1199
    assert_eq!(group_version.group, "example.io");
1✔
1200
    assert_eq!(group_version.version, "v1beta1");
1✔
1201
  }
1✔
1202

1203
  #[test]
1204
  fn test_preferred_group_version_returns_none_for_invalid_version_string() {
1✔
1205
    let api_group = DiscoveryApiGroup {
1✔
1206
      name: "example.io".into(),
1✔
1207
      preferred_version: Some(GroupVersionForDiscovery {
1✔
1208
        group_version: "too/many/slashes".into(),
1✔
1209
        version: "v1".into(),
1✔
1210
      }),
1✔
1211
      server_address_by_client_cidrs: None,
1✔
1212
      versions: vec![],
1✔
1213
    };
1✔
1214

1215
    assert!(preferred_group_version(&api_group).is_none());
1✔
1216
  }
1✔
1217

1218
  #[test]
1219
  fn test_should_retry_kubectl_refresh_for_auth_error() {
1✔
1220
    let error = anyhow!(kube::Error::Auth(AuthError::AuthExec(
1✔
1221
      "refresh failed".into()
1✔
1222
    )));
1✔
1223

1224
    assert!(should_retry_kubectl_refresh(&error));
1✔
1225
  }
1✔
1226

1227
  #[test]
1228
  fn test_should_retry_kubectl_refresh_for_unauthorized_api_error() {
1✔
1229
    let error = anyhow!(kube::Error::Api(
1✔
1230
      Status::failure("unauthorized", "Unauthorized")
1✔
1231
        .with_code(401)
1✔
1232
        .boxed()
1✔
1233
    ));
1✔
1234

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

1238
  #[test]
1239
  fn test_should_not_retry_kubectl_refresh_for_non_auth_error() {
1✔
1240
    let error = anyhow!("Failed to load Kubernetes config");
1✔
1241

1242
    assert!(!should_retry_kubectl_refresh(&error));
1✔
1243
  }
1✔
1244

1245
  #[allow(clippy::await_holding_lock)]
1246
  #[tokio::test]
1247
  async fn test_refresh_client_restores_home_route_and_resource_tab_state() {
1✔
1248
    let _env_lock = env_lock();
1✔
1249
    let previous_kubeconfig = env::var_os("KUBECONFIG");
1✔
1250
    let dir = temp_test_dir("refresh-route-state");
1✔
1251
    let kubeconfig_path = dir.join("config");
1✔
1252
    write_kubeconfig(&kubeconfig_path, valid_kubeconfig());
1✔
1253
    env::set_var("KUBECONFIG", &kubeconfig_path);
1✔
1254

1255
    let client = get_client(None)
1✔
1256
      .await
1✔
1257
      .expect("test kubeconfig should produce a client");
1✔
1258
    let app = Arc::new(Mutex::new(App::default()));
1✔
1259

1260
    {
1261
      let mut app = app.lock().await;
1✔
1262
      app.data.selected.context = Some("test-context".into());
1✔
1263
      app.data.selected.ns = Some("team-a".into());
1✔
1264
      let route = app.context_tabs.set_index(1).route.clone();
1✔
1265
      app.push_navigation_route(route);
1✔
1266
    }
1267

1268
    let mut network = Network::new(client, &app);
1✔
1269
    network.refresh_client().await;
1✔
1270

1271
    let app = app.lock().await;
1✔
1272
    assert_eq!(app.main_tabs.index, 0);
1✔
1273
    assert_eq!(app.context_tabs.index, 1);
1✔
1274
    assert_eq!(app.get_current_route().id, RouteId::Home);
1✔
1275
    assert_eq!(app.get_current_route().active_block, ActiveBlock::Services);
1✔
1276
    assert_eq!(app.data.selected.context.as_deref(), Some("test-context"));
1✔
1277
    assert_eq!(app.data.selected.ns.as_deref(), Some("team-a"));
1✔
1278

1279
    match previous_kubeconfig {
1✔
1280
      Some(value) => env::set_var("KUBECONFIG", value),
1✔
1281
      None => env::remove_var("KUBECONFIG"),
1✔
1282
    }
1✔
1283
    fs::remove_dir_all(dir).expect("temp test dir should be removed");
1✔
1284
  }
1✔
1285
}
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