diff --git a/tools/Cargo.lock b/tools/Cargo.lock index 6ceb5202f2a..fc5d7c67a97 100644 --- a/tools/Cargo.lock +++ b/tools/Cargo.lock @@ -2488,6 +2488,7 @@ dependencies = [ "simplelog", "snafu", "structopt", + "tabled", "tempfile", "tinytemplate", "tokio", diff --git a/tools/pubsys/Cargo.toml b/tools/pubsys/Cargo.toml index 7db58a2680f..b5773934192 100644 --- a/tools/pubsys/Cargo.toml +++ b/tools/pubsys/Cargo.toml @@ -41,6 +41,7 @@ serde_json = "1" simplelog = "0.12" snafu = "0.7" structopt = { version = "0.3", default-features = false } +tabled = "0.10" tempfile = "3" tinytemplate = "1" tokio = { version = "1", features = ["full"] } # LTS diff --git a/tools/pubsys/src/aws/mod.rs b/tools/pubsys/src/aws/mod.rs index defc6a01ccd..7cd95a33a72 100644 --- a/tools/pubsys/src/aws/mod.rs +++ b/tools/pubsys/src/aws/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod ami; pub(crate) mod promote_ssm; pub(crate) mod publish_ami; pub(crate) mod ssm; +pub(crate) mod validate_ssm; /// Builds a Region from the given region name. fn region_from_string(name: &str) -> Region { diff --git a/tools/pubsys/src/aws/ssm/mod.rs b/tools/pubsys/src/aws/ssm/mod.rs index 6f3d5e2ac5c..c06a8b32780 100644 --- a/tools/pubsys/src/aws/ssm/mod.rs +++ b/tools/pubsys/src/aws/ssm/mod.rs @@ -297,7 +297,7 @@ pub(crate) struct BuildContext<'a> { } /// A map of SsmKey to its value -type SsmParameters = HashMap; +pub(crate) type SsmParameters = HashMap; /// Parse the AMI input file fn parse_ami_input(regions: &[String], ssm_args: &SsmArgs) -> Result> { diff --git a/tools/pubsys/src/aws/ssm/ssm.rs b/tools/pubsys/src/aws/ssm/ssm.rs index e74eab886c1..fcfeb2961b3 100644 --- a/tools/pubsys/src/aws/ssm/ssm.rs +++ b/tools/pubsys/src/aws/ssm/ssm.rs @@ -7,8 +7,8 @@ use aws_sdk_ssm::output::{GetParametersOutput, PutParameterOutput}; use aws_sdk_ssm::types::SdkError; use aws_sdk_ssm::{Client as SsmClient, Region}; use futures::future::{join, ready}; -use futures::stream::{self, StreamExt}; -use log::{debug, error, trace, warn}; +use futures::stream::{self, FuturesUnordered, StreamExt}; +use log::{debug, error, info, trace, warn}; use snafu::{ensure, OptionExt, ResultExt}; use std::collections::{HashMap, HashSet}; use std::time::Duration; @@ -135,6 +135,88 @@ where Ok(parameters) } +/// Fetches all SSM parameters under a given prefix using the given clients +pub(crate) async fn get_parameters_by_prefix<'a>( + clients: &'a HashMap, + ssm_prefix: &str, +) -> HashMap<&'a Region, Result> { + // Build requests for parameters; we have to request with a regional client so we split them by + // region + let mut requests = Vec::with_capacity(clients.len()); + for region in clients.keys() { + trace!("Requesting parameters in {}", region); + let ssm_client: &SsmClient = &clients[region]; + let get_future = get_parameters_by_prefix_in_region(region, ssm_client, ssm_prefix); + + requests.push(join(ready(region), get_future)); + } + + // Send requests in parallel and wait for responses, collecting results into a list. + requests + .into_iter() + .collect::>() + .collect() + .await +} + +/// Fetches all SSM parameters under a given prefix in a single region +pub(crate) async fn get_parameters_by_prefix_in_region( + region: &Region, + client: &SsmClient, + ssm_prefix: &str, +) -> Result { + info!("Retrieving SSM parameters in {}", region.to_string()); + let mut parameters = HashMap::new(); + + // Send the request + let mut get_future = client + .get_parameters_by_path() + .path(ssm_prefix) + .recursive(true) + .into_paginator() + .send(); + + // Iterate over the retrieved parameters + while let Some(page) = get_future.next().await { + let retrieved_parameters = page + .context(error::GetParametersByPathSnafu { + path: ssm_prefix, + region: region.to_string(), + })? + .parameters() + .unwrap_or_default() + .to_owned(); + for parameter in retrieved_parameters { + // Insert a new key-value pair into the map, with the key containing region and parameter name + // and the value containing the parameter value + parameters.insert( + SsmKey::new( + region.to_owned(), + parameter + .name() + .ok_or(error::Error::MissingField { + region: region.to_string(), + field: "name".to_string(), + })? + .to_owned(), + ), + parameter + .value() + .ok_or(error::Error::MissingField { + region: region.to_string(), + field: "value".to_string(), + })? + .to_owned(), + ); + } + } + info!( + "SSM parameters in {} have been retrieved", + region.to_string() + ); + Ok(parameters) +} + /// Sets the values of the given SSM keys using the given clients pub(crate) async fn set_parameters( parameters_to_set: &SsmParameters, @@ -324,8 +406,8 @@ pub(crate) async fn validate_parameters( Ok(()) } -mod error { - use aws_sdk_ssm::error::GetParametersError; +pub(crate) mod error { + use aws_sdk_ssm::error::{GetParametersByPathError, GetParametersError}; use aws_sdk_ssm::types::SdkError; use snafu::Snafu; use std::error::Error as _; @@ -334,13 +416,28 @@ mod error { #[derive(Debug, Snafu)] #[snafu(visibility(pub(super)))] #[allow(clippy::large_enum_variant)] - pub(crate) enum Error { + pub enum Error { #[snafu(display("Failed to fetch SSM parameters in {}: {}", region, source.source().map(|x| x.to_string()).unwrap_or("unknown".to_string())))] GetParameters { region: String, source: SdkError, }, + #[snafu(display( + "Failed to fetch SSM parameters by path {} in {}: {}", + path, + region, + source + ))] + GetParametersByPath { + path: String, + region: String, + source: SdkError, + }, + + #[snafu(display("Missing field in parameter in {}: {}", region, field))] + MissingField { region: String, field: String }, + #[snafu(display("Response to {} was missing {}", request_type, missing))] MissingInResponse { region: String, @@ -369,4 +466,4 @@ mod error { } } pub(crate) use error::Error; -type Result = std::result::Result; +pub(crate) type Result = std::result::Result; diff --git a/tools/pubsys/src/aws/validate_ssm/mod.rs b/tools/pubsys/src/aws/validate_ssm/mod.rs new file mode 100644 index 00000000000..a6a1394b858 --- /dev/null +++ b/tools/pubsys/src/aws/validate_ssm/mod.rs @@ -0,0 +1,757 @@ +//! The validate_ssm module owns the 'validate-ssm' subcommand and controls the process of +//! validating SSM parameters and AMIs + +pub mod results; + +use self::results::{SsmValidationResult, SsmValidationResultStatus, SsmValidationResults}; +use super::ssm::ssm::get_parameters_by_prefix; +use super::ssm::{SsmKey, SsmParameters}; +use crate::aws::client::build_client_config; +use crate::Args; +use aws_sdk_ssm::{Client as SsmClient, Region}; +use log::{info, trace}; +use pubsys_config::InfraConfig; +use serde::Deserialize; +use snafu::ResultExt; +use std::collections::{HashMap, HashSet}; +use std::fs::File; +use std::path::PathBuf; +use structopt::{clap, StructOpt}; + +/// Validates SSM parameters and AMIs +#[derive(Debug, StructOpt)] +#[structopt(setting = clap::AppSettings::DeriveDisplayOrder)] +pub struct ValidateSsmArgs { + /// File holding the validation configuration + #[structopt(long, parse(from_os_str))] + validation_config_path: PathBuf, + + /// Optional path where the validation results should be written + #[structopt(long, parse(from_os_str))] + write_results_path: Option, + + #[structopt(long, requires = "write-results-path")] + /// Optional filter to only write validation results with these statuses to the above path + /// Available statuses are: `Correct`, `Incorrect`, `Missing`, `Unexpected` + write_results_filter: Option>, + + /// If this flag is added, print the results summary table as JSON instead of a + /// plaintext table + #[structopt(long)] + json: bool, +} + +/// Structure of the validation configuration file +#[derive(Debug, Deserialize)] +pub(crate) struct ValidationConfig { + /// Vec of paths to JSON files containing expected metadata (image ids and SSM parameters) + expected_metadata_lists: Vec, + + /// Vec of regions where the parameters should be validated + validation_regions: Vec, +} + +/// A structure that allows us to store a parameter value along with the AMI ID it refers to. In +/// some cases, then AMI ID *is* the parameter value and both fields will hold the AMI ID. In other +/// cases the parameter value is not the AMI ID, but we need to remember which AMI ID it refers to. +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct SsmValue { + /// The value of the SSM parameter + pub(crate) value: String, + + /// The ID of the AMI the parameter is associated with, used for validation result reporting + pub(crate) ami_id: String, +} + +/// Performs SSM parameter validation and returns the `SsmValidationResults` object +pub async fn validate( + args: &Args, + validate_ssm_args: &ValidateSsmArgs, +) -> Result { + info!("Parsing Infra.toml file"); + + // If a lock file exists, use that, otherwise use Infra.toml + let infra_config = InfraConfig::from_path_or_lock(&args.infra_config_path, false) + .context(error::ConfigSnafu)?; + + let aws = infra_config.aws.clone().unwrap_or_default(); + + trace!("Parsed infra config: {:#?}", infra_config); + + // Read the validation config file and parse it into the `ValidationConfig` struct + let validation_config_file = File::open(&validate_ssm_args.validation_config_path).context( + error::ReadValidationConfigSnafu { + path: validate_ssm_args.validation_config_path.clone(), + }, + )?; + let validation_config: ValidationConfig = serde_json::from_reader(validation_config_file) + .context(error::ParseValidationConfigSnafu)?; + + let ssm_prefix = aws.ssm_prefix.as_deref().unwrap_or(""); + + // Parse the parameter lists found in the validation config + info!("Parsing expected parameter lists"); + let expected_parameters = parse_parameter_lists( + validation_config.expected_metadata_lists, + &validation_config.validation_regions, + ) + .await?; + + info!("Parsed expected parameter lists"); + + // Create a Vec of Regions based on the region names in the validation config + let validation_regions: Vec = validation_config + .validation_regions + .iter() + .map(|s| Region::new(s.clone())) + .collect(); + + // Create a HashMap of SsmClients, one for each region where validation should happen + let base_region = &validation_regions[0]; + let mut ssm_clients = HashMap::with_capacity(validation_regions.len()); + + for region in &validation_regions { + let client_config = build_client_config(region, base_region, &aws).await; + let ssm_client = SsmClient::new(&client_config); + ssm_clients.insert(region.clone(), ssm_client); + } + + // Retrieve the SSM parameters using the SsmClients + info!("Retrieving SSM parameters"); + let parameters = get_parameters_by_prefix(&ssm_clients, ssm_prefix).await; + + // Validate the retrieved SSM parameters per region + info!("Validating SSM parameters"); + let results: HashMap>> = + parameters + .into_iter() + .map(|(region, region_result)| { + ( + region.clone(), + region_result.map(|result| { + validate_parameters_in_region( + expected_parameters.get(region).unwrap_or(&HashMap::new()), + &result, + ) + }), + ) + }) + .collect::>>>( + ); + + let validation_results = SsmValidationResults::new(results); + + // If a path was given to write the results to, write the results + if let Some(write_results_path) = &validate_ssm_args.write_results_path { + // Filter the results by given status, and if no statuses were given, get all results + info!("Writing results to file"); + let filtered_results = validation_results.get_results_for_status( + validate_ssm_args + .write_results_filter + .as_ref() + .unwrap_or(&vec![ + SsmValidationResultStatus::Correct, + SsmValidationResultStatus::Incorrect, + SsmValidationResultStatus::Missing, + SsmValidationResultStatus::Unexpected, + ]), + ); + + // Write the results as JSON + serde_json::to_writer_pretty( + &File::create(write_results_path).context(error::WriteValidationResultsSnafu { + path: write_results_path, + })?, + &filtered_results, + ) + .context(error::SerializeValidationResultsSnafu)?; + } + + Ok(validation_results) +} + +/// Validates SSM parameters in a single region, based on a HashMap (SsmKey, SsmValue) of expected +/// parameters and a HashMap (SsmKey, String) of actual retrieved parameters. Returns a HashSet of +/// SsmValidationResult objects. +pub(crate) fn validate_parameters_in_region( + expected_parameters: &HashMap, + actual_parameters: &SsmParameters, +) -> HashSet { + // Clone the HashMap of actual parameters so items can be removed + let mut actual_parameters = actual_parameters.clone(); + let mut results = HashSet::new(); + + // Validate all expected parameters, creating an SsmValidationResult object and + // removing the corresponding parameter from `actual_parameters` if found + for (ssm_key, ssm_value) in expected_parameters { + results.insert(SsmValidationResult::new( + ssm_key.name.to_owned(), + Some(ssm_value.value.clone()), + actual_parameters.get(ssm_key).map(|v| v.to_owned()), + ssm_key.region.clone(), + Some(ssm_value.ami_id.clone()), + )); + actual_parameters.remove(ssm_key); + } + + // Any remaining parameters in `actual_parameters` were not present in `expected_parameters` + // and therefore get the `Unexpected` status + for (ssm_key, ssm_value) in actual_parameters { + results.insert(SsmValidationResult::new( + ssm_key.name.to_owned(), + None, + Some(ssm_value), + ssm_key.region.clone(), + None, + )); + } + results +} + +type RegionName = String; +type AmiId = String; +type ParameterName = String; +type ParameterValue = String; + +/// Parse the lists of parameters whose paths are in `parameter_lists`. Only parse the parameters +/// in the regions present in `validation_regions`. Return a HashMap of Region mapped to a HashMap +/// of the parameters in that region, with each parameter being a mapping of `SsmKey` to `SsmValue`. +pub(crate) async fn parse_parameter_lists( + parameter_lists: Vec, + validation_regions: &[String], +) -> Result>> { + let mut parameter_map: HashMap> = HashMap::new(); + for parameter_list_path in parameter_lists { + // Parse the JSON list as a HashMap of region_name, mapped to a HashMap of ami_id, mapped to + // a HashMap of parameter_name and parameter_value + let parameter_list: HashMap< + RegionName, + HashMap>, + > = serde_json::from_reader(&File::open(parameter_list_path.clone()).context( + error::ReadExpectedParameterListSnafu { + path: parameter_list_path, + }, + )?) + .context(error::ParseExpectedParameterListSnafu)?; + + // Iterate over the parsed HashMap, converting the nested HashMap into a HashMap of Region + // mapped to a HashMap of SsmKey, SsmValue + parameter_list + .iter() + .filter(|(region, _)| validation_regions.contains(region)) + .flat_map(|(region, ami_ids)| { + ami_ids + .iter() + .map(move |(ami_id, param_names)| (region, ami_id, param_names)) + }) + .flat_map(|(region, ami_id, params)| { + params.iter().map(move |(parameter_name, parameter_value)| { + ( + region.clone(), + ami_id.clone(), + parameter_name.clone(), + parameter_value.clone(), + ) + }) + }) + .for_each(|(region, ami_id, parameter_name, parameter_value)| { + parameter_map + .entry(Region::new(region.clone())) + .or_insert(HashMap::new()) + .insert( + SsmKey::new(Region::new(region), parameter_name), + SsmValue { + value: parameter_value, + ami_id, + }, + ); + }); + } + Ok(parameter_map) +} + +/// Common entrypoint from main() +pub(crate) async fn run(args: &Args, validate_ssm_args: &ValidateSsmArgs) -> Result<()> { + let results = validate(args, validate_ssm_args).await?; + + if validate_ssm_args.json { + println!( + "{}", + serde_json::to_string_pretty(&results.get_json_summary()) + .context(error::SerializeResultsSummarySnafu)? + ) + } else { + println!("{}", results) + } + Ok(()) +} + +mod error { + use crate::aws::ssm::ssm; + use snafu::Snafu; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility(pub(super)))] + pub enum Error { + #[snafu(display("Error reading config: {}", source))] + Config { source: pubsys_config::Error }, + + #[snafu(display("Error reading validation config at path {}: {}", path.display(), source))] + ReadValidationConfig { + source: std::io::Error, + path: PathBuf, + }, + + #[snafu(display("Error parsing validation config: {}", source))] + ParseValidationConfig { source: serde_json::Error }, + + #[snafu(display("Missing field in validation config: {}", missing))] + MissingField { missing: String }, + + #[snafu(display("Missing region in expected parameters: {}", missing))] + MissingExpectedRegion { missing: String }, + + #[snafu(display("Missing region in actual parameters: {}", missing))] + MissingActualRegion { missing: String }, + + #[snafu(display("Found no parameters in source version {}", version))] + EmptySource { version: String }, + + #[snafu(display("Failed to fetch parameters from SSM: {}", source))] + FetchSsm { source: ssm::error::Error }, + + #[snafu(display("Infra.toml is missing {}", missing))] + MissingConfig { missing: String }, + + #[snafu(display("Failed to validate SSM parameters: {}", missing))] + ValidateSsm { missing: String }, + + #[snafu(display("Failed to validate SSM parameters in region: {}", region))] + ValidateSsmRegion { region: String }, + + #[snafu(display("Failed to parse AMI list: {}", source))] + ParseExpectedParameterList { source: serde_json::Error }, + + #[snafu(display("Failed to read AMI list: {}", path.display()))] + ReadExpectedParameterList { + source: std::io::Error, + path: PathBuf, + }, + + #[snafu(display("Invalid validation status filter: {}", filter))] + InvalidStatusFilter { filter: String }, + + #[snafu(display("Failed to serialize validation results to json: {}", source))] + SerializeValidationResults { source: serde_json::Error }, + + #[snafu(display("Failed to write validation results to {}: {}", path.display(), source))] + WriteValidationResults { + path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("Failed to serialize results summary into JSON: {}", source))] + SerializeResultsSummary { source: serde_json::Error }, + } +} + +pub(crate) use error::Error; +type Result = std::result::Result; + +#[cfg(test)] +mod test { + use crate::aws::{ + ssm::{SsmKey, SsmParameters}, + validate_ssm::{results::SsmValidationResult, validate_parameters_in_region, SsmValue}, + }; + use aws_sdk_ssm::Region; + use std::collections::{HashMap, HashSet}; + + // These tests assert that the parameters can be validated correctly. + + // Tests validation of parameters where the expected value is equal to the actual value + #[test] + fn validate_parameters_all_correct() { + let expected_parameters: HashMap = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + SsmValue { + value: "test1-parameter-value".to_string(), + ami_id: "test1-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + SsmValue { + value: "test2-parameter-value".to_string(), + ami_id: "test2-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + SsmValue { + value: "test3-parameter-value".to_string(), + ami_id: "test3-image-id".to_string(), + }, + ), + ]); + let actual_parameters: SsmParameters = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + "test1-parameter-value".to_string(), + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + "test2-parameter-value".to_string(), + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + "test3-parameter-value".to_string(), + ), + ]); + let expected_results = HashSet::from_iter(vec![ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + Some("test3-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + ]); + let results = validate_parameters_in_region(&expected_parameters, &actual_parameters); + + assert_eq!(results, expected_results); + } + + // Tests validation of parameters where the expected value is different from the actual value + #[test] + fn validate_parameters_all_incorrect() { + let expected_parameters: HashMap = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + SsmValue { + value: "test1-parameter-value".to_string(), + ami_id: "test1-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + SsmValue { + value: "test2-parameter-value".to_string(), + ami_id: "test2-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + SsmValue { + value: "test3-parameter-value".to_string(), + ami_id: "test3-image-id".to_string(), + }, + ), + ]); + let actual_parameters: SsmParameters = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + "test1-parameter-value-wrong".to_string(), + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + "test2-parameter-value-wrong".to_string(), + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + "test3-parameter-value-wrong".to_string(), + ), + ]); + let expected_results = HashSet::from_iter(vec![ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + Some("test3-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + ]); + let results = validate_parameters_in_region(&expected_parameters, &actual_parameters); + + assert_eq!(results, expected_results); + } + + // Tests validation of parameters where the actual value is missing + #[test] + fn validate_parameters_all_missing() { + let expected_parameters: HashMap = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + SsmValue { + value: "test1-parameter-value".to_string(), + ami_id: "test1-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + SsmValue { + value: "test2-parameter-value".to_string(), + ami_id: "test2-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + SsmValue { + value: "test3-parameter-value".to_string(), + ami_id: "test3-image-id".to_string(), + }, + ), + ]); + let actual_parameters: SsmParameters = HashMap::new(); + let expected_results = HashSet::from_iter(vec![ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + ]); + let results = validate_parameters_in_region(&expected_parameters, &actual_parameters); + + assert_eq!(results, expected_results); + } + + // Tests validation of parameters where the expected value is missing + #[test] + fn validate_parameters_all_unexpected() { + let expected_parameters: HashMap = HashMap::new(); + let actual_parameters: SsmParameters = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + "test1-parameter-value".to_string(), + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + "test2-parameter-value".to_string(), + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + "test3-parameter-value".to_string(), + ), + ]); + let expected_results = HashSet::from_iter(vec![ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + None, + Some("test3-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + None, + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + None, + Some("test2-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + ]); + let results = validate_parameters_in_region(&expected_parameters, &actual_parameters); + + assert_eq!(results, expected_results); + } + + // Tests validation of parameters where each status (Correct, Incorrect, Missing, Unexpected) + // happens once + #[test] + fn validate_parameters_mixed() { + let expected_parameters: HashMap = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + SsmValue { + value: "test1-parameter-value".to_string(), + ami_id: "test1-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + SsmValue { + value: "test2-parameter-value".to_string(), + ami_id: "test2-image-id".to_string(), + }, + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test3-parameter-name".to_string(), + }, + SsmValue { + value: "test3-parameter-value".to_string(), + ami_id: "test3-image-id".to_string(), + }, + ), + ]); + let actual_parameters: SsmParameters = HashMap::from([ + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test1-parameter-name".to_string(), + }, + "test1-parameter-value".to_string(), + ), + ( + SsmKey { + region: Region::new("us-west-2"), + name: "test2-parameter-name".to_string(), + }, + "test2-parameter-value-wrong".to_string(), + ), + ( + SsmKey { + region: Region::new("us-east-1"), + name: "test4-parameter-name".to_string(), + }, + "test4-parameter-value".to_string(), + ), + ]); + let expected_results = HashSet::from_iter(vec![ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + ]); + let results = validate_parameters_in_region(&expected_parameters, &actual_parameters); + + assert_eq!(results, expected_results); + } +} diff --git a/tools/pubsys/src/aws/validate_ssm/results.rs b/tools/pubsys/src/aws/validate_ssm/results.rs new file mode 100644 index 00000000000..f11c64d4c31 --- /dev/null +++ b/tools/pubsys/src/aws/validate_ssm/results.rs @@ -0,0 +1,686 @@ +//! The results module owns the reporting of SSM validation results. + +use crate::aws::ssm::ssm::Result; +use aws_sdk_ssm::Region; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fmt::{self, Display}; +use std::str::FromStr; +use tabled::{Table, Tabled}; + +/// Represent the possible status of an SSM validation +#[derive(Debug, Eq, Hash, PartialEq, Serialize, Deserialize)] +pub enum SsmValidationResultStatus { + /// The expected value was equal to the actual value + Correct, + + /// The expected value was different from the actual value + Incorrect, + + /// The parameter was expected but not included in the actual parameters + Missing, + + /// The parameter was present in the actual parameters but not expected + Unexpected, +} + +impl Display for SsmValidationResultStatus { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Self::Correct => write!(f, "Correct"), + Self::Incorrect => write!(f, "Incorrect"), + Self::Missing => write!(f, "Missing"), + Self::Unexpected => write!(f, "Unexpected"), + } + } +} + +impl FromStr for SsmValidationResultStatus { + type Err = super::Error; + + fn from_str(s: &str) -> std::result::Result { + match s { + "Correct" => Ok(Self::Correct), + "Incorrect" => Ok(Self::Incorrect), + "Missing" => Ok(Self::Missing), + "Unexpected" => Ok(Self::Unexpected), + filter => Err(Self::Err::InvalidStatusFilter { + filter: filter.to_string(), + }), + } + } +} + +/// Represents a single SSM validation result +#[derive(Debug, Eq, Hash, PartialEq, Tabled, Serialize)] +pub struct SsmValidationResult { + /// The name of the parameter + pub(crate) name: String, + + /// The expected value of the parameter + #[tabled(display_with = "display_option")] + pub(crate) expected_value: Option, + + /// The actual retrieved value of the parameter + #[tabled(display_with = "display_option")] + pub(crate) actual_value: Option, + + /// The region the parameter resides in + #[serde(serialize_with = "serialize_region")] + pub(crate) region: Region, + + /// The ID of the AMI the parameter is associated with + #[tabled(display_with = "display_option")] + pub(crate) ami_id: Option, + + /// The validation status of the parameter + pub(crate) status: SsmValidationResultStatus, +} + +fn display_option(option: &Option) -> &str { + match option { + Some(option) => option, + None => "N/A", + } +} + +fn serialize_region(region: &Region, serializer: S) -> std::result::Result +where + S: serde::Serializer, +{ + serializer.serialize_str(region.to_string().as_str()) +} + +impl SsmValidationResult { + pub(crate) fn new( + name: String, + expected_value: Option, + actual_value: Option, + region: Region, + ami_id: Option, + ) -> SsmValidationResult { + // Determine the validation status based on equality, presence, and absence of expected and + // actual parameter values + let status = match (&expected_value, &actual_value) { + (Some(expected_value), Some(actual_value)) if actual_value.eq(expected_value) => { + SsmValidationResultStatus::Correct + } + (Some(_), Some(_)) => SsmValidationResultStatus::Incorrect, + (_, None) => SsmValidationResultStatus::Missing, + (None, _) => SsmValidationResultStatus::Unexpected, + }; + SsmValidationResult { + name, + expected_value, + actual_value, + region, + ami_id, + status, + } + } +} + +#[derive(Tabled, Serialize)] +struct SsmValidationRegionSummary { + correct: i32, + incorrect: i32, + missing: i32, + unexpected: i32, + accessible: bool, +} + +impl From<&HashSet> for SsmValidationRegionSummary { + fn from(results: &HashSet) -> Self { + let mut region_validation = SsmValidationRegionSummary { + correct: 0, + incorrect: 0, + missing: 0, + unexpected: 0, + accessible: true, + }; + for validation_result in results { + match validation_result.status { + SsmValidationResultStatus::Correct => region_validation.correct += 1, + SsmValidationResultStatus::Incorrect => region_validation.incorrect += 1, + SsmValidationResultStatus::Missing => region_validation.missing += 1, + SsmValidationResultStatus::Unexpected => region_validation.unexpected += 1, + } + } + region_validation + } +} + +impl SsmValidationRegionSummary { + fn no_valid_results() -> Self { + // When the parameters in a region couldn't be retrieved, use `-1` to indicate this in the + // output table and set `accessible` to `false` + SsmValidationRegionSummary { + correct: -1, + incorrect: -1, + missing: -1, + unexpected: -1, + accessible: false, + } + } +} + +/// Represents all SSM validation results +#[derive(Debug)] +pub struct SsmValidationResults { + pub(crate) results: HashMap>>, +} + +impl Default for SsmValidationResults { + fn default() -> Self { + Self::new(HashMap::new()) + } +} + +impl Display for SsmValidationResults { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // Create a summary for each region, counting the number of parameters per status + let region_validations: HashMap = + self.get_results_summary(); + + // Represent the HashMap of summaries as a `Table` + let table = Table::new( + region_validations + .iter() + .map(|(region, results)| (region.to_string(), results)) + .collect::>(), + ) + .to_string(); + write!(f, "{}", table) + } +} + +impl SsmValidationResults { + pub fn new(results: HashMap>>) -> Self { + SsmValidationResults { results } + } + + /// Returns a HashSet containing all validation results whose status is present in + /// `requested_status` + pub fn get_results_for_status( + &self, + requested_status: &[SsmValidationResultStatus], + ) -> HashSet<&SsmValidationResult> { + let mut results = HashSet::new(); + for region_results in self.results.values().flatten() { + results.extend( + region_results + .iter() + .filter(|result| requested_status.contains(&result.status)) + .collect::>(), + ) + } + results + } + + fn get_results_summary(&self) -> HashMap { + self.results + .iter() + .map(|(region, region_result)| { + region_result + .as_ref() + .map(|region_validation| { + ( + region.clone(), + SsmValidationRegionSummary::from(region_validation), + ) + }) + .unwrap_or(( + region.clone(), + SsmValidationRegionSummary::no_valid_results(), + )) + }) + .collect() + } + + pub(crate) fn get_json_summary(&self) -> serde_json::Value { + serde_json::json!(self + .get_results_summary() + .into_iter() + .map(|(region, results)| (region.to_string(), results)) + .collect::>()) + } +} + +#[cfg(test)] +mod test { + use std::collections::{HashMap, HashSet}; + + use crate::aws::validate_ssm::results::{ + SsmValidationResult, SsmValidationResultStatus, SsmValidationResults, + }; + use aws_sdk_ssm::Region; + + // These tests assert that the `get_results_for_status` function returns the correct values. + + // Tests empty SsmValidationResults + #[test] + fn get_results_for_status_empty() { + let results = SsmValidationResults::new(HashMap::from([ + (Region::new("us-west-2"), Ok(HashSet::from([]))), + (Region::new("us-east-1"), Ok(HashSet::from([]))), + ])); + let results_filtered = results.get_results_for_status(&vec![ + SsmValidationResultStatus::Correct, + SsmValidationResultStatus::Incorrect, + SsmValidationResultStatus::Missing, + SsmValidationResultStatus::Unexpected, + ]); + + assert_eq!(results_filtered, HashSet::new()); + } + + // Tests the `Correct` status + #[test] + fn get_results_for_status_correct() { + let results = SsmValidationResults::new(HashMap::from([ + ( + Region::new("us-west-2"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + ])), + ), + ( + Region::new("us-east-1"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + ])), + ), + ])); + let results_filtered = + results.get_results_for_status(&vec![SsmValidationResultStatus::Correct]); + + assert_eq!( + results_filtered, + HashSet::from([ + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ) + ]) + ); + } + + // Tests a filter containing the `Correct` and `Incorrect` statuses + #[test] + fn get_results_for_status_correct_incorrect() { + let results = SsmValidationResults::new(HashMap::from([ + ( + Region::new("us-west-2"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + ])), + ), + ( + Region::new("us-east-1"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + ])), + ), + ])); + let results_filtered = results.get_results_for_status(&vec![ + SsmValidationResultStatus::Correct, + SsmValidationResultStatus::Incorrect, + ]); + + assert_eq!( + results_filtered, + HashSet::from([ + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + &SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + &SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ) + ]) + ); + } + + // Tests a filter containing all statuses + #[test] + fn get_results_for_status_all() { + let results = SsmValidationResults::new(HashMap::from([ + ( + Region::new("us-west-2"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + ])), + ), + ( + Region::new("us-east-1"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + ])), + ), + ])); + let results_filtered = results.get_results_for_status(&vec![ + SsmValidationResultStatus::Correct, + SsmValidationResultStatus::Incorrect, + SsmValidationResultStatus::Missing, + SsmValidationResultStatus::Unexpected, + ]); + + assert_eq!( + results_filtered, + HashSet::from([ + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + &SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + &SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + &SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ), + &SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-west-2"), + Some("test3-image-id".to_string()), + ), + &SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + &SsmValidationResult::new( + "test3-parameter-name".to_string(), + Some("test3-parameter-value".to_string()), + None, + Region::new("us-east-1"), + Some("test3-image-id".to_string()), + ), + &SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ) + ]) + ); + } + + // Tests the `Missing` filter when none of the SsmValidationResults have this status + #[test] + fn get_results_for_status_missing_none() { + let results = SsmValidationResults::new(HashMap::from([ + ( + Region::new("us-west-2"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-west-2"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-west-2"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-west-2"), + None, + ), + ])), + ), + ( + Region::new("us-east-1"), + Ok(HashSet::from([ + SsmValidationResult::new( + "test1-parameter-name".to_string(), + Some("test1-parameter-value".to_string()), + Some("test1-parameter-value".to_string()), + Region::new("us-east-1"), + Some("test1-image-id".to_string()), + ), + SsmValidationResult::new( + "test2-parameter-name".to_string(), + Some("test2-parameter-value".to_string()), + Some("test2-parameter-value-wrong".to_string()), + Region::new("us-east-1"), + Some("test2-image-id".to_string()), + ), + SsmValidationResult::new( + "test4-parameter-name".to_string(), + None, + Some("test4-parameter-value".to_string()), + Region::new("us-east-1"), + None, + ), + ])), + ), + ])); + let results_filtered = + results.get_results_for_status(&vec![SsmValidationResultStatus::Missing]); + + assert_eq!(results_filtered, HashSet::new()); + } +} diff --git a/tools/pubsys/src/main.rs b/tools/pubsys/src/main.rs index e0daee46495..adf99931a33 100644 --- a/tools/pubsys/src/main.rs +++ b/tools/pubsys/src/main.rs @@ -10,6 +10,7 @@ Currently implemented: * Marking EC2 AMIs public (or private again) * setting SSM parameters based on built AMIs * promoting SSM parameters from versioned entries to named (e.g. 'latest') +* validating SSM parameters by comparing the returned parameters in a region to a given list of parameters To be implemented: * high-level document describing pubsys usage with examples @@ -114,6 +115,14 @@ fn run() -> Result<()> { .context(error::PromoteSsmSnafu) }) } + SubCommand::ValidateSsm(ref validate_ssm_args) => { + let rt = Runtime::new().context(error::RuntimeSnafu)?; + rt.block_on(async { + aws::validate_ssm::run(&args, validate_ssm_args) + .await + .context(error::ValidateSsmSnafu) + }) + } SubCommand::UploadOva(ref upload_args) => { vmware::upload_ova::run(&args, upload_args).context(error::UploadOvaSnafu) } @@ -130,7 +139,7 @@ fn main() { /// Automates publishing of Bottlerocket updates #[derive(Debug, StructOpt)] #[structopt(setting = clap::AppSettings::DeriveDisplayOrder)] -struct Args { +pub struct Args { #[structopt(global = true, long, default_value = "INFO")] /// How much detail to log; from least to most: ERROR, WARN, INFO, DEBUG, TRACE log_level: LevelFilter, @@ -155,6 +164,7 @@ enum SubCommand { Ssm(aws::ssm::SsmArgs), PromoteSsm(aws::promote_ssm::PromoteArgs), + ValidateSsm(aws::validate_ssm::ValidateSsmArgs), UploadOva(vmware::upload_ova::UploadArgs), } @@ -224,6 +234,11 @@ mod error { UploadOva { source: crate::vmware::upload_ova::Error, }, + + #[snafu(display("Failed to validate SSM parameters: {}", source))] + ValidateSsm { + source: crate::aws::validate_ssm::Error, + }, } fn publish_ami_message(error: &crate::aws::publish_ami::Error) -> String {