From e139cf49aeb3a9608124349c4784d1684c3719e7 Mon Sep 17 00:00:00 2001 From: Piyush Jena Date: Wed, 4 Sep 2024 00:57:46 +0000 Subject: [PATCH] bootstrap-commands: implement bootstrap command execution --- packages/os/bootstrap-commands-tmpfiles.conf | 1 + packages/os/bootstrap-commands-toml | 18 + packages/os/bootstrap-commands.service | 17 + packages/os/os.spec | 23 +- packages/release/release.spec | 5 + .../systemd-logind-inhibit-maxdelay.conf | 4 + sources/Cargo.lock | 94 +++-- sources/Cargo.toml | 12 +- sources/api/apiserver/src/server/mod.rs | 6 +- sources/api/bootstrap-containers/src/main.rs | 12 +- sources/bootstrap-commands/Cargo.toml | 29 ++ sources/bootstrap-commands/README.md | 43 ++ sources/bootstrap-commands/README.tpl | 9 + sources/bootstrap-commands/build.rs | 3 + sources/bootstrap-commands/src/main.rs | 370 ++++++++++++++++++ 15 files changed, 599 insertions(+), 47 deletions(-) create mode 100644 packages/os/bootstrap-commands-tmpfiles.conf create mode 100644 packages/os/bootstrap-commands-toml create mode 100644 packages/os/bootstrap-commands.service create mode 100644 packages/release/systemd-logind-inhibit-maxdelay.conf create mode 100644 sources/bootstrap-commands/Cargo.toml create mode 100644 sources/bootstrap-commands/README.md create mode 100644 sources/bootstrap-commands/README.tpl create mode 100644 sources/bootstrap-commands/build.rs create mode 100644 sources/bootstrap-commands/src/main.rs diff --git a/packages/os/bootstrap-commands-tmpfiles.conf b/packages/os/bootstrap-commands-tmpfiles.conf new file mode 100644 index 000000000..86ab20fe9 --- /dev/null +++ b/packages/os/bootstrap-commands-tmpfiles.conf @@ -0,0 +1 @@ +d /etc/bootstrap-commands 0750 root root - diff --git a/packages/os/bootstrap-commands-toml b/packages/os/bootstrap-commands-toml new file mode 100644 index 000000000..bf8ecfa5c --- /dev/null +++ b/packages/os/bootstrap-commands-toml @@ -0,0 +1,18 @@ +[required-extensions] +bootstrap-commands= "v1" +std = { version = "v1", helpers = ["if_not_null", "toml_encode"]} ++++ +{{#if_not_null settings.bootstrap-commands}} +{{#each settings.bootstrap-commands}} +[bootstrap-commands."{{@key}}"] +{{#if_not_null this.commands}} +commands = {{ toml_encode this.commands }} +{{/if_not_null}} +{{#if_not_null this.mode}} +mode = "{{{this.mode}}}" +{{/if_not_null}} +{{#if_not_null this.essential}} +essential = {{this.essential}} +{{/if_not_null}} +{{/each}} +{{/if_not_null}} diff --git a/packages/os/bootstrap-commands.service b/packages/os/bootstrap-commands.service new file mode 100644 index 000000000..d0607b04b --- /dev/null +++ b/packages/os/bootstrap-commands.service @@ -0,0 +1,17 @@ +[Unit] +Description=Bootstrap Commands +# We depend on systemd-logind.service for running systemd-inhibit. +After=systemd-logind.service settings-applier.service apiserver.service +Requires=systemd-logind.service settings-applier.service apiserver.service +RefuseManualStart=true +RefuseManualStop=true + +[Service] +Type=oneshot +ExecStart=/usr/bin/systemd-inhibit --what=shutdown --why="Running bootstrap commands" --mode=delay /usr/bin/bootstrap-commands +RemainAfterExit=true +StandardError=journal+console +SyslogIdentifier=bootstrap-commands + +[Install] +RequiredBy=preconfigured.target diff --git a/packages/os/os.spec b/packages/os/os.spec index 3951761d4..71423e6b6 100644 --- a/packages/os/os.spec +++ b/packages/os/os.spec @@ -32,6 +32,7 @@ Source17: corndog-toml Source18: bootstrap-containers-toml Source19: host-containers-toml Source20: bottlerocket-fips-checks-metadata-json +Source21: bootstrap-commands-toml # 1xx sources: systemd units Source100: apiserver.service @@ -52,6 +53,7 @@ Source119: reboot-if-required.service Source120: warm-pool-wait.service Source122: has-boot-ever-succeeded.service Source123: pluto.service +Source124: bootstrap-commands.service # 2xx sources: tmpfilesd configs Source200: migration-tmpfiles.conf @@ -59,6 +61,7 @@ Source201: host-containers-tmpfiles.conf Source202: thar-be-updates-tmpfiles.conf Source203: bootstrap-containers-tmpfiles.conf Source204: storewolf-tmpfiles.conf +Source205: bootstrap-commands-tmpfiles.conf # 3xx sources: udev rules Source300: ephemeral-storage.rules @@ -74,6 +77,7 @@ BuildRequires: %{_cross_os}glibc-devel Requires: %{_cross_os}apiclient Requires: %{_cross_os}apiserver Requires: %{_cross_os}bloodhound +Requires: %{_cross_os}bootstrap-commands Requires: %{_cross_os}corndog Requires: %{_cross_os}certdog Requires: %{_cross_os}ghostdog @@ -246,6 +250,11 @@ Requires: %{_cross_os}binutils %description -n %{_cross_os}driverdog %{summary}. +%package -n %{_cross_os}bootstrap-commands +Summary: Manages bootstrap-commands +%description -n %{_cross_os}bootstrap-commands +%{summary}. + %package -n %{_cross_os}bootstrap-containers Summary: Manages bootstrap-containers Requires: %{_cross_os}host-ctr @@ -350,6 +359,7 @@ echo "** Output from non-static builds:" -p metricdog \ -p ghostdog \ -p corndog \ + -p bootstrap-commands \ -p bootstrap-containers \ -p prairiedog \ -p certdog \ @@ -385,7 +395,7 @@ for p in \ storewolf settings-committer \ migrator prairiedog certdog \ signpost updog metricdog logdog \ - ghostdog bootstrap-containers \ + ghostdog bootstrap-commands bootstrap-containers \ shimpei bloodhound \ bottlerocket-cis-checks \ bottlerocket-fips-checks \ @@ -473,14 +483,14 @@ if [ -s "%{_cross_repo_root_json}" ] ; then fi install -d %{buildroot}%{_cross_templatedir} -install -p -m 0644 %{S:5} %{S:6} %{S:7} %{S:8} %{S:14} %{S:15} %{S:16} %{S:17} %{S:18} %{S:19} \ +install -p -m 0644 %{S:5} %{S:6} %{S:7} %{S:8} %{S:14} %{S:15} %{S:16} %{S:17} %{S:18} %{S:19} %{S:21} \ %{buildroot}%{_cross_templatedir} install -d %{buildroot}%{_cross_unitdir} install -p -m 0644 \ %{S:100} %{S:102} %{S:103} %{S:105} \ %{S:106} %{S:107} %{S:110} %{S:111} %{S:112} \ - %{S:113} %{S:114} %{S:119} %{S:122} %{S:123} \ + %{S:113} %{S:114} %{S:119} %{S:122} %{S:123} %{S:124} \ %{buildroot}%{_cross_unitdir} sed -e 's|PREFIX|%{_cross_prefix}|g' %{S:115} > link-kernel-modules.service @@ -502,6 +512,7 @@ install -p -m 0644 %{S:201} %{buildroot}%{_cross_tmpfilesdir}/host-containers.co install -p -m 0644 %{S:202} %{buildroot}%{_cross_tmpfilesdir}/thar-be-updates.conf install -p -m 0644 %{S:203} %{buildroot}%{_cross_tmpfilesdir}/bootstrap-containers.conf install -p -m 0644 %{S:204} %{buildroot}%{_cross_tmpfilesdir}/storewolf.conf +install -p -m 0644 %{S:205} %{buildroot}%{_cross_tmpfilesdir}/bootstrap-commands.conf install -d %{buildroot}%{_cross_udevrulesdir} install -p -m 0644 %{S:300} %{buildroot}%{_cross_udevrulesdir}/80-ephemeral-storage.rules @@ -640,6 +651,12 @@ install -p -m 0644 %{S:400} %{S:401} %{S:402} %{buildroot}%{_cross_licensedir} %{_cross_bindir}/certdog %{_cross_templatedir}/certdog-toml +%files -n %{_cross_os}bootstrap-commands +%{_cross_bindir}/bootstrap-commands +%{_cross_unitdir}/bootstrap-commands.service +%{_cross_tmpfilesdir}/bootstrap-commands.conf +%{_cross_templatedir}/bootstrap-commands-toml + %files -n %{_cross_os}bootstrap-containers %{_cross_bindir}/bootstrap-containers %{_cross_unitdir}/bootstrap-containers@.service diff --git a/packages/release/release.spec b/packages/release/release.spec index eafa0a84c..530a5b685 100644 --- a/packages/release/release.spec +++ b/packages/release/release.spec @@ -85,6 +85,7 @@ Source1085: usr-libexec.mount.in Source1100: systemd-tmpfiles-setup-service-debug.conf Source1101: systemd-resolved-service-env.conf Source1102: systemd-networkd-service-env.conf +Source1103: systemd-logind-inhibit-maxdelay.conf # network link rules Source1200: 80-release.link @@ -172,6 +173,9 @@ install -p -m 0644 %{S:98} %{buildroot}%{_cross_libdir}/systemd/system.conf.d/80 install -d %{buildroot}%{_cross_libdir}/systemd/network install -p -m 0644 %{S:1200} %{buildroot}%{_cross_libdir}/systemd/network/80-release.link +install -d %{buildroot}%{_cross_libdir}/systemd/logind.conf.d +install -p -m 0644 %{S:1103} %{buildroot}%{_cross_libdir}/systemd/logind.conf.d/systemd-logind.conf + cat >%{buildroot}%{_cross_libdir}/os-release < Result { /// Reboots the machine async fn reboot() -> Result { debug!("Rebooting now"); - let output = Command::new("/sbin/shutdown") - .arg("-r") - .arg("now") + let output = Command::new("/usr/bin/systemctl") + .arg("reboot") + .arg("--check-inhibitors=yes") .output() .context(error::ShutdownSnafu)?; ensure!( diff --git a/sources/api/bootstrap-containers/src/main.rs b/sources/api/bootstrap-containers/src/main.rs index 773be9680..87fc80fe8 100644 --- a/sources/api/bootstrap-containers/src/main.rs +++ b/sources/api/bootstrap-containers/src/main.rs @@ -87,7 +87,7 @@ use std::path::{Path, PathBuf}; use std::process::{self, Command}; use std::str::FromStr; -use bottlerocket_modeled_types::{BootstrapContainerMode, Identifier, Url, ValidBase64}; +use bottlerocket_modeled_types::{BootstrapMode, Identifier, Url, ValidBase64}; const ENV_FILE_DIR: &str = "/etc/bootstrap-containers"; const DROPIN_FILE_DIR: &str = "/etc/systemd/system"; @@ -101,7 +101,7 @@ struct BootstrapContainer { #[serde(default, skip_serializing_if = "Option::is_none")] source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - mode: Option, + mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] user_data: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -134,7 +134,7 @@ enum Subcommand { #[derive(Debug)] struct MarkBootstrapArgs { container_id: String, - mode: BootstrapContainerMode, + mode: BootstrapMode, } /// Print a usage message in the event a bad arg is passed @@ -251,7 +251,7 @@ fn parse_mark_bootstrap_args(args: Vec) -> Result { Ok(Subcommand::MarkBootstrap(MarkBootstrapArgs { container_id, // Fail if 'mode' is invalid - mode: BootstrapContainerMode::try_from(mode).context(error::BootstrapContainerModeSnafu)?, + mode: BootstrapMode::try_from(mode).context(error::BootstrapModeSnafu)?, })) } @@ -605,9 +605,9 @@ mod error { source: base64::DecodeError, }, - // `try_from` in `BootstrapContainerMode` already returns a useful error message + // `try_from` in `BootstrapMode` already returns a useful error message #[snafu(display("Failed to parse mode: {}", source))] - BootstrapContainerMode { + BootstrapMode { source: bottlerocket_modeled_types::error::Error, }, diff --git a/sources/bootstrap-commands/Cargo.toml b/sources/bootstrap-commands/Cargo.toml new file mode 100644 index 000000000..1ff1ebc5b --- /dev/null +++ b/sources/bootstrap-commands/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bootstrap-commands" +version = "0.1.0" +authors = ["Piyush Jena "] +license = "Apache-2.0 OR MIT" +edition = "2021" +publish = false +build = "build.rs" +# Don't rebuild crate just because of changes to README. +exclude = ["README.md"] + +[dependencies] +base64.workspace = true +constants.workspace = true +log.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +simplelog.workspace = true +snafu.workspace = true +toml.workspace = true +itertools.workspace = true +bottlerocket-modeled-types.workspace = true +bottlerocket-settings-models.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[build-dependencies] +generate-readme.workspace = true diff --git a/sources/bootstrap-commands/README.md b/sources/bootstrap-commands/README.md new file mode 100644 index 000000000..e2bfb7e4e --- /dev/null +++ b/sources/bootstrap-commands/README.md @@ -0,0 +1,43 @@ +# bootstrap-commands + +Current version: 0.1.0 + +## Bootstrap commands + +`bootstrap-commands` ensures that bootstrap commands are executed as defined in the system +settings. It is called by `bootstrap-commands.service` which runs prior to the execution of +`bootstrap-containers`. + +Each bootstrap command is a set of Bottlerocket API commands. The settings are first rendered +into a config file. Then, the system is configured by going through all the bootstrap commands +in lexicographical order and running all the commands inside it. + +### Example: +Given a bootstrap command called `001-test-bootstrap-commands` with the following configuration: + +```toml +[settings.bootstrap-commands.001-test-bootstrap-commands] +commands = [[ "apiclient", "set", "motd=helloworld"]] +essential = true +mode = "always" +``` +This would set `/etc/motd` to "helloworld". + +## Additional Information: +Certain valid `apiclient` commands that work in a session may fail in `bootstrap-commands` +due to relevant services not running at the time of the launch of the systemd service. + +### Example: +```toml +[settings.bootstrap-commands.001-test-bootstrap-commands] +commands = [[ "apiclient", "exec", "admin", "ls"]] +essential = true +mode = "always" +``` +This command fails because `bootstrap-commands.service` which calls this binary is launched +prior to `preconfigured.target` while `host-containers@.service` which is a requirement for +running "exec" commands are launched after preconfigured.target. + +## Colophon + +This text was generated using [cargo-readme](https://crates.io/crates/cargo-readme), and includes the rustdoc from `src/main.rs`. diff --git a/sources/bootstrap-commands/README.tpl b/sources/bootstrap-commands/README.tpl new file mode 100644 index 000000000..bf207d023 --- /dev/null +++ b/sources/bootstrap-commands/README.tpl @@ -0,0 +1,9 @@ +# {{crate}} + +Current version: {{version}} + +{{readme}} + +## Colophon + +This text was generated using [cargo-readme](https://crates.io/crates/cargo-readme), and includes the rustdoc from `src/main.rs`. diff --git a/sources/bootstrap-commands/build.rs b/sources/bootstrap-commands/build.rs new file mode 100644 index 000000000..5b3a661c3 --- /dev/null +++ b/sources/bootstrap-commands/build.rs @@ -0,0 +1,3 @@ +fn main() { + generate_readme::from_main().unwrap(); +} diff --git a/sources/bootstrap-commands/src/main.rs b/sources/bootstrap-commands/src/main.rs new file mode 100644 index 000000000..7976897bd --- /dev/null +++ b/sources/bootstrap-commands/src/main.rs @@ -0,0 +1,370 @@ +/*! +# Bootstrap commands + +`bootstrap-commands` ensures that bootstrap commands are executed as defined in the system +settings. It is called by `bootstrap-commands.service` which runs prior to the execution of +`bootstrap-containers`. + +Each bootstrap command is a set of Bottlerocket API commands. The settings are first rendered +into a config file. Then, the system is configured by going through all the bootstrap commands +in lexicographical order and running all the commands inside it. + +## Example: +Given a bootstrap command called `001-test-bootstrap-commands` with the following configuration: + +```toml +[settings.bootstrap-commands.001-test-bootstrap-commands] +commands = [[ "apiclient", "set", "motd=helloworld"]] +essential = true +mode = "always" +``` +This would set `/etc/motd` to "helloworld". + +# Additional Information: +Certain valid `apiclient` commands that work in a session may fail in `bootstrap-commands` +due to relevant services not running at the time of the launch of the systemd service. + +## Example: +```toml +[settings.bootstrap-commands.001-test-bootstrap-commands] +commands = [[ "apiclient", "exec", "admin", "ls"]] +essential = true +mode = "always" +``` +This command fails because `bootstrap-commands.service` which calls this binary is launched +prior to `preconfigured.target` while `host-containers@.service` which is a requirement for +running "exec" commands are launched after preconfigured.target. +*/ + +use log::info; +use serde::Deserialize; +use simplelog::{Config as LogConfig, LevelFilter, SimpleLogger}; +use snafu::{ensure, OptionExt, ResultExt}; +use std::collections::BTreeMap; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{self, Command}; +use std::str::FromStr; + +use bottlerocket_modeled_types::{ApiclientCommand, BootstrapMode, Identifier}; + +const DEFAULT_CONFIG_PATH: &str = "/etc/bootstrap-commands/bootstrap-commands.toml"; + +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +struct BootstrapCommandConfig { + #[serde(default)] + bootstrap_commands: BTreeMap, +} + +impl BootstrapCommandConfig { + // Gets an iterator for bootstrap_commands, sorted in lexicographical order of their names. + fn iter(self) -> impl Iterator { + self.bootstrap_commands.into_iter() + } +} + +#[derive(Debug, PartialEq, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct BootstrapCommand { + #[serde(default)] + commands: Vec, + #[serde(default)] + mode: BootstrapMode, + #[serde(default)] + essential: bool, +} + +/// Stores user-supplied global arguments +struct Args { + log_level: LevelFilter, + config_path: PathBuf, +} + +impl Default for Args { + fn default() -> Self { + Self { + log_level: LevelFilter::Info, + config_path: PathBuf::from_str(DEFAULT_CONFIG_PATH).unwrap(), + } + } +} + +/// Wrapper around process::Command that adds error checking. +fn run_command(bin_path: &str, args: I) -> Result<()> +where + I: IntoIterator, + S: AsRef, +{ + let mut command = Command::new(bin_path); + + command + .args(args) + .status() + .context(error::ExecutionFailureSnafu { command })?; + + Ok(()) +} + +fn handle_bootstrap_command(name: S, bootstrap_command: BootstrapCommand) -> Result<()> +where + S: AsRef, +{ + let name = name.as_ref(); + let mode = bootstrap_command.mode.as_ref(); + let commands = &bootstrap_command.commands; + + if mode == "off" { + // If mode is 'off', just log this information. + info!("Bootstrap command mode for '{}' is 'off'", name); + return Ok(()); + } + + info!("Processing bootstrap command '{}' ... ", name); + + for command in commands.iter() { + let (cmd, args) = command.get_command_and_args(); + run_command(cmd, args)?; + } + + if mode == "once" { + let formatted = format!("settings.bootstrap-commands.{}.mode=off", name); + info!("Turning off bootstrap command '{}'", name); + run_command("apiclient", ["set", formatted.as_str()])?; + } + + info!("Successfully ran bootstrap command '{}'", name); + + Ok(()) +} + +/// Read our config file for the set of defined bootstrap commands +fn get_bootstrap_commands

(config_path: P) -> Result +where + P: AsRef, +{ + let config_str = fs::read_to_string(config_path.as_ref()).context(error::ReadConfigSnafu { + config_path: config_path.as_ref(), + })?; + + let config: BootstrapCommandConfig = + toml::from_str(&config_str).context(error::DeserializationSnafu { + config_path: config_path.as_ref(), + })?; + + Ok(config) +} + +/// Parse the args to the program and return an Args struct +fn parse_args(args: env::Args) -> Result { + let mut global_args = Args::default(); + + let mut iter = args.skip(1); + while let Some(arg) = iter.next() { + match arg.as_ref() { + // Global args + "--log-level" => { + let log_level = iter.next().context(error::UsageSnafu { + message: "Did not give argument to --log-level", + })?; + global_args.log_level = LevelFilter::from_str(&log_level) + .context(error::LogLevelSnafu { log_level })?; + } + + "-c" | "--config-path" => { + let config_str = iter.next().context(error::UsageSnafu { + message: "Did not give argument to --config-path", + })?; + global_args.config_path = PathBuf::from(config_str.as_str()); + } + + _ => (), + } + } + + Ok(global_args) +} + +fn run() -> Result<()> { + let args = parse_args(env::args())?; + + // SimpleLogger will send errors to stderr and anything less to stdout. + SimpleLogger::init(args.log_level, LogConfig::default()).context(error::LoggerSnafu)?; + + let bootstrap_commands = get_bootstrap_commands(args.config_path)?; + + for (bootstrap_command_name, bootstrap_command) in bootstrap_commands.iter() { + let name = bootstrap_command_name.as_ref(); + let essential = bootstrap_command.essential; + let status = handle_bootstrap_command(name, bootstrap_command); + + ensure!( + !essential || status.is_ok(), + error::BootstrapCommandExecutionSnafu { name } + ) + } + + Ok(()) +} + +fn main() { + if let Err(e) = run() { + eprintln!("{}", e); + process::exit(1); + } +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= + +mod error { + use snafu::Snafu; + use std::path::PathBuf; + use std::process::Command; + + #[derive(Debug, Snafu)] + #[snafu(visibility(pub(super)))] + pub(super) enum Error { + #[snafu(display("Failed to read settings from config at {}: {}", config_path.display(), source))] + ReadConfig { + config_path: PathBuf, + source: std::io::Error, + }, + + #[snafu(display("Failed to deserialize settings from config at {}: {}", config_path.display(), source))] + Deserialization { + config_path: PathBuf, + source: toml::de::Error, + }, + + #[snafu(display("Bootstrap command '{}' failed.", name))] + BootstrapCommandExecution { name: String }, + + #[snafu(display("Failed to execute '{:?}': {}", command, source))] + ExecutionFailure { + command: Command, + source: std::io::Error, + }, + + #[snafu(display("Logger setup error: {}", source))] + Logger { source: log::SetLoggerError }, + + #[snafu(display("Invalid log level '{}'", log_level))] + LogLevel { + log_level: String, + source: log::ParseLevelError, + }, + + #[snafu(display("{}", message))] + Usage { message: String }, + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_get_bootstrap_commands() { + let config_toml = r#"[bootstrap-commands."002-test-bootstrap-commands"] + commands = [["apiclient", "set", "motd=helloworld2"], ["apiclient", "set", "motd=helloworld3"]] + mode = "once" + essential = true + + [bootstrap-commands."001-test-bootstrap-commands"] + commands = [["apiclient", "set", "motd=helloworld1"]] + mode = "always" + essential = true + "#; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let temp_config = Path::join(temp_dir.path(), "bootstrap-commands.toml"); + let _ = std::fs::write(&temp_config, config_toml).unwrap(); + + let bootstrap_command_config = get_bootstrap_commands(&temp_config).unwrap(); + let bootstrap_commands = bootstrap_command_config.bootstrap_commands; + + let mut expected_bootstrap_commands = BTreeMap::new(); + let testcmd_1 = ApiclientCommand::try_from(vec![ + "apiclient".to_string(), + "set".to_string(), + "motd=helloworld1".to_string(), + ]) + .unwrap(); + let testcmd_2 = ApiclientCommand::try_from(vec![ + "apiclient".to_string(), + "set".to_string(), + "motd=helloworld2".to_string(), + ]) + .unwrap(); + let testcmd_3 = ApiclientCommand::try_from(vec![ + "apiclient".to_string(), + "set".to_string(), + "motd=helloworld3".to_string(), + ]) + .unwrap(); + expected_bootstrap_commands.insert( + Identifier::try_from("001-test-bootstrap-commands").unwrap(), + BootstrapCommand { + commands: vec![testcmd_1], + mode: BootstrapMode::try_from("always").unwrap(), + essential: true, + }, + ); + expected_bootstrap_commands.insert( + Identifier::try_from("002-test-bootstrap-commands").unwrap(), + BootstrapCommand { + commands: vec![testcmd_2, testcmd_3], + mode: BootstrapMode::try_from("once").unwrap(), + essential: true, + }, + ); + + assert_eq!(bootstrap_commands, expected_bootstrap_commands) + } + + #[test] + fn test_get_bootstrap_commands_defaults() { + let config_toml = r#"[bootstrap-commands."001-test-bootstrap-commands"] + commands = [] + "#; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let temp_config = Path::join(temp_dir.path(), "bootstrap-commands.toml"); + let _ = std::fs::write(&temp_config, config_toml).unwrap(); + + let bootstrap_command_config = get_bootstrap_commands(&temp_config).unwrap(); + let bootstrap_commands = bootstrap_command_config.bootstrap_commands; + + let mut expected_bootstrap_commands = BTreeMap::new(); + expected_bootstrap_commands.insert( + Identifier::try_from("001-test-bootstrap-commands").unwrap(), + BootstrapCommand { + commands: vec![], + mode: BootstrapMode::try_from("off").unwrap(), + essential: false, + }, + ); + + assert_eq!(bootstrap_commands, expected_bootstrap_commands) + } + + #[test] + fn test_get_bootstrap_commands_invalid() { + let config_toml = r#"[bootstrap-commands."001-test-bootstrap-commands"] + commands = [["/usr/bin/touch", "helloworld.txt"], ["apiclient", "set", "motd=helloworld3"]] + mode = "once" + essential = true + "#; + + let temp_dir = tempfile::TempDir::new().unwrap(); + let temp_config = Path::join(temp_dir.path(), "bootstrap-commands.toml"); + let _ = std::fs::write(&temp_config, config_toml).unwrap(); + + // It should fail because one of the commands is not valid. + assert!(get_bootstrap_commands(&temp_config).is_err()); + } +} + +type Result = std::result::Result;