From 1dcc7305eb0ce3837e3eb40c66f80fe6b070f8ad Mon Sep 17 00:00:00 2001 From: "Sean P. Kelly" Date: Mon, 23 Oct 2023 01:19:40 +0000 Subject: [PATCH 1/3] chore: create a cargo workspace This will allow us to make subcrates for proc-macros. --- Cargo.toml | 32 +++---------------- bottlerocket-settings-sdk/Cargo.toml | 28 ++++++++++++++++ .../src}/cli/mod.rs | 0 .../src}/cli/proto1.rs | 0 .../src}/example/empty.rs | 0 .../src}/example/mod.rs | 0 .../src}/extension/builder.rs | 0 .../src}/extension/mod.rs | 0 .../src}/extension/proto1.rs | 0 {src => bottlerocket-settings-sdk/src}/lib.rs | 0 .../src}/migrate/linear/erased.rs | 0 .../src}/migrate/linear/extensionbuilder.rs | 0 .../src}/migrate/linear/interface.rs | 0 .../src}/migrate/linear/mod.rs | 0 .../src}/migrate/linear/validator.rs | 0 .../src}/migrate/mod.rs | 0 .../src}/model/erased.rs | 0 .../src}/model/mod.rs | 0 .../tests}/colliding_versions/mod.rs | 0 .../tests}/motd/mod.rs | 0 .../tests}/motd/v1.rs | 0 .../tests}/motd/v2.rs | 0 .../tests}/sample_extensions.rs | 0 23 files changed, 32 insertions(+), 28 deletions(-) create mode 100644 bottlerocket-settings-sdk/Cargo.toml rename {src => bottlerocket-settings-sdk/src}/cli/mod.rs (100%) rename {src => bottlerocket-settings-sdk/src}/cli/proto1.rs (100%) rename {src => bottlerocket-settings-sdk/src}/example/empty.rs (100%) rename {src => bottlerocket-settings-sdk/src}/example/mod.rs (100%) rename {src => bottlerocket-settings-sdk/src}/extension/builder.rs (100%) rename {src => bottlerocket-settings-sdk/src}/extension/mod.rs (100%) rename {src => bottlerocket-settings-sdk/src}/extension/proto1.rs (100%) rename {src => bottlerocket-settings-sdk/src}/lib.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/linear/erased.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/linear/extensionbuilder.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/linear/interface.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/linear/mod.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/linear/validator.rs (100%) rename {src => bottlerocket-settings-sdk/src}/migrate/mod.rs (100%) rename {src => bottlerocket-settings-sdk/src}/model/erased.rs (100%) rename {src => bottlerocket-settings-sdk/src}/model/mod.rs (100%) rename {tests => bottlerocket-settings-sdk/tests}/colliding_versions/mod.rs (100%) rename {tests => bottlerocket-settings-sdk/tests}/motd/mod.rs (100%) rename {tests => bottlerocket-settings-sdk/tests}/motd/v1.rs (100%) rename {tests => bottlerocket-settings-sdk/tests}/motd/v2.rs (100%) rename {tests => bottlerocket-settings-sdk/tests}/sample_extensions.rs (100%) diff --git a/Cargo.toml b/Cargo.toml index 86cb34a8..dc1eea0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,28 +1,4 @@ -[package] -name = "bottlerocket-settings-sdk" -version = "0.1.0" -license = "Apache-2.0 OR MIT" -edition = "2021" - -[dependencies] -clap = { version = "4", features = ["derive"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1.0" -snafu = "0.7" -tracing = { version = "0.1", features = ["log"] } - -[dev-dependencies] -anyhow = "1" -ctor = "0.2" -env_logger = "0.10" -log = "0.4" -maplit = "1" - -[features] -default = ["extension", "proto1"] - -# If extension is disabled, only the models structures are avalable. -extension = [] - -# Enable Bottlerocket settings extensions CLI proto1. -proto1 = [] +[workspace] +members = [ + "bottlerocket-settings-sdk", +] diff --git a/bottlerocket-settings-sdk/Cargo.toml b/bottlerocket-settings-sdk/Cargo.toml new file mode 100644 index 00000000..86cb34a8 --- /dev/null +++ b/bottlerocket-settings-sdk/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bottlerocket-settings-sdk" +version = "0.1.0" +license = "Apache-2.0 OR MIT" +edition = "2021" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1.0" +snafu = "0.7" +tracing = { version = "0.1", features = ["log"] } + +[dev-dependencies] +anyhow = "1" +ctor = "0.2" +env_logger = "0.10" +log = "0.4" +maplit = "1" + +[features] +default = ["extension", "proto1"] + +# If extension is disabled, only the models structures are avalable. +extension = [] + +# Enable Bottlerocket settings extensions CLI proto1. +proto1 = [] diff --git a/src/cli/mod.rs b/bottlerocket-settings-sdk/src/cli/mod.rs similarity index 100% rename from src/cli/mod.rs rename to bottlerocket-settings-sdk/src/cli/mod.rs diff --git a/src/cli/proto1.rs b/bottlerocket-settings-sdk/src/cli/proto1.rs similarity index 100% rename from src/cli/proto1.rs rename to bottlerocket-settings-sdk/src/cli/proto1.rs diff --git a/src/example/empty.rs b/bottlerocket-settings-sdk/src/example/empty.rs similarity index 100% rename from src/example/empty.rs rename to bottlerocket-settings-sdk/src/example/empty.rs diff --git a/src/example/mod.rs b/bottlerocket-settings-sdk/src/example/mod.rs similarity index 100% rename from src/example/mod.rs rename to bottlerocket-settings-sdk/src/example/mod.rs diff --git a/src/extension/builder.rs b/bottlerocket-settings-sdk/src/extension/builder.rs similarity index 100% rename from src/extension/builder.rs rename to bottlerocket-settings-sdk/src/extension/builder.rs diff --git a/src/extension/mod.rs b/bottlerocket-settings-sdk/src/extension/mod.rs similarity index 100% rename from src/extension/mod.rs rename to bottlerocket-settings-sdk/src/extension/mod.rs diff --git a/src/extension/proto1.rs b/bottlerocket-settings-sdk/src/extension/proto1.rs similarity index 100% rename from src/extension/proto1.rs rename to bottlerocket-settings-sdk/src/extension/proto1.rs diff --git a/src/lib.rs b/bottlerocket-settings-sdk/src/lib.rs similarity index 100% rename from src/lib.rs rename to bottlerocket-settings-sdk/src/lib.rs diff --git a/src/migrate/linear/erased.rs b/bottlerocket-settings-sdk/src/migrate/linear/erased.rs similarity index 100% rename from src/migrate/linear/erased.rs rename to bottlerocket-settings-sdk/src/migrate/linear/erased.rs diff --git a/src/migrate/linear/extensionbuilder.rs b/bottlerocket-settings-sdk/src/migrate/linear/extensionbuilder.rs similarity index 100% rename from src/migrate/linear/extensionbuilder.rs rename to bottlerocket-settings-sdk/src/migrate/linear/extensionbuilder.rs diff --git a/src/migrate/linear/interface.rs b/bottlerocket-settings-sdk/src/migrate/linear/interface.rs similarity index 100% rename from src/migrate/linear/interface.rs rename to bottlerocket-settings-sdk/src/migrate/linear/interface.rs diff --git a/src/migrate/linear/mod.rs b/bottlerocket-settings-sdk/src/migrate/linear/mod.rs similarity index 100% rename from src/migrate/linear/mod.rs rename to bottlerocket-settings-sdk/src/migrate/linear/mod.rs diff --git a/src/migrate/linear/validator.rs b/bottlerocket-settings-sdk/src/migrate/linear/validator.rs similarity index 100% rename from src/migrate/linear/validator.rs rename to bottlerocket-settings-sdk/src/migrate/linear/validator.rs diff --git a/src/migrate/mod.rs b/bottlerocket-settings-sdk/src/migrate/mod.rs similarity index 100% rename from src/migrate/mod.rs rename to bottlerocket-settings-sdk/src/migrate/mod.rs diff --git a/src/model/erased.rs b/bottlerocket-settings-sdk/src/model/erased.rs similarity index 100% rename from src/model/erased.rs rename to bottlerocket-settings-sdk/src/model/erased.rs diff --git a/src/model/mod.rs b/bottlerocket-settings-sdk/src/model/mod.rs similarity index 100% rename from src/model/mod.rs rename to bottlerocket-settings-sdk/src/model/mod.rs diff --git a/tests/colliding_versions/mod.rs b/bottlerocket-settings-sdk/tests/colliding_versions/mod.rs similarity index 100% rename from tests/colliding_versions/mod.rs rename to bottlerocket-settings-sdk/tests/colliding_versions/mod.rs diff --git a/tests/motd/mod.rs b/bottlerocket-settings-sdk/tests/motd/mod.rs similarity index 100% rename from tests/motd/mod.rs rename to bottlerocket-settings-sdk/tests/motd/mod.rs diff --git a/tests/motd/v1.rs b/bottlerocket-settings-sdk/tests/motd/v1.rs similarity index 100% rename from tests/motd/v1.rs rename to bottlerocket-settings-sdk/tests/motd/v1.rs diff --git a/tests/motd/v2.rs b/bottlerocket-settings-sdk/tests/motd/v2.rs similarity index 100% rename from tests/motd/v2.rs rename to bottlerocket-settings-sdk/tests/motd/v2.rs diff --git a/tests/sample_extensions.rs b/bottlerocket-settings-sdk/tests/sample_extensions.rs similarity index 100% rename from tests/sample_extensions.rs rename to bottlerocket-settings-sdk/tests/sample_extensions.rs From f94d62f152dc37a356e753daa74739286300a24b Mon Sep 17 00:00:00 2001 From: "Sean P. Kelly" Date: Mon, 23 Oct 2023 02:50:32 +0000 Subject: [PATCH 2/3] feat: add ability to define helpers This commit doesn't yet add support for extensions to call helpers, but it does add the HelperDef trait needed to define them, as well as a procedural macro to make it easier to define a helper given a function. --- Cargo.toml | 1 + bottlerocket-settings-sdk/Cargo.toml | 1 + bottlerocket-settings-sdk/src/helper/mod.rs | 116 ++++++++++++++++++++ bottlerocket-settings-sdk/src/lib.rs | 2 + bottlerocket-template-helper/Cargo.toml | 23 ++++ bottlerocket-template-helper/src/lib.rs | 104 ++++++++++++++++++ bottlerocket-template-helper/tests/tests.rs | 38 +++++++ 7 files changed, 285 insertions(+) create mode 100644 bottlerocket-settings-sdk/src/helper/mod.rs create mode 100644 bottlerocket-template-helper/Cargo.toml create mode 100644 bottlerocket-template-helper/src/lib.rs create mode 100644 bottlerocket-template-helper/tests/tests.rs diff --git a/Cargo.toml b/Cargo.toml index dc1eea0d..c4b265e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,5 @@ [workspace] members = [ "bottlerocket-settings-sdk", + "bottlerocket-template-helper", ] diff --git a/bottlerocket-settings-sdk/Cargo.toml b/bottlerocket-settings-sdk/Cargo.toml index 86cb34a8..3ae02d53 100644 --- a/bottlerocket-settings-sdk/Cargo.toml +++ b/bottlerocket-settings-sdk/Cargo.toml @@ -5,6 +5,7 @@ license = "Apache-2.0 OR MIT" edition = "2021" [dependencies] +bottlerocket-template-helper = { path = "../bottlerocket-template-helper", version = "0.1" } clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1.0" diff --git a/bottlerocket-settings-sdk/src/helper/mod.rs b/bottlerocket-settings-sdk/src/helper/mod.rs new file mode 100644 index 00000000..4587dfcb --- /dev/null +++ b/bottlerocket-settings-sdk/src/helper/mod.rs @@ -0,0 +1,116 @@ +//! Provides types for creating custom helper functions for use in Bottlerocket's templating engine. +//! +//! See the documentation of [`HelperDef`] for more information. +pub use bottlerocket_template_helper::template_helper; + +/// This trait allows users to create custom helper functions for use in Bottlerocket's templating +/// configuration system. +/// +/// Helpers are used to run arbitrary rendering code when writing config files. +/// +/// # Helper Definitions +/// Any type that implements [`HelperDef`] can be used as a helper. You can use the +/// [`template_helper`] annotation to generate a function that implements [`HelperDef`] for you, so +/// long as: +/// * Your function arguments implement [`serde::Deserialize`] +/// * Your return value is a `Result` where `T` implements [`serde::Serialize`] +/// and `E` implements `Into>`. +/// +/// # Example +/// +/// ``` +/// use bottlerocket_settings_sdk::helper::{HelperDef, template_helper}; +/// use serde_json::json; +/// +/// #[template_helper(ident = join_strings_helper)] +/// fn join_strings(lhs: String, rhs: String) -> Result { +/// Ok(format!("{}{}", lhs, rhs)) +/// } +/// +/// assert_eq!( +/// join_strings_helper.helper_fn(vec![json!("hello "), json!("world")]).unwrap(), +/// json!("hello world") +/// ); +/// +/// ``` +pub trait HelperDef { + /// Executes the helper. + /// + /// All inputs are provided as a list of JSON values, and a resulting JSON value is expected as + /// output. + fn helper_fn(&self, args: Vec) -> Result; +} + +impl) -> Result> HelperDef for F { + fn helper_fn(&self, args: Vec) -> Result { + self(args) + } +} + +#[macro_export] +/// Creates a map of helper names to helper definitions. +/// +/// This macro is useful for providing template helpers from a settings model: +/// +/// ``` +/// # use std::collections::HashMap; +/// use bottlerocket_settings_sdk::{ +/// HelperDef, provide_template_helpers, template_helper}; +/// +/// #[template_helper(ident = exclaim_helper)] +/// fn exclaim(s: String) -> Result { +/// Ok(format!("{}!", s)) +/// } +/// +/// fn template_helpers() -> HashMap> { +/// provide_template_helpers! { +/// "exclaim" => exclaim_helper, +/// } +/// } +/// ``` +macro_rules! provide_template_helpers { + ($($helper_name:expr => $helper:ident),* $(,)?) => { + { + let mut helpers = std::collections::HashMap::new(); + $( + helpers.insert( + $helper_name.to_string(), + Box::new($helper)as Box + ); + )* + helpers + } + }; +} + +mod error { + #![allow(missing_docs)] + use snafu::Snafu; + + /// Error type used in helper definitions. + #[derive(Debug, Snafu)] + #[snafu(visibility(pub))] + pub enum HelperError { + #[snafu(display( + "Helper called with incorrect arity: expected {} args, but {} provided", + expected_args, + provided_args + ))] + Arity { + expected_args: usize, + provided_args: usize, + }, + + #[snafu(display("Failed to execute helper: {}", source))] + HelperExecute { + source: Box, + }, + + #[snafu(display("Failed to parse incoming value from JSON: {}", source))] + JSONParse { source: serde_json::Error }, + + #[snafu(display("Failed to parse outgoing value to JSON: {}", source))] + JSONSerialize { source: serde_json::Error }, + } +} +pub use error::HelperError; diff --git a/bottlerocket-settings-sdk/src/lib.rs b/bottlerocket-settings-sdk/src/lib.rs index af049d85..d21e482e 100644 --- a/bottlerocket-settings-sdk/src/lib.rs +++ b/bottlerocket-settings-sdk/src/lib.rs @@ -22,12 +22,14 @@ tool wishes to invoke a settings extension and parse the output. pub mod cli; #[cfg(feature = "extension")] pub mod extension; +pub mod helper; #[cfg(feature = "extension")] pub mod migrate; pub mod model; #[cfg(feature = "extension")] pub use crate::extension::SettingsExtension; +pub use helper::{HelperDef, HelperError}; #[cfg(feature = "extension")] pub use migrate::{ LinearMigrator, LinearMigratorExtensionBuilder, LinearMigratorModel, LinearlyMigrateable, diff --git a/bottlerocket-template-helper/Cargo.toml b/bottlerocket-template-helper/Cargo.toml new file mode 100644 index 00000000..0aafe91f --- /dev/null +++ b/bottlerocket-template-helper/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bottlerocket-template-helper" +version = "0.1.0" +license = "Apache-2.0 OR MIT" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +path = "src/lib.rs" +proc-macro = true + +[dependencies] +darling = "0.20" +proc-macro2 = "1" +quote = "1" +serde_json = "1" +syn = { version = "1", default-features = false, features = ["full", "parsing", "printing", "proc-macro", "visit-mut"] } + + +[dev-dependencies] +anyhow = "1" +bottlerocket-settings-sdk = { path = "../bottlerocket-settings-sdk", version = "0.1" } diff --git a/bottlerocket-template-helper/src/lib.rs b/bottlerocket-template-helper/src/lib.rs new file mode 100644 index 00000000..e3fd30f9 --- /dev/null +++ b/bottlerocket-template-helper/src/lib.rs @@ -0,0 +1,104 @@ +//! This crate provides a procedural macro for defining template helpers in settings extensions. +//! See the documentation in [`bottlerocket-settings-sdk::helper`] for more information. +use darling::{ast::NestedMeta, FromMeta}; +use proc_macro::TokenStream; +use quote::quote; +use syn::{self, FnArg, ItemFn}; + +#[derive(FromMeta)] +struct MacroArgs { + ident: syn::Ident, + vis: Option, +} + +/// Defines a [`bottlerocket-settings-sdk::helper::HelperDef`] based on a given function. +/// +/// This macro requires: +/// * Your function arguments implement [`serde::Deserialize`] +/// * Your return value is a `Result` where `T` implements [`serde::Serialize`] +/// and `E` implements `Into>`. +/// +/// To define a `HelperDef` called `my_helper` based on a function, you could do something like: +/// +/// ``` +/// use bottlerocket_settings_sdk::helper::{HelperDef, template_helper}; +/// +/// #[template_helper(ident = my_helper)] +/// fn help_with(list_of_things: Vec) -> Result, anyhow::Error> { +/// Ok(list_of_things +/// .into_iter() +/// .map(|s| format!("Helped with '{s}'!")) +/// .collect()) +/// } +/// ``` +#[proc_macro_attribute] +pub fn template_helper(args: TokenStream, input: TokenStream) -> TokenStream { + let args: MacroArgs = + MacroArgs::from_list(&NestedMeta::parse_meta_list(args.into()).unwrap()).unwrap(); + + let helper_fn_name = args.ident; + + let fn_ast: ItemFn = syn::parse2(input.into()).unwrap(); + let fn_name = fn_ast.sig.ident.clone(); + + let num_args = fn_ast.sig.inputs.len(); + let arg_types: Vec> = fn_ast + .sig + .inputs + .iter() + .map(|arg| match arg { + FnArg::Receiver(_) => { + panic!("template_helper macro does not work on methods that take `self`") + } + FnArg::Typed(t) => t.ty.clone(), + }) + .collect(); + + let mut helper_fn: ItemFn = syn::parse2(quote! { + fn #helper_fn_name( + args: Vec, + ) -> std::result::Result< + serde_json::Value, + bottlerocket_settings_sdk::HelperError + > { + if args.len() != #num_args { + return Err(bottlerocket_settings_sdk::HelperError::Arity { + expected_args: #num_args, + provided_args: args.len(), + }); + } + + // Call the input function with our dynamically generated list of arguments. + // We know that `args` is the correct length because we checked above, so we can let + // the macro unwrap values that it takes. + let mut args = args.into_iter(); + #fn_name(#( + { + let arg: #arg_types = match serde_json::from_value(args.next().unwrap()) { + Ok(parsed) => parsed, + Err(e) => return Err(bottlerocket_settings_sdk::HelperError::JSONParse { source: e }) + }; + arg + } + ),*) + .map_err(|e| bottlerocket_settings_sdk::HelperError::HelperExecute { + source: e.into(), + }) + .and_then(|result| serde_json::to_value(result).map_err(|e| { + bottlerocket_settings_sdk::HelperError::JSONSerialize { source: e } + })) + } + }) + .unwrap(); + + if let Some(visibility) = args.vis { + helper_fn.vis = syn::parse_str(&visibility).unwrap(); + } + + quote! { + #fn_ast + + #helper_fn + } + .into() +} diff --git a/bottlerocket-template-helper/tests/tests.rs b/bottlerocket-template-helper/tests/tests.rs new file mode 100644 index 00000000..c13dd0ec --- /dev/null +++ b/bottlerocket-template-helper/tests/tests.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use bottlerocket_settings_sdk::HelperDef; +use bottlerocket_template_helper::template_helper; +use serde_json::json; + +#[template_helper(ident = join_strings_helper)] +fn join_strings(lhs: String, rhs: String) -> Result { + Ok(lhs + &rhs) +} + +#[test] +fn call_join_strings() { + assert_eq!( + join_strings_helper + .helper_fn(vec![json!("hello "), json!("world!")]) + .unwrap(), + json!("hello world!"), + ); + + assert!(join_strings_helper(vec![json!("too"), json!("many"), json!("args")]).is_err()); + + assert!(join_strings_helper + .helper_fn(vec![json!("too"), json!("many"), json!("args")]) + .is_err()); + + assert!(join_strings_helper(vec![json!("too few args")]).is_err()); +} + +#[template_helper(ident = no_args_helper)] +fn no_args() -> Result { + Ok(String::new()) +} + +#[test] +fn call_no_args() { + assert_eq!(no_args_helper(vec![]).unwrap(), json!("")); + assert!(no_args_helper(vec![json!("sneaky arg")]).is_err()); +} From 80cd263d246afb44a812a95f9ac86864c71b2464 Mon Sep 17 00:00:00 2001 From: "Sean P. Kelly" Date: Mon, 23 Oct 2023 17:16:57 +0000 Subject: [PATCH 3/3] feat: enable extensions to expose template helpers This allows extensions to provide arbitrary code to be run during template rendering. --- bottlerocket-settings-sdk/src/cli/proto1.rs | 6 ++- .../src/extension/mod.rs | 3 ++ .../src/extension/proto1.rs | 24 +++++++-- bottlerocket-settings-sdk/src/lib.rs | 2 +- bottlerocket-settings-sdk/src/model/erased.rs | 33 +++++++++++- bottlerocket-settings-sdk/src/model/mod.rs | 41 +++++++++++++- .../tests}/migration_validation/mod.rs | 0 bottlerocket-settings-sdk/tests/motd/v1.rs | 48 ++++++++++++++++- bottlerocket-settings-sdk/tests/motd/v2.rs | 53 ++++++++++++++++++- .../tests/sample_extensions.rs | 39 ++++++++++++++ 10 files changed, 239 insertions(+), 10 deletions(-) rename {tests => bottlerocket-settings-sdk/tests}/migration_validation/mod.rs (100%) 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") + }) + } }