Skip to content

Commit d1eee00

Browse files
authored
Merge pull request #46 from piyush-jena/develop
feat: add settings models for bootstrap commands
2 parents ae1466d + 1493a8a commit d1eee00

File tree

10 files changed

+298
-16
lines changed

10 files changed

+298
-16
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ members = [
2121
# These will eventually live in the kit workspaces that own the related software
2222
"bottlerocket-settings-models/settings-extensions/autoscaling",
2323
"bottlerocket-settings-models/settings-extensions/aws",
24+
"bottlerocket-settings-models/settings-extensions/bootstrap-commands",
2425
"bottlerocket-settings-models/settings-extensions/bootstrap-containers",
2526
"bottlerocket-settings-models/settings-extensions/cloudformation",
2627
"bottlerocket-settings-models/settings-extensions/container-registry",
@@ -63,6 +64,7 @@ bottlerocket-string-impls-for = { path = "./bottlerocket-settings-models/string-
6364
## Settings Extensions
6465
settings-extension-autoscaling = { path = "./bottlerocket-settings-models/settings-extensions/autoscaling", version = "0.1" }
6566
settings-extension-aws = { path = "./bottlerocket-settings-models/settings-extensions/aws", version = "0.1" }
67+
settings-extension-bootstrap-commands = { path = "./bottlerocket-settings-models/settings-extensions/bootstrap-commands", version = "0.1" }
6668
settings-extension-bootstrap-containers = { path = "./bottlerocket-settings-models/settings-extensions/bootstrap-containers", version = "0.1" }
6769
settings-extension-cloudformation = { path = "./bottlerocket-settings-models/settings-extensions/cloudformation", version = "0.1" }
6870
settings-extension-container-registry = { path = "./bottlerocket-settings-models/settings-extensions/container-registry", version = "0.1" }

bottlerocket-settings-models/modeled-types/src/lib.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ pub mod error {
7171
#[snafu(display("Invalid Kubernetes authentication mode '{}'", input))]
7272
InvalidAuthenticationMode { input: String },
7373

74-
#[snafu(display("Invalid bootstrap container mode '{}'", input))]
75-
InvalidBootstrapContainerMode { input: String },
74+
#[snafu(display("Invalid bootstrap mode '{}'", input))]
75+
InvalidBootstrapMode { input: String },
7676

7777
#[snafu(display("Given invalid cluster name '{}': {}", name, msg))]
7878
InvalidClusterName { name: String, msg: String },
@@ -86,6 +86,9 @@ pub mod error {
8686
#[snafu(display("Invalid Linux lockdown mode '{}'", input))]
8787
InvalidLockdown { input: String },
8888

89+
#[snafu(display("Invalid Bottlerocket API Command '{:?}'", input))]
90+
InvalidCommand { input: Vec<String> },
91+
8992
#[snafu(display("Invalid sysctl key '{}': {}", input, msg))]
9093
InvalidSysctlKey { input: String, msg: String },
9194

bottlerocket-settings-models/modeled-types/src/shared.rs

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,7 @@ mod test_etc_hosts_entries {
385385
/// character in user-facing identifiers. It stores the original form and makes it accessible
386386
/// through standard traits. Its purpose is to validate input for identifiers like container names
387387
/// that might be used to create files/directories.
388-
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
388+
#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)]
389389
pub struct Identifier {
390390
inner: String,
391391
}
@@ -945,50 +945,125 @@ string_impls_for!(Lockdown, "Lockdown");
945945

946946
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
947947

948+
/// ApiclientCommand represents a valid Bootstrap Command. It stores the command as a vector of
949+
/// strings and ensures that the first argument is apiclient.
950+
#[derive(Debug, Clone, Eq, PartialEq, Hash, Default, Serialize)]
951+
pub struct ApiclientCommand(Vec<String>);
952+
953+
impl ApiclientCommand {
954+
pub fn get_command_and_args(&self) -> (&str, &[String]) {
955+
self.0
956+
.split_first()
957+
.map(|(command, rest)| (command.as_str(), rest))
958+
.unwrap_or_default()
959+
}
960+
}
961+
962+
// Custom deserializer added to enforce rules to make sure the command is valid.
963+
impl<'de> serde::Deserialize<'de> for ApiclientCommand {
964+
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
965+
where
966+
D: serde::Deserializer<'de>,
967+
{
968+
let original: Vec<String> = serde::Deserialize::deserialize(deserializer)?;
969+
Self::try_from(original).map_err(|e| {
970+
<D::Error as serde::de::Error>::custom(format!(
971+
"Unable to deserialize into ApiclientCommand: {}",
972+
e
973+
))
974+
})
975+
}
976+
}
977+
978+
impl TryFrom<Vec<String>> for ApiclientCommand {
979+
type Error = error::Error;
980+
981+
fn try_from(input: Vec<String>) -> std::result::Result<Self, error::Error> {
982+
let first_word = input.first().map(String::as_str);
983+
ensure!(
984+
matches!(first_word, Some("apiclient")),
985+
error::InvalidCommandSnafu { input },
986+
);
987+
988+
Ok(ApiclientCommand(input))
989+
}
990+
}
991+
992+
#[cfg(test)]
993+
mod test_valid_apiclient_command {
994+
use super::ApiclientCommand;
995+
use std::convert::TryFrom;
996+
997+
#[test]
998+
fn valid_apiclient_command() {
999+
assert!(ApiclientCommand::try_from(vec![
1000+
"apiclient".to_string(),
1001+
"set".to_string(),
1002+
"motd=helloworld".to_string()
1003+
])
1004+
.is_ok());
1005+
}
1006+
1007+
#[test]
1008+
fn empty_apiclient_command() {
1009+
assert!(ApiclientCommand::try_from(Vec::new()).is_err());
1010+
}
1011+
1012+
#[test]
1013+
fn invalid_apiclient_command() {
1014+
assert!(ApiclientCommand::try_from(vec![
1015+
"/usr/bin/touch".to_string(),
1016+
"helloworld".to_string()
1017+
])
1018+
.is_err());
1019+
}
1020+
}
1021+
1022+
// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^=
9481023
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
949-
pub struct BootstrapContainerMode {
1024+
pub struct BootstrapMode {
9501025
inner: String,
9511026
}
9521027

953-
impl TryFrom<&str> for BootstrapContainerMode {
1028+
impl TryFrom<&str> for BootstrapMode {
9541029
type Error = error::Error;
9551030

9561031
fn try_from(input: &str) -> Result<Self, error::Error> {
9571032
ensure!(
9581033
matches!(input, "off" | "once" | "always"),
959-
error::InvalidBootstrapContainerModeSnafu { input }
1034+
error::InvalidBootstrapModeSnafu { input }
9601035
);
961-
Ok(BootstrapContainerMode {
1036+
Ok(BootstrapMode {
9621037
inner: input.to_string(),
9631038
})
9641039
}
9651040
}
9661041

967-
impl Default for BootstrapContainerMode {
1042+
impl Default for BootstrapMode {
9681043
fn default() -> Self {
969-
BootstrapContainerMode {
1044+
BootstrapMode {
9701045
inner: "off".to_string(),
9711046
}
9721047
}
9731048
}
9741049

975-
string_impls_for!(BootstrapContainerMode, "BootstrapContainerMode");
1050+
string_impls_for!(BootstrapMode, "BootstrapMode");
9761051

9771052
#[cfg(test)]
9781053
mod test_valid_container_mode {
979-
use super::BootstrapContainerMode;
1054+
use super::BootstrapMode;
9801055
use std::convert::TryFrom;
9811056

9821057
#[test]
9831058
fn valid_container_mode() {
9841059
for ok in &["off", "once", "always"] {
985-
assert!(BootstrapContainerMode::try_from(*ok).is_ok());
1060+
assert!(BootstrapMode::try_from(*ok).is_ok());
9861061
}
9871062
}
9881063

9891064
#[test]
9901065
fn invalid_container_mode() {
991-
assert!(BootstrapContainerMode::try_from("invalid").is_err());
1066+
assert!(BootstrapMode::try_from("invalid").is_err());
9921067
}
9931068
}
9941069

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "settings-extension-bootstrap-commands"
3+
version = "0.1.0"
4+
authors = ["Piyush Jena <[email protected]>"]
5+
license = "Apache-2.0 OR MIT"
6+
edition = "2021"
7+
publish = false
8+
9+
[dependencies]
10+
bottlerocket-modeled-types.workspace = true
11+
bottlerocket-model-derive.workspace = true
12+
bottlerocket-settings-sdk.workspace = true
13+
env_logger.workspace = true
14+
serde = { workspace = true, features = ["derive"] }
15+
serde_json.workspace = true
16+
snafu.workspace = true
17+
18+
[lints]
19+
workspace = true
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
[extension]
2+
supported-versions = [
3+
"v1"
4+
]
5+
default-version = "v1"
6+
7+
[v1]
8+
[v1.validation.cross-validates]
9+
10+
[v1.templating]
11+
helpers = []
12+
13+
[v1.generation.requires]
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//! Settings related to bootstrap commands.
2+
use bottlerocket_model_derive::model;
3+
use bottlerocket_modeled_types::{ApiclientCommand, BootstrapMode, Identifier};
4+
use bottlerocket_settings_sdk::{GenerateResult, SettingsModel};
5+
use serde::{Deserialize, Deserializer, Serialize, Serializer};
6+
use std::{collections::BTreeMap, convert::Infallible};
7+
8+
#[derive(Clone, Debug, Default, PartialEq)]
9+
pub struct BootstrapCommandsSettingsV1 {
10+
pub bootstrap_commands: BTreeMap<Identifier, BootstrapCommand>,
11+
}
12+
13+
// Custom serializer/deserializer added to maintain backwards
14+
// compatibility with models created prior to settings extensions.
15+
impl Serialize for BootstrapCommandsSettingsV1 {
16+
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
17+
where
18+
S: Serializer,
19+
{
20+
self.bootstrap_commands.serialize(serializer)
21+
}
22+
}
23+
24+
impl<'de> Deserialize<'de> for BootstrapCommandsSettingsV1 {
25+
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
26+
where
27+
D: Deserializer<'de>,
28+
{
29+
let bootstrap_commands = BTreeMap::deserialize(deserializer)?;
30+
Ok(Self { bootstrap_commands })
31+
}
32+
}
33+
34+
#[model(impl_default = true)]
35+
struct BootstrapCommand {
36+
commands: Vec<ApiclientCommand>,
37+
mode: BootstrapMode,
38+
essential: bool,
39+
}
40+
41+
impl SettingsModel for BootstrapCommandsSettingsV1 {
42+
type PartialKind = Self;
43+
type ErrorKind = Infallible;
44+
45+
fn get_version() -> &'static str {
46+
"v1"
47+
}
48+
49+
fn set(_current_value: Option<Self>, _target: Self) -> Result<()> {
50+
// Set anything that parses as BootstrapCommandsSettingsV1.
51+
Ok(())
52+
}
53+
54+
fn generate(
55+
existing_partial: Option<Self::PartialKind>,
56+
_dependent_settings: Option<serde_json::Value>,
57+
) -> Result<GenerateResult<Self::PartialKind, Self>> {
58+
Ok(GenerateResult::Complete(
59+
existing_partial.unwrap_or_default(),
60+
))
61+
}
62+
63+
fn validate(_value: Self, _validated_settings: Option<serde_json::Value>) -> Result<()> {
64+
// Validate anything that parses as BootstrapCommandsSettingsV1.
65+
Ok(())
66+
}
67+
}
68+
69+
#[cfg(test)]
70+
mod test_bootstrap_command {
71+
use super::*;
72+
use serde_json::json;
73+
74+
#[test]
75+
fn test_generate_bootstrap_command_settings() {
76+
let generated = BootstrapCommandsSettingsV1::generate(None, None).unwrap();
77+
78+
assert_eq!(
79+
generated,
80+
GenerateResult::Complete(BootstrapCommandsSettingsV1 {
81+
bootstrap_commands: BTreeMap::new(),
82+
})
83+
)
84+
}
85+
86+
#[test]
87+
fn test_serde_bootstrap_command() {
88+
let test_json = json!({
89+
"mybootstrap": {
90+
"commands": [ ["apiclient", "motd=hello"], ],
91+
"mode": "once",
92+
"essential": true,
93+
}
94+
});
95+
96+
let bootstrap_commands: BootstrapCommandsSettingsV1 =
97+
serde_json::from_value(test_json.clone()).unwrap();
98+
99+
let mut expected_bootstrap_commands: BTreeMap<Identifier, BootstrapCommand> =
100+
BTreeMap::new();
101+
expected_bootstrap_commands.insert(
102+
Identifier::try_from("mybootstrap").unwrap(),
103+
BootstrapCommand {
104+
commands: Some(vec![ApiclientCommand::try_from(vec![
105+
"apiclient".to_string(),
106+
"motd=hello".to_string(),
107+
])
108+
.unwrap()]),
109+
mode: Some(BootstrapMode::try_from("once").unwrap()),
110+
essential: Some(true),
111+
},
112+
);
113+
114+
assert_eq!(
115+
bootstrap_commands,
116+
BootstrapCommandsSettingsV1 {
117+
bootstrap_commands: expected_bootstrap_commands
118+
}
119+
);
120+
121+
let serialized_json: serde_json::Value = serde_json::to_string(&bootstrap_commands)
122+
.map(|s| serde_json::from_str(&s).unwrap())
123+
.unwrap();
124+
125+
assert_eq!(serialized_json, test_json);
126+
}
127+
128+
#[test]
129+
fn test_serde_invalid_bootstrap_command() {
130+
let test_err_json = json!({
131+
"mybootstrap1": {
132+
"commands": [ ["/usr/bin/touch", "helloworld"], ],
133+
"mode": "once",
134+
"essential": true,
135+
}
136+
});
137+
138+
let bootstrap_commands_err: std::result::Result<
139+
BootstrapCommandsSettingsV1,
140+
serde_json::Error,
141+
> = serde_json::from_value(test_err_json.clone());
142+
143+
// This has invalid command. It should fail.
144+
assert!(bootstrap_commands_err.is_err());
145+
}
146+
}
147+
148+
type Result<T> = std::result::Result<T, Infallible>;
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
use bottlerocket_settings_sdk::{BottlerocketSetting, NullMigratorExtensionBuilder};
2+
use settings_extension_bootstrap_commands::BootstrapCommandsSettingsV1;
3+
use std::process::ExitCode;
4+
5+
fn main() -> ExitCode {
6+
env_logger::init();
7+
8+
match NullMigratorExtensionBuilder::with_name("bootstrap-commands")
9+
.with_models(vec![
10+
BottlerocketSetting::<BootstrapCommandsSettingsV1>::model(),
11+
])
12+
.build()
13+
{
14+
Ok(extension) => extension.run(),
15+
Err(e) => {
16+
println!("{}", e);
17+
ExitCode::FAILURE
18+
}
19+
}
20+
}

0 commit comments

Comments
 (0)