From 733f2f3a7bd7a5a9e6501f5ee1f1291ca8708e6a Mon Sep 17 00:00:00 2001 From: Zac Mrowicki Date: Fri, 15 Jan 2021 21:28:24 +0000 Subject: [PATCH] Add conditional compilation to early-boot-config This change adds a simple solution for conditionally compiling early-boot-config based on the current variant. As part of the change, local file handling (which was previously broken) is split into its own trait implementation and only compiled into the program for "aws-dev" variants. We also add the ability to read a mounted cdrom for user data. --- sources/Cargo.lock | 20 + sources/api/early-boot-config/Cargo.toml | 2 + sources/api/early-boot-config/build.rs | 23 ++ sources/api/early-boot-config/src/main.rs | 365 ++++-------------- sources/api/early-boot-config/src/provider.rs | 22 ++ .../api/early-boot-config/src/provider/aws.rs | 253 ++++++++++++ .../early-boot-config/src/provider/cdrom.rs | 237 ++++++++++++ .../src/provider/local_file.rs | 66 ++++ sources/api/early-boot-config/src/settings.rs | 32 ++ .../test_data/namespaced_keys.xml | 10 + .../early-boot-config/test_data/ovf-env.xml | 9 + 11 files changed, 739 insertions(+), 300 deletions(-) create mode 100644 sources/api/early-boot-config/src/provider.rs create mode 100644 sources/api/early-boot-config/src/provider/aws.rs create mode 100644 sources/api/early-boot-config/src/provider/cdrom.rs create mode 100644 sources/api/early-boot-config/src/provider/local_file.rs create mode 100644 sources/api/early-boot-config/src/settings.rs create mode 100644 sources/api/early-boot-config/test_data/namespaced_keys.xml create mode 100644 sources/api/early-boot-config/test_data/ovf-env.xml diff --git a/sources/Cargo.lock b/sources/Cargo.lock index 6728d700352..891a497eda7 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -888,11 +888,13 @@ name = "early-boot-config" version = "0.1.0" dependencies = [ "apiclient", + "base64 0.13.0", "cargo-readme", "http", "log", "reqwest", "serde", + "serde-xml-rs", "serde_json", "simplelog", "snafu", @@ -2554,6 +2556,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-xml-rs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bf1ba0696ccf0872866277143ff1fd14d22eec235d2b23702f95e6660f7dfa" +dependencies = [ + "log", + "serde", + "thiserror", + "xml-rs", +] + [[package]] name = "serde_derive" version = "1.0.118" @@ -3631,3 +3645,9 @@ dependencies = [ "winapi 0.2.8", "winapi-build", ] + +[[package]] +name = "xml-rs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07db065a5cf61a7e4ba64f29e67db906fb1787316516c4e6e5ff0fea1efcd8a" diff --git a/sources/api/early-boot-config/Cargo.toml b/sources/api/early-boot-config/Cargo.toml index 1acfb874371..7368ff8fcb0 100644 --- a/sources/api/early-boot-config/Cargo.toml +++ b/sources/api/early-boot-config/Cargo.toml @@ -11,11 +11,13 @@ exclude = ["README.md"] [dependencies] apiclient = { path = "../apiclient" } +base64 = "0.13" http = "0.2" log = "0.4" reqwest = { version = "0.10", default-features = false, features = ["blocking"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" +serde-xml-rs = "0.4.1" simplelog = "0.9" snafu = "0.6" tokio = { version = "0.2", default-features = false, features = ["macros", "rt-threaded"] } diff --git a/sources/api/early-boot-config/build.rs b/sources/api/early-boot-config/build.rs index 49828a1c42f..f920bb9c390 100644 --- a/sources/api/early-boot-config/build.rs +++ b/sources/api/early-boot-config/build.rs @@ -4,8 +4,31 @@ use std::env; use std::fs::File; use std::io::Write; use std::path::PathBuf; +use std::process; fn main() { + // The code below emits `cfg` operators to conditionally compile this program based on the + // current variant. + // TODO: Replace this approach when the build system supports ideas like "variant + // tags": https://github.com/bottlerocket-os/bottlerocket/issues/1260 + println!("cargo:rerun-if-env-changed=VARIANT"); + if let Ok(variant) = env::var("VARIANT") { + if variant == "aws-dev" { + println!("cargo:rustc-cfg=bottlerocket_platform=\"aws-dev\""); + } else if variant.starts_with("aws") { + println!("cargo:rustc-cfg=bottlerocket_platform=\"aws\""); + } else if variant.starts_with("vmware") { + println!("cargo:rustc-cfg=bottlerocket_platform=\"vmware\""); + } else { + eprintln!( + "For local builds, you must set the 'VARIANT' environment variable so we know which data \ + provider to build. Valid values are the directories in models/src/variants/, for \ + example 'aws-k8s-1.17'." + ); + process::exit(1); + } + } + // Check for environment variable "SKIP_README". If it is set, // skip README generation if env::var_os("SKIP_README").is_some() { diff --git a/sources/api/early-boot-config/src/main.rs b/sources/api/early-boot-config/src/main.rs index ed8b1836d22..a85e2ab36fa 100644 --- a/sources/api/early-boot-config/src/main.rs +++ b/sources/api/early-boot-config/src/main.rs @@ -15,15 +15,15 @@ Currently, Amazon EC2 is supported through the IMDSv1 HTTP API. Data will be ta #[macro_use] extern crate log; -use http::StatusCode; -use reqwest::blocking::Client; -use serde::Serialize; -use serde_json::json; use simplelog::{Config as LogConfig, LevelFilter, TermLogger, TerminalMode}; -use snafu::{ensure, OptionExt, ResultExt}; -use std::path::Path; +use snafu::{ensure, ResultExt}; +use std::fs; use std::str::FromStr; -use std::{env, fs, process}; +use std::{env, process}; + +mod provider; +mod settings; +use crate::provider::PlatformDataProvider; // TODO // Tests! @@ -39,302 +39,27 @@ const TRANSACTION: &str = "bottlerocket-launch"; // We create it after running successfully. const MARKER_FILE: &str = "/var/lib/bottlerocket/early-boot-config.ran"; -mod error { - use http::StatusCode; - use snafu::Snafu; - - // Taken from pluto. - // Extracts the status code from a reqwest::Error and converts it to a string to be displayed - fn get_bad_status_code(source: &reqwest::Error) -> String { - source - .status() - .as_ref() - .map(|i| i.as_str()) - .unwrap_or("Unknown") - .to_string() - } - - use std::io; - use std::path::PathBuf; - - #[derive(Debug, Snafu)] - #[snafu(visibility = "pub(super)")] - pub(super) enum Error { - #[snafu(display("Error {}ing '{}': {}", method, uri, source))] - Request { - method: String, - uri: String, - source: reqwest::Error, - }, - - #[snafu(display("Response '{}' from '{}': {}", get_bad_status_code(&source), uri, source))] - BadResponse { uri: String, source: reqwest::Error }, - - #[snafu(display("Error {}ing '{}': {}", method, uri, source))] - APIRequest { - method: String, - uri: String, - source: apiclient::Error, - }, - - #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] - Response { - method: String, - uri: String, - code: StatusCode, - response_body: String, - }, - - #[snafu(display( - "Unable to read response body when {}ing '{}' (code {}) - {}", - method, - uri, - code, - source - ))] - ResponseBody { - method: String, - uri: String, - code: StatusCode, - source: reqwest::Error, - }, - - #[snafu(display("Error parsing TOML user data: {}", source))] - TOMLUserDataParse { source: toml::de::Error }, - - #[snafu(display("Data is not a TOML table"))] - UserDataNotTomlTable, - - #[snafu(display("TOML data did not contain 'settings' section"))] - UserDataMissingSettings, - - #[snafu(display("Error serializing TOML to JSON: {}", source))] - SettingsToJSON { source: serde_json::error::Error }, - - #[snafu(display("Error deserializing from JSON: {}", source))] - DeserializeJson { source: serde_json::error::Error }, - - #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] - InputFileRead { path: PathBuf, source: io::Error }, - - #[snafu(display("Instance identity document missing {}", missing))] - IdentityDocMissingData { missing: String }, - - #[snafu(display("Logger setup error: {}", source))] - Logger { source: log::SetLoggerError }, - } -} - -type Result = std::result::Result; - -/// Support for new platforms can be added by implementing this trait. -trait PlatformDataProvider { - /// You should return a list of SettingsJson, representing the settings changes you want to - /// send to the API. - /// - /// This is a list so that handling multiple data sources within a platform can feel more - /// natural; you can also send all changes in one entry if you like. - fn platform_data(&self) -> Result>; -} - -/// Unit struct for AWS so we can implement the PlatformDataProvider trait. -struct AwsDataProvider; - -impl AwsDataProvider { - // Currently only able to get fetch session tokens from `latest` - // FIXME Pin to a date version that supports IMDSv2 once such a date version is available. - const IMDS_TOKEN_ENDPOINT: &'static str = "http://169.254.169.254/latest/api/token"; - - const USER_DATA_FILE: &'static str = "/etc/early-boot-config/user-data"; - const USER_DATA_ENDPOINT: &'static str = "http://169.254.169.254/2018-09-24/user-data"; - const IDENTITY_DOCUMENT_FILE: &'static str = "/etc/early-boot-config/identity-document"; - const IDENTITY_DOCUMENT_ENDPOINT: &'static str = - "http://169.254.169.254/2018-09-24/dynamic/instance-identity/document"; - - /// Helper to fetch an IMDSv2 session token that is valid for 60 seconds. - fn fetch_imds_session_token(client: &Client) -> Result { - let uri = Self::IMDS_TOKEN_ENDPOINT; - let response = client - .put(uri) - .header("X-aws-ec2-metadata-token-ttl-seconds", "60") - .send() - .context(error::Request { method: "PUT", uri })? - .error_for_status() - .context(error::BadResponse { uri })?; - let code = response.status(); - response.text().context(error::ResponseBody { - method: "PUT", - uri, - code, - }) - } - - /// Helper to fetch data from IMDS, preferring an override file if present. - /// - /// IMDS returns a 404 if no user data was given, for example; we return Ok(None) to represent - /// this, otherwise Ok(Some(body)) with the response body. - fn fetch_imds( - file: &str, - client: &Client, - session_token: &str, - uri: &str, - description: &str, - ) -> Result> { - if Path::new(file).exists() { - info!("{} file found at {}, using it", description, file); - return Ok(Some( - fs::read_to_string(file).context(error::InputFileRead { path: file })?, - )); - } - debug!("Requesting {} from {}", description, uri); - let response = client - .get(uri) - .header("X-aws-ec2-metadata-token", session_token) - .send() - .context(error::Request { method: "GET", uri })?; - trace!("IMDS response: {:?}", &response); - - match response.status() { - code @ StatusCode::OK => { - info!("Received {}", description); - let response_body = response.text().context(error::ResponseBody { - method: "GET", - uri, - code, - })?; - trace!("Response text: {:?}", &response_body); - - Ok(Some(response_body)) - } - - // IMDS returns 404 if no user data is given, or if IMDS is disabled, for example - StatusCode::NOT_FOUND => Ok(None), - - code @ _ => { - let response_body = response.text().context(error::ResponseBody { - method: "GET", - uri, - code, - })?; - trace!("Response text: {:?}", &response_body); - - error::Response { - method: "GET", - uri, - code, - response_body, - } - .fail() - } - } - } - - /// Fetches user data, which is expected to be in TOML form and contain a `[settings]` section, - /// returning a SettingsJson representing the inside of that section. - fn user_data(client: &Client, session_token: &str) -> Result> { - let desc = "user data"; - let uri = Self::USER_DATA_ENDPOINT; - let file = Self::USER_DATA_FILE; - - let user_data_str = match Self::fetch_imds(file, client, session_token, uri, desc) { - Err(e) => return Err(e), - Ok(None) => return Ok(None), - Ok(Some(s)) => s, - }; - trace!("Received user data: {}", user_data_str); - - // Remove outer "settings" layer before sending to API - let mut val: toml::Value = - toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; - let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; - let inner = table - .remove("settings") - .context(error::UserDataMissingSettings)?; - - SettingsJson::from_val(&inner, desc).map(|s| Some(s)) - } - - /// Fetches the instance identity, returning a SettingsJson representing the values from the - /// document which we'd like to send to the API - currently just region. - fn identity_document(client: &Client, session_token: &str) -> Result> { - let desc = "instance identity document"; - let uri = Self::IDENTITY_DOCUMENT_ENDPOINT; - let file = Self::IDENTITY_DOCUMENT_FILE; - - let iid_str = match Self::fetch_imds(file, client, session_token, uri, desc) { - Err(e) => return Err(e), - Ok(None) => return Ok(None), - Ok(Some(s)) => s, - }; - trace!("Received instance identity document: {}", iid_str); - - // Grab region from instance identity document. - let iid: serde_json::Value = - serde_json::from_str(&iid_str).context(error::DeserializeJson)?; - let region = iid - .get("region") - .context(error::IdentityDocMissingData { missing: "region" })?; - let val = json!({ "aws": {"region": region} }); - - SettingsJson::from_val(&val, desc).map(|s| Some(s)) +/// This function returns the appropriate data provider for this variant. It exists primarily to +/// keep the ugly bits of conditional compilation out of the main function. +fn create_provider() -> Result> { + #[cfg(bottlerocket_platform = "aws")] + { + Ok(Box::new(provider::aws::AwsDataProvider)) } -} - -impl PlatformDataProvider for AwsDataProvider { - /// Return settings changes from the instance identity document and user data. - fn platform_data(&self) -> Result> { - let mut output = Vec::new(); - let client = Client::new(); - - let session_token = Self::fetch_imds_session_token(&client)?; - - // Instance identity doc first, so the user has a chance to override - match Self::identity_document(&client, &session_token) { - Err(e) => return Err(e), - Ok(None) => warn!("No instance identity document found."), - Ok(Some(s)) => output.push(s), - } - // Optional user-specified configuration / overrides - match Self::user_data(&client, &session_token) { - Err(e) => return Err(e), - Ok(None) => warn!("No user data found."), - Ok(Some(s)) => output.push(s), + #[cfg(bottlerocket_platform = "aws-dev")] + { + use std::path::Path; + if Path::new(provider::local_file::LocalFileDataProvider::USER_DATA_FILE).exists() { + Ok(Box::new(provider::local_file::LocalFileDataProvider)) + } else { + Ok(Box::new(provider::aws::AwsDataProvider)) } - - Ok(output) } -} - -/// This function determines which provider we're currently running on. -fn find_provider() -> Result> { - // FIXME: We need to decide what we're going to do with this in the future; ask each - // provider if they should be used? In what order? - Ok(Box::new(AwsDataProvider)) -} - -/// SettingsJson represents a change that a provider would like to make in the API. -#[derive(Debug)] -struct SettingsJson { - json: String, - desc: String, -} -impl SettingsJson { - /// Construct a SettingsJson from a serializable object and a description of that object, - /// which is used for logging. - /// - /// The serializable object is typically something like a toml::Value or serde_json::Value, - /// since they can be easily deserialized from text input in the platform, and manipulated as - /// desired. - fn from_val(data: &impl Serialize, desc: S) -> Result - where - S: Into, + #[cfg(bottlerocket_platform = "vmware")] { - Ok(Self { - json: serde_json::to_string(&data).context(error::SettingsToJSON)?, - desc: desc.into(), - }) + Ok(Box::new(provider::cdrom::CdromDataProvider)) } } @@ -410,13 +135,21 @@ async fn run() -> Result<()> { info!("early-boot-config started"); // Figure out the current provider - info!("Detecting platform data provider"); - let data_provider = find_provider()?; + let data_provider = create_provider()?; info!("Retrieving platform-specific data"); let uri = &format!("{}?tx={}", API_SETTINGS_URI, TRANSACTION); let method = "PATCH"; - for settings_json in data_provider.platform_data()? { + for settings_json in data_provider + .platform_data() + .context(error::ProviderError)? + { + // Don't send an empty request to the API + if settings_json.json.is_empty() { + warn!("{} was empty", settings_json.desc); + continue; + } + info!("Sending {} to API", settings_json.desc); trace!("Request body: {}", settings_json.json); let (code, response_body) = @@ -454,3 +187,35 @@ async fn main() { process::exit(1); } } + +mod error { + use http::StatusCode; + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(super) enum Error { + #[snafu(display("Error {}ing '{}': {}", method, uri, source))] + APIRequest { + method: String, + uri: String, + source: apiclient::Error, + }, + + #[snafu(display("Provider error: {}", source))] + ProviderError { source: Box }, + + #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] + Response { + method: String, + uri: String, + code: StatusCode, + response_body: String, + }, + + #[snafu(display("Logger setup error: {}", source))] + Logger { source: log::SetLoggerError }, + } +} + +type Result = std::result::Result; diff --git a/sources/api/early-boot-config/src/provider.rs b/sources/api/early-boot-config/src/provider.rs new file mode 100644 index 00000000000..ae7d8bc41c9 --- /dev/null +++ b/sources/api/early-boot-config/src/provider.rs @@ -0,0 +1,22 @@ +//! The provider module owns the `PlatformDataProvider` trait + +use crate::settings::SettingsJson; + +#[cfg(any(bottlerocket_platform = "aws", bottlerocket_platform = "aws-dev"))] +pub(crate) mod aws; + +#[cfg(bottlerocket_platform = "aws-dev")] +pub(crate) mod local_file; + +#[cfg(bottlerocket_platform = "vmware")] +pub(crate) mod cdrom; + +/// Support for new platforms can be added by implementing this trait. +pub(crate) trait PlatformDataProvider { + /// You should return a list of SettingsJson, representing the settings changes you want to + /// send to the API. + /// + /// This is a list so that handling multiple data sources within a platform can feel more + /// natural; you can also send all changes in one entry if you like. + fn platform_data(&self) -> std::result::Result, Box>; +} diff --git a/sources/api/early-boot-config/src/provider/aws.rs b/sources/api/early-boot-config/src/provider/aws.rs new file mode 100644 index 00000000000..0c9b7dee73f --- /dev/null +++ b/sources/api/early-boot-config/src/provider/aws.rs @@ -0,0 +1,253 @@ +//! The aws module implements the `PlatformDataProvider` trait for gathering userdata on AWS. + +use super::{PlatformDataProvider, SettingsJson}; +use http::StatusCode; +use reqwest::blocking::Client; +use serde_json::json; +use snafu::{OptionExt, ResultExt}; +use std::fs; +use std::path::Path; + +/// Unit struct for AWS so we can implement the PlatformDataProvider trait. +pub(crate) struct AwsDataProvider; + +impl AwsDataProvider { + // Currently only able to get fetch session tokens from `latest` + // FIXME Pin to a date version that supports IMDSv2 once such a date version is available. + const IMDS_TOKEN_ENDPOINT: &'static str = "http://169.254.169.254/latest/api/token"; + + const USER_DATA_ENDPOINT: &'static str = "http://169.254.169.254/2018-09-24/user-data"; + const IDENTITY_DOCUMENT_FILE: &'static str = "/etc/early-boot-config/identity-document"; + const IDENTITY_DOCUMENT_ENDPOINT: &'static str = + "http://169.254.169.254/2018-09-24/dynamic/instance-identity/document"; + + /// Helper to fetch an IMDSv2 session token that is valid for 60 seconds. + fn fetch_imds_session_token(client: &Client) -> Result { + let uri = Self::IMDS_TOKEN_ENDPOINT; + let response = client + .put(uri) + .header("X-aws-ec2-metadata-token-ttl-seconds", "60") + .send() + .context(error::Request { method: "PUT", uri })? + .error_for_status() + .context(error::BadResponse { uri })?; + let code = response.status(); + response.text().context(error::ResponseBody { + method: "PUT", + uri, + code, + }) + } + + /// Helper to fetch data from IMDS, preferring an override file if present. + /// + /// IMDS returns a 404 if no user data was given, for example; we return Ok(None) to represent + /// this, otherwise Ok(Some(body)) with the response body. + fn fetch_imds( + client: &Client, + session_token: &str, + uri: &str, + description: &str, + ) -> Result> { + debug!("Requesting {} from {}", description, uri); + let response = client + .get(uri) + .header("X-aws-ec2-metadata-token", session_token) + .send() + .context(error::Request { method: "GET", uri })?; + trace!("IMDS response: {:?}", &response); + + match response.status() { + code @ StatusCode::OK => { + info!("Received {}", description); + let response_body = response.text().context(error::ResponseBody { + method: "GET", + uri, + code, + })?; + trace!("Response text: {:?}", &response_body); + + Ok(Some(response_body)) + } + + // IMDS returns 404 if no user data is given, or if IMDS is disabled, for example + StatusCode::NOT_FOUND => Ok(None), + + code @ _ => { + let response_body = response.text().context(error::ResponseBody { + method: "GET", + uri, + code, + })?; + trace!("Response text: {:?}", &response_body); + + error::Response { + method: "GET", + uri, + code, + response_body, + } + .fail() + } + } + } + + /// Fetches user data, which is expected to be in TOML form and contain a `[settings]` section, + /// returning a SettingsJson representing the inside of that section. + fn user_data(client: &Client, session_token: &str) -> Result> { + let desc = "user data"; + let uri = Self::USER_DATA_ENDPOINT; + + let user_data_str = match Self::fetch_imds(client, session_token, uri, desc) { + Err(e) => return Err(e), + Ok(None) => return Ok(None), + Ok(Some(s)) => s, + }; + trace!("Received user data: {}", user_data_str); + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, desc).context(error::SettingsToJSON)?; + Ok(Some(json)) + } + + /// Fetches the instance identity, returning a SettingsJson representing the values from the + /// document which we'd like to send to the API - currently just region. + fn identity_document(client: &Client, session_token: &str) -> Result> { + let desc = "instance identity document"; + let uri = Self::IDENTITY_DOCUMENT_ENDPOINT; + let file = Self::IDENTITY_DOCUMENT_FILE; + + let iid_str = if Path::new(file).exists() { + info!("{} found at {}, using it", desc, file); + fs::read_to_string(file).context(error::InputFileRead { path: file })? + } else { + match Self::fetch_imds(client, session_token, uri, desc) { + Err(e) => return Err(e), + Ok(None) => return Ok(None), + Ok(Some(s)) => s, + } + }; + trace!("Received instance identity document: {}", iid_str); + + // Grab region from instance identity document. + let iid: serde_json::Value = + serde_json::from_str(&iid_str).context(error::DeserializeJson)?; + let region = iid + .get("region") + .context(error::IdentityDocMissingData { missing: "region" })?; + let val = json!({ "aws": {"region": region} }); + + let json = SettingsJson::from_val(&val, desc).context(error::SettingsToJSON)?; + Ok(Some(json)) + } +} + +impl PlatformDataProvider for AwsDataProvider { + /// Return settings changes from the instance identity document and user data. + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + let client = Client::new(); + + let session_token = Self::fetch_imds_session_token(&client)?; + + // Instance identity doc first, so the user has a chance to override + match Self::identity_document(&client, &session_token) { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No instance identity document found."), + Ok(Some(s)) => output.push(s), + } + + // Optional user-specified configuration / overrides + match Self::user_data(&client, &session_token) { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No user data found."), + Ok(Some(s)) => output.push(s), + } + + Ok(output) + } +} + +mod error { + use http::StatusCode; + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + // Taken from pluto. + // Extracts the status code from a reqwest::Error and converts it to a string to be displayed + fn get_bad_status_code(source: &reqwest::Error) -> String { + source + .status() + .as_ref() + .map(|i| i.as_str()) + .unwrap_or("Unknown") + .to_string() + } + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Response '{}' from '{}': {}", get_bad_status_code(&source), uri, source))] + BadResponse { uri: String, source: reqwest::Error }, + + #[snafu(display("Error deserializing from JSON: {}", source))] + DeserializeJson { source: serde_json::error::Error }, + + #[snafu(display("Instance identity document missing {}", missing))] + IdentityDocMissingData { missing: String }, + + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display("Error {}ing '{}': {}", method, uri, source))] + Request { + method: String, + uri: String, + source: reqwest::Error, + }, + + #[snafu(display("Error {} when {}ing '{}': {}", code, method, uri, response_body))] + Response { + method: String, + uri: String, + code: StatusCode, + response_body: String, + }, + + #[snafu(display( + "Unable to read response body when {}ing '{}' (code {}) - {}", + method, + uri, + code, + source + ))] + ResponseBody { + method: String, + uri: String, + code: StatusCode, + source: reqwest::Error, + }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + } +} + +type Result = std::result::Result; diff --git a/sources/api/early-boot-config/src/provider/cdrom.rs b/sources/api/early-boot-config/src/provider/cdrom.rs new file mode 100644 index 00000000000..d9927691e07 --- /dev/null +++ b/sources/api/early-boot-config/src/provider/cdrom.rs @@ -0,0 +1,237 @@ +//! The cdrom module implements the `PlatformDataProvider` trait for gathering userdata from a +//! mounted CDRom. + +use super::{PlatformDataProvider, SettingsJson}; +use serde::Deserialize; +use snafu::{ensure, OptionExt, ResultExt}; +use std::ffi::OsStr; +use std::fs::{self, File}; +use std::io::BufReader; +use std::path::Path; + +pub(crate) struct CdromDataProvider; + +impl CdromDataProvider { + // This program expects that the CD-ROM is already mounted. Mounting happens elsewhere in a + // systemd unit file + const CD_ROM_MOUNT: &'static str = "/media/cdrom"; + // A mounted CD-ROM may contain an OVF file or a user-supplied file named `user-data` + const USER_DATA_FILENAMES: [&'static str; 5] = [ + "user-data", + "ovf-env.xml", + "OVF-ENV.XML", + "ovf_env.xml", + "OVF_ENV.XML", + ]; + + /// Given the list of acceptable filenames, ensure only 1 exists and parse + /// it for user data + fn user_data() -> Result> { + let mut user_data_files = Self::USER_DATA_FILENAMES + .iter() + .map(|filename| Path::new(Self::CD_ROM_MOUNT).join(filename)) + .filter(|file| file.exists()); + + let user_data_file = match user_data_files.next() { + Some(file) => file, + None => return Ok(None), + }; + + ensure!( + user_data_files.next().is_none(), + error::UserDataFileCount { + location: Self::CD_ROM_MOUNT + } + ); + + // XML files require extra processing, while a user-supplied file should already be in TOML + // format + info!("'{}' exists, using it", user_data_file.display()); + let user_data_str = match user_data_file.extension().and_then(OsStr::to_str) { + Some("xml") | Some("XML") => Self::ovf_user_data(user_data_file)?, + // Since we only look for a specific list of file names, we should never find a file + // with an extension we don't understand. + Some(_) => unreachable!(), + None => fs::read_to_string(&user_data_file).context(error::InputFileRead { + path: user_data_file, + })?, + }; + + if user_data_str.is_empty() { + return Ok(None); + } + trace!("Received user data: {}", user_data_str); + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, "user data").context(error::SettingsToJSON)?; + Ok(Some(json)) + } + + /// Read and base64 decode user data contained in an OVF file + // In VMWare, user data is supplied to the host via an XML file. Within + // the XML file, there is a `PropertySection` that contains `Property` elements + // with attributes. User data is base64 encoded inside a `Property` element with + // the attribute "user-data". + // + fn ovf_user_data>(path: P) -> Result { + let path = path.as_ref(); + let file = File::open(path).context(error::InputFileRead { path })?; + let reader = BufReader::new(file); + + // Deserialize the OVF file, dropping everything we don't care about + let ovf: Environment = + serde_xml_rs::from_reader(reader).context(error::XmlDeserialize { path })?; + + // We have seen the keys in the `Property` section be "namespaced" like "oe:key" or + // "of:key". Since we aren't trying to validate the schema beyond the presence of the + // elements we care about, we can ignore the namespacing. An example of this type of + // namespacing can be found in the unit test sample data. `serde_xml_rs` effectively + // ignores these namespaces and returns "key" / "value": + // https://github.com/Rreverser/serde-xml-rs/issues/64#issuecomment=540448434 + let mut base64_str = String::new(); + let user_data_key = "user-data"; + for property in ovf.property_section.properties { + if property.key == user_data_key { + base64_str = property.value; + break; + } + } + + // Base64 decode the &str + let decoded_bytes = base64::decode(&base64_str).context(error::Base64Decode { + base64_string: base64_str.to_string(), + })?; + + // Create a valid utf8 str + let decoded = std::str::from_utf8(&decoded_bytes).context(error::InvalidUTF8 { + base64_string: base64_str.to_string(), + })?; + + Ok(decoded.to_string()) + } +} + +impl PlatformDataProvider for CdromDataProvider { + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + + match Self::user_data() { + Err(e) => return Err(e).map_err(Into::into), + Ok(None) => warn!("No user data found."), + Ok(Some(s)) => output.push(s), + } + Ok(output) + } +} + +// =^..^= =^..^= =^..^= =^..^= + +// Minimal expected structure for an OVF file with user data +#[derive(Debug, Deserialize)] +struct Environment { + #[serde(rename = "PropertySection", default)] + pub property_section: PropertySection, +} + +#[derive(Default, Debug, Deserialize)] +struct PropertySection { + #[serde(rename = "Property", default)] + pub properties: Vec, +} + +#[derive(Debug, Deserialize)] +struct Property { + pub key: String, + pub value: String, +} + +// =^..^= =^..^= =^..^= =^..^= + +mod error { + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Unable to base64 decode string '{}': '{}'", base64_string, source))] + Base64Decode { + base64_string: String, + source: base64::DecodeError, + }, + + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display( + "Invalid (non-utf8) output from base64 string '{}': {}", + base64_string, + source + ))] + InvalidUTF8 { + base64_string: String, + source: std::str::Utf8Error, + }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("Found multiple user data files in '{}', expected 1", location))] + UserDataFileCount { location: String }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + + #[snafu(display("Unable to deserialize XML from: '{}': {}", path.display(), source))] + XmlDeserialize { + path: PathBuf, + source: serde_xml_rs::Error, + }, + } +} + +type Result = std::result::Result; + +#[cfg(test)] +mod test { + use super::*; + use std::path::PathBuf; + + fn test_data() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("test_data") + } + + #[test] + fn test_read_xml_user_data_namespaced_keys() { + let xml = test_data().join("namespaced_keys.xml"); + let expected_user_data = "settings.motd = \"hello\""; + + let actual_user_data = CdromDataProvider::ovf_user_data(xml).unwrap(); + + assert_eq!(actual_user_data, expected_user_data) + } + + #[test] + fn test_read_xml_user_data() { + let xml = test_data().join("ovf-env.xml"); + let expected_user_data = "settings.motd = \"hello\""; + + let actual_user_data = CdromDataProvider::ovf_user_data(xml).unwrap(); + + assert_eq!(actual_user_data, expected_user_data) + } +} diff --git a/sources/api/early-boot-config/src/provider/local_file.rs b/sources/api/early-boot-config/src/provider/local_file.rs new file mode 100644 index 00000000000..4ee63e72b2f --- /dev/null +++ b/sources/api/early-boot-config/src/provider/local_file.rs @@ -0,0 +1,66 @@ +//! The local_file module implements the `PlatformDataProvider` trait for gathering userdata from +//! local file + +use super::{PlatformDataProvider, SettingsJson}; +use snafu::{OptionExt, ResultExt}; +use std::fs; + +pub(crate) struct LocalFileDataProvider; + +impl LocalFileDataProvider { + pub(crate) const USER_DATA_FILE: &'static str = "/etc/early-boot-config/user-data"; +} + +impl PlatformDataProvider for LocalFileDataProvider { + fn platform_data(&self) -> std::result::Result, Box> { + let mut output = Vec::new(); + info!("'{}' exists, using it", Self::USER_DATA_FILE); + + let user_data_str = + fs::read_to_string(Self::USER_DATA_FILE).context(error::InputFileRead { + path: Self::USER_DATA_FILE, + })?; + + if user_data_str.is_empty() { + return Ok(output); + } + + // Remove outer "settings" layer before sending to API + let mut val: toml::Value = + toml::from_str(&user_data_str).context(error::TOMLUserDataParse)?; + let table = val.as_table_mut().context(error::UserDataNotTomlTable)?; + let inner = table + .remove("settings") + .context(error::UserDataMissingSettings)?; + + let json = SettingsJson::from_val(&inner, "user data").context(error::SettingsToJSON)?; + output.push(json); + + Ok(output) + } +} + +mod error { + use snafu::Snafu; + use std::io; + use std::path::PathBuf; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub(crate) enum Error { + #[snafu(display("Unable to read input file '{}': {}", path.display(), source))] + InputFileRead { path: PathBuf, source: io::Error }, + + #[snafu(display("Error serializing TOML to JSON: {}", source))] + SettingsToJSON { source: serde_json::error::Error }, + + #[snafu(display("Error parsing TOML user data: {}", source))] + TOMLUserDataParse { source: toml::de::Error }, + + #[snafu(display("TOML data did not contain 'settings' section"))] + UserDataMissingSettings, + + #[snafu(display("Data is not a TOML table"))] + UserDataNotTomlTable, + } +} diff --git a/sources/api/early-boot-config/src/settings.rs b/sources/api/early-boot-config/src/settings.rs new file mode 100644 index 00000000000..3ecd9ad3c3b --- /dev/null +++ b/sources/api/early-boot-config/src/settings.rs @@ -0,0 +1,32 @@ +//! The settings module owns the `SettingsJson` struct which contains the JSON settings data being +//! sent to the API. + +use serde::Serialize; + +/// SettingsJson represents a change that a provider would like to make in the API. +#[derive(Debug)] +pub(crate) struct SettingsJson { + pub(crate) json: String, + pub(crate) desc: String, +} + +impl SettingsJson { + /// Construct a SettingsJson from a serializable object and a description of that object, + /// which is used for logging. + /// + /// The serializable object is typically something like a toml::Value or serde_json::Value, + /// since they can be easily deserialized from text input in the platform, and manipulated as + /// desired. + pub(crate) fn from_val( + data: &impl Serialize, + desc: S, + ) -> Result + where + S: Into, + { + Ok(Self { + json: serde_json::to_string(&data)?, + desc: desc.into(), + }) + } +} diff --git a/sources/api/early-boot-config/test_data/namespaced_keys.xml b/sources/api/early-boot-config/test_data/namespaced_keys.xml new file mode 100644 index 00000000000..4a8afafc73b --- /dev/null +++ b/sources/api/early-boot-config/test_data/namespaced_keys.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/sources/api/early-boot-config/test_data/ovf-env.xml b/sources/api/early-boot-config/test_data/ovf-env.xml new file mode 100644 index 00000000000..6b0254f5374 --- /dev/null +++ b/sources/api/early-boot-config/test_data/ovf-env.xml @@ -0,0 +1,9 @@ + + + + + + + + +