diff --git a/bottlerocket-settings-sdk/Cargo.toml b/bottlerocket-settings-sdk/Cargo.toml index 2d957d3a..0bc3b8f5 100644 --- a/bottlerocket-settings-sdk/Cargo.toml +++ b/bottlerocket-settings-sdk/Cargo.toml @@ -20,6 +20,7 @@ ctor = "0.2" env_logger = "0.10" log = "0.4" maplit = "1" +tempfile = { version = "3", default-features = false } [features] default = ["extension", "proto1"] diff --git a/bottlerocket-settings-sdk/src/cli/proto1.rs b/bottlerocket-settings-sdk/src/cli/proto1.rs index 324106da..c48ff053 100644 --- a/bottlerocket-settings-sdk/src/cli/proto1.rs +++ b/bottlerocket-settings-sdk/src/cli/proto1.rs @@ -1,6 +1,7 @@ //! Bottlerocket Settings Extension CLI proto1 definition. #![allow(missing_docs)] use argh::FromArgs; +use serde::{Deserialize, Serialize}; /// Use Settings Extension CLI protocol proto1. #[derive(FromArgs, Debug)] @@ -9,6 +10,12 @@ pub struct Protocol1 { /// the command to invoke against the settings extension #[argh(subcommand)] pub command: Proto1Command, + + #[argh( + option, + description = "file that contains input json for the proto1 command" + )] + pub input_file: Option, } /// The command to invoke against the settings extension. @@ -39,97 +46,154 @@ impl Proto1Command {} /// Validates that a new setting value can be persisted to the Bottlerocket datastore. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "set")] -pub struct SetCommand { +pub struct SetCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct SetArguments { /// the version of the setting which should be used - #[argh(option)] pub setting_version: String, /// the requested value to be set for the incoming setting - #[argh(option)] pub value: serde_json::Value, /// the current value of this settings tree - #[argh(option)] pub current_value: Option, } /// Dynamically generates a value for this setting given, possibly from other settings. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "generate")] -pub struct GenerateCommand { +pub struct GenerateCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct GenerateArguments { /// the version of the setting which should be used - #[argh(option)] pub setting_version: String, /// a json value containing any partially generated data for this setting - #[argh(option)] pub existing_partial: Option, /// a json value containing any requested settings partials needed to generate this one - #[argh(option)] pub required_settings: Option, } /// Validates an incoming setting, possibly cross-validated with other settings. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "validate")] -pub struct ValidateCommand { +pub struct ValidateCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct ValidateArguments { /// the version of the setting which should be used - #[argh(option)] pub setting_version: String, /// a json value containing any partially generated data for this setting - #[argh(option)] pub value: serde_json::Value, /// a json value containing any requested settings partials needed to generate this one - #[argh(option)] pub required_settings: Option, } /// Migrates a setting value from one version to another. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "migrate")] -pub struct MigrateCommand { +pub struct MigrateCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct MigrateArguments { /// a json value containing the current value of the setting - #[argh(option)] pub value: serde_json::Value, /// the version of the settings data being migrated - #[argh(option)] pub from_version: String, /// the desired resulting version for the settings data - #[argh(option)] pub target_version: String, } /// Migrates a setting value from one version to all other known versions. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "flood-migrate")] -pub struct FloodMigrateCommand { +pub struct FloodMigrateCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct FloodMigrateArguments { /// a json value containing the current value of the setting - #[argh(option)] pub value: serde_json::Value, /// the version of the settings data being migrated - #[argh(option)] pub from_version: String, } /// Executes a template helper to assist in rendering values to a configuration file. #[derive(FromArgs, Debug)] #[argh(subcommand, name = "helper")] -pub struct TemplateHelperCommand { +pub struct TemplateHelperCommand {} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub struct TemplateHelperArguments { /// the version of the setting which should be used - #[argh(option)] pub setting_version: String, /// the name of the helper to call - #[argh(option)] pub helper_name: String, /// the arguments for the given helper - #[argh(option)] pub arg: Vec, } + +pub mod input { + use core::fmt::Display; + use core::str::FromStr; + use std::convert::Infallible; + use std::path::Path; + + #[derive(Debug)] + pub enum InputFile { + Stdin, + NormalFile(String), + } + + impl Display for InputFile { + fn fmt(&self, formatter: &mut std::fmt::Formatter) -> Result<(), std::fmt::Error> { + match self { + Self::Stdin => formatter.write_str("stdin"), + Self::NormalFile(filename) => formatter.write_str(&filename), + } + } + } + + impl Default for InputFile { + fn default() -> InputFile { + InputFile::Stdin + } + } + + impl AsRef for InputFile { + fn as_ref(&self) -> &Path { + match self { + Self::Stdin => Path::new("/dev/stdin"), + Self::NormalFile(filename) => Path::new(filename), + } + } + } + + impl FromStr for InputFile { + type Err = Infallible; + + fn from_str(input: &str) -> Result { + match input { + "/dev/stdin" => Ok(Self::Stdin), + "-" => Ok(Self::Stdin), + "stdin" => Ok(Self::Stdin), + x => Ok(Self::NormalFile(String::from(x))), + } + } + } +} diff --git a/bottlerocket-settings-sdk/src/extension/mod.rs b/bottlerocket-settings-sdk/src/extension/mod.rs index 4ae53c89..a05ed311 100644 --- a/bottlerocket-settings-sdk/src/extension/mod.rs +++ b/bottlerocket-settings-sdk/src/extension/mod.rs @@ -108,7 +108,9 @@ where debug!(?args, "CLI arguments"); match args.protocol { - cli::Protocol::Proto1(p) => proto1::run_extension(self, p.command), + cli::Protocol::Proto1(p) => { + proto1::run_extension(self, p.command, p.input_file.unwrap_or_default()) + } } } @@ -140,7 +142,9 @@ where info!(cli_protocol = %args.protocol, "Starting settings extensions."); match args.protocol { - cli::Protocol::Proto1(p) => proto1::try_run_extension(self, p.command), + cli::Protocol::Proto1(p) => { + proto1::try_run_extension(self, p.command, p.input_file.unwrap_or_default()) + } } } @@ -247,6 +251,15 @@ pub mod error { #[snafu(display("Failed to parse CLI arguments: {}", parser_output))] ParseCLIArgs { parser_output: String }, + #[snafu(display("Failed to parse to JSON: {}", source))] + ParseJSON { source: serde_json::Error }, + + #[snafu(display("Failed to read from '{}': {}", filename, source))] + ReadInput { + filename: String, + source: std::io::Error, + }, + #[snafu(display("Failed to write settings extension output as JSON: {}", source))] SerializeResult { source: serde_json::Error }, diff --git a/bottlerocket-settings-sdk/src/extension/proto1.rs b/bottlerocket-settings-sdk/src/extension/proto1.rs index 2ac7a3f4..d5a2e3d3 100644 --- a/bottlerocket-settings-sdk/src/extension/proto1.rs +++ b/bottlerocket-settings-sdk/src/extension/proto1.rs @@ -4,8 +4,8 @@ //! with function name collisions if needed. use super::{error, SettingsExtensionError}; use crate::cli::proto1::{ - FloodMigrateCommand, GenerateCommand, MigrateCommand, Proto1Command, SetCommand, - TemplateHelperCommand, ValidateCommand, + input::InputFile, FloodMigrateArguments, GenerateArguments, MigrateArguments, Proto1Command, + SetArguments, TemplateHelperArguments, ValidateArguments, }; use crate::migrate::Migrator; use crate::model::erased::AsTypeErasedModel; @@ -19,8 +19,12 @@ use tracing::instrument; /// /// Results are printed to stdout/stderr, adhering to Bottlerocket settings extension CLI proto1. /// Once the extension has run, the program terminates. -pub fn run_extension(extension: P, cmd: Proto1Command) -> ExitCode { - match try_run_extension(extension, cmd) { +pub fn run_extension( + extension: P, + cmd: Proto1Command, + input_file: InputFile, +) -> ExitCode { + match try_run_extension(extension, cmd, input_file) { Ok(output) => { println!("{}", &output); ExitCode::SUCCESS @@ -39,6 +43,7 @@ pub fn run_extension(extension: P, cmd: Proto1Command) -> ExitCode { pub fn try_run_extension( extension: P, cmd: Proto1Command, + input_file: InputFile, ) -> Result> where P: Proto1, @@ -47,13 +52,35 @@ where let json_stringify = |value| serde_json::to_string_pretty(&value).context(error::SerializeResultSnafu); + let input = std::fs::read_to_string(&input_file).context(error::ReadInputSnafu { + filename: input_file.to_string(), + })?; + match cmd { - Proto1Command::Set(s) => extension.set(s).map(|_| String::new()), - Proto1Command::Generate(g) => extension.generate(g).and_then(json_stringify), - Proto1Command::Migrate(m) => extension.migrate(m).and_then(json_stringify), - Proto1Command::FloodMigrate(m) => extension.flood_migrate(m).and_then(json_stringify), - Proto1Command::Validate(v) => extension.validate(v).map(|_| String::new()), - Proto1Command::Helper(h) => extension.template_helper(h).and_then(json_stringify), + Proto1Command::Set(_) => { + let s = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.set(s).map(|_| String::new()) + } + Proto1Command::Generate(_) => { + let g = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.generate(g).and_then(json_stringify) + } + Proto1Command::Migrate(_) => { + let m = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.migrate(m).and_then(json_stringify) + } + Proto1Command::FloodMigrate(_) => { + let m = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.flood_migrate(m).and_then(json_stringify) + } + Proto1Command::Validate(_) => { + let v = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.validate(v).map(|_| String::new()) + } + Proto1Command::Helper(_) => { + let h = serde_json::from_str(&input).context(error::ParseJSONSnafu)?; + extension.template_helper(h).and_then(json_stringify) + } } } @@ -63,26 +90,29 @@ where pub trait Proto1: Debug { type MigratorErrorKind: std::error::Error + Send + Sync + 'static; - fn set(&self, args: SetCommand) -> Result<(), SettingsExtensionError>; + fn set( + &self, + args: SetArguments, + ) -> Result<(), SettingsExtensionError>; fn generate( &self, - args: GenerateCommand, + args: GenerateArguments, ) -> Result>; fn migrate( &self, - args: MigrateCommand, + args: MigrateArguments, ) -> Result>; fn flood_migrate( &self, - args: FloodMigrateCommand, + args: FloodMigrateArguments, ) -> Result>; fn validate( &self, - args: ValidateCommand, + args: ValidateArguments, ) -> Result<(), SettingsExtensionError>; fn template_helper( &self, - args: TemplateHelperCommand, + args: TemplateHelperArguments, ) -> Result>; } @@ -94,7 +124,10 @@ where type MigratorErrorKind = Mi::ErrorKind; #[instrument(err)] - fn set(&self, args: SetCommand) -> Result<(), SettingsExtensionError> { + fn set( + &self, + args: SetArguments, + ) -> Result<(), SettingsExtensionError> { self.model(&args.setting_version) .context(error::NoSuchModelSnafu { setting_version: args.setting_version, @@ -107,7 +140,7 @@ where #[instrument(err)] fn generate( &self, - args: GenerateCommand, + args: GenerateArguments, ) -> Result> { self.model(&args.setting_version) .context(error::NoSuchModelSnafu { @@ -124,7 +157,7 @@ where #[instrument(err)] fn migrate( &self, - args: MigrateCommand, + args: MigrateArguments, ) -> Result> { let model = self .model(&args.from_version) @@ -153,7 +186,7 @@ where #[instrument(err)] fn flood_migrate( &self, - args: FloodMigrateCommand, + args: FloodMigrateArguments, ) -> Result> { let model = self .model(&args.from_version) @@ -178,7 +211,7 @@ where #[instrument(err)] fn validate( &self, - args: ValidateCommand, + args: ValidateArguments, ) -> Result<(), SettingsExtensionError> { self.model(&args.setting_version) .context(error::NoSuchModelSnafu { @@ -191,7 +224,7 @@ where fn template_helper( &self, - args: TemplateHelperCommand, + args: TemplateHelperArguments, ) -> Result> { self.model(&args.setting_version) .context(error::NoSuchModelSnafu { diff --git a/bottlerocket-settings-sdk/tests/sample_extensions.rs b/bottlerocket-settings-sdk/tests/sample_extensions.rs index 2433c81e..fea0f8f1 100644 --- a/bottlerocket-settings-sdk/tests/sample_extensions.rs +++ b/bottlerocket-settings-sdk/tests/sample_extensions.rs @@ -22,34 +22,70 @@ mod motd; /// We also define some helpers for invoking the CLI interface generated by the SDK. mod helpers { use super::*; + use bottlerocket_settings_sdk::cli::proto1::{ + FloodMigrateArguments, GenerateArguments, MigrateArguments, SetArguments, + TemplateHelperArguments, ValidateArguments, + }; - /// Wrapper around "extension.set" which uses the CLI. - pub fn set_cli( + fn cli( extension: SettingsExtension, - version: &str, - value: serde_json::Value, - ) -> Result<()> + subcommand: &str, + args: impl serde::Serialize, + ) -> Result where Mi: Migrator, Mo: AsTypeErasedModel, { + let temp_dir = tempfile::TempDir::new().context("Failed to create temp dir")?; + let input_file = std::path::Path::join(temp_dir.path(), "input.json"); + let _ = std::fs::write( + &input_file, + serde_json::to_string(&args).context("Failed to serialize args")?, + ); + extension .try_run_with_args(&[ "extension", "proto1", - "set", - "--setting-version", - version, - "--value", - value.to_string().as_str(), + "--input-file", + input_file.to_str().unwrap(), + subcommand, ]) .context("Failed to run settings extension CLI") - .map(|s| { - assert!(s.is_empty()); - () + .and_then(|s| { + if s.is_empty() { + return Ok(serde_json::Value::default()); + } + serde_json::from_str(s.as_str()).context("Failed to parse CLI result") }) } + /// Wrapper around "extension.set" which uses the CLI. + pub fn set_cli( + extension: SettingsExtension, + version: &str, + value: serde_json::Value, + ) -> Result<()> + where + Mi: Migrator, + Mo: AsTypeErasedModel, + { + cli( + extension, + "set", + SetArguments { + setting_version: String::from(version), + value, + current_value: None, + }, + ) + .context("failed to run set CLI") + .map(|v| { + assert!(v.is_null()); + () + }) + } + /// Wrapper around "extension.generate" which uses the CLI. pub fn generate_cli( extension: SettingsExtension, @@ -63,34 +99,19 @@ mod helpers { P: DeserializeOwned, C: DeserializeOwned, { - let mut args: Vec = vec![ - "extension", - "proto1", + cli( + extension, "generate", - "--setting-version", - version, - ] - .into_iter() - .map(str::to_string) - .collect(); - - if let Some(existing_partial) = &existing_partial { - args.append(&mut vec![ - "--existing-partial".to_string(), - existing_partial.to_string(), - ]); - } - if let Some(required_settings) = required_settings { - args.append(&mut vec![ - "--required-settings".to_string(), - required_settings.to_string(), - ]); - } - - extension - .try_run_with_args(args) - .context("Failed to run settings extension CLI") - .and_then(|s| serde_json::from_str(s.as_str()).context("Failed to parse CLI result")) + GenerateArguments { + setting_version: String::from(version), + existing_partial, + required_settings, + }, + ) + .context("failed to run generate CLI") + .and_then(|v| { + serde_json::from_value(v).context("failed to convert json value to concrete type") + }) } /// Wrapper around "extension.validate" which uses the CLI. @@ -104,33 +125,20 @@ mod helpers { Mi: Migrator, Mo: AsTypeErasedModel, { - let mut args: Vec = vec![ - "extension", - "proto1", + cli( + extension, "validate", - "--setting-version", - version, - "--value", - value.to_string().as_str(), - ] - .into_iter() - .map(str::to_string) - .collect(); - - if let Some(required_settings) = required_settings { - args.append(&mut vec![ - "--required-settings".to_string(), - required_settings.to_string(), - ]); - } - - extension - .try_run_with_args(args) - .context("Failed to run settings extension CLI") - .map(|s| { - assert!(s.is_empty()); - () - }) + ValidateArguments { + setting_version: String::from(version), + value, + required_settings, + }, + ) + .context("failed to run set CLI") + .map(|v| { + assert!(v.is_null()); + () + }) } /// Wrapper around target migrations which uses the CLI. @@ -144,25 +152,15 @@ mod helpers { Mi: Migrator, Mo: AsTypeErasedModel, { - let value = value.to_string(); - let args = vec![ - "extension", - "proto1", + cli( + extension, "migrate", - "--value", - &value, - "--from-version", - from_version, - "--target-version", - target_version, - ]; - - extension - .try_run_with_args(args) - .context("Failed to run settings extension CLI") - .and_then(|s| { - serde_json::from_str(s.as_str()).context("Failed to parse CLI result as JSON") - }) + MigrateArguments { + value, + from_version: from_version.to_string(), + target_version: target_version.to_string(), + }, + ) } /// Wrapper around flood migrations which uses the CLI. @@ -175,23 +173,14 @@ mod helpers { Mi: Migrator, Mo: AsTypeErasedModel, { - let value = value.to_string(); - let args = vec![ - "extension", - "proto1", + cli( + extension, "flood-migrate", - "--value", - &value, - "--from-version", - from_version, - ]; - - extension - .try_run_with_args(args) - .context("Failed to run settings extension CLI") - .and_then(|s| { - serde_json::from_str(s.as_str()).context("Failed to parse CLI result as JSON") - }) + FloodMigrateArguments { + value, + from_version: from_version.to_string(), + }, + ) } /// Wrapper around "extension.template_helper" which uses the CLI. @@ -205,31 +194,14 @@ mod helpers { Mi: Migrator, Mo: AsTypeErasedModel, { - let template_args: Vec = args - .into_iter() - .map(|arg| vec!["--arg".to_string(), arg.to_string()]) - .flatten() - .collect(); - - let args = [ - "extension", - "proto1", + cli( + extension, "helper", - "--setting-version", - version, - "--helper-name", - helper_name, - ] - .into_iter() - .map(str::to_string) - .chain(template_args) - .collect::>(); - - extension - .try_run_with_args(args) - .context("Failed to run settings extension CLI") - .and_then(|s| { - serde_json::from_str(s.as_str()).context("Failed to parse CLI result as JSON") - }) + TemplateHelperArguments { + setting_version: String::from(version), + helper_name: helper_name.to_string(), + arg: args, + }, + ) } }