eksup/k8s/
checks.rs

1use std::collections::{HashMap, HashSet};
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use tabled::{
6  settings::{locator::ByColumnName, Disable, Margin, Style},
7  Table, Tabled,
8};
9
10use crate::{
11  finding::{self, Findings},
12  k8s::resources::{self, Resource},
13  version,
14};
15
16/// Node details as viewed from the Kubernetes API
17///
18/// Contains information related to the Kubernetes component versions
19#[derive(Clone, Debug, Serialize, Deserialize, Tabled)]
20#[tabled(rename_all = "UpperCase")]
21pub struct VersionSkew {
22  #[tabled(inline)]
23  pub finding: finding::Finding,
24  pub name: String,
25  #[tabled(skip)]
26  pub kubelet_version: String,
27  #[tabled(rename = "NODE")]
28  pub kubernetes_version: String,
29  #[tabled(rename = "CONTROL PLANE")]
30  pub control_plane_version: String,
31  #[tabled(rename = "SKEW")]
32  pub version_skew: String,
33}
34
35#[derive(Debug, Serialize, Deserialize, Tabled)]
36#[tabled(rename_all = "UpperCase")]
37pub struct VersionSkewSummary {
38  #[tabled(inline)]
39  pub version_skew: VersionSkew,
40  pub quantity: i32,
41}
42
43impl Findings for Vec<VersionSkew> {
44  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
45    if self.is_empty() {
46      return Ok(format!(
47        "{leading_whitespace}✅ - No reported findings regarding version skew between the control plane and nodes"
48      ));
49    }
50
51    let mut summary: HashMap<(String, String, String, String, String), VersionSkewSummary> = HashMap::new();
52    for node in self {
53      let key = (
54        node.finding.code.to_string(),
55        node.finding.symbol.to_owned(),
56        node.finding.remediation.to_string(),
57        node.kubernetes_version.to_owned(),
58        node.control_plane_version.to_owned(),
59      );
60
61      if let Some(summary) = summary.get_mut(&key) {
62        summary.quantity += 1;
63      } else {
64        summary.insert(
65          key,
66          VersionSkewSummary {
67            version_skew: node.clone(),
68            quantity: 1,
69          },
70        );
71      }
72    }
73
74    let mut summary_tbl = Table::new(summary);
75    summary_tbl
76      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
77      .with(Disable::column(ByColumnName::new("String")))
78      .with(Disable::column(ByColumnName::new("NAME")))
79      .with(Style::markdown());
80
81    let mut table = Table::new(self);
82    table
83      .with(Disable::column(ByColumnName::new("CHECK")))
84      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
85      .with(Style::markdown());
86
87    Ok(format!("{summary_tbl}\n\n{table}\n"))
88  }
89
90  fn to_stdout_table(&self) -> Result<String> {
91    if self.is_empty() {
92      return Ok("".to_owned());
93    }
94
95    let mut table = Table::new(self);
96    table.with(Style::sharp());
97
98    Ok(format!("{table}\n"))
99  }
100}
101
102/// Returns all of the nodes in the cluster
103pub async fn version_skew(nodes: &[resources::Node], cluster_version: &str) -> Result<Vec<VersionSkew>> {
104  let mut findings = vec![];
105
106  for node in nodes {
107    let control_plane_minor_version = version::parse_minor(cluster_version)?;
108    let version_skew = control_plane_minor_version - node.minor_version;
109    if version_skew == 0 {
110      continue;
111    }
112
113    // Prior to upgrade, the node version should not be more than 1 version behind
114    // the control plane version. If it is, the node must be upgraded before
115    // attempting the cluster upgrade
116    let mut remediation = match version_skew {
117      1 => finding::Remediation::Recommended,
118      _ => finding::Remediation::Required,
119    };
120
121    if let Some(labels) = &node.labels {
122      if labels.contains_key("eks.amazonaws.com/nodegroup") {
123        // Nodes created by EKS managed nodegroups are required to match control plane
124        // before the control plane will permit an upgrade
125        remediation = finding::Remediation::Required;
126      }
127    }
128
129    if node.name.starts_with("fargate-") {
130      // Nodes created by EKS Fargate are required to match control plane
131      // before the control plane will permit an upgrade
132      remediation = finding::Remediation::Required;
133    }
134
135    let finding = finding::Finding {
136      code: finding::Code::K8S001,
137      symbol: remediation.symbol(),
138      remediation,
139    };
140
141    let node = VersionSkew {
142      finding,
143      name: node.name.to_owned(),
144      kubelet_version: node.kubelet_version.to_owned(),
145      kubernetes_version: format!("v{}", version::normalize(&node.kubelet_version).unwrap()),
146      control_plane_version: format!("v{cluster_version}"),
147      version_skew: format!("+{version_skew}"),
148    };
149
150    findings.push(node)
151  }
152
153  Ok(findings)
154}
155
156#[derive(Debug, Serialize, Deserialize, Tabled)]
157#[tabled(rename_all = "UpperCase")]
158pub struct MinReplicas {
159  #[tabled(inline)]
160  pub finding: finding::Finding,
161  #[tabled(inline)]
162  pub resource: Resource,
163  /// Number of replicas
164  pub replicas: i32,
165}
166
167impl Findings for Vec<MinReplicas> {
168  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
169    if self.is_empty() {
170      return Ok(format!(
171        "{leading_whitespace}✅ - All relevant Kubernetes workloads have at least 3 replicas specified"
172      ));
173    }
174
175    let mut table = Table::new(self);
176    table
177      .with(Disable::column(ByColumnName::new("CHECK")))
178      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
179      .with(Style::markdown());
180
181    Ok(format!("{table}\n"))
182  }
183
184  fn to_stdout_table(&self) -> Result<String> {
185    if self.is_empty() {
186      return Ok("".to_owned());
187    }
188
189    let mut table = Table::new(self);
190    table.with(Style::sharp());
191
192    Ok(format!("{table}\n"))
193  }
194}
195
196#[derive(Debug, Serialize, Deserialize, Tabled)]
197#[tabled(rename_all = "UpperCase")]
198pub struct MinReadySeconds {
199  #[tabled(inline)]
200  pub finding: finding::Finding,
201  #[tabled(inline)]
202  pub resource: Resource,
203  /// Min ready seconds
204  pub seconds: i32,
205}
206
207impl Findings for Vec<MinReadySeconds> {
208  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
209    if self.is_empty() {
210      return Ok(format!(
211        "{leading_whitespace}✅ - All relevant Kubernetes workloads minReadySeconds set to more than 0"
212      ));
213    }
214
215    let mut table = Table::new(self);
216    table
217      .with(Disable::column(ByColumnName::new("CHECK")))
218      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
219      .with(Style::markdown());
220
221    Ok(format!("{table}\n"))
222  }
223
224  fn to_stdout_table(&self) -> Result<String> {
225    if self.is_empty() {
226      return Ok("".to_owned());
227    }
228
229    let mut table = Table::new(self);
230    table.with(Style::sharp());
231
232    Ok(format!("{table}\n"))
233  }
234}
235
236#[derive(Debug, Serialize, Deserialize, Tabled)]
237#[tabled(rename_all = "UpperCase")]
238pub struct PodDisruptionBudget {
239  #[tabled(inline)]
240  pub finding: finding::Finding,
241  #[tabled(inline)]
242  pub resource: Resource,
243  // Has pod associated pod disruption budget
244  // TODO - more relevant information than just present?
245}
246
247#[derive(Debug, Serialize, Deserialize, Tabled)]
248#[tabled(rename_all = "UpperCase")]
249pub struct PodTopologyDistribution {
250  #[tabled(inline)]
251  pub finding: finding::Finding,
252  #[tabled(inline)]
253  pub resource: Resource,
254
255  pub anti_affinity: bool,
256  pub topology_spread_constraints: bool,
257}
258
259impl Findings for Vec<PodTopologyDistribution> {
260  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
261    if self.is_empty() {
262      return Ok(format!(
263        "{leading_whitespace}✅ - All relevant Kubernetes workloads have either podAntiAffinity or topologySpreadConstraints set"
264      ));
265    }
266
267    let mut table = Table::new(self);
268    table
269      .with(Disable::column(ByColumnName::new("CHECK")))
270      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
271      .with(Style::markdown());
272
273    Ok(format!("{table}\n"))
274  }
275
276  fn to_stdout_table(&self) -> Result<String> {
277    if self.is_empty() {
278      return Ok("".to_owned());
279    }
280
281    let mut table = Table::new(self);
282    table.with(Style::sharp());
283
284    Ok(format!("{table}\n"))
285  }
286}
287
288#[derive(Debug, Serialize, Deserialize, Tabled)]
289#[tabled(rename_all = "UpperCase")]
290pub struct Probe {
291  #[tabled(inline)]
292  pub finding: finding::Finding,
293
294  #[tabled(inline)]
295  pub resource: Resource,
296  #[tabled(rename = "READINESS PROBE")]
297  pub readiness_probe: bool,
298}
299
300impl Findings for Vec<Probe> {
301  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
302    if self.is_empty() {
303      return Ok(format!(
304        "{leading_whitespace}✅ - All relevant Kubernetes workloads have a readiness probe configured"
305      ));
306    }
307
308    let mut table = Table::new(self);
309    table
310      .with(Disable::column(ByColumnName::new("CHECK")))
311      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
312      .with(Style::markdown());
313
314    Ok(format!("{table}\n"))
315  }
316
317  fn to_stdout_table(&self) -> Result<String> {
318    if self.is_empty() {
319      return Ok("".to_owned());
320    }
321
322    let mut table = Table::new(self);
323    table.with(Style::sharp());
324
325    Ok(format!("{table}\n"))
326  }
327}
328
329#[derive(Debug, Serialize, Deserialize, Tabled)]
330#[tabled(rename_all = "UpperCase")]
331pub struct TerminationGracePeriod {
332  #[tabled(inline)]
333  pub finding: finding::Finding,
334
335  #[tabled(inline)]
336  pub resource: Resource,
337  /// Min ready seconds
338  pub termination_grace_period: i64,
339}
340
341impl Findings for Vec<TerminationGracePeriod> {
342  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
343    if self.is_empty() {
344      return Ok(format!(
345        "{leading_whitespace}✅ - No StatefulSet workloads have a terminationGracePeriodSeconds set to more than 0"
346      ));
347    }
348
349    let mut table = Table::new(self);
350    table
351      .with(Disable::column(ByColumnName::new("CHECK")))
352      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
353      .with(Style::markdown());
354
355    Ok(format!("{table}\n"))
356  }
357
358  fn to_stdout_table(&self) -> Result<String> {
359    if self.is_empty() {
360      return Ok("".to_owned());
361    }
362
363    let mut table = Table::new(self);
364    table.with(Style::sharp());
365
366    Ok(format!("{table}\n"))
367  }
368}
369
370#[derive(Debug, Serialize, Deserialize, Tabled)]
371#[tabled(rename_all = "UpperCase")]
372pub struct DockerSocket {
373  #[tabled(inline)]
374  pub finding: finding::Finding,
375
376  #[tabled(inline)]
377  pub resource: Resource,
378
379  pub docker_socket: bool,
380}
381
382impl Findings for Vec<DockerSocket> {
383  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
384    if self.is_empty() {
385      return Ok(format!(
386        "{leading_whitespace}✅ - No relevant Kubernetes workloads are found to be utilizing the Docker socket"
387      ));
388    }
389
390    let mut table = Table::new(self);
391    table
392      .with(Disable::column(ByColumnName::new("CHECK")))
393      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
394      .with(Style::markdown());
395
396    Ok(format!("{table}\n"))
397  }
398
399  fn to_stdout_table(&self) -> Result<String> {
400    if self.is_empty() {
401      return Ok("".to_owned());
402    }
403
404    let mut table = Table::new(self);
405    table.with(Style::sharp());
406
407    Ok(format!("{table}\n"))
408  }
409}
410
411#[derive(Debug, Serialize, Deserialize, Tabled)]
412#[tabled(rename_all = "UpperCase")]
413pub struct PodSecurityPolicy {
414  #[tabled(inline)]
415  pub finding: finding::Finding,
416
417  #[tabled(inline)]
418  pub resource: Resource,
419}
420
421impl Findings for Vec<PodSecurityPolicy> {
422  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
423    if self.is_empty() {
424      return Ok(format!(
425        "{leading_whitespace}✅ - No PodSecurityPolicys were found within the cluster"
426      ));
427    }
428
429    let mut table = Table::new(self);
430    table
431      .with(Disable::column(ByColumnName::new("CHECK")))
432      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
433      .with(Style::markdown());
434
435    Ok(format!("{table}\n"))
436  }
437
438  fn to_stdout_table(&self) -> Result<String> {
439    if self.is_empty() {
440      return Ok("".to_owned());
441    }
442
443    let mut table = Table::new(self);
444    table.with(Style::sharp());
445
446    Ok(format!("{table}\n"))
447  }
448}
449
450#[derive(Clone, Debug, Serialize, Deserialize, Tabled)]
451#[tabled(rename_all = "UpperCase")]
452pub struct KubeProxyVersionSkew {
453  #[tabled(inline)]
454  pub finding: finding::Finding,
455  #[tabled(rename = "KUBELET")]
456  pub kubelet_version: String,
457  #[tabled(rename = "KUBE PROXY")]
458  pub kube_proxy_version: String,
459  #[tabled(rename = "SKEW")]
460  pub version_skew: String,
461}
462
463pub async fn kube_proxy_version_skew(
464  nodes: &[resources::Node],
465  resources: &[resources::StdResource],
466) -> Result<Vec<KubeProxyVersionSkew>> {
467  let kube_proxy = match resources
468    .iter()
469    .filter(|r| r.metadata.kind == resources::Kind::DaemonSet && r.metadata.name == "kube-proxy")
470    .collect::<Vec<_>>()
471    .first()
472  {
473    Some(k) => k.to_owned(),
474    None => {
475      println!("Unable to find kube-proxy");
476      return Ok(vec![]);
477    }
478  };
479
480  let ptmpl = kube_proxy.spec.template.as_ref().unwrap();
481  let pspec = ptmpl.spec.as_ref().unwrap();
482  let kproxy_minor_version = pspec
483    .containers
484    .iter()
485    .map(|container| {
486      // TODO - this seems brittle
487      let image = container.image.as_ref().unwrap().split(':').collect::<Vec<_>>()[1];
488      version::parse_minor(image).unwrap()
489    })
490    .next()
491    .context("Unable to find image version for kube-proxy")?;
492
493  let findings = nodes
494    .iter()
495    .map(|node| node.minor_version)
496    .collect::<HashSet<_>>()
497    .into_iter()
498    .filter(|node_ver| node_ver != &kproxy_minor_version)
499    .map(|node_ver| {
500      let remediation = finding::Remediation::Required;
501      let finding = finding::Finding {
502        code: finding::Code::K8S011,
503        symbol: remediation.symbol(),
504        remediation,
505      };
506
507      KubeProxyVersionSkew {
508        finding,
509        kubelet_version: format!("v1.{node_ver}"),
510        kube_proxy_version: format!("v1.{kproxy_minor_version}"),
511        version_skew: format!("{}", kproxy_minor_version - node_ver),
512      }
513    })
514    .collect();
515
516  Ok(findings)
517}
518
519impl Findings for Vec<KubeProxyVersionSkew> {
520  fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
521    if self.is_empty() {
522      return Ok(format!(
523        "{leading_whitespace}✅ - `kube-proxy` version is aligned with the node/`kubelet` versions in use"
524      ));
525    }
526
527    let mut table = Table::new(self);
528    table
529      .with(Disable::column(ByColumnName::new("CHECK")))
530      .with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
531      .with(Style::markdown());
532
533    Ok(format!("{table}\n"))
534  }
535
536  fn to_stdout_table(&self) -> Result<String> {
537    if self.is_empty() {
538      return Ok("".to_owned());
539    }
540
541    let mut table = Table::new(self);
542    table.with(Style::sharp());
543
544    Ok(format!("{table}\n"))
545  }
546}
547
548pub trait K8sFindings {
549  fn get_resource(&self) -> Resource;
550
551  /// K8S002 - check if resources contain a minimum of 3 replicas
552  fn min_replicas(&self) -> Option<MinReplicas>;
553
554  /// K8S003 - check if resources contain minReadySeconds > 0
555  fn min_ready_seconds(&self) -> Option<MinReadySeconds>;
556
557  // /// K8S004 - check if resources have associated podDisruptionBudgets
558  // fn pod_disruption_budget(&self) -> Option<PodDisruptionBudget>;
559
560  /// K8S005 - check if resources have podAntiAffinity or topologySpreadConstraints
561  fn pod_topology_distribution(&self) -> Option<PodTopologyDistribution>;
562
563  /// K8S006 - check if resources have readinessProbe
564  fn readiness_probe(&self) -> Option<Probe>;
565
566  /// K8S007 - check if StatefulSets have terminationGracePeriodSeconds == 0
567  fn termination_grace_period(&self) -> Option<TerminationGracePeriod>;
568
569  /// K8S008 - check if resources use the Docker socket
570  fn docker_socket(&self, target_version: &str) -> Option<DockerSocket>;
571
572  // K8S009 - pod security policies (separate from workload resources)
573}