diff --git a/Cargo.toml b/Cargo.toml index d3383fd..1d5f1cd 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", @@ -62,6 +63,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 5e07dae..937deb5 100644 --- a/bottlerocket-settings-models/modeled-types/src/lib.rs +++ b/bottlerocket-settings-models/modeled-types/src/lib.rs @@ -71,6 +71,12 @@ pub mod error { #[snafu(display("Invalid Kubernetes authentication mode '{}'", input))] InvalidAuthenticationMode { input: String }, + #[snafu(display("Invalid Bottlerocket API Command '{:?}'", input))] + InvalidApiCommand { input: Vec }, + + #[snafu(display("Invalid bootstrap command mode '{}'", input))] + InvalidBootstrapCommandMode { input: String }, + #[snafu(display("Invalid bootstrap container mode '{}'", input))] InvalidBootstrapContainerMode { input: String }, diff --git a/bottlerocket-settings-models/modeled-types/src/shared.rs b/bottlerocket-settings-models/modeled-types/src/shared.rs index cbd2a30..5321238 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,6 +945,107 @@ string_impls_for!(Lockdown, "Lockdown"); // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +/// ApiCommand represents a valid Bottlerocket API 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, Deserialize)] +pub struct ApiCommand(pub Vec); + +impl TryFrom> for ApiCommand { + type Error = error::Error; + + fn try_from(input: Vec) -> Result { + ensure!( + input.is_empty() || (input[0] == "apiclient"), + error::InvalidApiCommandSnafu { input } + ); + + Ok(ApiCommand(input)) + } +} + +#[cfg(test)] +mod test_valid_api_command { + use super::ApiCommand; + use std::convert::TryFrom; + + #[test] + fn valid_api_command() { + assert!( + ApiCommand::try_from(vec!["apiclient".to_string(), "motd=helloworld".to_string()]) + .is_ok() + ); + } + + #[test] + fn empty_api_command() { + assert!(ApiCommand::try_from(Vec::new()).is_ok()); + } + + #[test] + fn invalid_api_command() { + assert!(ApiCommand::try_from(vec![ + "invalid".to_string(), + "arg1".to_string(), + "arg2".to_string() + ]) + .is_err()); + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +/// BootstrapCommandMode determines when the specified set of commands should be executed during +/// the boot process of a system: 'always' runs them on every boot, 'once' runs them only on the +/// first boot, and 'off' disables their execution. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct BootstrapCommandMode { + inner: String, +} + +impl TryFrom<&str> for BootstrapCommandMode { + type Error = error::Error; + + fn try_from(input: &str) -> Result { + ensure!( + matches!(input, "off" | "once" | "always"), + error::InvalidBootstrapCommandModeSnafu { input } + ); + Ok(BootstrapCommandMode { + inner: input.to_string(), + }) + } +} + +impl Default for BootstrapCommandMode { + fn default() -> Self { + BootstrapCommandMode { + inner: "off".to_string(), + } + } +} + +string_impls_for!(BootstrapCommandMode, "BootstrapCommandMode"); + +#[cfg(test)] +mod test_valid_bootstrap_command_mode { + use super::BootstrapCommandMode; + use std::convert::TryFrom; + + #[test] + fn valid_bootstrap_command_mode() { + for ok in &["off", "once", "always"] { + assert!(BootstrapCommandMode::try_from(*ok).is_ok()); + } + } + + #[test] + fn invalid_bootstrap_command_mode() { + assert!(BootstrapCommandMode::try_from("invalid").is_err()); + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct BootstrapContainerMode { inner: String, 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 0000000..86e5db4 --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/Cargo.toml @@ -0,0 +1,15 @@ +[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 = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" 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 0000000..727dfb2 --- /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 0000000..ad9a0a5 --- /dev/null +++ b/bottlerocket-settings-models/settings-extensions/bootstrap-commands/src/lib.rs @@ -0,0 +1,130 @@ +//! Settings related to bootstrap commands. +use bottlerocket_model_derive::model; +use bottlerocket_modeled_types::{ApiCommand, BootstrapCommandMode, Identifier}; +use bottlerocket_settings_sdk::{GenerateResult, SettingsModel}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::{collections::HashMap, convert::Infallible}; + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BootstrapCommandsSettingsV1 { + pub bootstrap_commands: HashMap, +} + +// 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 = HashMap::deserialize(deserializer)?; + Ok(Self { bootstrap_commands }) + } +} + +#[model(impl_default = true)] +struct BootstrapCommand { + commands: Vec, + mode: BootstrapCommandMode, + essential: bool, +} + +type Result = std::result::Result; + +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 { + 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: HashMap::new(), + }) + ) + } + + #[test] + fn test_serde_bootstrap_command() { + let test_json = json!({ + "mybootstrap": { + "commands": [ ["apiclient", "motd=hello"], ], + "mode": "once", + "essential": true, + } + }); + + let test_json_str = test_json.to_string(); + + let bootstrap_commands: BootstrapCommandsSettingsV1 = + serde_json::from_str(&test_json_str).unwrap(); + + let mut expected_bootstrap_commands: HashMap = HashMap::new(); + expected_bootstrap_commands.insert( + Identifier::try_from("mybootstrap").unwrap(), + BootstrapCommand { + commands: Some(vec![ApiCommand::try_from(vec![ + "apiclient".to_string(), + "motd=hello".to_string(), + ]) + .unwrap()]), + mode: Some(BootstrapCommandMode::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); + } +} 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 0000000..b75fc74 --- /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-models/Cargo.toml b/bottlerocket-settings-models/settings-models/Cargo.toml index 0d5e95e..e4f9893 100644 --- a/bottlerocket-settings-models/settings-models/Cargo.toml +++ b/bottlerocket-settings-models/settings-models/Cargo.toml @@ -27,6 +27,7 @@ toml = "0.8" # 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 d64f588..fbc7424 100644 --- a/bottlerocket-settings-models/settings-models/src/lib.rs +++ b/bottlerocket-settings-models/settings-models/src/lib.rs @@ -29,6 +29,7 @@ pub use crate::boot::BootSettingsV1; pub use crate::kubernetes::KubernetesSettingsV1; pub use settings_extension_autoscaling::AutoScalingSettingsV1; pub use settings_extension_aws::AwsSettingsV1; +pub use settings_extension_bootstrap_commands::BootstrapCommandsSettingsV1; pub use settings_extension_bootstrap_containers::BootstrapContainersSettingsV1; pub use settings_extension_cloudformation::CloudFormationSettingsV1; pub use settings_extension_container_registry::RegistrySettingsV1;