use anyhow::{Context, Result};
use aws_sdk_autoscaling::types::AutoScalingGroup;
use aws_sdk_ec2::Client as Ec2Client;
use aws_sdk_eks::{
types::{Addon, Cluster, Nodegroup},
Client as EksClient,
};
use itertools::Itertools;
use kube::Client as K8sClient;
use serde::{Deserialize, Serialize};
use tabled::{
settings::{locator::ByColumnName, Disable, Margin, Style},
Table, Tabled,
};
use crate::{
eks::resources,
finding::{self, Findings},
k8s,
output::tabled_vec_to_string,
version,
};
#[derive(Debug, Serialize, Deserialize, Tabled)]
pub struct ClusterHealthIssue {
#[tabled(inline)]
pub finding: finding::Finding,
pub code: String,
pub message: String,
#[tabled(display_with = "tabled_vec_to_string")]
pub resource_ids: Vec<String>,
}
impl Findings for Vec<ClusterHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no reported health issues on the cluster control plane"
));
}
let mut table = Table::new(self);
table
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn cluster_health(cluster: &Cluster) -> Result<Vec<ClusterHealthIssue>> {
let health = cluster.health();
match health {
Some(health) => match health.issues() {
Some(issues) => Ok(
issues
.iter()
.filter_map(|issue| {
issue.code.as_ref().map(|_| {
let code = &issue.code().unwrap().to_owned();
let remediation = finding::Remediation::Required;
let finding = finding::Finding {
code: finding::Code::EKS002,
symbol: remediation.symbol(),
remediation,
};
ClusterHealthIssue {
finding,
code: code.as_str().to_string(),
message: issue.message().unwrap_or_default().to_string(),
resource_ids: issue.resource_ids().unwrap_or_default().to_owned(),
}
})
})
.collect(),
),
None => Ok(vec![]),
},
None => Ok(vec![]),
}
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct InsufficientSubnetIps {
#[tabled(inline)]
pub finding: finding::Finding,
pub id: String,
pub available_ips: i32,
}
impl Findings for Vec<InsufficientSubnetIps> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There is sufficient IP space in the subnets provided"
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn control_plane_ips(ec2_client: &Ec2Client, cluster: &Cluster) -> Result<Vec<InsufficientSubnetIps>> {
let subnet_ids = match cluster.resources_vpc_config() {
Some(vpc_config) => match vpc_config.subnet_ids() {
Some(subnet_ids) => subnet_ids.to_owned(),
None => return Ok(vec![]),
},
None => return Ok(vec![]),
};
let subnet_ips = resources::get_subnet_ips(ec2_client, subnet_ids).await?;
let availability_zone_ips: Vec<(String, i32)> = subnet_ips
.iter()
.group_by(|subnet| subnet.availability_zone_id.clone())
.into_iter()
.map(|(az, subnets)| {
let total_ips = subnets.map(|subnet| subnet.available_ips).sum();
(az, total_ips)
})
.collect();
if availability_zone_ips
.iter()
.filter(|(_az, ips)| ips >= &5)
.collect::<Vec<_>>()
.len()
>= 2
{
return Ok(vec![]);
}
let remediation = finding::Remediation::Required;
let finding = finding::Finding {
code: finding::Code::EKS001,
symbol: remediation.symbol(),
remediation,
};
Ok(
availability_zone_ips
.iter()
.map(|(az, ips)| InsufficientSubnetIps {
finding: finding.clone(),
id: az.clone(),
available_ips: *ips,
})
.collect(),
)
}
pub(crate) async fn pod_ips(
ec2_client: &Ec2Client,
k8s_client: &K8sClient,
required_ips: i32,
recommended_ips: i32,
) -> Result<Vec<InsufficientSubnetIps>> {
let eniconfigs = k8s::get_eniconfigs(k8s_client).await?;
if eniconfigs.is_empty() {
return Ok(vec![]);
}
let subnet_ids = eniconfigs
.iter()
.map(|eniconfig| eniconfig.spec.subnet.as_ref().unwrap().to_owned())
.collect();
let subnet_ips = resources::get_subnet_ips(ec2_client, subnet_ids).await?;
let available_ips: i32 = subnet_ips.iter().map(|subnet| subnet.available_ips).sum();
if available_ips >= recommended_ips {
return Ok(vec![]);
}
let remediation = if available_ips >= required_ips {
finding::Remediation::Required
} else {
finding::Remediation::Recommended
};
let finding = finding::Finding {
code: finding::Code::AWS002,
symbol: remediation.symbol(),
remediation,
};
Ok(
subnet_ips
.iter()
.group_by(|subnet| subnet.availability_zone_id.clone())
.into_iter()
.map(|(az, subnets)| InsufficientSubnetIps {
finding: finding.clone(),
id: az,
available_ips: subnets.map(|subnet| subnet.available_ips).sum(),
})
.collect(),
)
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct AddonVersionCompatibility {
#[tabled(inline)]
pub finding: finding::Finding,
pub name: String,
#[tabled(rename = "CURRENT")]
pub version: String,
#[tabled(skip)]
pub current_kubernetes_version: resources::AddonVersion,
#[tabled(inline)]
pub target_kubernetes_version: resources::AddonVersion,
}
impl Findings for Vec<AddonVersionCompatibility> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no reported addon version compatibility issues."
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn addon_version_compatibility(
client: &EksClient,
cluster_version: &str,
addons: &[Addon],
) -> Result<Vec<AddonVersionCompatibility>> {
let mut addon_versions = Vec::new();
let target_k8s_version = format!("1.{}", version::parse_minor(cluster_version)? + 1);
for addon in addons {
let name = addon.addon_name().unwrap_or_default().to_owned();
let version = addon.addon_version().unwrap_or_default().to_owned();
let current_kubernetes_version = resources::get_addon_versions(client, &name, cluster_version).await?;
let target_kubernetes_version = resources::get_addon_versions(client, &name, &target_k8s_version).await?;
#[allow(clippy::if_same_then_else)]
let remediation = if !target_kubernetes_version.supported_versions.contains(&version) {
Some(finding::Remediation::Required)
} else if !current_kubernetes_version.supported_versions.contains(&version) {
Some(finding::Remediation::Required)
} else if current_kubernetes_version.latest != version {
Some(finding::Remediation::Recommended)
} else {
None
};
if let Some(remediation) = remediation {
let finding = finding::Finding {
code: finding::Code::EKS005,
symbol: remediation.symbol(),
remediation,
};
addon_versions.push(AddonVersionCompatibility {
finding,
name,
version,
current_kubernetes_version,
target_kubernetes_version,
})
}
}
Ok(addon_versions)
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct AddonHealthIssue {
#[tabled(inline)]
pub finding: finding::Finding,
pub name: String,
pub code: String,
pub message: String,
#[tabled(display_with = "tabled_vec_to_string")]
pub resource_ids: Vec<String>,
}
impl Findings for Vec<AddonHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no reported addon health issues."
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn addon_health(addons: &[Addon]) -> Result<Vec<AddonHealthIssue>> {
let health_issues = addons
.iter()
.flat_map(|addon| {
let name = addon.addon_name().unwrap_or_default();
match addon.health() {
Some(health) => match health.issues() {
Some(issues) => issues
.iter()
.filter_map(|issue| {
issue.code.as_ref().map(|_| {
let code = issue.code().unwrap();
let remediation = finding::Remediation::Required;
let finding = finding::Finding {
code: finding::Code::EKS004,
symbol: remediation.symbol(),
remediation,
};
AddonHealthIssue {
finding,
name: name.to_owned(),
code: code.as_str().to_string(),
message: issue.message().unwrap_or_default().to_owned(),
resource_ids: issue.resource_ids().unwrap_or_default().to_owned(),
}
})
})
.collect::<Vec<AddonHealthIssue>>(),
None => vec![],
},
None => vec![],
}
})
.collect();
Ok(health_issues)
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct NodegroupHealthIssue {
#[tabled(inline)]
pub finding: finding::Finding,
pub name: String,
pub code: String,
pub message: String,
}
impl Findings for Vec<NodegroupHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no reported nodegroup health issues."
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn eks_managed_nodegroup_health(nodegroups: &[Nodegroup]) -> Result<Vec<NodegroupHealthIssue>> {
let health_issues = nodegroups
.iter()
.flat_map(|nodegroup| {
let name = nodegroup.nodegroup_name().unwrap_or_default();
match nodegroup.health() {
Some(health) => match health.issues() {
Some(issues) => issues
.iter()
.filter_map(|issue| {
issue.code.as_ref().map(|_| {
let code = &issue.code().unwrap().to_owned();
let remediation = finding::Remediation::Required;
let finding = finding::Finding {
code: finding::Code::EKS003,
symbol: remediation.symbol(),
remediation,
};
NodegroupHealthIssue {
finding,
name: name.to_owned(),
code: code.as_str().to_string(),
message: issue.message().unwrap_or_default().to_owned(),
}
})
})
.collect::<Vec<NodegroupHealthIssue>>(),
None => vec![],
},
None => vec![],
}
})
.collect();
Ok(health_issues)
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct ManagedNodeGroupUpdate {
#[tabled(inline)]
pub finding: finding::Finding,
#[tabled(rename = "MANAGED NODEGROUP")]
pub name: String,
#[tabled(skip)]
pub autoscaling_group_name: String,
#[tabled(inline)]
pub launch_template: resources::LaunchTemplate,
}
impl Findings for Vec<ManagedNodeGroupUpdate> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no pending updates for the EKS managed nodegroup(s)"
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn eks_managed_nodegroup_update(
client: &Ec2Client,
nodegroup: &Nodegroup,
) -> Result<Vec<ManagedNodeGroupUpdate>> {
let launch_template_spec = nodegroup.launch_template();
match launch_template_spec {
Some(launch_template_spec) => {
let launch_template_id = launch_template_spec.id().unwrap().to_owned();
let launch_template = resources::get_launch_template(client, &launch_template_id).await?;
match nodegroup.resources() {
Some(resources) => {
let asgs = match resources.auto_scaling_groups() {
Some(groups) => groups,
None => return Ok(vec![]),
};
let updates = asgs
.iter()
.map(|asg| {
let remediation = finding::Remediation::Recommended;
let finding = finding::Finding {
code: finding::Code::EKS006,
symbol: remediation.symbol(),
remediation,
};
ManagedNodeGroupUpdate {
finding,
name: nodegroup.nodegroup_name().unwrap_or_default().to_owned(),
autoscaling_group_name: asg.name().unwrap_or_default().to_owned(),
launch_template: launch_template.to_owned(),
}
})
.filter(|asg| asg.launch_template.current_version != asg.launch_template.latest_version)
.collect();
Ok(updates)
}
None => Ok(vec![]),
}
}
None => Ok(vec![]),
}
}
#[derive(Debug, Serialize, Deserialize, Tabled)]
#[tabled(rename_all = "UpperCase")]
pub struct AutoscalingGroupUpdate {
#[tabled(inline)]
pub finding: finding::Finding,
#[tabled(rename = "AUTOSCALING GROUP")]
pub name: String,
#[tabled(inline)]
pub launch_template: resources::LaunchTemplate,
}
impl Findings for Vec<AutoscalingGroupUpdate> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Result<String> {
if self.is_empty() {
return Ok(format!(
"{leading_whitespace}✅ - There are no pending updates for the self-managed nodegroup(s)"
));
}
let mut table = Table::new(self);
table
.with(Disable::column(ByColumnName::new("CHECK")))
.with(Margin::new(1, 0, 0, 0).fill('\t', 'x', 'x', 'x'))
.with(Style::markdown());
Ok(format!("{table}\n"))
}
fn to_stdout_table(&self) -> Result<String> {
if self.is_empty() {
return Ok("".to_owned());
}
let mut table = Table::new(self);
table.with(Style::sharp());
Ok(format!("{table}\n"))
}
}
pub(crate) async fn self_managed_nodegroup_update(
client: &Ec2Client,
asg: &AutoScalingGroup,
) -> Result<Option<AutoscalingGroupUpdate>> {
let name = asg.auto_scaling_group_name().unwrap_or_default().to_owned();
let lt_spec = asg
.launch_template()
.context("Launch template not found, launch configuration is not supported")?;
let launch_template =
resources::get_launch_template(client, lt_spec.launch_template_id().unwrap_or_default()).await?;
if launch_template.current_version != launch_template.latest_version {
let remediation = finding::Remediation::Recommended;
let finding = finding::Finding {
code: finding::Code::EKS007,
symbol: remediation.symbol(),
remediation,
};
let update = AutoscalingGroupUpdate {
finding,
name,
launch_template,
};
Ok(Some(update))
} else {
Ok(None)
}
}