From 2e49159e908d17222f9151f1d1be00a90c3f0a62 Mon Sep 17 00:00:00 2001 From: Tom Kirchner Date: Fri, 19 Nov 2021 13:23:08 -0800 Subject: [PATCH] Add 'apiclient get' for simple API retrieval --- README.md | 2 +- sources/api/apiclient/README.md | 15 ++-- sources/api/apiclient/README.tpl | 11 ++- sources/api/apiclient/src/get.rs | 72 ++++++++++++++++ sources/api/apiclient/src/get/merge_json.rs | 96 +++++++++++++++++++++ sources/api/apiclient/src/lib.rs | 5 +- sources/api/apiclient/src/main.rs | 78 ++++++++++++++++- sources/updater/README.md | 4 +- 8 files changed, 261 insertions(+), 22 deletions(-) create mode 100644 sources/api/apiclient/src/get.rs create mode 100644 sources/api/apiclient/src/get/merge_json.rs diff --git a/README.md b/README.md index 30a8437e7a7..4ebffcec782 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ Here we'll describe the settings you can configure on your Bottlerocket instance You can see the current settings with an API request: ``` -apiclient -u /settings +apiclient get settings ``` This will return all of the current settings in JSON format. diff --git a/sources/api/apiclient/README.md b/sources/api/apiclient/README.md index d85e4439db0..34b5683606a 100644 --- a/sources/api/apiclient/README.md +++ b/sources/api/apiclient/README.md @@ -14,18 +14,17 @@ It can be pointed to another socket using `--socket-path`, for example for local The most important use is probably checking your current settings: ``` -apiclient -u /settings +apiclient get settings ``` -You can also request the values of specific settings using `keys`: +`get` will request all settings whose names start with the given prefix, so you can drill down into specific areas of interest: ``` -apiclient -u /settings?keys=settings.motd,settings.kernel.lockdown +apiclient get settings.host-containers.admin ``` -Or, request all settings whose names start with a given `prefix`. -(Note: here, the prefix should not start with "settings." since it's assumed.) +Or, request some specific settings: ``` -apiclient -u /settings?prefix=host-containers.admin +apiclient get settings.motd settings.kernel.lockdown ``` ### Set mode @@ -193,8 +192,8 @@ For example, if you want the name "FOO", you can `PATCH` to `/settings?tx=FOO` a ## apiclient library The apiclient library provides high-level methods to interact with the Bottlerocket API. See -the documentation for submodules [`apply`], [`exec`], [`reboot`], [`set`], and [`update`] for -high-level helpers. +the documentation for submodules [`apply`], [`exec`], [`get`], [`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 43d6d7fd516..f3aac066426 100644 --- a/sources/api/apiclient/README.tpl +++ b/sources/api/apiclient/README.tpl @@ -14,18 +14,17 @@ It can be pointed to another socket using `--socket-path`, for example for local The most important use is probably checking your current settings: ``` -apiclient -u /settings +apiclient get settings ``` -You can also request the values of specific settings using `keys`: +`get` will request all settings whose names start with the given prefix, so you can drill down into specific areas of interest: ``` -apiclient -u /settings?keys=settings.motd,settings.kernel.lockdown +apiclient get settings.host-containers.admin ``` -Or, request all settings whose names start with a given `prefix`. -(Note: here, the prefix should not start with "settings." since it's assumed.) +Or, request some specific settings: ``` -apiclient -u /settings?prefix=host-containers.admin +apiclient get settings.motd settings.kernel.lockdown ``` ### Set mode diff --git a/sources/api/apiclient/src/get.rs b/sources/api/apiclient/src/get.rs new file mode 100644 index 00000000000..79a36e4f290 --- /dev/null +++ b/sources/api/apiclient/src/get.rs @@ -0,0 +1,72 @@ +use snafu::{OptionExt, ResultExt}; +use std::path::Path; + +mod merge_json; +use merge_json::merge_json; + +/// Fetches the given prefixes from the API and merges them into a single Value. (It's not +/// expected that given prefixes would overlap, but if they do, later ones take precedence.) +pub async fn get_prefixes

(socket_path: P, prefixes: Vec) -> Result +where + P: AsRef, +{ + let mut results: Vec = Vec::with_capacity(prefixes.len()); + + // Fetch all given prefixes into separate Values. + for prefix in prefixes { + let uri = format!("/?prefix={}", prefix); + let method = "GET"; + let (_status, body) = crate::raw_request(&socket_path, &uri, method, None) + .await + .context(error::Request { uri, method })?; + let value = serde_json::from_str(&body).context(error::ResponseJson { body })?; + results.push(value); + } + + // Merge results together. + results + .into_iter() + .reduce(|mut merge_into, merge_from| { + merge_json(&mut merge_into, merge_from); + merge_into + }) + .context(error::NoPrefixes) +} + +/// Fetches the given URI from the API and returns the result as an untyped Value. +pub async fn get_uri

(socket_path: P, uri: String) -> Result +where + P: AsRef, +{ + let method = "GET"; + let (_status, body) = crate::raw_request(&socket_path, &uri, method, None) + .await + .context(error::Request { uri, method })?; + serde_json::from_str(&body).context(error::ResponseJson { body }) +} + +mod error { + use snafu::Snafu; + + #[derive(Debug, Snafu)] + #[snafu(visibility = "pub(super)")] + pub enum Error { + #[snafu(display("Must give prefixes to query"))] + NoPrefixes, + + #[snafu(display("Failed {} request to '{}': {}", method, uri, source))] + Request { + method: String, + uri: String, + source: crate::Error, + }, + + #[snafu(display("Response contained invalid JSON '{}' - {}", body, source))] + ResponseJson { + body: String, + source: serde_json::Error, + }, + } +} +pub use error::Error; +pub type Result = std::result::Result; diff --git a/sources/api/apiclient/src/get/merge_json.rs b/sources/api/apiclient/src/get/merge_json.rs new file mode 100644 index 00000000000..cb4c7030dc2 --- /dev/null +++ b/sources/api/apiclient/src/get/merge_json.rs @@ -0,0 +1,96 @@ +use serde_json::{map::Entry, Value}; + +/// This modifies the first given JSON Value by inserting any values from the second Value. +/// +/// This is done recursively. Any time a scalar or array is seen, the left side is set to match +/// the right side. Any time an object is seen, we iterate through the keys of the objects; if the +/// left side does not have the key from the right side, it's inserted, otherwise we recursively +/// merge the values in each object for that key. +// Logic and tests taken from storewolf::merge-toml, modified for serde_json. +pub(super) fn merge_json(merge_into: &mut Value, merge_from: Value) { + match (merge_into, merge_from) { + // If we see objects, we recursively merge each key. + (Value::Object(merge_into), Value::Object(merge_from)) => { + for (merge_from_key, merge_from_val) in merge_from.into_iter() { + // Check if the left has the same key as the right. + match merge_into.entry(merge_from_key) { + // If not, we can just insert the value. + Entry::Vacant(entry) => { + entry.insert(merge_from_val); + } + // If so, we need to recursively merge; we don't want to replace an entire + // table, for example, because the left may have some distinct inner keys. + Entry::Occupied(ref mut entry) => { + merge_json(entry.get_mut(), merge_from_val); + } + } + } + } + + // If we see a scalar, we replace the left with the right. We treat arrays like scalars so + // behavior is clear - no question about whether we're appending right onto left, etc. + (merge_into, merge_from) => { + *merge_into = merge_from; + } + } +} + +#[cfg(test)] +mod test { + use super::merge_json; + use serde_json::json; + + #[test] + fn recursion() { + let mut left = json! {{ + "top1": "left top1", + "top2": "left top2", + "settings": { + "inner": { + "inner_setting1": "left inner_setting1", + "inner_setting2": "left inner_setting2" + } + } + }}; + let right = json! {{ + "top1": "right top1", + "settings": { + "setting": "right setting", + "inner": { + "inner_setting1": "right inner_setting1", + "inner_setting3": "right inner_setting3" + } + } + }}; + let expected = json! {{ + // "top1" is being overwritten from right. + "top1": "right top1", + // "top2" is only in the left and remains. + "top2": "left top2", + "settings": { + // "setting" is only in the right side. + "setting": "right setting", + // "inner" tests that recursion works. + "inner": { + // inner_setting1 is replaced. + "inner_setting1": "right inner_setting1", + // 2 is untouched. + "inner_setting2": "left inner_setting2", + // 3 is new. + "inner_setting3": "right inner_setting3" + } + } + }}; + merge_json(&mut left, right); + assert_eq!(left, expected); + } + + #[test] + fn array() { + let mut left = json!({"a": [1, 2, 3]}); + let right = json!({"a": [4, 5]}); + let expected = json!({"a": [4, 5]}); + merge_json(&mut left, right); + assert_eq!(left, expected); + } +} diff --git a/sources/api/apiclient/src/lib.rs b/sources/api/apiclient/src/lib.rs index 33c72a664b7..b40af23d66c 100644 --- a/sources/api/apiclient/src/lib.rs +++ b/sources/api/apiclient/src/lib.rs @@ -1,8 +1,8 @@ #![deny(rust_2018_idioms)] //! The apiclient library provides high-level methods to interact with the Bottlerocket API. See -//! the documentation for submodules [`apply`], [`exec`], [`reboot`], [`set`], and [`update`] for -//! high-level helpers. +//! the documentation for submodules [`apply`], [`exec`], [`get`], [`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. @@ -23,6 +23,7 @@ use std::path::Path; pub mod apply; pub mod exec; +pub mod get; pub mod reboot; pub mod set; pub mod update; diff --git a/sources/api/apiclient/src/main.rs b/sources/api/apiclient/src/main.rs index 5ede7a82913..b7f398c29a7 100644 --- a/sources/api/apiclient/src/main.rs +++ b/sources/api/apiclient/src/main.rs @@ -6,7 +6,7 @@ // 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::{apply, exec, reboot, set, update}; +use apiclient::{apply, exec, get, reboot, set, update}; use constants; use datastore::{serialize_scalar, Key, KeyType}; use log::{info, log_enabled, trace, warn}; @@ -44,6 +44,7 @@ impl Default for Args { enum Subcommand { Apply(ApplyArgs), Exec(ExecArgs), + Get(GetArgs), Raw(RawArgs), Reboot(RebootArgs), Set(SetArgs), @@ -64,6 +65,13 @@ struct ExecArgs { tty: Option, } +/// Stores user-supplied arguments for the 'get' subcommand. +#[derive(Debug)] +enum GetArgs { + Prefixes(Vec), + Uri(String), +} + /// Stores user-supplied arguments for the 'raw' subcommand. #[derive(Debug)] struct RawArgs { @@ -122,6 +130,7 @@ fn usage() -> ! { 'raw' is the default subcommand and may be omitted. apply Applies settings from TOML/JSON files at given URIs, or from stdin. + get Retrieve and print settings. set Changes settings and applies them to the system. update check Prints information about available updates. update apply Applies available updates. @@ -142,6 +151,14 @@ fn usage() -> ! { reboot options: None. + get options: + [ PREFIX [PREFIX ...] ] The settings you want to get. Full settings names work fine, + or you can specify prefixes to fetch all settings under them. + [ /desired-uri ] The API URI to fetch. Cannot be specified with prefixes. + + If neither prefixes nor URI are specified, get will show + settings and OS info. + set options: KEY=VALUE [KEY=VALUE ...] The settings you want to set. For example: settings.motd="hi there" settings.ecs.cluster=example @@ -217,7 +234,7 @@ fn parse_args(args: env::Args) -> (Args, Subcommand) { } // Subcommands - "raw" | "apply" | "exec" | "reboot" | "set" | "update" + "raw" | "apply" | "exec" | "get" | "reboot" | "set" | "update" if subcommand.is_none() && !arg.starts_with('-') => { subcommand = Some(arg) @@ -233,6 +250,7 @@ fn parse_args(args: env::Args) -> (Args, Subcommand) { None | Some("raw") => return (global_args, parse_raw_args(subcommand_args)), Some("apply") => return (global_args, parse_apply_args(subcommand_args)), Some("exec") => return (global_args, parse_exec_args(subcommand_args)), + Some("get") => return (global_args, parse_get_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)), @@ -347,6 +365,46 @@ fn parse_exec_args(args: Vec) -> Subcommand { }) } +/// Parses arguments for the 'get' subcommand. +fn parse_get_args(args: Vec) -> Subcommand { + let mut prefixes = vec![]; + let mut uri = None; + + let mut iter = args.into_iter(); + while let Some(arg) = iter.next() { + match &arg { + x if x.starts_with('-') => usage_msg(&format!("Unknown argument '{}'", x)), + + x if x.starts_with('/') => { + if let Some(_existing_val) = uri.replace(arg) { + usage_msg("You can only specify one URI."); + } + } + + // All other arguments are settings prefixes to fetch. + _ => prefixes.push(arg), + } + } + + if let Some(uri) = uri { + if !prefixes.is_empty() { + usage_msg("You can specify prefixes or a URI, but not both."); + } + Subcommand::Get(GetArgs::Uri(uri)) + } else if !prefixes.is_empty() { + if uri.is_some() { + usage_msg("You can specify prefixes or a URI, but not both."); + } + Subcommand::Get(GetArgs::Prefixes(prefixes)) + } else { + // A reasonable default is showing OS info and settings. + Subcommand::Get(GetArgs::Prefixes(vec![ + "os.".to_string(), + "settings.".to_string(), + ])) + } +} + /// Parses arguments for the 'reboot' subcommand. fn parse_reboot_args(args: Vec) -> Subcommand { if !args.is_empty() { @@ -607,6 +665,17 @@ async fn run() -> Result<()> { .context(error::Exec)?; } + Subcommand::Get(get) => { + let result = match get { + GetArgs::Uri(uri) => get::get_uri(&args.socket_path, uri).await, + GetArgs::Prefixes(prefixes) => get::get_prefixes(&args.socket_path, prefixes).await, + }; + let value = result.context(error::Get)?; + let pretty = + serde_json::to_string_pretty(&value).expect("JSON Value already validated as JSON"); + println!("{}", pretty); + } + Subcommand::Reboot(_reboot) => { reboot::reboot(&args.socket_path) .await @@ -692,7 +761,7 @@ async fn main() { } mod error { - use apiclient::{apply, exec, reboot, set, update}; + use apiclient::{apply, exec, get, reboot, set, update}; use snafu::Snafu; #[derive(Debug, Snafu)] @@ -715,6 +784,9 @@ mod error { #[snafu(display("Failed to exec: {}", source))] Exec { source: exec::Error }, + #[snafu(display("Failed to get settings: {}", source))] + Get { source: get::Error }, + #[snafu(display("Logger setup error: {}", source))] Logger { source: log::SetLoggerError }, diff --git a/sources/updater/README.md b/sources/updater/README.md index 9b5979853af..ee711f405ae 100644 --- a/sources/updater/README.md +++ b/sources/updater/README.md @@ -85,7 +85,7 @@ apiclient raw -u /actions/refresh-updates -m POST Now you can see the list of available updates, along with the chosen update, according to your `version-lock` [setting](../../README.md#updates-settings): ``` -apiclient raw -u /updates/status +apiclient get /updates/status ``` This will return the current update status in JSON format. The status should look something like the following (pretty-printed): @@ -128,7 +128,7 @@ apiclient raw -u /actions/prepare-update -m POST After you request that the update be prepared, you can check the update status again until it reflects the new version in the staging partition. ``` -apiclient raw -u /updates/status +apiclient get /updates/status ``` If the staging partition shows the new version, you can proceed to "activate" the update.