Skip to content

Commit

Permalink
feat: add settings models for bootstrap commands
Browse files Browse the repository at this point in the history
  • Loading branch information
piyush-jena committed Aug 28, 2024
1 parent cebbd4c commit 1493a8a
Show file tree
Hide file tree
Showing 10 changed files with 298 additions and 16 deletions.
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
7 changes: 5 additions & 2 deletions bottlerocket-settings-models/modeled-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -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<String> },

#[snafu(display("Invalid sysctl key '{}': {}", input, msg))]
InvalidSysctlKey { input: String, msg: String },

Expand Down
97 changes: 86 additions & 11 deletions bottlerocket-settings-models/modeled-types/src/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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<String>);

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<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let original: Vec<String> = serde::Deserialize::deserialize(deserializer)?;
Self::try_from(original).map_err(|e| {
<D::Error as serde::de::Error>::custom(format!(
"Unable to deserialize into ApiclientCommand: {}",
e
))
})
}
}

impl TryFrom<Vec<String>> for ApiclientCommand {
type Error = error::Error;

fn try_from(input: Vec<String>) -> std::result::Result<Self, error::Error> {
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<Self, error::Error> {
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());
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
[package]
name = "settings-extension-bootstrap-commands"
version = "0.1.0"
authors = ["Piyush Jena <[email protected]>"]
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[extension]
supported-versions = [
"v1"
]
default-version = "v1"

[v1]
[v1.validation.cross-validates]

[v1.templating]
helpers = []

[v1.generation.requires]
Original file line number Diff line number Diff line change
@@ -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<Identifier, BootstrapCommand>,
}

// Custom serializer/deserializer added to maintain backwards
// compatibility with models created prior to settings extensions.
impl Serialize for BootstrapCommandsSettingsV1 {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
self.bootstrap_commands.serialize(serializer)
}
}

impl<'de> Deserialize<'de> for BootstrapCommandsSettingsV1 {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let bootstrap_commands = BTreeMap::deserialize(deserializer)?;
Ok(Self { bootstrap_commands })
}
}

#[model(impl_default = true)]
struct BootstrapCommand {
commands: Vec<ApiclientCommand>,
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<Self>, _target: Self) -> Result<()> {
// Set anything that parses as BootstrapCommandsSettingsV1.
Ok(())
}

fn generate(
existing_partial: Option<Self::PartialKind>,
_dependent_settings: Option<serde_json::Value>,
) -> Result<GenerateResult<Self::PartialKind, Self>> {
Ok(GenerateResult::Complete(
existing_partial.unwrap_or_default(),
))
}

fn validate(_value: Self, _validated_settings: Option<serde_json::Value>) -> 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<Identifier, BootstrapCommand> =
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<T> = std::result::Result<T, Infallible>;
Original file line number Diff line number Diff line change
@@ -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::<BootstrapCommandsSettingsV1>::model(),
])
.build()
{
Ok(extension) => extension.run(),
Err(e) => {
println!("{}", e);
ExitCode::FAILURE
}
}
}
Loading

0 comments on commit 1493a8a

Please sign in to comment.