refactor: add thiserror error tree and tracing logging
SunbeamError enum with typed variants (Kube, Config, Network, Secrets, Build, Identity, ExternalTool, Io, Json, Yaml, Other) each mapping to a process exit code. ResultExt trait replaces anyhow's .context(). main.rs initializes tracing-subscriber with RUST_LOG env filter and routes all errors to exit codes via SunbeamError::exit_code(). Removes anyhow dependency.
This commit is contained in:
76
Cargo.lock
generated
76
Cargo.lock
generated
@@ -1982,6 +1982,15 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md5"
|
||||
version = "0.7.0"
|
||||
@@ -2063,6 +2072,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -3368,6 +3386,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -3524,7 +3551,6 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
name = "sunbeam"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -3550,8 +3576,11 @@ dependencies = [
|
||||
"sha2",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3670,6 +3699,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -3901,6 +3939,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4002,6 +4070,12 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
|
||||
@@ -6,12 +6,14 @@ description = "Sunbeam local dev stack manager"
|
||||
|
||||
[dependencies]
|
||||
# Core
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Kubernetes
|
||||
kube = { version = "0.99", features = ["client", "runtime", "derive", "ws"] }
|
||||
|
||||
348
src/error.rs
Normal file
348
src/error.rs
Normal file
@@ -0,0 +1,348 @@
|
||||
//! Unified error tree for the sunbeam CLI.
|
||||
//!
|
||||
//! Every module returns `Result<T, SunbeamError>`. Errors bubble up to `main`,
|
||||
//! which maps them to exit codes and log output.
|
||||
|
||||
/// Exit codes for the sunbeam CLI.
|
||||
pub mod exit {
|
||||
pub const SUCCESS: i32 = 0;
|
||||
pub const GENERAL: i32 = 1;
|
||||
pub const USAGE: i32 = 2;
|
||||
pub const KUBE: i32 = 3;
|
||||
pub const CONFIG: i32 = 4;
|
||||
pub const NETWORK: i32 = 5;
|
||||
pub const SECRETS: i32 = 6;
|
||||
pub const BUILD: i32 = 7;
|
||||
pub const IDENTITY: i32 = 8;
|
||||
pub const EXTERNAL_TOOL: i32 = 9;
|
||||
}
|
||||
|
||||
/// Top-level error type for the sunbeam CLI.
|
||||
///
|
||||
/// Each variant maps to a logical error category with its own exit code.
|
||||
/// Leaf errors (io, json, yaml, kube, reqwest, etc.) are converted via `From` impls.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum SunbeamError {
|
||||
/// Kubernetes API or cluster-related error.
|
||||
#[error("{context}")]
|
||||
Kube {
|
||||
context: String,
|
||||
#[source]
|
||||
source: Option<kube::Error>,
|
||||
},
|
||||
|
||||
/// Configuration error (missing config, invalid config, bad arguments).
|
||||
#[error("{0}")]
|
||||
Config(String),
|
||||
|
||||
/// Network/HTTP error.
|
||||
#[error("{context}")]
|
||||
Network {
|
||||
context: String,
|
||||
#[source]
|
||||
source: Option<reqwest::Error>,
|
||||
},
|
||||
|
||||
/// OpenBao / Vault error.
|
||||
#[error("{0}")]
|
||||
Secrets(String),
|
||||
|
||||
/// Image build error.
|
||||
#[error("{0}")]
|
||||
Build(String),
|
||||
|
||||
/// Identity / user management error (Kratos, Hydra).
|
||||
#[error("{0}")]
|
||||
Identity(String),
|
||||
|
||||
/// External tool error (kustomize, linkerd, buildctl, yarn, etc.).
|
||||
#[error("{tool}: {detail}")]
|
||||
ExternalTool { tool: String, detail: String },
|
||||
|
||||
/// IO error.
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: String,
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// JSON serialization/deserialization error.
|
||||
#[error("{0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// YAML serialization/deserialization error.
|
||||
#[error("{0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
|
||||
/// Catch-all for errors that don't fit a specific category.
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Convenience type alias used throughout the codebase.
|
||||
pub type Result<T> = std::result::Result<T, SunbeamError>;
|
||||
|
||||
impl SunbeamError {
|
||||
/// Map this error to a process exit code.
|
||||
pub fn exit_code(&self) -> i32 {
|
||||
match self {
|
||||
SunbeamError::Config(_) => exit::CONFIG,
|
||||
SunbeamError::Kube { .. } => exit::KUBE,
|
||||
SunbeamError::Network { .. } => exit::NETWORK,
|
||||
SunbeamError::Secrets(_) => exit::SECRETS,
|
||||
SunbeamError::Build(_) => exit::BUILD,
|
||||
SunbeamError::Identity(_) => exit::IDENTITY,
|
||||
SunbeamError::ExternalTool { .. } => exit::EXTERNAL_TOOL,
|
||||
SunbeamError::Io { .. } => exit::GENERAL,
|
||||
SunbeamError::Json(_) => exit::GENERAL,
|
||||
SunbeamError::Yaml(_) => exit::GENERAL,
|
||||
SunbeamError::Other(_) => exit::GENERAL,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// From impls for automatic conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl From<kube::Error> for SunbeamError {
|
||||
fn from(e: kube::Error) -> Self {
|
||||
SunbeamError::Kube {
|
||||
context: e.to_string(),
|
||||
source: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SunbeamError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
SunbeamError::Network {
|
||||
context: e.to_string(),
|
||||
source: Some(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for SunbeamError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
SunbeamError::Io {
|
||||
context: "IO error".into(),
|
||||
source: e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lettre::transport::smtp::Error> for SunbeamError {
|
||||
fn from(e: lettre::transport::smtp::Error) -> Self {
|
||||
SunbeamError::Network {
|
||||
context: format!("SMTP error: {e}"),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lettre::error::Error> for SunbeamError {
|
||||
fn from(e: lettre::error::Error) -> Self {
|
||||
SunbeamError::Other(format!("Email error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<base64::DecodeError> for SunbeamError {
|
||||
fn from(e: base64::DecodeError) -> Self {
|
||||
SunbeamError::Other(format!("Base64 decode error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::string::FromUtf8Error> for SunbeamError {
|
||||
fn from(e: std::string::FromUtf8Error) -> Self {
|
||||
SunbeamError::Other(format!("UTF-8 error: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context extension trait (replaces anyhow's .context())
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Extension trait that adds `.ctx()` to `Result<T, E>` for adding context strings.
|
||||
/// Replaces `anyhow::Context`.
|
||||
pub trait ResultExt<T> {
|
||||
/// Add context to an error, converting it to `SunbeamError`.
|
||||
fn ctx(self, context: &str) -> Result<T>;
|
||||
|
||||
/// Add lazy context to an error.
|
||||
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T>;
|
||||
}
|
||||
|
||||
impl<T, E: Into<SunbeamError>> ResultExt<T> for std::result::Result<T, E> {
|
||||
fn ctx(self, context: &str) -> Result<T> {
|
||||
self.map_err(|e| {
|
||||
let inner = e.into();
|
||||
match inner {
|
||||
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
|
||||
context: context.to_string(),
|
||||
source,
|
||||
},
|
||||
SunbeamError::Network { source, .. } => SunbeamError::Network {
|
||||
context: context.to_string(),
|
||||
source,
|
||||
},
|
||||
SunbeamError::Io { source, .. } => SunbeamError::Io {
|
||||
context: context.to_string(),
|
||||
source,
|
||||
},
|
||||
other => SunbeamError::Other(format!("{context}: {other}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
|
||||
self.map_err(|e| {
|
||||
let context = f();
|
||||
let inner = e.into();
|
||||
match inner {
|
||||
SunbeamError::Kube { source, .. } => SunbeamError::Kube {
|
||||
context,
|
||||
source,
|
||||
},
|
||||
SunbeamError::Network { source, .. } => SunbeamError::Network {
|
||||
context,
|
||||
source,
|
||||
},
|
||||
SunbeamError::Io { source, .. } => SunbeamError::Io {
|
||||
context,
|
||||
source,
|
||||
},
|
||||
other => SunbeamError::Other(format!("{context}: {other}")),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> ResultExt<T> for Option<T> {
|
||||
fn ctx(self, context: &str) -> Result<T> {
|
||||
self.ok_or_else(|| SunbeamError::Other(context.to_string()))
|
||||
}
|
||||
|
||||
fn with_ctx<F: FnOnce() -> String>(self, f: F) -> Result<T> {
|
||||
self.ok_or_else(|| SunbeamError::Other(f()))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience constructors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
impl SunbeamError {
|
||||
pub fn kube(context: impl Into<String>) -> Self {
|
||||
SunbeamError::Kube {
|
||||
context: context.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn config(msg: impl Into<String>) -> Self {
|
||||
SunbeamError::Config(msg.into())
|
||||
}
|
||||
|
||||
pub fn network(context: impl Into<String>) -> Self {
|
||||
SunbeamError::Network {
|
||||
context: context.into(),
|
||||
source: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secrets(msg: impl Into<String>) -> Self {
|
||||
SunbeamError::Secrets(msg.into())
|
||||
}
|
||||
|
||||
pub fn build(msg: impl Into<String>) -> Self {
|
||||
SunbeamError::Build(msg.into())
|
||||
}
|
||||
|
||||
pub fn identity(msg: impl Into<String>) -> Self {
|
||||
SunbeamError::Identity(msg.into())
|
||||
}
|
||||
|
||||
pub fn tool(tool: impl Into<String>, detail: impl Into<String>) -> Self {
|
||||
SunbeamError::ExternalTool {
|
||||
tool: tool.into(),
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// bail! macro replacement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Like anyhow::bail! but produces a SunbeamError::Other.
|
||||
#[macro_export]
|
||||
macro_rules! bail {
|
||||
($($arg:tt)*) => {
|
||||
return Err($crate::error::SunbeamError::Other(format!($($arg)*)))
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_exit_codes() {
|
||||
assert_eq!(SunbeamError::config("bad").exit_code(), exit::CONFIG);
|
||||
assert_eq!(SunbeamError::kube("fail").exit_code(), exit::KUBE);
|
||||
assert_eq!(SunbeamError::network("fail").exit_code(), exit::NETWORK);
|
||||
assert_eq!(SunbeamError::secrets("fail").exit_code(), exit::SECRETS);
|
||||
assert_eq!(SunbeamError::build("fail").exit_code(), exit::BUILD);
|
||||
assert_eq!(SunbeamError::identity("fail").exit_code(), exit::IDENTITY);
|
||||
assert_eq!(
|
||||
SunbeamError::tool("kustomize", "not found").exit_code(),
|
||||
exit::EXTERNAL_TOOL
|
||||
);
|
||||
assert_eq!(SunbeamError::Other("oops".into()).exit_code(), exit::GENERAL);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_formatting() {
|
||||
let e = SunbeamError::tool("kustomize", "build failed");
|
||||
assert_eq!(e.to_string(), "kustomize: build failed");
|
||||
|
||||
let e = SunbeamError::config("missing --domain");
|
||||
assert_eq!(e.to_string(), "missing --domain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_kube_from() {
|
||||
// Just verify the From impl compiles and categorizes correctly
|
||||
let e = SunbeamError::kube("test");
|
||||
assert!(matches!(e, SunbeamError::Kube { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_context_extension() {
|
||||
let result: std::result::Result<(), std::io::Error> =
|
||||
Err(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
|
||||
let mapped = result.ctx("reading config");
|
||||
assert!(mapped.is_err());
|
||||
let e = mapped.unwrap_err();
|
||||
assert!(e.to_string().starts_with("reading config"));
|
||||
assert_eq!(e.exit_code(), exit::GENERAL); // IO maps to general
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_option_context() {
|
||||
let val: Option<i32> = None;
|
||||
let result = val.ctx("value not found");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err().to_string(), "value not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bail_macro() {
|
||||
fn failing() -> Result<()> {
|
||||
bail!("something went wrong: {}", 42);
|
||||
}
|
||||
let e = failing().unwrap_err();
|
||||
assert_eq!(e.to_string(), "something went wrong: 42");
|
||||
}
|
||||
}
|
||||
38
src/main.rs
38
src/main.rs
@@ -1,3 +1,6 @@
|
||||
#[macro_use]
|
||||
mod error;
|
||||
|
||||
mod checks;
|
||||
mod cli;
|
||||
mod cluster;
|
||||
@@ -14,16 +17,35 @@ mod tools;
|
||||
mod update;
|
||||
mod users;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
if let Err(e) = run().await {
|
||||
eprintln!("\nERROR: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Initialize tracing subscriber.
|
||||
// Respects RUST_LOG env var (e.g. RUST_LOG=debug, RUST_LOG=sunbeam=trace).
|
||||
// Default: warn for dependencies, info for sunbeam.
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("sunbeam=info,warn")
|
||||
}),
|
||||
)
|
||||
.with_target(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
match cli::dispatch().await {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
let code = e.exit_code();
|
||||
tracing::error!("{e}");
|
||||
|
||||
// Print source chain for non-trivial errors
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(cause) = source {
|
||||
tracing::debug!("caused by: {cause}");
|
||||
source = std::error::Error::source(cause);
|
||||
}
|
||||
|
||||
async fn run() -> Result<()> {
|
||||
cli::dispatch().await
|
||||
std::process::exit(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user