diff --git a/bottlerocket-settings-sdk/src/cli/proto1.rs b/bottlerocket-settings-sdk/src/cli/proto1.rs index 039f5543..f0c96728 100644 --- a/bottlerocket-settings-sdk/src/cli/proto1.rs +++ b/bottlerocket-settings-sdk/src/cli/proto1.rs @@ -94,13 +94,17 @@ pub struct MigrateCommand { #[derive(Args, Debug)] pub struct TemplateHelperCommand { + /// The version of the setting which should be used + #[arg(long)] + pub setting_version: String, + /// The name of the helper to call #[arg(long)] pub helper_name: String, /// The arguments for the given helper #[arg(long, value_parser = parse_json)] - pub args: Vec, + pub arg: Vec, } /// Helper for `clap` to parse JSON values. diff --git a/bottlerocket-settings-sdk/src/extension/mod.rs b/bottlerocket-settings-sdk/src/extension/mod.rs index fb24e450..a084894e 100644 --- a/bottlerocket-settings-sdk/src/extension/mod.rs +++ b/bottlerocket-settings-sdk/src/extension/mod.rs @@ -236,6 +236,9 @@ pub mod error { #[snafu(display("Set operation failed: {}", source))] Set { source: BottlerocketSettingError }, + #[snafu(display("Template helper execution failed: {}", source))] + TemplateHelper { source: BottlerocketSettingError }, + #[snafu(display("Validate operation failed: {}", source))] Validate { source: BottlerocketSettingError }, diff --git a/bottlerocket-settings-sdk/src/extension/proto1.rs b/bottlerocket-settings-sdk/src/extension/proto1.rs index 07314dae..136e77f0 100644 --- a/bottlerocket-settings-sdk/src/extension/proto1.rs +++ b/bottlerocket-settings-sdk/src/extension/proto1.rs @@ -4,7 +4,8 @@ //! with function name collisions if needed. use super::{error, SettingsExtensionError}; use crate::cli::proto1::{ - GenerateCommand, MigrateCommand, Proto1Command, SetCommand, ValidateCommand, + GenerateCommand, MigrateCommand, Proto1Command, SetCommand, TemplateHelperCommand, + ValidateCommand, }; use crate::migrate::Migrator; use crate::model::erased::AsTypeErasedModel; @@ -51,9 +52,7 @@ where Proto1Command::Generate(g) => extension.generate(g).and_then(json_stringify), Proto1Command::Migrate(m) => extension.migrate(m).and_then(json_stringify), Proto1Command::Validate(v) => extension.validate(v).map(|_| String::new()), - Proto1Command::Helper(_h) => { - todo!("https://github.com/bottlerocket-os/bottlerocket-settings-sdk/issues/3") - } + Proto1Command::Helper(h) => extension.template_helper(h).and_then(json_stringify), } } @@ -76,6 +75,10 @@ pub trait Proto1: Debug { &self, args: ValidateCommand, ) -> Result<(), SettingsExtensionError>; + fn template_helper( + &self, + args: TemplateHelperCommand, + ) -> Result>; } impl Proto1 for SettingsExtension @@ -160,4 +163,17 @@ where .validate(args.value, args.required_settings) .context(error::ValidateSnafu) } + + fn template_helper( + &self, + args: TemplateHelperCommand, + ) -> Result> { + self.model(&args.setting_version) + .context(error::NoSuchModelSnafu { + setting_version: args.setting_version, + })? + .as_model() + .execute_template_helper(&args.helper_name, args.arg) + .context(error::TemplateHelperSnafu) + } } diff --git a/bottlerocket-settings-sdk/src/lib.rs b/bottlerocket-settings-sdk/src/lib.rs index d21e482e..51c1483d 100644 --- a/bottlerocket-settings-sdk/src/lib.rs +++ b/bottlerocket-settings-sdk/src/lib.rs @@ -29,7 +29,7 @@ pub mod model; #[cfg(feature = "extension")] pub use crate::extension::SettingsExtension; -pub use helper::{HelperDef, HelperError}; +pub use helper::{template_helper, HelperDef, HelperError}; #[cfg(feature = "extension")] pub use migrate::{ LinearMigrator, LinearMigratorExtensionBuilder, LinearMigratorModel, LinearlyMigrateable, diff --git a/bottlerocket-settings-sdk/src/model/erased.rs b/bottlerocket-settings-sdk/src/model/erased.rs index 0c8c60b3..5c6c9c8d 100644 --- a/bottlerocket-settings-sdk/src/model/erased.rs +++ b/bottlerocket-settings-sdk/src/model/erased.rs @@ -6,7 +6,7 @@ //! This module contains traits which erase the underlying [`SettingsModel`] types, allowing the //! SDK to refer to the [`SettingsModel`]s as a collection of trait objects. use super::{error, BottlerocketSetting, BottlerocketSettingError, GenerateResult, SettingsModel}; -use snafu::ResultExt; +use snafu::{OptionExt, ResultExt}; use std::any::Any; use std::fmt::Debug; use tracing::{debug, instrument}; @@ -73,6 +73,13 @@ pub trait TypeErasedModel: Debug { &self, value: serde_json::Value, ) -> Result, BottlerocketSettingError>; + + /// Executes a template helper associated with this model version. + fn execute_template_helper( + &self, + helper_name: &str, + args: Vec, + ) -> Result; } /// A helper trait used to "upcast" supertraits over the [`TypeErasedModel`] trait. @@ -191,6 +198,30 @@ impl TypeErasedModel for BottlerocketSetting { }) } + fn execute_template_helper( + &self, + helper_name: &str, + args: Vec, + ) -> Result { + let all_helpers = T::template_helpers() + .map_err(Into::into) + .context(error::FetchTemplateHelpersSnafu)?; + + let helper = all_helpers + .get(helper_name) + .context(error::FetchTemplateHelperSnafu { + helper_name: helper_name.to_string(), + helper_version: T::get_version(), + })?; + + helper + .helper_fn(args) + .context(error::ExecuteTemplateHelperSnafu { + helper_name: helper_name.to_string(), + helper_version: T::get_version(), + }) + } + #[instrument(skip(self), err)] fn parse_erased( &self, diff --git a/bottlerocket-settings-sdk/src/model/mod.rs b/bottlerocket-settings-sdk/src/model/mod.rs index a8008c80..b049635b 100644 --- a/bottlerocket-settings-sdk/src/model/mod.rs +++ b/bottlerocket-settings-sdk/src/model/mod.rs @@ -1,8 +1,11 @@ //! Provides the [`SettingsModel`] trait interface required to model new settings in the //! Bottlerocket API using the settings SDK. +use crate::HelperDef; use serde::Deserialize; use serde::{de::DeserializeOwned, Serialize}; -use std::{fmt::Debug, marker::PhantomData}; +use std::collections::HashMap; +use std::fmt::Debug; +use std::marker::PhantomData; #[doc(hidden)] pub mod erased; @@ -98,6 +101,11 @@ pub trait SettingsModel: Sized + Serialize + DeserializeOwned + Debug { _value: Self, _validated_settings: Option, ) -> Result<(), Self::ErrorKind>; + + /// Returns the set of template helpers associated with this settings model. + fn template_helpers() -> Result>, Self::ErrorKind> { + Ok(HashMap::new()) + } } /// This struct wraps [`SettingsModel`]s in a referencable object which is passed to the @@ -169,6 +177,7 @@ where mod error { #![allow(missing_docs)] + use crate::HelperError; use snafu::Snafu; /// The error type returned when interacting with a user-defined @@ -190,6 +199,36 @@ mod error { source: serde_json::Error, }, + #[snafu(display( + "Failed to execute helper '{}@{}': {}", + helper_name, + helper_version, + source + ))] + ExecuteTemplateHelper { + helper_name: String, + helper_version: &'static str, + source: HelperError, + }, + + #[snafu(display( + "Failed to find template helper '{}@{}' from settings extension", + helper_name, + helper_version, + ))] + FetchTemplateHelper { + helper_name: String, + helper_version: &'static str, + }, + + #[snafu(display( + "Failed to request template helpers from settings extension: {}", + source + ))] + FetchTemplateHelpers { + source: Box, + }, + #[snafu(display( "Failed to run 'generate' on setting version '{}': {}", version, diff --git a/tests/migration_validation/mod.rs b/bottlerocket-settings-sdk/tests/migration_validation/mod.rs similarity index 100% rename from tests/migration_validation/mod.rs rename to bottlerocket-settings-sdk/tests/migration_validation/mod.rs diff --git a/bottlerocket-settings-sdk/tests/motd/v1.rs b/bottlerocket-settings-sdk/tests/motd/v1.rs index 53a0683e..5ad81c0f 100644 --- a/bottlerocket-settings-sdk/tests/motd/v1.rs +++ b/bottlerocket-settings-sdk/tests/motd/v1.rs @@ -1,9 +1,14 @@ use std::convert::Infallible; use super::*; -use bottlerocket_settings_sdk::{GenerateResult, LinearlyMigrateable, NoMigration, SettingsModel}; +use bottlerocket_settings_sdk::{ + provide_template_helpers, GenerateResult, HelperDef, LinearlyMigrateable, NoMigration, + SettingsModel, +}; +use bottlerocket_template_helper::template_helper; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::collections::HashMap; #[derive(Debug, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct MotdV1(pub Option); @@ -43,6 +48,12 @@ impl SettingsModel for MotdV1 { // No need to do any additional validation, any MotdV1 is acceptable Ok(()) } + + fn template_helpers() -> Result>> { + Ok(provide_template_helpers! { + "exclaim" => exclaim_helper, + }) + } } impl LinearlyMigrateable for MotdV1 { @@ -66,6 +77,11 @@ impl LinearlyMigrateable for MotdV1 { } } +#[template_helper(ident = exclaim_helper)] +fn exclaim(i: String) -> Result { + Ok(i + "!") +} + #[test] fn test_motdv1_set_success() { // When set is called on motdv1 with a string input, @@ -109,3 +125,33 @@ fn test_motdv1_validate() { fn test_motdv1_failure() { assert!(validate_cli(motd_settings_extension(), "v1", json!([1, 2, 3]), None).is_err(),); } + +#[test] +fn test_no_such_helper() { + assert!(template_helper_cli(motd_settings_extension(), "v1", "no_such_helper", vec![]).is_err()) +} + +#[test] +fn test_run_exclaim_helper() { + assert_eq!( + template_helper_cli( + motd_settings_extension(), + "v1", + "exclaim", + vec![json!("Hello")] + ) + .unwrap(), + json!("Hello!") + ); +} + +#[test] +fn test_helper_too_many_args() { + assert!(template_helper_cli( + motd_settings_extension(), + "v1", + "exclaim", + vec![json!("too"), json!("many"), json!("arguments")] + ) + .is_err()); +} diff --git a/bottlerocket-settings-sdk/tests/motd/v2.rs b/bottlerocket-settings-sdk/tests/motd/v2.rs index 89def75e..50258408 100644 --- a/bottlerocket-settings-sdk/tests/motd/v2.rs +++ b/bottlerocket-settings-sdk/tests/motd/v2.rs @@ -1,6 +1,12 @@ +use std::collections::HashMap; + use super::*; use anyhow::Result; -use bottlerocket_settings_sdk::{GenerateResult, LinearlyMigrateable, NoMigration, SettingsModel}; +use bottlerocket_settings_sdk::{ + provide_template_helpers, GenerateResult, HelperDef, LinearlyMigrateable, NoMigration, + SettingsModel, +}; +use bottlerocket_template_helper::template_helper; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -46,6 +52,13 @@ impl SettingsModel for MotdV2 { Ok(()) } + + fn template_helpers() -> Result>> { + Ok(provide_template_helpers! { + "exclaim" => exclaim_helper, + "question" => question_helper, + }) + } } impl LinearlyMigrateable for MotdV2 { @@ -70,6 +83,16 @@ impl LinearlyMigrateable for MotdV2 { } } +#[template_helper(ident = exclaim_helper)] +fn exclaim(i: String) -> Result { + Ok(i + "!!") +} + +#[template_helper(ident = question_helper)] +fn question(one: String, two: String) -> Result { + Ok(format!("{}? {}??", one, two)) +} + #[test] fn test_motdv2_set_success() { // When set is called on motdv2 with allowed input, @@ -103,3 +126,31 @@ fn test_motdv2_generate() { GenerateResult::::Complete(MotdV2(vec![])) ); } + +#[test] +fn test_run_exclaim_helper() { + assert_eq!( + template_helper_cli( + motd_settings_extension(), + "v2", + "exclaim", + vec![json!("Hello")] + ) + .unwrap(), + json!("Hello!!") + ); +} + +#[test] +fn test_run_question_helper() { + assert_eq!( + template_helper_cli( + motd_settings_extension(), + "v2", + "question", + vec![json!("two args"), json!("really")] + ) + .unwrap(), + json!("two args? really??") + ); +} diff --git a/bottlerocket-settings-sdk/tests/sample_extensions.rs b/bottlerocket-settings-sdk/tests/sample_extensions.rs index 481c9c1b..dbc17fb7 100644 --- a/bottlerocket-settings-sdk/tests/sample_extensions.rs +++ b/bottlerocket-settings-sdk/tests/sample_extensions.rs @@ -194,4 +194,43 @@ mod helpers { serde_json::from_str(s.as_str()).context("Failed to parse CLI result as JSON") }) } + + /// Wrapper around "extension.template_helper" which uses the CLI. + pub fn template_helper_cli( + extension: SettingsExtension, + version: &str, + helper_name: &str, + args: Vec, + ) -> Result + where + 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", + "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") + }) + } }