use std::{collections::HashSet, env};
use anyhow::{bail, Result};
use aws_config::meta::region::RegionProviderChain;
use aws_sdk_autoscaling::{
model::{AutoScalingGroup, Filter as AsgFilter},
Client as AsgClient,
};
use aws_sdk_ec2::Client as Ec2Client;
use aws_sdk_eks::{
model::{Addon, Cluster, FargateProfile, Nodegroup},
Client as EksClient,
};
use aws_types::region::Region;
use kube::Client as K8sClient;
use serde::{Deserialize, Serialize};
use crate::{
finding::{self, Findings},
k8s, version,
};
pub(crate) async fn get_config(region: &Option<String>) -> Result<aws_config::SdkConfig> {
let aws_region = match region {
Some(region) => Region::new(region.to_owned()),
None => env::var("AWS_REGION").ok().map(Region::new).unwrap(),
};
let region_provider = RegionProviderChain::first_try(aws_region).or_default_provider();
Ok(aws_config::from_env().region(region_provider).load().await)
}
pub(crate) async fn get_cluster(client: &EksClient, name: &str) -> Result<Cluster> {
let request = client.describe_cluster().name(name);
let response = request.send().await?;
match response.cluster {
Some(cluster) => Ok(cluster),
None => bail!("Cluster {name} not found"),
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ClusterHealthIssue {
pub(crate) code: String,
pub(crate) message: String,
pub(crate) resource_ids: Vec<String>,
pub(crate) remediation: finding::Remediation,
pub(crate) fcode: finding::Code,
}
impl Findings for Vec<ClusterHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no reported health issues on the cluster control plane"
));
}
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| _ | Code | Message | Resource IDs |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :---: | :------ | :----------- |\n"
));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | {} |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.code,
finding.message,
finding
.resource_ids
.iter()
.map(|f| format!("`{f}`"))
.collect::<Vec<String>>()
.join(", "),
))
}
Some(table)
}
}
pub(crate) async fn cluster_health(cluster: &Cluster) -> Result<Vec<ClusterHealthIssue>> {
let health = cluster.health();
match health {
Some(health) => {
let issues = health
.issues()
.unwrap()
.to_owned()
.iter()
.map(|issue| {
let code = &issue.code().unwrap().to_owned();
ClusterHealthIssue {
code: code.as_str().to_string(),
message: issue.message().unwrap().to_string(),
resource_ids: issue.resource_ids().unwrap().to_owned(),
remediation: finding::Remediation::Required,
fcode: finding::Code::EKS002,
}
})
.collect();
Ok(issues)
}
None => Ok(vec![]),
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
struct SubnetIPs {
ids: Vec<String>,
available_ips: i32,
}
async fn get_subnet_ips(client: &Ec2Client, subnet_ids: Vec<String>) -> Result<SubnetIPs> {
let subnets = client
.describe_subnets()
.set_subnet_ids(Some(subnet_ids))
.send()
.await?
.subnets
.unwrap();
let available_ips = subnets
.iter()
.map(|subnet| subnet.available_ip_address_count.unwrap())
.sum();
let ids = subnets
.iter()
.map(|subnet| subnet.subnet_id().unwrap().to_string())
.collect::<Vec<String>>();
Ok(SubnetIPs { ids, available_ips })
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct InsufficientSubnetIps {
pub(crate) ids: Vec<String>,
pub(crate) available_ips: i32,
pub(crate) remediation: finding::Remediation,
pub(crate) code: finding::Code,
}
impl Findings for Option<InsufficientSubnetIps> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
match self {
Some(finding) => {
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| - | Subnet IDs | Available IPs |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :---------- | :-----------: |\n"
));
table.push_str(&format!(
"{}| {} | {} | `{}` |\n",
leading_whitespace,
finding.remediation.symbol(),
finding
.ids
.iter()
.map(|f| format!("`{f}`"))
.collect::<Vec<String>>()
.join(", "),
finding.available_ips,
));
Some(table)
}
None => Some(format!(
"{leading_whitespace}✅ - There is sufficient IP space in the subnets provided"
)),
}
}
}
pub(crate) async fn control_plane_ips(
ec2_client: &Ec2Client,
cluster: &Cluster,
) -> Result<Option<InsufficientSubnetIps>> {
let subnet_ids = cluster.resources_vpc_config().unwrap().subnet_ids().unwrap().to_owned();
let subnet_ips = get_subnet_ips(ec2_client, subnet_ids).await?;
if subnet_ips.available_ips >= 5 {
return Ok(None);
}
let finding = InsufficientSubnetIps {
ids: subnet_ips.ids,
available_ips: subnet_ips.available_ips,
remediation: finding::Remediation::Required,
code: finding::Code::EKS001,
};
Ok(Some(finding))
}
pub(crate) async fn pod_ips(
ec2_client: &Ec2Client,
k8s_client: &K8sClient,
required_ips: i32,
recommended_ips: i32,
) -> Result<Option<InsufficientSubnetIps>> {
let eniconfigs = k8s::get_eniconfigs(k8s_client).await?;
if eniconfigs.is_empty() {
return Ok(None);
}
let subnet_ids = eniconfigs
.iter()
.map(|eniconfig| eniconfig.spec.subnet.as_ref().unwrap().to_owned())
.collect();
let subnet_ips = get_subnet_ips(ec2_client, subnet_ids).await?;
if subnet_ips.available_ips >= recommended_ips {
return Ok(None);
}
let remediation = if subnet_ips.available_ips >= required_ips {
finding::Remediation::Required
} else {
finding::Remediation::Recommended
};
let finding = InsufficientSubnetIps {
ids: subnet_ips.ids,
available_ips: subnet_ips.available_ips,
remediation,
code: finding::Code::AWS002,
};
Ok(Some(finding))
}
pub(crate) async fn get_addons(client: &EksClient, cluster_name: &str) -> Result<Vec<Addon>> {
let addon_names = client
.list_addons()
.cluster_name(cluster_name)
.max_results(100)
.send()
.await?
.addons
.unwrap_or_default();
let mut addons = Vec::new();
for addon_name in &addon_names {
let response = client
.describe_addon()
.cluster_name(cluster_name)
.addon_name(addon_name)
.send()
.await?
.addon;
if let Some(addon) = response {
addons.push(addon);
}
}
Ok(addons)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct AddonVersion {
pub(crate) latest: String,
pub(crate) default: String,
pub(crate) supported_versions: HashSet<String>,
}
async fn get_addon_versions(client: &EksClient, name: &str, kubernetes_version: &str) -> Result<AddonVersion> {
let describe = client
.describe_addon_versions()
.addon_name(name)
.kubernetes_version(kubernetes_version)
.send()
.await?;
let addon = describe.addons().unwrap().get(0).unwrap();
let addon_version = addon.addon_versions().unwrap();
let latest_version = addon_version.first().unwrap().addon_version().unwrap();
let default_version = addon
.addon_versions()
.unwrap()
.iter()
.filter(|v| v.compatibilities().unwrap().iter().any(|c| c.default_version))
.map(|v| v.addon_version().unwrap())
.next()
.unwrap();
let supported_versions: HashSet<String> = addon
.addon_versions()
.unwrap()
.iter()
.map(|v| v.addon_version().unwrap().to_owned())
.collect();
Ok(AddonVersion {
latest: latest_version.to_owned(),
default: default_version.to_owned(),
supported_versions,
})
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct AddonVersionCompatibility {
pub(crate) name: String,
pub(crate) version: String,
pub(crate) current_kubernetes_version: AddonVersion,
pub(crate) target_kubernetes_version: AddonVersion,
pub(crate) remediation: finding::Remediation,
pub(crate) code: finding::Code,
}
impl Findings for Vec<AddonVersionCompatibility> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no reported addon version compatibility issues."
));
}
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| - | Name | Version | Next Default | Next Latest |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :---- | :-----: | :----------: | :---------: |\n"
));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | `{}` | `{}` |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.name,
finding.version,
finding.target_kubernetes_version.default,
finding.target_kubernetes_version.latest,
))
}
Some(table)
}
}
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().to_owned();
let version = addon.addon_version().unwrap().to_owned();
let current_kubernetes_version = get_addon_versions(client, &name, cluster_version).await?;
let target_kubernetes_version = 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 {
addon_versions.push(AddonVersionCompatibility {
name,
version,
current_kubernetes_version,
target_kubernetes_version,
remediation,
code: finding::Code::EKS005,
})
}
}
Ok(addon_versions)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct AddonHealthIssue {
pub(crate) name: String,
pub(crate) code: String,
pub(crate) message: String,
pub(crate) resource_ids: Vec<String>,
pub(crate) remediation: finding::Remediation,
pub(crate) fcode: finding::Code,
}
impl Findings for Vec<AddonHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no reported addon health issues."
));
}
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| - | Name | Code | Message | Resource IDs |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :---- | :---: | :------ | :----------- |\n"
));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | `{}` | {} |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.name,
finding.code,
finding.message,
finding
.resource_ids
.iter()
.map(|f| format!("`{f}`"))
.collect::<Vec<String>>()
.join(", "),
))
}
Some(table)
}
}
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();
let health = addon.health().unwrap();
health
.issues()
.unwrap()
.iter()
.map(|issue| {
let code = issue.code().unwrap();
AddonHealthIssue {
name: name.to_owned(),
code: code.as_str().to_string(),
message: issue.message().unwrap().to_owned(),
resource_ids: issue.resource_ids().unwrap().to_owned(),
remediation: finding::Remediation::Required,
fcode: finding::Code::EKS004,
}
})
.collect::<Vec<AddonHealthIssue>>()
})
.collect();
Ok(health_issues)
}
pub(crate) async fn get_eks_managed_nodegroups(client: &EksClient, cluster_name: &str) -> Result<Vec<Nodegroup>> {
let nodegroup_names = client
.list_nodegroups()
.cluster_name(cluster_name)
.max_results(100)
.send()
.await?
.nodegroups
.unwrap_or_default();
let mut nodegroups = Vec::new();
for nodegroup_name in nodegroup_names {
let response = client
.describe_nodegroup()
.cluster_name(cluster_name)
.nodegroup_name(nodegroup_name)
.send()
.await?
.nodegroup;
if let Some(nodegroup) = response {
nodegroups.push(nodegroup);
}
}
Ok(nodegroups)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct NodegroupHealthIssue {
pub(crate) name: String,
pub(crate) code: String,
pub(crate) message: String,
pub(crate) remediation: finding::Remediation,
pub(crate) fcode: finding::Code,
}
impl Findings for Vec<NodegroupHealthIssue> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no reported nodegroup health issues."
));
}
let mut table = String::new();
table.push_str(&format!("{leading_whitespace}| - | Name | Code | Message |\n"));
table.push_str(&format!("{leading_whitespace}| :---: | :---- | :---: | :------ |\n"));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | `{}` |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.name,
finding.code,
finding.message,
))
}
Some(table)
}
}
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();
let health = nodegroup.health().unwrap();
let issues = health.issues().unwrap();
issues.iter().map(|issue| {
let code = issue.code().unwrap();
let message = issue.message().unwrap();
NodegroupHealthIssue {
name: name.to_owned(),
code: code.as_str().to_owned(),
message: message.to_owned(),
remediation: finding::Remediation::Required,
fcode: finding::Code::EKS003,
}
})
})
.collect();
Ok(health_issues)
}
pub(crate) async fn get_self_managed_nodegroups(
client: &AsgClient,
cluster_name: &str,
) -> Result<Vec<AutoScalingGroup>> {
let keys = vec![
format!("k8s.io/cluster/{cluster_name}"),
format!("kubernetes.io/cluster/{cluster_name}"),
];
let filter = AsgFilter::builder()
.set_name(Some("tag-key".to_string()))
.set_values(Some(keys))
.build();
let response = client.describe_auto_scaling_groups().filters(filter).send().await?;
let groups = response.auto_scaling_groups().map(|groups| groups.to_vec());
match groups {
Some(groups) => {
let filtered = groups
.into_iter()
.filter(|group| {
group
.tags()
.unwrap_or_default()
.iter()
.all(|tag| tag.key().unwrap_or_default() != "eks:nodegroup-name")
})
.collect();
Ok(filtered)
}
None => Ok(vec![]),
}
}
pub(crate) async fn _get_fargate_profiles(client: &EksClient, cluster_name: &str) -> Result<Vec<FargateProfile>> {
let profile_names = client
.list_fargate_profiles()
.cluster_name(cluster_name)
.max_results(100)
.send()
.await?
.fargate_profile_names
.unwrap_or_default();
let mut profiles = Vec::new();
for profile_name in &profile_names {
let response = client
.describe_fargate_profile()
.cluster_name(cluster_name)
.fargate_profile_name(profile_name)
.send()
.await?
.fargate_profile;
if let Some(profile) = response {
profiles.push(profile);
}
}
Ok(profiles)
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct LaunchTemplate {
pub(crate) name: String,
pub(crate) id: String,
pub(crate) current_version: String,
pub(crate) latest_version: String,
}
async fn get_launch_template(client: &Ec2Client, id: &str) -> Result<LaunchTemplate> {
let output = client
.describe_launch_templates()
.set_launch_template_ids(Some(vec![id.to_string()]))
.send()
.await?;
let template = output
.launch_templates
.unwrap()
.into_iter()
.map(|lt| LaunchTemplate {
name: lt.launch_template_name.unwrap(),
id: lt.launch_template_id.unwrap(),
current_version: lt.default_version_number.unwrap().to_string(),
latest_version: lt.latest_version_number.unwrap().to_string(),
})
.next();
match template {
Some(t) => Ok(t),
None => bail!("Unable to find launch template with id: {id}"),
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct ManagedNodeGroupUpdate {
pub(crate) name: String,
pub(crate) autoscaling_group_name: String,
pub(crate) launch_template: LaunchTemplate,
pub(crate) remediation: finding::Remediation,
pub(crate) fcode: finding::Code,
}
impl Findings for Vec<ManagedNodeGroupUpdate> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no pending updates for the EKS managed nodegroup(s)"
));
}
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| - | MNG Name | Launch Template ID | Current | Latest |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :-------- | :----------------- | :-----: | :----: |\n"
));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | `{}` | `{}` |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.name,
finding.launch_template.id,
finding.launch_template.current_version,
finding.launch_template.latest_version,
))
}
Some(table)
}
}
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 = get_launch_template(client, &launch_template_id).await?;
let updates = nodegroup
.resources()
.unwrap()
.auto_scaling_groups()
.unwrap()
.iter()
.map(|asg| ManagedNodeGroupUpdate {
name: nodegroup.nodegroup_name().unwrap().to_owned(),
autoscaling_group_name: asg.name().unwrap().to_owned(),
launch_template: launch_template.to_owned(),
remediation: finding::Remediation::Recommended,
fcode: finding::Code::EKS006,
})
.filter(|asg| asg.launch_template.current_version != asg.launch_template.latest_version)
.collect();
Ok(updates)
}
None => Ok(vec![]),
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct AutoscalingGroupUpdate {
pub(crate) name: String,
pub(crate) launch_template: LaunchTemplate,
pub(crate) remediation: finding::Remediation,
pub(crate) fcode: finding::Code,
}
impl Findings for Vec<AutoscalingGroupUpdate> {
fn to_markdown_table(&self, leading_whitespace: &str) -> Option<String> {
if self.is_empty() {
return Some(format!(
"{leading_whitespace}✅ - There are no pending updates for the self-managed nodegroup(s)"
));
}
let mut table = String::new();
table.push_str(&format!(
"{leading_whitespace}| - | ASG Name | Launch Template ID | Current | Latest |\n"
));
table.push_str(&format!(
"{leading_whitespace}| :---: | :------- | :----------------- | :-----: | :----: |\n"
));
for finding in self {
table.push_str(&format!(
"{}| {} | `{}` | `{}` | `{}` | `{}` |\n",
leading_whitespace,
finding.remediation.symbol(),
finding.name,
finding.launch_template.id,
finding.launch_template.current_version,
finding.launch_template.latest_version,
))
}
Some(table)
}
}
pub(crate) async fn self_managed_nodegroup_update(
client: &Ec2Client,
asg: &AutoScalingGroup,
) -> Result<Option<AutoscalingGroupUpdate>> {
let name = asg.auto_scaling_group_name().unwrap().to_owned();
let launch_template_id = asg.launch_template().unwrap().launch_template_id().unwrap().to_owned();
let launch_template = get_launch_template(client, &launch_template_id).await?;
if launch_template.current_version != launch_template.latest_version {
let update = AutoscalingGroupUpdate {
name,
launch_template,
remediation: finding::Remediation::Recommended,
fcode: finding::Code::EKS007,
};
Ok(Some(update))
} else {
Ok(None)
}
}