eksup 0.14.0

A CLI to aid in upgrading Amazon EKS clusters
Documentation
pub mod analysis;
pub mod clients;
pub mod config;
pub mod eks;
pub mod finding;
pub mod k8s;
pub mod output;
pub mod playbook;
pub mod version;

use std::{env, str};

use anyhow::{Context, Result};
use aws_config::default_provider::{credentials::DefaultCredentialsChain, region::DefaultRegionChain};
use aws_sdk_eks::config::Region;
use clap::{
  Args, Parser, Subcommand,
  builder::styling::{AnsiColor, Color, Style, Styles},
};
use clap_verbosity_flag::Verbosity;
use clients::AwsClients;
use indicatif::{ProgressBar, ProgressFinish, ProgressStyle};
use serde::{Deserialize, Serialize};

fn get_styles() -> Styles {
  Styles::styled()
    .header(
      Style::new()
        .bold()
        .underline()
        .fg_color(Some(Color::Ansi(AnsiColor::Green))),
    )
    .literal(Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::BrightCyan))))
    .usage(Style::new().bold().fg_color(Some(Color::Ansi(AnsiColor::Green))))
    .placeholder(
      Style::new()
        .bold()
        .underline()
        .fg_color(Some(Color::Ansi(AnsiColor::Yellow))),
    )
}

#[derive(Parser, Debug)]
#[command(author, about, version)]
#[command(propagate_version = true)]
#[command(styles=get_styles())]
pub struct Cli {
  #[command(subcommand)]
  pub commands: Commands,

  #[clap(flatten)]
  pub verbose: Verbosity,
}

#[derive(Debug, Subcommand)]
pub enum Commands {
  #[command(arg_required_else_help = true)]
  Analyze(Analysis),
  #[command(arg_required_else_help = true)]
  Create(Create),

  /// Generate shell completion script for the given shell
  #[command(arg_required_else_help = true)]
  Completion {
    /// The shell to generate completions for
    #[arg(value_enum)]
    shell: clap_complete::Shell,
  },

  /// Generate the man page for eksup
  Man,
}

/// Analyze an Amazon EKS cluster for potential upgrade issues
#[derive(Args, Debug, Serialize, Deserialize)]
pub struct Analysis {
  /// The name of the cluster to analyze
  #[arg(short, long, alias = "cluster-name", value_enum)]
  pub cluster: String,

  /// The AWS region where the cluster is provisioned
  #[arg(short, long)]
  pub region: Option<String>,

  /// The AWS profile to use to access the cluster
  #[arg(short, long)]
  pub profile: Option<String>,

  #[arg(short, long, value_enum, default_value_t)]
  pub format: output::Format,

  /// Write to file instead of stdout
  #[arg(short, long)]
  pub output: Option<String>,

  /// Target Kubernetes version for the upgrade (e.g. "1.34"). Defaults to current + 1
  #[arg(short = 't', long, alias = "target")]
  pub target_version: Option<String>,

  /// Exclude recommendations from the output
  #[arg(long)]
  pub ignore_recommended: bool,

  /// Path to an eksup configuration file (default: .eksup.yaml in cwd)
  #[arg(long)]
  pub config: Option<String>,

  /// Include findings suppressed by .eksup.yaml ignore rules
  #[arg(long)]
  pub show_suppressed: bool,
}

/// Create artifacts using the analysis data
#[derive(Args, Debug, Serialize, Deserialize)]
pub struct Create {
  #[command(subcommand)]
  pub command: CreateCommands,
}

#[derive(Debug, Subcommand, Serialize, Deserialize)]
pub enum CreateCommands {
  #[command(arg_required_else_help = true)]
  Playbook(Playbook),
}

/// Create a playbook for upgrading an Amazon EKS cluster
#[derive(Args, Debug, Serialize, Deserialize)]
pub struct Playbook {
  /// The name of the cluster to analyze
  #[arg(short, long, alias = "cluster-name", value_enum)]
  pub cluster: String,

  /// The AWS region where the cluster is provisioned
  #[arg(short, long)]
  pub region: Option<String>,

  /// The AWS profile to use to access the cluster
  #[arg(short, long)]
  pub profile: Option<String>,

  /// Name of the playbook saved locally
  #[arg(short, long)]
  pub filename: Option<String>,

  /// Target Kubernetes version for the upgrade (e.g. "1.34"). Defaults to current + 1
  #[arg(short = 't', long, alias = "target")]
  pub target_version: Option<String>,

  /// Exclude recommendations from the output
  #[arg(long)]
  pub ignore_recommended: bool,

  /// Path to an eksup configuration file (default: .eksup.yaml in cwd)
  #[arg(long)]
  pub config: Option<String>,

  /// Include findings suppressed by .eksup.yaml ignore rules
  #[arg(long)]
  pub show_suppressed: bool,
}

fn new_spinner() -> ProgressBar {
  let spinner = ProgressBar::new_spinner()
    .with_style(ProgressStyle::with_template("{spinner:.cyan} {msg}").unwrap())
    .with_finish(ProgressFinish::AndClear);
  spinner.enable_steady_tick(std::time::Duration::from_millis(80));
  spinner
}

pub async fn analyze(args: Analysis) -> Result<()> {
  let spinner = new_spinner();

  let config = config::load(args.config.as_deref())?;

  spinner.set_message("Loading AWS configuration...");
  let aws_config = get_config(&args.region, &args.profile).await?;
  let aws = clients::RealAwsClients::new(&aws_config);

  spinner.set_message("Fetching cluster details...");
  let cluster = aws.get_cluster(&args.cluster).await?;
  let cluster_version = cluster.version().context("Cluster version not found")?;

  let target_minor = match &args.target_version {
    Some(tv) => version::validate_target_version(tv, cluster_version)?,
    None => match version::check_version_supported(cluster_version)? {
      Some(target) => target,
      None => {
        spinner.finish_and_clear();
        println!("Cluster is already at the latest supported version: {cluster_version}");
        println!("Nothing to upgrade at this time");
        return Ok(());
      }
    },
  };

  spinner.set_message("Connecting to cluster...");
  let k8s = clients::RealK8sClients::new(&args.cluster).await?;

  spinner.set_message("Analyzing cluster...");
  let mut results = analysis::analyze(&aws, &k8s, &cluster, target_minor, &config).await?;
  if args.ignore_recommended {
    results.filter_recommended();
  }

  spinner.finish_and_clear();
  output::output(&results, &args.format, &args.output, args.show_suppressed)?;

  Ok(())
}

/// Get the configuration to authn/authz with AWS that will be used across AWS clients
async fn get_config(region: &Option<String>, profile: &Option<String>) -> Result<aws_config::SdkConfig> {
  let region = match region {
    Some(region) => Some(Region::new(region.to_owned())),
    None => match profile {
      Some(profile) => {
        DefaultRegionChain::builder()
          .profile_name(profile)
          .build()
          .region()
          .await
      }
      None => match env::var("AWS_REGION") {
        Ok(region) => Some(Region::new(region)),
        Err(_) => env::var("AWS_DEFAULT_REGION").ok().map(Region::new),
      },
    },
  };

  let mut creds = DefaultCredentialsChain::builder().region(region.clone());

  if let Some(profile) = profile {
    creds = creds.profile_name(profile);
  };

  let config = aws_config::from_env()
    .credentials_provider(creds.build().await)
    .region(region)
    .load()
    .await;

  Ok(config)
}

pub async fn create(args: Create) -> Result<()> {
  match args.command {
    CreateCommands::Playbook(playbook) => {
      let spinner = new_spinner();

      let config = config::load(playbook.config.as_deref())?;

      spinner.set_message("Loading AWS configuration...");
      let aws_config = get_config(&playbook.region, &playbook.profile).await?;
      let region = aws_config.region().context("AWS region not configured")?.to_string();

      let aws = clients::RealAwsClients::new(&aws_config);

      spinner.set_message("Fetching cluster details...");
      let cluster = aws.get_cluster(&playbook.cluster).await?;
      let cluster_version = cluster.version().context("Cluster version not found")?;

      let target_minor = match &playbook.target_version {
        Some(tv) => version::validate_target_version(tv, cluster_version)?,
        None => match version::check_version_supported(cluster_version)? {
          Some(target) => target,
          None => {
            spinner.finish_and_clear();
            println!("Cluster is already at the latest supported version: {cluster_version}");
            println!("Nothing to upgrade at this time");
            return Ok(());
          }
        },
      };

      spinner.set_message("Connecting to cluster...");
      let k8s = clients::RealK8sClients::new(&playbook.cluster).await?;

      spinner.set_message("Analyzing cluster...");
      let mut results = analysis::analyze(&aws, &k8s, &cluster, target_minor, &config).await?;
      if playbook.ignore_recommended {
        results.filter_recommended();
      }

      spinner.set_message("Creating playbook...");
      playbook::create(playbook, region, &cluster, results, target_minor)?;
      spinner.finish_and_clear();
    }
  }

  Ok(())
}

#[cfg(test)]
mod tests {
  use clap::CommandFactory;
  use clap_complete::{Shell, generate};

  use super::*;

  #[test]
  fn completion_generates_for_all_shells() {
    let mut cmd = Cli::command();
    for shell in [Shell::Bash, Shell::Elvish, Shell::Fish, Shell::PowerShell, Shell::Zsh] {
      let mut buf = Vec::new();
      generate(shell, &mut cmd, "eksup", &mut buf);
      assert!(!buf.is_empty(), "{shell:?} produced empty completion output");
    }
  }

  #[test]
  fn man_renders() {
    let mut buf = Vec::new();
    clap_mangen::Man::new(Cli::command()).render(&mut buf).unwrap();
    assert!(!buf.is_empty(), "man page rendered empty");
  }
}