Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
40 changes: 38 additions & 2 deletions src/api/errors.rs
Original file line number Diff line number Diff line change
@@ -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<GalliumApiErrorResponse>,
},
#[snafu(display(
"Failed to decode response body ({}) from {}. Body: {}",
ctx.status, ctx.url, ctx.body
))]
DecodeResponse {
ctx: Box<ResponseContext>,
source: serde_json::Error,
},
#[snafu(display(
"Failed to decode error response ({}) from {}. Body: {}",
ctx.status, ctx.url, ctx.body
))]
DecodeErrorResponse {
ctx: Box<ResponseContext>,
source: serde_json::Error,
},
#[snafu(display("Failed to read response body ({status}) from {url}"))]
ReadResponseBody {
status: StatusCode,
url: Box<Url>,
source: reqwest::Error,
},
#[snafu(transparent)]
Request { source: reqwest::Error },
#[snafu(transparent)]
Expand Down
46 changes: 42 additions & 4 deletions src/api/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -107,11 +111,35 @@ impl ApiClient {
eprintln!("{msg}");
}

if response.status().is_success() {
Ok(response.json::<T>().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::<T>(&bytes).context(DecodeResponseSnafu {
ctx: Box::new(ResponseContext {
status,
url,
body: truncate_body(&bytes),
}),
})
} else {
let error = serde_json::from_slice::<GalliumApiErrorResponse>(&bytes).context(
DecodeErrorResponseSnafu {
ctx: Box::new(ResponseContext {
status,
url: url.clone(),
body: truncate_body(&bytes),
}),
},
)?;
Err(ApiClientError::Api {
error: response.json::<GalliumApiErrorResponse>().await?,
status,
url,
error: Box::new(error),
})
}
}
Expand All @@ -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())
}
24 changes: 22 additions & 2 deletions src/task_common/error.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 17 additions & 14 deletions src/tasks/import/source_scan.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,9 +22,10 @@ pub async fn scan_import_sources(
qemu_img: &QemuImgCmdProvider,
source: &Path,
) -> Result<ScanResult, TaskError> {
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
Expand Down Expand Up @@ -51,17 +54,17 @@ async fn scan_directory(
qemu_img: &QemuImgCmdProvider,
dir: &Path,
) -> Result<ScanResult, TaskError> {
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()
Expand Down Expand Up @@ -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",
})
}
}
9 changes: 5 additions & 4 deletions src/tasks/login.rs
Original file line number Diff line number Diff line change
@@ -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(),
Expand All @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/tasks_internal/proxy.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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")?;
}
};
Expand Down