Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add settings models for bootstrap commands #46

Merged
merged 1 commit into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)]
piyush-jena marked this conversation as resolved.
Show resolved Hide resolved
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")),
piyush-jena marked this conversation as resolved.
Show resolved Hide resolved
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()
piyush-jena marked this conversation as resolved.
Show resolved Hide resolved
])
.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 {
piyush-jena marked this conversation as resolved.
Show resolved Hide resolved
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>,
piyush-jena marked this conversation as resolved.
Show resolved Hide resolved
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