diff --git a/Cargo.toml b/Cargo.toml index e16a48cc..10d1e92b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,6 +21,7 @@ members = [ # These will eventually live in the kit workspaces that own the related software "bottlerocket-settings-models/settings-extensions/autoscaling", "bottlerocket-settings-models/settings-extensions/aws", + "bottlerocket-settings-models/settings-extensions/bootstrap-commands", "bottlerocket-settings-models/settings-extensions/bootstrap-containers", "bottlerocket-settings-models/settings-extensions/cloudformation", "bottlerocket-settings-models/settings-extensions/container-registry", @@ -63,6 +64,7 @@ bottlerocket-string-impls-for = { path = "./bottlerocket-settings-models/string- ## Settings Extensions settings-extension-autoscaling = { path = "./bottlerocket-settings-models/settings-extensions/autoscaling", version = "0.1" } settings-extension-aws = { path = "./bottlerocket-settings-models/settings-extensions/aws", version = "0.1" } +settings-extension-bootstrap-commands = { path = "./bottlerocket-settings-models/settings-extensions/bootstrap-commands", version = "0.1" } settings-extension-bootstrap-containers = { path = "./bottlerocket-settings-models/settings-extensions/bootstrap-containers", version = "0.1" } settings-extension-cloudformation = { path = "./bottlerocket-settings-models/settings-extensions/cloudformation", version = "0.1" } settings-extension-container-registry = { path = "./bottlerocket-settings-models/settings-extensions/container-registry", version = "0.1" } diff --git a/bottlerocket-settings-models/modeled-types/src/lib.rs b/bottlerocket-settings-models/modeled-types/src/lib.rs index 5e07daed..43b3d4d9 100644 --- a/bottlerocket-settings-models/modeled-types/src/lib.rs +++ b/bottlerocket-settings-models/modeled-types/src/lib.rs @@ -71,8 +71,8 @@ pub mod error { #[snafu(display("Invalid Kubernetes authentication mode '{}'", input))] InvalidAuthenticationMode { input: String }, - #[snafu(display("Invalid bootstrap container mode '{}'", input))] - InvalidBootstrapContainerMode { input: String }, + #[snafu(display("Invalid bootstrap mode '{}'", input))] + InvalidBootstrapMode { input: String }, #[snafu(display("Given invalid cluster name '{}': {}", name, msg))] InvalidClusterName { name: String, msg: String }, @@ -86,6 +86,9 @@ pub mod error { #[snafu(display("Invalid Linux lockdown mode '{}'", input))] InvalidLockdown { input: String }, + #[snafu(display("Invalid Bottlerocket API Command '{:?}'", input))] + InvalidCommand { input: Vec }, + #[snafu(display("Invalid sysctl key '{}': {}", input, msg))] InvalidSysctlKey { input: String, msg: String }, diff --git a/bottlerocket-settings-models/modeled-types/src/shared.rs b/bottlerocket-settings-models/modeled-types/src/shared.rs index 44ca998d..85bbc14b 100644 --- a/bottlerocket-settings-models/modeled-types/src/shared.rs +++ b/bottlerocket-settings-models/modeled-types/src/shared.rs @@ -385,7 +385,7 @@ mod test_etc_hosts_entries { /// character in user-facing identifiers. It stores the original form and makes it accessible /// through standard traits. Its purpose is to validate input for identifiers like container names /// that might be used to create files/directories. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)] pub struct Identifier { inner: String, } @@ -945,50 +945,125 @@ string_impls_for!(Lockdown, "Lockdown"); // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +/// ApiclientCommand represents a valid Bootstrap Command. It stores the command as a vector of +/// strings and ensures that the first argument is apiclient. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize)] +pub struct ApiclientCommand(Vec); + +impl ApiclientCommand { + pub fn get_command_and_args(&self) -> (&str, &[String]) { + self.0 + .split_first() + .map(|(command, rest)| (command.as_str(), rest)) + .unwrap_or_default() + } +} + +// Custom deserializer added to enforce rules to make sure the command is valid. +impl<'de> serde::Deserialize<'de> for ApiclientCommand { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + let original: Vec = serde::Deserialize::deserialize(deserializer)?; + Self::try_from(original).map_err(|e| { + ::custom(format!( + "Unable to deserialize into ApiclientCommand: {}", + e + )) + }) + } +} + +impl TryFrom> for ApiclientCommand { + type Error = error::Error; + + fn try_from(input: Vec) -> std::result::Result { + let first_word = input.first().map(String::as_str); + ensure!( + matches!(first_word, Some("apiclient")), + error::InvalidCommandSnafu { input }, + ); + + Ok(ApiclientCommand(input)) + } +} + +#[cfg(test)] +mod test_valid_apiclient_command { + use super::ApiclientCommand; + use std::convert::TryFrom; + + #[test] + fn valid_apiclient_command() { + assert!(ApiclientCommand::try_from(vec![ + "apiclient".to_string(), + "set".to_string(), + "motd=helloworld".to_string() + ]) + .is_ok()); + } + + #[test] + fn empty_apiclient_command() { + assert!(ApiclientCommand::try_from(Vec::new()).is_err()); + } + + #[test] + fn invalid_apiclient_command() { + assert!(ApiclientCommand::try_from(vec![ + "/usr/bin/touch".to_string(), + "helloworld".to_string() + ]) + .is_err()); + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= #[derive(Debug, Clone, Eq, PartialEq, Hash)] -pub struct BootstrapContainerMode { +pub struct BootstrapMode { inner: String, } -impl TryFrom<&str> for BootstrapContainerMode { +impl TryFrom<&str> for BootstrapMode { type Error = error::Error; fn try_from(input: &str) -> Result { ensure!( matches!(input, "off" | "once" | "always"), - error::InvalidBootstrapContainerModeSnafu { input } + error::InvalidBootstrapModeSnafu { input } ); - Ok(BootstrapContainerMode { + Ok(BootstrapMode { inner: input.to_string(), }) } } -impl Default for BootstrapContainerMode { +impl Default for BootstrapMode { fn default() -> Self { - BootstrapContainerMode { + BootstrapMode { inner: "off".to_string(), } } } -string_impls_for!(BootstrapContainerMode, "BootstrapContainerMode"); +string_impls_for!(BootstrapMode, "BootstrapMode"); #[cfg(test)] mod test_valid_container_mode { - use super::BootstrapContainerMode; + use super::BootstrapMode; use std::convert::TryFrom; #[test] fn valid_container_mode() { for ok in &["off", "once", "always"] { - assert!(BootstrapContainerMode::try_from(*ok).is_ok()); + assert!(BootstrapMode::try_from(*ok).is_ok()); } } #[test] fn invalid_container_mode() { - assert!(BootstrapContainerMode::try_from("invalid").is_err()); + assert!(BootstrapMode::try_from("invalid").is_err()); } } diff --git a/bottlerocket-settings-models/settings-extensions/bootstrap-commands/Cargo.toml b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/Cargo.toml new file mode 100644 index 00000000..db76230b --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "settings-extension-bootstrap-commands" +version = "0.1.0" +authors = ["Piyush Jena "] +license = "Apache-2.0 OR MIT" +edition = "2021" +publish = false + +[dependencies] +bottlerocket-modeled-types.workspace = true +bottlerocket-model-derive.workspace = true +bottlerocket-settings-sdk.workspace = true +env_logger.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +snafu.workspace = true + +[lints] +workspace = true diff --git a/bottlerocket-settings-models/settings-extensions/bootstrap-commands/bootstrap-commands.toml b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/bootstrap-commands.toml new file mode 100644 index 00000000..727dfb27 --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/bootstrap-commands.toml @@ -0,0 +1,13 @@ +[extension] +supported-versions = [ + "v1" +] +default-version = "v1" + +[v1] +[v1.validation.cross-validates] + +[v1.templating] +helpers = [] + +[v1.generation.requires] diff --git a/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/lib.rs b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/lib.rs new file mode 100644 index 00000000..c828e2e2 --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/lib.rs @@ -0,0 +1,148 @@ +//! Settings related to bootstrap commands. +use bottlerocket_model_derive::model; +use bottlerocket_modeled_types::{ApiclientCommand, BootstrapMode, Identifier}; +use bottlerocket_settings_sdk::{GenerateResult, SettingsModel}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::{collections::BTreeMap, convert::Infallible}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BootstrapCommandsSettingsV1 { + pub bootstrap_commands: BTreeMap, +} + +// Custom serializer/deserializer added to maintain backwards +// compatibility with models created prior to settings extensions. +impl Serialize for BootstrapCommandsSettingsV1 { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: Serializer, + { + self.bootstrap_commands.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for BootstrapCommandsSettingsV1 { + fn deserialize(deserializer: D) -> std::result::Result + where + D: Deserializer<'de>, + { + let bootstrap_commands = BTreeMap::deserialize(deserializer)?; + Ok(Self { bootstrap_commands }) + } +} + +#[model(impl_default = true)] +struct BootstrapCommand { + commands: Vec, + mode: BootstrapMode, + essential: bool, +} + +impl SettingsModel for BootstrapCommandsSettingsV1 { + type PartialKind = Self; + type ErrorKind = Infallible; + + fn get_version() -> &'static str { + "v1" + } + + fn set(_current_value: Option, _target: Self) -> Result<()> { + // Set anything that parses as BootstrapCommandsSettingsV1. + Ok(()) + } + + fn generate( + existing_partial: Option, + _dependent_settings: Option, + ) -> Result> { + Ok(GenerateResult::Complete( + existing_partial.unwrap_or_default(), + )) + } + + fn validate(_value: Self, _validated_settings: Option) -> Result<()> { + // Validate anything that parses as BootstrapCommandsSettingsV1. + Ok(()) + } +} + +#[cfg(test)] +mod test_bootstrap_command { + use super::*; + use serde_json::json; + + #[test] + fn test_generate_bootstrap_command_settings() { + let generated = BootstrapCommandsSettingsV1::generate(None, None).unwrap(); + + assert_eq!( + generated, + GenerateResult::Complete(BootstrapCommandsSettingsV1 { + bootstrap_commands: BTreeMap::new(), + }) + ) + } + + #[test] + fn test_serde_bootstrap_command() { + let test_json = json!({ + "mybootstrap": { + "commands": [ ["apiclient", "motd=hello"], ], + "mode": "once", + "essential": true, + } + }); + + let bootstrap_commands: BootstrapCommandsSettingsV1 = + serde_json::from_value(test_json.clone()).unwrap(); + + let mut expected_bootstrap_commands: BTreeMap = + BTreeMap::new(); + expected_bootstrap_commands.insert( + Identifier::try_from("mybootstrap").unwrap(), + BootstrapCommand { + commands: Some(vec![ApiclientCommand::try_from(vec![ + "apiclient".to_string(), + "motd=hello".to_string(), + ]) + .unwrap()]), + mode: Some(BootstrapMode::try_from("once").unwrap()), + essential: Some(true), + }, + ); + + assert_eq!( + bootstrap_commands, + BootstrapCommandsSettingsV1 { + bootstrap_commands: expected_bootstrap_commands + } + ); + + let serialized_json: serde_json::Value = serde_json::to_string(&bootstrap_commands) + .map(|s| serde_json::from_str(&s).unwrap()) + .unwrap(); + + assert_eq!(serialized_json, test_json); + } + + #[test] + fn test_serde_invalid_bootstrap_command() { + let test_err_json = json!({ + "mybootstrap1": { + "commands": [ ["/usr/bin/touch", "helloworld"], ], + "mode": "once", + "essential": true, + } + }); + + let bootstrap_commands_err: std::result::Result< + BootstrapCommandsSettingsV1, + serde_json::Error, + > = serde_json::from_value(test_err_json.clone()); + + // This has invalid command. It should fail. + assert!(bootstrap_commands_err.is_err()); + } +} + +type Result = std::result::Result; diff --git a/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/main.rs b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/main.rs new file mode 100644 index 00000000..b75fc741 --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/main.rs @@ -0,0 +1,20 @@ +use bottlerocket_settings_sdk::{BottlerocketSetting, NullMigratorExtensionBuilder}; +use settings_extension_bootstrap_commands::BootstrapCommandsSettingsV1; +use std::process::ExitCode; + +fn main() -> ExitCode { + env_logger::init(); + + match NullMigratorExtensionBuilder::with_name("bootstrap-commands") + .with_models(vec![ + BottlerocketSetting::::model(), + ]) + .build() + { + Ok(extension) => extension.run(), + Err(e) => { + println!("{}", e); + ExitCode::FAILURE + } + } +} diff --git a/bottlerocket-settings-models/settings-extensions/bootstrap-containers/src/lib.rs b/bottlerocket-settings-models/settings-extensions/bootstrap-containers/src/lib.rs index ee1124ed..15e721c0 100644 --- a/bottlerocket-settings-models/settings-extensions/bootstrap-containers/src/lib.rs +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-containers/src/lib.rs @@ -1,6 +1,6 @@ //! Settings related to bootstrap containers. use bottlerocket_model_derive::model; -use bottlerocket_modeled_types::{BootstrapContainerMode, Identifier, Url, ValidBase64}; +use bottlerocket_modeled_types::{BootstrapMode, Identifier, Url, ValidBase64}; use bottlerocket_settings_sdk::{GenerateResult, SettingsModel}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use std::{collections::HashMap, convert::Infallible}; @@ -36,7 +36,7 @@ impl<'de> Deserialize<'de> for BootstrapContainersSettingsV1 { #[model(impl_default = true)] struct BootstrapContainer { source: Url, - mode: BootstrapContainerMode, + mode: BootstrapMode, user_data: ValidBase64, essential: bool, } @@ -115,7 +115,7 @@ mod test { ) .unwrap(), ), - mode: Some(BootstrapContainerMode::try_from("once").unwrap()), + mode: Some(BootstrapMode::try_from("once").unwrap()), user_data: Some(ValidBase64::try_from("dXNlcmRhdGE=").unwrap()), essential: Some(true), }, diff --git a/bottlerocket-settings-models/settings-models/Cargo.toml b/bottlerocket-settings-models/settings-models/Cargo.toml index 7a48d25b..d1d6eedd 100644 --- a/bottlerocket-settings-models/settings-models/Cargo.toml +++ b/bottlerocket-settings-models/settings-models/Cargo.toml @@ -27,6 +27,7 @@ toml.workspace = true # settings extensions settings-extension-autoscaling.workspace = true settings-extension-aws.workspace = true +settings-extension-bootstrap-commands.workspace = true settings-extension-bootstrap-containers.workspace = true settings-extension-cloudformation.workspace = true settings-extension-container-registry.workspace = true diff --git a/bottlerocket-settings-models/settings-models/src/lib.rs b/bottlerocket-settings-models/settings-models/src/lib.rs index 8f7d65e9..8b8448bd 100644 --- a/bottlerocket-settings-models/settings-models/src/lib.rs +++ b/bottlerocket-settings-models/settings-models/src/lib.rs @@ -27,6 +27,7 @@ pub use bottlerocket_string_impls_for as string_impls_for; pub use crate::boot::BootSettingsV1; pub use settings_extension_autoscaling::{self, AutoScalingSettingsV1}; pub use settings_extension_aws::{self, AwsSettingsV1}; +pub use settings_extension_bootstrap_commands::{self, BootstrapCommandsSettingsV1}; pub use settings_extension_bootstrap_containers::{self, BootstrapContainersSettingsV1}; pub use settings_extension_cloudformation::{self, CloudFormationSettingsV1}; pub use settings_extension_container_registry::{self, RegistrySettingsV1};