From b92700d363ebfc5230b8d2b4ac07c4367ea63ae4 Mon Sep 17 00:00:00 2001 From: Sienna Meridian Satterwhite Date: Sat, 21 Mar 2026 14:34:23 +0000 Subject: [PATCH] =?UTF-8?q?refactor:=20SDK=20core=20modules=20=E2=80=94=20?= =?UTF-8?q?error,=20config,=20output,=20constants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move foundational modules into sunbeam-sdk. All crate-internal references remain unchanged since these are sibling modules within the SDK crate. --- sunbeam-sdk/src/config.rs | 404 +++++++++++++++++++++++++++++++++++ sunbeam-sdk/src/constants.rs | 16 ++ sunbeam-sdk/src/error.rs | 365 +++++++++++++++++++++++++++++++ sunbeam-sdk/src/lib.rs | 19 ++ sunbeam-sdk/src/output.rs | 92 ++++++++ 5 files changed, 896 insertions(+) create mode 100644 sunbeam-sdk/src/config.rs create mode 100644 sunbeam-sdk/src/constants.rs create mode 100644 sunbeam-sdk/src/error.rs create mode 100644 sunbeam-sdk/src/lib.rs create mode 100644 sunbeam-sdk/src/output.rs diff --git a/sunbeam-sdk/src/config.rs b/sunbeam-sdk/src/config.rs new file mode 100644 index 0000000..b5b9726 --- /dev/null +++ b/sunbeam-sdk/src/config.rs @@ -0,0 +1,404 @@ +use crate::error::{Result, ResultExt, SunbeamError}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::OnceLock; + +// --------------------------------------------------------------------------- +// Config data model +// --------------------------------------------------------------------------- + +/// Sunbeam configuration stored at ~/.sunbeam.json. +/// +/// Supports kubectl-style named contexts. Each context bundles a domain, +/// kube context, SSH host, and infrastructure directory. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SunbeamConfig { + /// The active context name. If empty, uses "default". + #[serde(default, rename = "current-context")] + pub current_context: String, + + /// Named contexts. + #[serde(default)] + pub contexts: HashMap, + + // --- Legacy fields (migrated on load) --- + #[serde(default, skip_serializing_if = "String::is_empty")] + pub production_host: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub infra_directory: String, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub acme_email: String, +} + +/// A named context — everything needed to target a specific environment. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Context { + /// The domain suffix (e.g. "sunbeam.pt", "192.168.105.3.sslip.io"). + #[serde(default)] + pub domain: String, + + /// Kubernetes context name (e.g. "production", "sunbeam"). + #[serde(default, rename = "kube-context")] + pub kube_context: String, + + /// SSH host for production tunnel (e.g. "sienna@62.210.145.138"). + #[serde(default, rename = "ssh-host")] + pub ssh_host: String, + + /// Infrastructure directory root. + #[serde(default, rename = "infra-dir")] + pub infra_dir: String, + + /// ACME email for cert-manager. + #[serde(default, rename = "acme-email")] + pub acme_email: String, +} + +// --------------------------------------------------------------------------- +// Active context (set once at startup, read everywhere) +// --------------------------------------------------------------------------- + +static ACTIVE_CONTEXT: OnceLock = OnceLock::new(); + +/// Initialize the active context. Called once from cli::dispatch(). +pub fn set_active_context(ctx: Context) { + let _ = ACTIVE_CONTEXT.set(ctx); +} + +/// Get the active context. Panics if not initialized (should never happen +/// after dispatch starts). +pub fn active_context() -> &'static Context { + ACTIVE_CONTEXT.get().expect("active context not initialized") +} + +/// Get the domain from the active context. Returns empty string if not set. +pub fn domain() -> &'static str { + ACTIVE_CONTEXT + .get() + .map(|c| c.domain.as_str()) + .unwrap_or("") +} + +// --------------------------------------------------------------------------- +// Config file I/O +// --------------------------------------------------------------------------- + +fn config_path() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".sunbeam.json") +} + +/// Load configuration from ~/.sunbeam.json, return default if not found. +/// Migrates legacy flat config to context-based format. +pub fn load_config() -> SunbeamConfig { + let path = config_path(); + if !path.exists() { + return SunbeamConfig::default(); + } + let mut config: SunbeamConfig = match std::fs::read_to_string(&path) { + Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { + crate::output::warn(&format!( + "Failed to parse config from {}: {e}", + path.display() + )); + SunbeamConfig::default() + }), + Err(e) => { + crate::output::warn(&format!( + "Failed to read config from {}: {e}", + path.display() + )); + SunbeamConfig::default() + } + }; + + // Migrate legacy flat fields into a "production" context + if !config.production_host.is_empty() && !config.contexts.contains_key("production") { + let domain = derive_domain_from_host(&config.production_host); + config.contexts.insert( + "production".to_string(), + Context { + domain, + kube_context: "production".to_string(), + ssh_host: config.production_host.clone(), + infra_dir: config.infra_directory.clone(), + acme_email: config.acme_email.clone(), + }, + ); + if config.current_context.is_empty() { + config.current_context = "production".to_string(); + } + } + + config +} + +/// Save configuration to ~/.sunbeam.json. +pub fn save_config(config: &SunbeamConfig) -> Result<()> { + let path = config_path(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_ctx(|| { + format!("Failed to create config directory: {}", parent.display()) + })?; + } + let content = serde_json::to_string_pretty(config)?; + std::fs::write(&path, content) + .with_ctx(|| format!("Failed to save config to {}", path.display()))?; + crate::output::ok(&format!("Configuration saved to {}", path.display())); + Ok(()) +} + +/// Resolve the context to use, given CLI flags and config. +/// +/// Priority (same as kubectl): +/// 1. `--context` flag (explicit context name) +/// 2. `current-context` from config +/// 3. Default to "local" +pub fn resolve_context( + config: &SunbeamConfig, + _env_flag: &str, + context_override: Option<&str>, + domain_override: &str, +) -> Context { + let context_name = if let Some(explicit) = context_override { + explicit.to_string() + } else if !config.current_context.is_empty() { + config.current_context.clone() + } else { + "local".to_string() + }; + + let mut ctx = config + .contexts + .get(&context_name) + .cloned() + .unwrap_or_else(|| { + // Synthesize defaults for well-known names + match context_name.as_str() { + "local" => Context { + kube_context: "sunbeam".to_string(), + ..Default::default() + }, + "production" => Context { + kube_context: "production".to_string(), + ssh_host: config.production_host.clone(), + infra_dir: config.infra_directory.clone(), + acme_email: config.acme_email.clone(), + domain: derive_domain_from_host(&config.production_host), + ..Default::default() + }, + _ => Default::default(), + } + }); + + // CLI flags override context values + if !domain_override.is_empty() { + ctx.domain = domain_override.to_string(); + } + + ctx +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Derive a domain from an SSH host (e.g. "user@admin.sunbeam.pt" → "sunbeam.pt"). +fn derive_domain_from_host(host: &str) -> String { + let raw = host.split('@').last().unwrap_or(host); + let raw = raw.split(':').next().unwrap_or(raw); + let parts: Vec<&str> = raw.split('.').collect(); + if parts.len() >= 2 { + format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]) + } else { + String::new() + } +} + +/// Get production host from config or SUNBEAM_SSH_HOST environment variable. +pub fn get_production_host() -> String { + let config = load_config(); + // Check active context first + if let Some(ctx) = ACTIVE_CONTEXT.get() { + if !ctx.ssh_host.is_empty() { + return ctx.ssh_host.clone(); + } + } + if !config.production_host.is_empty() { + return config.production_host; + } + std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default() +} + +/// Infrastructure manifests directory as a Path. +pub fn get_infra_dir() -> PathBuf { + // Check active context + if let Some(ctx) = ACTIVE_CONTEXT.get() { + if !ctx.infra_dir.is_empty() { + return PathBuf::from(&ctx.infra_dir); + } + } + let configured = load_config().infra_directory; + if !configured.is_empty() { + return PathBuf::from(configured); + } + // Dev fallback + std::env::current_exe() + .ok() + .and_then(|p| p.canonicalize().ok()) + .and_then(|p| { + let mut dir = p.as_path(); + for _ in 0..10 { + dir = dir.parent()?; + if dir.join("infrastructure").is_dir() { + return Some(dir.join("infrastructure")); + } + } + None + }) + .unwrap_or_else(|| PathBuf::from("infrastructure")) +} + +/// Monorepo root directory (parent of the infrastructure directory). +pub fn get_repo_root() -> PathBuf { + get_infra_dir() + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +/// Clear configuration file. +pub fn clear_config() -> Result<()> { + let path = config_path(); + if path.exists() { + std::fs::remove_file(&path) + .with_ctx(|| format!("Failed to remove {}", path.display()))?; + crate::output::ok(&format!("Configuration cleared from {}", path.display())); + } else { + crate::output::warn("No configuration file found to clear"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = SunbeamConfig::default(); + assert!(config.current_context.is_empty()); + assert!(config.contexts.is_empty()); + } + + #[test] + fn test_derive_domain_from_host() { + assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt"); + assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138"); + assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt"); + assert_eq!(derive_domain_from_host("localhost"), ""); + } + + #[test] + fn test_legacy_migration() { + let json = r#"{ + "production_host": "sienna@62.210.145.138", + "infra_directory": "/path/to/infra", + "acme_email": "ops@sunbeam.pt" + }"#; + let config: SunbeamConfig = serde_json::from_str(json).unwrap(); + // After load_config migration, contexts would be populated. + // Here we just test the struct deserializes legacy fields. + assert_eq!(config.production_host, "sienna@62.210.145.138"); + assert!(config.contexts.is_empty()); // migration happens in load_config() + } + + #[test] + fn test_context_roundtrip() { + let mut config = SunbeamConfig::default(); + config.current_context = "production".to_string(); + config.contexts.insert( + "production".to_string(), + Context { + domain: "sunbeam.pt".to_string(), + kube_context: "production".to_string(), + ssh_host: "sienna@server.sunbeam.pt".to_string(), + infra_dir: "/home/infra".to_string(), + acme_email: "ops@sunbeam.pt".to_string(), + }, + ); + let json = serde_json::to_string(&config).unwrap(); + let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(loaded.current_context, "production"); + let ctx = loaded.contexts.get("production").unwrap(); + assert_eq!(ctx.domain, "sunbeam.pt"); + assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt"); + } + + #[test] + fn test_resolve_context_explicit_flag() { + let mut config = SunbeamConfig::default(); + config.contexts.insert( + "production".to_string(), + Context { + domain: "sunbeam.pt".to_string(), + kube_context: "production".to_string(), + ..Default::default() + }, + ); + // --context production explicitly selects the named context + let ctx = resolve_context(&config, "", Some("production"), ""); + assert_eq!(ctx.domain, "sunbeam.pt"); + assert_eq!(ctx.kube_context, "production"); + } + + #[test] + fn test_resolve_context_current_context() { + let mut config = SunbeamConfig::default(); + config.current_context = "staging".to_string(); + config.contexts.insert( + "staging".to_string(), + Context { + domain: "staging.example.com".to_string(), + ..Default::default() + }, + ); + // No --context flag, uses current-context + let ctx = resolve_context(&config, "", None, ""); + assert_eq!(ctx.domain, "staging.example.com"); + } + + #[test] + fn test_resolve_context_domain_override() { + let config = SunbeamConfig::default(); + let ctx = resolve_context(&config, "", None, "custom.example.com"); + assert_eq!(ctx.domain, "custom.example.com"); + } + + #[test] + fn test_resolve_context_defaults_local() { + let config = SunbeamConfig::default(); + // No current-context, no --context flag → defaults to "local" + let ctx = resolve_context(&config, "", None, ""); + assert_eq!(ctx.kube_context, "sunbeam"); + } + + #[test] + fn test_resolve_context_flag_overrides_current() { + let mut config = SunbeamConfig::default(); + config.current_context = "staging".to_string(); + config.contexts.insert( + "staging".to_string(), + Context { domain: "staging.example.com".to_string(), ..Default::default() }, + ); + config.contexts.insert( + "prod".to_string(), + Context { domain: "prod.example.com".to_string(), ..Default::default() }, + ); + // --context prod overrides current-context "staging" + let ctx = resolve_context(&config, "", Some("prod"), ""); + assert_eq!(ctx.domain, "prod.example.com"); + } +} diff --git a/sunbeam-sdk/src/constants.rs b/sunbeam-sdk/src/constants.rs new file mode 100644 index 0000000..5ab992e --- /dev/null +++ b/sunbeam-sdk/src/constants.rs @@ -0,0 +1,16 @@ +//! Shared constants used across multiple modules. + +pub const GITEA_ADMIN_USER: &str = "gitea_admin"; + +pub const MANAGED_NS: &[&str] = &[ + "data", + "devtools", + "ingress", + "lasuite", + "matrix", + "media", + "monitoring", + "ory", + "storage", + "vault-secrets-operator", +]; diff --git a/sunbeam-sdk/src/error.rs b/sunbeam-sdk/src/error.rs new file mode 100644 index 0000000..470774f --- /dev/null +++ b/sunbeam-sdk/src/error.rs @@ -0,0 +1,365 @@ +//! 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. +#[allow(dead_code)] +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, + }, + SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")), + SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")), + SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")), + SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")), + SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool { + tool, + detail: format!("{context}: {detail}"), + }, + 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, + }, + SunbeamError::Secrets(msg) => SunbeamError::Secrets(format!("{context}: {msg}")), + SunbeamError::Config(msg) => SunbeamError::Config(format!("{context}: {msg}")), + SunbeamError::Build(msg) => SunbeamError::Build(format!("{context}: {msg}")), + SunbeamError::Identity(msg) => SunbeamError::Identity(format!("{context}: {msg}")), + SunbeamError::ExternalTool { tool, detail } => SunbeamError::ExternalTool { + tool, + detail: format!("{context}: {detail}"), + }, + 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/sunbeam-sdk/src/lib.rs b/sunbeam-sdk/src/lib.rs new file mode 100644 index 0000000..39ddabb --- /dev/null +++ b/sunbeam-sdk/src/lib.rs @@ -0,0 +1,19 @@ +#[macro_use] +pub mod error; + +pub mod auth; +pub mod checks; +pub mod cluster; +pub mod config; +pub mod constants; +pub mod gitea; +pub mod images; +pub mod kube; +pub mod manifests; +pub mod openbao; +pub mod output; +pub mod pm; +pub mod secrets; +pub mod services; +pub mod update; +pub mod users; diff --git a/sunbeam-sdk/src/output.rs b/sunbeam-sdk/src/output.rs new file mode 100644 index 0000000..af5f5a9 --- /dev/null +++ b/sunbeam-sdk/src/output.rs @@ -0,0 +1,92 @@ +/// Print a step header. +pub fn step(msg: &str) { + println!("\n==> {msg}"); +} + +/// Print a success/info line. +pub fn ok(msg: &str) { + println!(" {msg}"); +} + +/// Print a warning to stderr. +pub fn warn(msg: &str) { + eprintln!(" WARN: {msg}"); +} + +/// Return an aligned text table. Columns padded to max width. +pub fn table(rows: &[Vec], headers: &[&str]) -> String { + if headers.is_empty() { + return String::new(); + } + + let mut col_widths: Vec = headers.iter().map(|h| h.len()).collect(); + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < col_widths.len() { + col_widths[i] = col_widths[i].max(cell.len()); + } + } + } + + let header_line: String = headers + .iter() + .enumerate() + .map(|(i, h)| format!("{:>() + .join(" "); + + let separator: String = col_widths + .iter() + .map(|&w| "-".repeat(w)) + .collect::>() + .join(" "); + + let mut lines = vec![header_line, separator]; + + for row in rows { + let cells: Vec = (0..headers.len()) + .map(|i| { + let val = row.get(i).map(|s| s.as_str()).unwrap_or(""); + format!("{: