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:
2026-03-20 13:15:26 +00:00
parent ec235685bf
commit cc0b6a833e
4 changed files with 457 additions and 11 deletions

76
Cargo.lock generated
View File

@@ -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"

View File

@@ -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
View 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");
}
}

View File

@@ -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);
}
}
}