diff --git a/Cargo.lock b/Cargo.lock index 4a09035..65e8d06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 87df835..76249bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..6326787 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,348 @@ +//! Unified error tree for the sunbeam CLI. +//! +//! Every module returns `Result`. 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, + }, + + /// Configuration error (missing config, invalid config, bad arguments). + #[error("{0}")] + Config(String), + + /// Network/HTTP error. + #[error("{context}")] + Network { + context: String, + #[source] + source: Option, + }, + + /// 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 = std::result::Result; + +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 for SunbeamError { + fn from(e: kube::Error) -> Self { + SunbeamError::Kube { + context: e.to_string(), + source: Some(e), + } + } +} + +impl From for SunbeamError { + fn from(e: reqwest::Error) -> Self { + SunbeamError::Network { + context: e.to_string(), + source: Some(e), + } + } +} + +impl From for SunbeamError { + fn from(e: std::io::Error) -> Self { + SunbeamError::Io { + context: "IO error".into(), + source: e, + } + } +} + +impl From for SunbeamError { + fn from(e: lettre::transport::smtp::Error) -> Self { + SunbeamError::Network { + context: format!("SMTP error: {e}"), + source: None, + } + } +} + +impl From for SunbeamError { + fn from(e: lettre::error::Error) -> Self { + SunbeamError::Other(format!("Email error: {e}")) + } +} + +impl From for SunbeamError { + fn from(e: base64::DecodeError) -> Self { + SunbeamError::Other(format!("Base64 decode error: {e}")) + } +} + +impl From 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` for adding context strings. +/// Replaces `anyhow::Context`. +pub trait ResultExt { + /// Add context to an error, converting it to `SunbeamError`. + fn ctx(self, context: &str) -> Result; + + /// Add lazy context to an error. + fn with_ctx String>(self, f: F) -> Result; +} + +impl> ResultExt for std::result::Result { + fn ctx(self, context: &str) -> Result { + 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 String>(self, f: F) -> Result { + 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 ResultExt for Option { + fn ctx(self, context: &str) -> Result { + self.ok_or_else(|| SunbeamError::Other(context.to_string())) + } + + fn with_ctx String>(self, f: F) -> Result { + self.ok_or_else(|| SunbeamError::Other(f())) + } +} + +// --------------------------------------------------------------------------- +// Convenience constructors +// --------------------------------------------------------------------------- + +impl SunbeamError { + pub fn kube(context: impl Into) -> Self { + SunbeamError::Kube { + context: context.into(), + source: None, + } + } + + pub fn config(msg: impl Into) -> Self { + SunbeamError::Config(msg.into()) + } + + pub fn network(context: impl Into) -> Self { + SunbeamError::Network { + context: context.into(), + source: None, + } + } + + pub fn secrets(msg: impl Into) -> Self { + SunbeamError::Secrets(msg.into()) + } + + pub fn build(msg: impl Into) -> Self { + SunbeamError::Build(msg.into()) + } + + pub fn identity(msg: impl Into) -> Self { + SunbeamError::Identity(msg.into()) + } + + pub fn tool(tool: impl Into, detail: impl Into) -> 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 = 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"); + } +} diff --git a/src/main.rs b/src/main.rs index a581693..290761f 100644 --- a/src/main.rs +++ b/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); + } + + std::process::exit(code); + } } } - -async fn run() -> Result<()> { - cli::dispatch().await -}