//! This module allows application of settings from URIs or stdin. The inputs are expected to be //! TOML settings files, in the same format as user data, or the JSON equivalent. The inputs are //! pulled and applied to the API server in a single transaction. use crate::rando; use futures::future::{join, ready, TryFutureExt}; use futures::stream::{self, StreamExt}; use reqwest::Url; use serde::de::{Deserialize, IntoDeserializer}; use snafu::{futures::try_future::TryFutureExt as SnafuTryFutureExt, OptionExt, ResultExt}; use std::path::Path; use tokio::io::AsyncReadExt; /// Reads settings in TOML or JSON format from files at the requested URIs (or from stdin, if given /// "-"), then commits them in a single transaction and applies them to the system. pub async fn apply

(socket_path: P, input_sources: Vec) -> Result<()> where P: AsRef, { // We want to retrieve URIs in parallel because they're arbitrary and could be slow. First, we // build a list of request futures, and we store the source of the data with the future for // inclusion in later error messages. let mut get_requests = Vec::with_capacity(input_sources.len()); for input_source in &input_sources { let get_future = get(input_source); let info_future = ready(input_source); get_requests.push(join(info_future, get_future)); } // Stream out the requests and await responses (in order). let get_request_stream = stream::iter(get_requests).buffered(4); let get_responses: Vec<(&String, Result)> = get_request_stream.collect().await; // Reformat the responses to (model-verified) JSON we can send to the API. let mut changes = Vec::with_capacity(get_responses.len()); for (input_source, get_response) in get_responses { let response = get_response?; let json = format_change(&response, input_source)?; changes.push((input_source, json)); } // We use a specific transaction ID so we don't commit any other changes that may be pending. let transaction = format!("apiclient-apply-{}", rando()); // Send the settings changes to the server in the same transaction. (They're quick local // requests, so don't add the complexity of making them run concurrently.) for (input_source, json) in changes { let uri = format!("/settings?tx={}", transaction); let method = "PATCH"; let (_status, _body) = crate::raw_request(&socket_path, &uri, method, Some(json)) .await .context(error::PatchSnafu { input_source, 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::CommitApplySnafu { uri })?; Ok(()) } /// Retrieves the given source location and returns the result in a String. async fn get(input_source: S) -> Result where S: Into, { let input_source = input_source.into(); // Read from stdin if "-" was given. if input_source == "-" { let mut output = String::new(); tokio::io::stdin() .read_to_string(&mut output) .context(error::StdinReadSnafu) .await?; return Ok(output); } // Otherwise, the input should be a URI; parse it to know what kind. // Until reqwest handles file:// URIs: https://github.com/seanmonstar/reqwest/issues/178 let uri = Url::parse(&input_source).context(error::UriSnafu { input_source: &input_source, })?; if uri.scheme() == "file" { // Turn the URI to a file path, and return a future that reads it. let path = uri.to_file_path().ok().context(error::FileUriSnafu { input_source: &input_source, })?; tokio::fs::read_to_string(path) .context(error::FileReadSnafu { input_source }) .await } else { // Return a future that contains the text of the (non-file) URI. reqwest::get(uri) .and_then(|response| ready(response.error_for_status())) .and_then(|response| response.text()) .context(error::ReqwestSnafu { uri: input_source, method: "GET", }) .await } } /// Takes a string of TOML or JSON settings data, verifies that it fits the model, and reserializes /// it to JSON for sending to the API. fn format_change(input: &str, input_source: &str) -> Result { // Try to parse the input as (arbitrary) TOML. If that fails, try to parse it as JSON. let mut json_val = match toml::from_str::(input) { Ok(toml_val) => { // We need JSON for the API. serde lets us convert between Deserialize-able types by // reusing the deserializer. Turn the TOML value into a JSON value. let d = toml_val.into_deserializer(); serde_json::Value::deserialize(d).context(error::TomlToJsonSnafu { input_source })? } Err(toml_err) => { // TOML failed, try JSON; include the toml parsing error, because if they intended to // give TOML we should still tell them what was wrong with it. serde_json::from_str(input).context(error::InputTypeSnafu { input_source, toml_err, }) }?, }; // Remove outer "settings" layer before sending to API or deserializing it into the model, // neither of which expects it. let json_object = json_val .as_object_mut() .context(error::ModelTypeSnafu { input_source })?; let json_inner = json_object .remove("settings") .context(error::MissingSettingsSnafu { input_source })?; // Deserialize into the model to confirm the settings are valid. let _settings = model::Settings::deserialize(&json_inner) .context(error::ModelDeserializeSnafu { input_source })?; // Return JSON text we can send to the API. serde_json::to_string(&json_inner).context(error::JsonSerializeSnafu { input_source }) } mod error { use snafu::Snafu; #[derive(Debug, Snafu)] #[snafu(visibility(pub(super)))] pub enum Error { #[snafu(display("Failed to commit combined settings to '{}': {}", uri, source))] CommitApply { uri: String, #[snafu(source(from(crate::Error, Box::new)))] source: Box, }, #[snafu(display("Failed to read given file '{}': {}", input_source, source))] FileRead { input_source: String, source: std::io::Error, }, #[snafu(display("Given invalid file URI '{}'", input_source))] FileUri { input_source: String }, #[snafu(display( "Input '{}' is not valid TOML or JSON. (TOML error: {}) (JSON error: {})", input_source, toml_err, source ))] InputType { input_source: String, toml_err: toml::de::Error, source: serde_json::Error, }, #[snafu(display( "Failed to serialize settings from '{}' to JSON: {}", input_source, source ))] JsonSerialize { input_source: String, source: serde_json::Error, }, #[snafu(display( "Settings from '{}' did not contain a 'settings' key at top level", input_source ))] MissingSettings { input_source: String }, #[snafu(display( "Failed to deserialize settings from '{}' into this variant's model: {}", input_source, source ))] ModelDeserialize { input_source: String, source: serde_json::Error, }, #[snafu(display("Settings from '{}' are not a TOML table / JSON object", input_source))] ModelType { input_source: String }, #[snafu(display( "Failed to {} settings from '{}' to '{}': {}", method, input_source, uri, source ))] Patch { input_source: String, uri: String, method: String, #[snafu(source(from(crate::Error, Box::new)))] source: Box, }, #[snafu(display("Failed {} request to '{}': {}", method, uri, source))] Reqwest { method: String, uri: String, source: reqwest::Error, }, #[snafu(display("Failed to read standard input: {}", source))] StdinRead { source: std::io::Error }, #[snafu(display( "Failed to translate TOML from '{}' to JSON for API: {}", input_source, source ))] TomlToJson { input_source: String, source: toml::de::Error, }, #[snafu(display("Given invalid URI '{}': {}", input_source, source))] Uri { input_source: String, source: url::ParseError, }, } } pub use error::Error; pub type Result = std::result::Result;