From 41ad62a0b44b1dcc3289117c2cba0aa9ba99bfce Mon Sep 17 00:00:00 2001 From: Tom Kirchner Date: Mon, 30 Nov 2020 23:23:10 +0000 Subject: [PATCH 1/2] apiclient: move 'reboot' to a library submodule --- sources/api/apiclient/README.md | 13 +++++++++-- sources/api/apiclient/README.tpl | 9 ++++++++ sources/api/apiclient/src/lib.rs | 5 +++-- sources/api/apiclient/src/main.rs | 27 +++++++++------------- sources/api/apiclient/src/reboot.rs | 35 +++++++++++++++++++++++++++++ 5 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 sources/api/apiclient/src/reboot.rs diff --git a/sources/api/apiclient/README.md b/sources/api/apiclient/README.md index ad27c43266d..259a494521d 100644 --- a/sources/api/apiclient/README.md +++ b/sources/api/apiclient/README.md @@ -37,6 +37,15 @@ apiclient update apply --check --reboot > Note that available updates are controlled by your settings under `settings.updates`; see [README](../../../README.md#updates-settings) for details. +### Reboot mode + +This will reboot the system. +You should use this after updating if you didn't specify the `--reboot` flag. + +``` +apiclient reboot +``` + ### Raw mode Raw mode lets you make HTTP requests to a UNIX socket. @@ -77,8 +86,8 @@ apiclient raw -m GET -u /tx ## apiclient library -The apiclient library provides high-level methods to interact with the Bottlerocket API. See -the documentation for the [`update`] submodule for high-level helpers. +The apiclient library provides high-level methods to interact with the Bottlerocket API. +See the documentation for submodules like [`reboot`] and [`update`] for high-level helpers. For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods to query an HTTP API over a Unix-domain socket. diff --git a/sources/api/apiclient/README.tpl b/sources/api/apiclient/README.tpl index 236c4d110e8..0d3882cddc2 100644 --- a/sources/api/apiclient/README.tpl +++ b/sources/api/apiclient/README.tpl @@ -37,6 +37,15 @@ apiclient update apply --check --reboot > Note that available updates are controlled by your settings under `settings.updates`; see [README](../../../README.md#updates-settings) for details. +### Reboot mode + +This will reboot the system. +You should use this after updating if you didn't specify the `--reboot` flag. + +``` +apiclient reboot +``` + ### Raw mode Raw mode lets you make HTTP requests to a UNIX socket. diff --git a/sources/api/apiclient/src/lib.rs b/sources/api/apiclient/src/lib.rs index 8988ca068ad..0271838d8d7 100644 --- a/sources/api/apiclient/src/lib.rs +++ b/sources/api/apiclient/src/lib.rs @@ -1,7 +1,7 @@ #![deny(rust_2018_idioms)] -//! The apiclient library provides high-level methods to interact with the Bottlerocket API. See -//! the documentation for the [`update`] submodule for high-level helpers. +//! The apiclient library provides high-level methods to interact with the Bottlerocket API. +//! See the documentation for submodules like [`reboot`] and [`update`] for high-level helpers. //! //! For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods //! to query an HTTP API over a Unix-domain socket. @@ -19,6 +19,7 @@ use hyper_unix_connector::{UnixClient, Uri}; use snafu::{ensure, ResultExt}; use std::path::Path; +pub mod reboot; pub mod update; mod error { diff --git a/sources/api/apiclient/src/main.rs b/sources/api/apiclient/src/main.rs index f3dd452e18f..2c4fbe4d02f 100644 --- a/sources/api/apiclient/src/main.rs +++ b/sources/api/apiclient/src/main.rs @@ -2,7 +2,7 @@ //! API, for example an `update` subcommand that wraps the individual API calls needed to update //! the host. There's also a low-level `raw` subcommand for direct interaction. -use apiclient::update; +use apiclient::{reboot, update}; use log::{info, log_enabled, trace, warn}; use simplelog::{ConfigBuilder as LogConfigBuilder, LevelFilter, TermLogger, TerminalMode}; use snafu::ResultExt; @@ -287,18 +287,6 @@ fn parse_cancel_args(args: Vec) -> UpdateSubcommand { // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= -/// Requests a reboot through the API. -async fn reboot(args: &Args) -> Result<()> { - let uri = "/actions/reboot"; - let method = "POST"; - let (_status, _body) = apiclient::raw_request(&args.socket_path, uri, method, None) - .await - .context(error::Request { uri, method })?; - - info!("Rebooting, goodbye..."); - Ok(()) -} - /// Requests an update status check through the API, printing the updated status, in a pretty /// format if possible. async fn check(args: &Args) -> Result { @@ -353,7 +341,9 @@ async fn run() -> Result<()> { } Subcommand::Reboot(_reboot) => { - reboot(&args).await?; + reboot::reboot(&args.socket_path) + .await + .context(error::Reboot)?; } Subcommand::Update(subcommand) => match subcommand { @@ -378,7 +368,9 @@ async fn run() -> Result<()> { // If the user requested it, and if we applied an update, reboot. (update::apply // will fail if no update was available or it couldn't apply the update.) if apply.reboot { - reboot(&args).await?; + reboot::reboot(&args.socket_path) + .await + .context(error::Reboot)?; } else { info!("Update has been applied and will take effect on next reboot."); } @@ -407,7 +399,7 @@ async fn main() { } mod error { - use apiclient::update; + use apiclient::{reboot, update}; use snafu::Snafu; #[derive(Debug, Snafu)] @@ -425,6 +417,9 @@ mod error { #[snafu(display("Logger setup error: {}", source))] Logger { source: log::SetLoggerError }, + #[snafu(display("Failed to reboot: {}", source))] + Reboot { source: reboot::Error }, + #[snafu(display("Failed {} request to '{}': {}", method, uri, source))] Request { method: String, diff --git a/sources/api/apiclient/src/reboot.rs b/sources/api/apiclient/src/reboot.rs new file mode 100644 index 00000000000..83d1d627819 --- /dev/null +++ b/sources/api/apiclient/src/reboot.rs @@ -0,0 +1,35 @@ +use log::info; +use snafu::ResultExt; +use std::path::Path; + +/// Requests a reboot through the API. +pub async fn reboot

(socket_path: P) -> Result<()> +where + P: AsRef, +{ + let uri = "/actions/reboot"; + let method = "POST"; + let (_status, _body) = crate::raw_request(&socket_path, uri, method, None) + .await + .context(error::Request { uri, method })?; + + info!("Rebooting, goodbye..."); + Ok(()) +} + +mod error { + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub enum Error { + #[snafu(display("Failed {} request to '{}': {}", method, uri, source))] + Request { + method: String, + uri: String, + source: crate::Error, + }, + } +} +pub use error::Error; +pub type Result = std::result::Result; From 357bfc01cd52bc9851eab3ea18c72721e9c0215b Mon Sep 17 00:00:00 2001 From: Tom Kirchner Date: Sat, 5 Dec 2020 00:05:56 +0000 Subject: [PATCH 2/2] apiclient: add high-level 'set' subcommand for changing settings This gives a more natural key=value input format suitable for most changes, and takes care of committing and applying the API transaction. --- sources/Cargo.lock | 3 + sources/api/apiclient/Cargo.toml | 3 + sources/api/apiclient/README.md | 52 +++++++- sources/api/apiclient/README.tpl | 48 +++++++ sources/api/apiclient/src/lib.rs | 5 +- sources/api/apiclient/src/main.rs | 205 +++++++++++++++++++++++++++++- sources/api/apiclient/src/set.rs | 61 +++++++++ 7 files changed, 368 insertions(+), 9 deletions(-) create mode 100644 sources/api/apiclient/src/set.rs diff --git a/sources/Cargo.lock b/sources/Cargo.lock index cb254422f72..6728d700352 100644 --- a/sources/Cargo.lock +++ b/sources/Cargo.lock @@ -366,10 +366,13 @@ name = "apiclient" version = "0.1.0" dependencies = [ "cargo-readme", + "datastore", "http", "hyper", "hyper-unix-connector", "log", + "models", + "rand 0.8.1", "serde_json", "simplelog", "snafu", diff --git a/sources/api/apiclient/Cargo.toml b/sources/api/apiclient/Cargo.toml index 1b64508fbe5..2d6807544e8 100644 --- a/sources/api/apiclient/Cargo.toml +++ b/sources/api/apiclient/Cargo.toml @@ -10,12 +10,15 @@ build = "build.rs" exclude = ["README.md"] [dependencies] +datastore = { path = "../datastore" } http = "0.2" hyper = { version = "0.13", default-features = false } hyper-unix-connector = "0.1" # when we update hyper to 0.14+ and tokio to 1 # hyper-unix-connector = "0.2" log = "0.4" +models = { path = "../../models" } +rand = "0.8" serde_json = "1.0" simplelog = "0.9" snafu = "0.6" diff --git a/sources/api/apiclient/README.md b/sources/api/apiclient/README.md index 259a494521d..a215271b958 100644 --- a/sources/api/apiclient/README.md +++ b/sources/api/apiclient/README.md @@ -10,6 +10,54 @@ There's also a low-level `raw` subcommand for direct interaction with the HTTP A It talks to the Bottlerocket socket by default. It can be pointed to another socket using `--socket-path`, for example for local testing. +### Set mode + +This allows you to change settings on the system. + +After the settings are changed, they'll be committed and applied. +For example, if you change an NTP setting, the NTP configuration will be updated and the daemon will be restarted. + +#### Key=value input + +There are two input methods. +The simpler method looks like this: + +``` +apiclient set settings.x.y.z=VALUE +``` + +The "settings." prefix on the setting names is optional; this makes it easy to copy and paste settings from documentation, but you can skip the prefix when typing them manually. +Here's an example call: + +``` +apiclient set kernel.lockdown=integrity motd="hi there" +``` + +If you're changing a setting whose name requires quoting, please quote the whole key=value argument, so the inner quotes aren't eaten by the shell: + +``` +apiclient set 'kubernetes.node-labels."my.label"=hello' +``` + +#### JSON input + +This simpler key=value form is convenient for most changes, but sometimes you'll want to specify input in JSON form. +This can be useful if you have multiple changes within a subsection: + +``` +apiclient set --json '{"kernel": {"sysctl": {"vm.max_map_count": "262144", "user.max_user_namespaces": "16384"}}}' +``` + +It can also be useful if your desired value is "complex" or looks like a different type. +For example, the "vm.max_map_count" value set above looks like an integer, but the kernel requires a string, so it has to be specified in JSON form and as a string. + +As another example, if you want settings.motd to be "42", running `apiclient set motd=42` would fail because `42` is seen as an integer, and motd is a string. +You can use JSON form to set it: + +``` +apiclient set --json '{"motd": "42"}' +``` + ### Update mode To start, you can check what updates are available: @@ -86,8 +134,8 @@ apiclient raw -m GET -u /tx ## apiclient library -The apiclient library provides high-level methods to interact with the Bottlerocket API. -See the documentation for submodules like [`reboot`] and [`update`] for high-level helpers. +The apiclient library provides high-level methods to interact with the Bottlerocket API. See +the documentation for submodules [`reboot`], [`set`], and [`update`] for high-level helpers. For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods to query an HTTP API over a Unix-domain socket. diff --git a/sources/api/apiclient/README.tpl b/sources/api/apiclient/README.tpl index 0d3882cddc2..4c8396e3339 100644 --- a/sources/api/apiclient/README.tpl +++ b/sources/api/apiclient/README.tpl @@ -10,6 +10,54 @@ There's also a low-level `raw` subcommand for direct interaction with the HTTP A It talks to the Bottlerocket socket by default. It can be pointed to another socket using `--socket-path`, for example for local testing. +### Set mode + +This allows you to change settings on the system. + +After the settings are changed, they'll be committed and applied. +For example, if you change an NTP setting, the NTP configuration will be updated and the daemon will be restarted. + +#### Key=value input + +There are two input methods. +The simpler method looks like this: + +``` +apiclient set settings.x.y.z=VALUE +``` + +The "settings." prefix on the setting names is optional; this makes it easy to copy and paste settings from documentation, but you can skip the prefix when typing them manually. +Here's an example call: + +``` +apiclient set kernel.lockdown=integrity motd="hi there" +``` + +If you're changing a setting whose name requires quoting, please quote the whole key=value argument, so the inner quotes aren't eaten by the shell: + +``` +apiclient set 'kubernetes.node-labels."my.label"=hello' +``` + +#### JSON input + +This simpler key=value form is convenient for most changes, but sometimes you'll want to specify input in JSON form. +This can be useful if you have multiple changes within a subsection: + +``` +apiclient set --json '{"kernel": {"sysctl": {"vm.max_map_count": "262144", "user.max_user_namespaces": "16384"}}}' +``` + +It can also be useful if your desired value is "complex" or looks like a different type. +For example, the "vm.max_map_count" value set above looks like an integer, but the kernel requires a string, so it has to be specified in JSON form and as a string. + +As another example, if you want settings.motd to be "42", running `apiclient set motd=42` would fail because `42` is seen as an integer, and motd is a string. +You can use JSON form to set it: + +``` +apiclient set --json '{"motd": "42"}' +``` + ### Update mode To start, you can check what updates are available: diff --git a/sources/api/apiclient/src/lib.rs b/sources/api/apiclient/src/lib.rs index 0271838d8d7..59663b203ae 100644 --- a/sources/api/apiclient/src/lib.rs +++ b/sources/api/apiclient/src/lib.rs @@ -1,7 +1,7 @@ #![deny(rust_2018_idioms)] -//! The apiclient library provides high-level methods to interact with the Bottlerocket API. -//! See the documentation for submodules like [`reboot`] and [`update`] for high-level helpers. +//! The apiclient library provides high-level methods to interact with the Bottlerocket API. See +//! the documentation for submodules [`reboot`], [`set`], and [`update`] for high-level helpers. //! //! For more control, and to handle APIs without high-level wrappers, there are also 'raw' methods //! to query an HTTP API over a Unix-domain socket. @@ -20,6 +20,7 @@ use snafu::{ensure, ResultExt}; use std::path::Path; pub mod reboot; +pub mod set; pub mod update; mod error { diff --git a/sources/api/apiclient/src/main.rs b/sources/api/apiclient/src/main.rs index 2c4fbe4d02f..f23519020ca 100644 --- a/sources/api/apiclient/src/main.rs +++ b/sources/api/apiclient/src/main.rs @@ -2,10 +2,16 @@ //! API, for example an `update` subcommand that wraps the individual API calls needed to update //! the host. There's also a low-level `raw` subcommand for direct interaction. -use apiclient::{reboot, update}; +// This file contains the arg parsing and high-level behavior. (Massaging input data, making +// library calls based on the given flags, etc.) The library modules contain the code for talking +// to the API, which is intended to be reusable by other crates. + +use apiclient::{reboot, set, update}; +use datastore::{serialize_scalar, Key, KeyType}; use log::{info, log_enabled, trace, warn}; use simplelog::{ConfigBuilder as LogConfigBuilder, LevelFilter, TermLogger, TerminalMode}; use snafu::ResultExt; +use std::collections::HashMap; use std::env; use std::process; use std::str::FromStr; @@ -35,6 +41,7 @@ impl Default for Args { enum Subcommand { Raw(RawArgs), Reboot(RebootArgs), + Set(SetArgs), Update(UpdateSubcommand), } @@ -50,6 +57,13 @@ struct RawArgs { #[derive(Debug)] struct RebootArgs {} +/// Stores user-supplied arguments for the 'set' subcommand. +#[derive(Debug)] +enum SetArgs { + Simple(HashMap), + Json(serde_json::Value), +} + /// Stores the 'update' subcommand specified by the user. #[derive(Debug)] enum UpdateSubcommand { @@ -76,7 +90,7 @@ struct CancelArgs {} /// Informs the user about proper usage of the program and exits. fn usage() -> ! { let msg = &format!( - r"Usage: apiclient [SUBCOMMAND] [OPTION]... + r#"Usage: apiclient [SUBCOMMAND] [OPTION]... Global options: -s, --socket-path PATH Override the server socket path. Default: {socket} @@ -87,6 +101,7 @@ fn usage() -> ! { Subcommands: raw Makes an HTTP request and prints the response on stdout. 'raw' is the default subcommand and may be omitted. + set Changes settings and applies them to the system. update check Prints information about available updates. update apply Applies available updates. update cancel Deactivates an applied update. @@ -100,6 +115,17 @@ fn usage() -> ! { reboot options: None. + set options: + KEY=VALUE [KEY=VALUE ...] The settings you want to set. For example: + settings.motd="hi there" settings.ecs.cluster=example + The "settings." prefix is optional. + Settings with dots in the name require nested quotes: + 'kubernetes.node-labels."my.label"=hello' + -j, --json JSON Alternatively, you can specify settings in JSON format, + which can simplify setting multiple values, and is necessary + for some numeric settings. For example: + -j '{{"kernel": {{"sysctl": {{"vm.max_map_count": "262144"}}}}}}' + update check options: None. @@ -108,7 +134,7 @@ fn usage() -> ! { -r, --reboot Automatically reboot if an update was found and applied. update cancel options: - None.", + None."#, socket = DEFAULT_API_SOCKET, method = DEFAULT_METHOD, ); @@ -123,6 +149,7 @@ fn usage_msg>(msg: S) -> ! { } // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +// Arg parsing /// Parses user arguments into an Args structure. fn parse_args(args: env::Args) -> (Args, Subcommand) { @@ -155,7 +182,9 @@ fn parse_args(args: env::Args) -> (Args, Subcommand) { } // Subcommands - "raw" | "reboot" | "update" if subcommand.is_none() && !arg.starts_with('-') => { + "raw" | "reboot" | "set" | "update" + if subcommand.is_none() && !arg.starts_with('-') => + { subcommand = Some(arg) } @@ -168,6 +197,7 @@ fn parse_args(args: env::Args) -> (Args, Subcommand) { // Default subcommand is 'raw' None | Some("raw") => return (global_args, parse_raw_args(subcommand_args)), Some("reboot") => return (global_args, parse_reboot_args(subcommand_args)), + Some("set") => return (global_args, parse_set_args(subcommand_args)), Some("update") => return (global_args, parse_update_args(subcommand_args)), _ => usage_msg("Missing or unknown subcommand"), } @@ -223,6 +253,89 @@ fn parse_reboot_args(args: Vec) -> Subcommand { Subcommand::Reboot(RebootArgs {}) } +/// Parses arguments for the 'set' subcommand. +// Note: the API doesn't allow setting non-settings keys, e.g. services, configuration-files, and +// metadata. If we allow it in the future, we should revisit this 'set' parsing code and decide +// what formats to accept. This code currently makes it as convenient as possible to set settings, +// by adding/removing a "settings" prefix as necessary. +fn parse_set_args(args: Vec) -> Subcommand { + let mut simple = HashMap::new(); + let mut json = None; + + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match arg.as_ref() { + "-j" | "--json" if json.is_some() => { + usage_msg( + "Can't specify the --json argument multiple times. You can set as many \ + settings as needed within the JSON object.", + ); + } + "-j" | "--json" if json.is_none() => { + let raw_json = iter + .next() + .unwrap_or_else(|| usage_msg("Did not give argument to -j | --json")); + + let input_val: serde_json::Value = + serde_json::from_str(&raw_json).unwrap_or_else(|e| { + usage_msg(&format!("Couldn't parse given JSON input: {}", e)) + }); + + let mut input_map = match input_val { + serde_json::Value::Object(map) => map, + _ => usage_msg("JSON input must be an object (map)"), + }; + + // To be nice, if the user specified a "settings" layer around their data, we + // remove it. (This should only happen if there's a single key, since we only + // allow setting settings; fail otherwise. If we allow setting other types in the + // future, we'll have to do more map manipulation here to save the other values.) + if let Some(val) = input_map.remove("settings") { + match val { + serde_json::Value::Object(map) => input_map.extend(map), + _ => usage_msg("JSON 'settings' value must be an object (map)"), + }; + } + + json = Some(input_map.into()); + } + + x if x.contains('=') => { + let mut split = x.splitn(2, '='); + let raw_key = split.next().unwrap(); + let value = split.next().unwrap(); + + let mut key = Key::new(KeyType::Data, raw_key).unwrap_or_else(|_| { + usage_msg(&format!("Given key '{}' is not a valid format", raw_key)) + }); + + // Add "settings" prefix if the user didn't give a known prefix, to ease usage + let key_prefix = &key.segments()[0]; + if key_prefix != "settings" { + let mut segments = key.segments().clone(); + segments.insert(0, "settings".to_string()); + key = Key::from_segments(KeyType::Data, &segments) + .expect("Adding prefix to key resulted in invalid key?!"); + } + + simple.insert(key, value.to_string()); + } + + x => usage_msg(&format!("Unknown argument '{}'", x)), + } + } + + if json.is_some() && !simple.is_empty() { + usage_msg("Cannot specify key=value pairs and --json settings with 'set'"); + } else if let Some(json) = json { + Subcommand::Set(SetArgs::Json(json)) + } else if !simple.is_empty() { + Subcommand::Set(SetArgs::Simple(simple)) + } else { + usage_msg("Must specify key=value settings or --json settings with 'set'"); + } +} + /// Parses the desired subcommand of 'update'. fn parse_update_args(args: Vec) -> Subcommand { let mut subcommand = None; @@ -286,6 +399,7 @@ fn parse_cancel_args(args: Vec) -> UpdateSubcommand { } // =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +// Helpers /// Requests an update status check through the API, printing the updated status, in a pretty /// format if possible. @@ -305,6 +419,44 @@ async fn check(args: &Args) -> Result { Ok(output) } +/// We want the key=val form of 'set' to be as simple as possible; we don't want users to have to +/// annotate or structure their input too much just to tell us the data type, but unfortunately +/// knowledge of the data type is required to deserialize with the current datastore ser/de code. +/// +/// To simplify usage, we use some heuristics to determine the type of each input. We try to parse +/// each value as a number and boolean, and if those fail, we assume a string. (API communication +/// is in JSON form, limiting the set of types; the API doesn't allow arrays or null, and "objects" +/// (maps) are represented natively through our nested tree-like settings structure.) +/// +/// If this goes wrong -- for example the user wants a string "42" -- we'll get a deserialization +/// error, and can print a clear error and request the user use JSON input form to handle +/// situations with more complex types. +/// +/// If you have an idea for how to improve deserialization so we don't have to do this, please say! +fn massage_set_input(input_map: HashMap) -> Result> { + // Deserialize the given value into the matching Rust type. When we find a matching type, we + // serialize back out to the data store format, which is required to build a Settings object + // through the data store deserialization code. + let mut massaged_map = HashMap::with_capacity(input_map.len()); + for (key, in_val) in input_map { + let serialized = if let Ok(b) = serde_json::from_str::(&in_val) { + serialize_scalar(&b).context(error::Serialize)? + } else if let Ok(u) = serde_json::from_str::(&in_val) { + serialize_scalar(&u).context(error::Serialize)? + } else if let Ok(f) = serde_json::from_str::(&in_val) { + serialize_scalar(&f).context(error::Serialize)? + } else { + // No deserialization, already a string, just serialize + serialize_scalar(&in_val).context(error::Serialize)? + }; + massaged_map.insert(key, serialized); + } + Ok(massaged_map) +} + +// =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= =^..^= +// Main dispatch + /// Main entry point, dispatches subcommands. async fn run() -> Result<()> { let (args, subcommand) = parse_args(env::args()); @@ -346,6 +498,32 @@ async fn run() -> Result<()> { .context(error::Reboot)?; } + Subcommand::Set(set) => { + let settings: model::Settings; + match set { + SetArgs::Simple(input_map) => { + // For key=val, we need some type information to deserialize into a Settings. + trace!("Original key=value input: {:#?}", input_map); + let massaged_map = massage_set_input(input_map)?; + trace!("Massaged key=value input: {:#?}", massaged_map); + + // The data store deserialization code understands how to turn the key names + // (a.b.c) and serialized values into the nested Settings structure. + settings = datastore::deserialization::from_map(&massaged_map) + .context(error::DeserializeMap)?; + } + SetArgs::Json(json) => { + // No processing to do on JSON input; the format determines the types. serde + // can turn a Value into the nested Settings structure itself. + settings = serde_json::from_value(json).context(error::DeserializeJson)?; + } + }; + + set::set(&args.socket_path, &settings) + .await + .context(error::Set)?; + } + Subcommand::Update(subcommand) => match subcommand { UpdateSubcommand::Check(_check) => { check(&args).await?; @@ -399,7 +577,7 @@ async fn main() { } mod error { - use apiclient::{reboot, update}; + use apiclient::{reboot, set, update}; use snafu::Snafu; #[derive(Debug, Snafu)] @@ -414,6 +592,17 @@ mod error { #[snafu(display("Failed to check for updates: {}", source))] Check { source: update::Error }, + #[snafu(display("Unable to deserialize input JSON into model: {}", source))] + DeserializeJson { source: serde_json::Error }, + + // This is an important error, it's shown when the user uses 'apiclient set' with the + // key=value form and we don't have enough data to deserialize the value. It's not the + // user's fault and so we want to be very clear and give an alternative. + #[snafu(display("Unable to match your input to the data model. We may not have enough type information. Please try the --json input form. Cause: {}", source))] + DeserializeMap { + source: datastore::deserialization::Error, + }, + #[snafu(display("Logger setup error: {}", source))] Logger { source: log::SetLoggerError }, @@ -426,6 +615,12 @@ mod error { uri: String, source: apiclient::Error, }, + + #[snafu(display("Unable to serialize data: {}", source))] + Serialize { source: serde_json::Error }, + + #[snafu(display("Failed to change settings: {}", source))] + Set { source: set::Error }, } } type Result = std::result::Result; diff --git a/sources/api/apiclient/src/set.rs b/sources/api/apiclient/src/set.rs new file mode 100644 index 00000000000..58396fd13cf --- /dev/null +++ b/sources/api/apiclient/src/set.rs @@ -0,0 +1,61 @@ +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use snafu::ResultExt; +use std::path::Path; + +/// Changes the requested settings through the API, then commits and applies the transaction +/// containing those changes. The given Settings only has to be populated (i.e. Option::Some) with +/// the settings you want to change. If you're deserializing a request from a user, for example, +/// the created Settings will only have the requested keys populated. +pub async fn set

(socket_path: P, settings: &model::Settings) -> Result<()> +where + P: AsRef, +{ + // We use a specific transaction ID so we don't commit any other changes that may be pending. + let transaction = format!("apiclient-set-{}", rando()); + + // Send the settings changes to the server. + let uri = format!("/settings?tx={}", transaction); + let method = "PATCH"; + let request_body = serde_json::to_string(&settings).context(error::Serialize)?; + let (_status, _body) = crate::raw_request(&socket_path, &uri, method, Some(request_body)) + .await + .context(error::Request { uri, method })?; + + // Commit the transaction and apply it to the system. + let uri = format!("/tx/commit_and_apply?tx={}", transaction); + let method = "POST"; + let (_status, _body) = crate::raw_request(&socket_path, &uri, method, None) + .await + .context(error::Request { uri, method })?; + + Ok(()) +} + +/// Generates a random ID, affectionately known as a 'rando'. +fn rando() -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(16) + .map(char::from) + .collect() +} + +mod error { + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub enum Error { + #[snafu(display("Unable to serialize data: {}", source))] + Serialize { source: serde_json::Error }, + + #[snafu(display("Failed {} request to '{}': {}", method, uri, source))] + Request { + method: String, + uri: String, + source: crate::Error, + }, + } +} +pub use error::Error; +pub type Result = std::result::Result;