diff --git a/Cargo.lock b/Cargo.lock index 89aac8a..3f27b1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -670,6 +670,7 @@ dependencies = [ "time", "tokio", "tokio-tungstenite", + "unicode-ellipsis", "unicode-segmentation", "url", "which", @@ -2242,6 +2243,16 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "unicode-ellipsis" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4012d7b80e1b8f0d9764491cbc700c034c6744fa3208fcf345b156a553d8339" +dependencies = [ + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "unicode-ident" version = "1.0.12" @@ -2256,9 +2267,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" diff --git a/Cargo.toml b/Cargo.toml index ca7b4f6..3fc79e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,8 @@ tempfile = "3.27.0" time = "0.3.47" tokio = { version = "1.50.0", features = ["rt", "macros", "io-std", "fs"] } tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "stream", "connect"], default-features = false } -unicode-segmentation = "1.12.0" +unicode-ellipsis = "0.4.0" +unicode-segmentation = "1.13.2" url = { version = "2.5.0" } which = "8.0.2" x509-parser = "0.18.1" diff --git a/src/api/errors.rs b/src/api/errors.rs index 149c58b..b56bcb6 100644 --- a/src/api/errors.rs +++ b/src/api/errors.rs @@ -1,10 +1,46 @@ use crate::api::common_api::entities::GalliumApiErrorResponse; +use reqwest::StatusCode; use snafu::prelude::*; +use url::Url; + +#[derive(Debug)] +pub struct ResponseContext { + pub status: StatusCode, + pub url: Url, + pub body: String, +} #[derive(Debug, Snafu)] +#[snafu(visibility(pub(crate)))] pub enum ApiClientError { - #[snafu(display("API Error: {:?}", error))] - Api { error: GalliumApiErrorResponse }, + #[snafu(display("API error {status} from {url}: {error:?}"))] + Api { + status: StatusCode, + url: Url, + error: Box, + }, + #[snafu(display( + "Failed to decode response body ({}) from {}. Body: {}", + ctx.status, ctx.url, ctx.body + ))] + DecodeResponse { + ctx: Box, + source: serde_json::Error, + }, + #[snafu(display( + "Failed to decode error response ({}) from {}. Body: {}", + ctx.status, ctx.url, ctx.body + ))] + DecodeErrorResponse { + ctx: Box, + source: serde_json::Error, + }, + #[snafu(display("Failed to read response body ({status}) from {url}"))] + ReadResponseBody { + status: StatusCode, + url: Box, + source: reqwest::Error, + }, #[snafu(transparent)] Request { source: reqwest::Error }, #[snafu(transparent)] diff --git a/src/api/mod.rs b/src/api/mod.rs index f075d88..fd35d19 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,5 +1,8 @@ use crate::api::common_api::entities::GalliumApiErrorResponse; -use crate::api::errors::ApiClientError; +use crate::api::errors::{ + ApiClientError, DecodeErrorResponseSnafu, DecodeResponseSnafu, ReadResponseBodySnafu, + ResponseContext, +}; use crate::api::cluster_vm_api::ClusterVmApi; use crate::api::command_v2_api::CommandApi; @@ -9,6 +12,7 @@ use crate::api::vm_service_api::VmServiceApi; use crate::helpers::auth::AccessToken; use reqwest::header; use serde::de::DeserializeOwned; +use snafu::ResultExt; use std::sync::Arc; use url::Url; @@ -107,11 +111,35 @@ impl ApiClient { eprintln!("{msg}"); } - if response.status().is_success() { - Ok(response.json::().await?) + let status = response.status(); + let url = response.url().clone(); + let bytes = response.bytes().await.context(ReadResponseBodySnafu { + status, + url: Box::new(url.clone()), + })?; + + if status.is_success() { + serde_json::from_slice::(&bytes).context(DecodeResponseSnafu { + ctx: Box::new(ResponseContext { + status, + url, + body: truncate_body(&bytes), + }), + }) } else { + let error = serde_json::from_slice::(&bytes).context( + DecodeErrorResponseSnafu { + ctx: Box::new(ResponseContext { + status, + url: url.clone(), + body: truncate_body(&bytes), + }), + }, + )?; Err(ApiClientError::Api { - error: response.json::().await?, + status, + url, + error: Box::new(error), }) } } @@ -136,3 +164,13 @@ impl ApiClient { CommandApi::new(self.clone()) } } + +fn truncate_body(bytes: &[u8]) -> String { + const MAX: usize = 2048; + let s = String::from_utf8_lossy(bytes); + if s.len() <= MAX { + return s.into_owned(); + } + let truncated = unicode_ellipsis::truncate_str(&s, MAX); + format!("{truncated} ({} bytes total)", bytes.len()) +} diff --git a/src/task_common/error.rs b/src/task_common/error.rs index ef2f833..d7161d6 100644 --- a/src/task_common/error.rs +++ b/src/task_common/error.rs @@ -1,12 +1,21 @@ use crate::api::errors::ApiClientError; use crate::helpers::helper_cmd_error::HelperCommandError; use snafu::prelude::*; +use std::path::PathBuf; #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum TaskError { - #[snafu(display("Missing or invalid input for {field}"))] - UserInputInvalid { field: &'static str }, + #[snafu(display("Failed to read input for {field}"))] + UserInputInvalid { + field: &'static str, + source: dialoguer::Error, + }, + #[snafu(display("Integer overflow computing {field}"))] + IntegerOverflow { + field: &'static str, + source: std::num::TryFromIntError, + }, #[snafu(display("Value '{val}' for {field} invalid: {reason}"))] UserInputInvalidValueReason { val: String, @@ -37,6 +46,17 @@ pub enum TaskError { }, #[snafu(display("Helper command error"))] HelperCommand { source: HelperCommandError }, + #[snafu(display("Filesystem error ({op}) at {}", path.display()))] + Filesystem { + op: &'static str, + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("Background task '{task}' panicked"))] + BackgroundTaskPanicked { + task: &'static str, + source: tokio::task::JoinError, + }, #[snafu(display("Failed to initialize {name}"))] Initialize { name: &'static str, diff --git a/src/tasks/import/source_scan.rs b/src/tasks/import/source_scan.rs index 0220d79..0db77bc 100644 --- a/src/tasks/import/source_scan.rs +++ b/src/tasks/import/source_scan.rs @@ -1,6 +1,8 @@ use crate::helpers::qemu::qemu_img_cmd_provider::QemuImgCmdProvider; use crate::helpers::qemu::qemu_img_info; -use crate::task_common::error::{HelperCommandSnafu, TaskError}; +use crate::task_common::error::{ + FilesystemSnafu, HelperCommandSnafu, IntegerOverflowSnafu, TaskError, +}; use snafu::ResultExt; use std::path::{Path, PathBuf}; use tokio::fs; @@ -20,9 +22,10 @@ pub async fn scan_import_sources( qemu_img: &QemuImgCmdProvider, source: &Path, ) -> Result { - let source_metadata = fs::metadata(source) - .await - .whatever_context::<_, TaskError>("Query filesystem metadata")?; + let source_metadata = fs::metadata(source).await.context(FilesystemSnafu { + op: "query metadata", + path: source.to_path_buf(), + })?; if source_metadata.is_dir() { scan_directory(qemu_img, source).await @@ -51,17 +54,17 @@ async fn scan_directory( qemu_img: &QemuImgCmdProvider, dir: &Path, ) -> Result { - let mut entries = fs::read_dir(dir) - .await - .whatever_context::<_, TaskError>("Read source directory")?; + let mut entries = fs::read_dir(dir).await.context(FilesystemSnafu { + op: "read directory", + path: dir.to_path_buf(), + })?; let mut sources = vec![]; let mut warnings = vec![]; - while let Some(entry) = entries - .next_entry() - .await - .whatever_context::<_, TaskError>("Read directory entry")? - { + while let Some(entry) = entries.next_entry().await.context(FilesystemSnafu { + op: "read directory entry", + path: dir.to_path_buf(), + })? { let path = entry.path(); let is_supported = path .extension() @@ -113,8 +116,8 @@ impl ImportSource { self.virtual_size_bytes .div_ceil(ONE_GB) .try_into() - .map_err(|_| TaskError::InvalidState { - reason: "virtual_size_bytes integer overflow", + .context(IntegerOverflowSnafu { + field: "virtual_size_bytes", }) } } diff --git a/src/tasks/login.rs b/src/tasks/login.rs index 9e4cb9c..dff125c 100644 --- a/src/tasks/login.rs +++ b/src/tasks/login.rs @@ -1,16 +1,17 @@ use crate::api::login_api::entities::GalliumLoginRequest; use crate::helpers::dotfile::{read_dotfile, write_dotfile}; -use crate::task_common::error::TaskError; +use crate::task_common::error::{TaskError, UserInputInvalidSnafu}; +use snafu::ResultExt; pub(crate) async fn login(args: &crate::args::GlobalArguments) -> Result<(), TaskError> { let email: String = dialoguer::Input::new() .with_prompt("email") .interact_text() - .map_err(|_| TaskError::UserInputInvalid { field: "email" })?; + .context(UserInputInvalidSnafu { field: "email" })?; let password: String = dialoguer::Password::new() .with_prompt("password") .interact() - .map_err(|_| TaskError::UserInputInvalid { field: "password" })?; + .context(UserInputInvalidSnafu { field: "password" })?; let mut login_request = GalliumLoginRequest { email: email.clone(), @@ -30,7 +31,7 @@ pub(crate) async fn login(args: &crate::args::GlobalArguments) -> Result<(), Tas .with_prompt("one-time password from your authenticator") .interact_text() .map(Some) - .map_err(|_| TaskError::UserInputInvalid { field: "otp" })?; + .context(UserInputInvalidSnafu { field: "otp" })?; } else { login_response = resp; break; diff --git a/src/tasks_internal/proxy.rs b/src/tasks_internal/proxy.rs index d0a5b70..c9c2f42 100644 --- a/src/tasks_internal/proxy.rs +++ b/src/tasks_internal/proxy.rs @@ -1,4 +1,4 @@ -use crate::task_common::error::TaskError; +use crate::task_common::error::{BackgroundTaskPanickedSnafu, TaskError}; use futures_util::{SinkExt, StreamExt}; use snafu::prelude::*; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -44,12 +44,12 @@ pub(crate) async fn proxy(args: &ProxyArguments) -> Result<(), TaskError> { tokio::select! { result = i => { result - .whatever_context::<_, TaskError>("stdin proxy task panicked")? + .context(BackgroundTaskPanickedSnafu { task: "stdin proxy" })? .whatever_context::<_, TaskError>("writing to websocket")?; } result = o => { result - .whatever_context::<_, TaskError>("stdout proxy task panicked")? + .context(BackgroundTaskPanickedSnafu { task: "stdout proxy" })? .whatever_context::<_, TaskError>("writing to stdout")?; } };