refactor: SDK core modules — error, config, output, constants
Move foundational modules into sunbeam-sdk. All crate-internal references remain unchanged since these are sibling modules within the SDK crate.
This commit is contained in:
404
sunbeam-sdk/src/config.rs
Normal file
404
sunbeam-sdk/src/config.rs
Normal file
@@ -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<String, Context>,
|
||||
|
||||
// --- 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<Context> = 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");
|
||||
}
|
||||
}
|
||||
16
sunbeam-sdk/src/constants.rs
Normal file
16
sunbeam-sdk/src/constants.rs
Normal file
@@ -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",
|
||||
];
|
||||
365
sunbeam-sdk/src/error.rs
Normal file
365
sunbeam-sdk/src/error.rs
Normal file
@@ -0,0 +1,365 @@
|
||||
//! 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.
|
||||
#[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<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,
|
||||
},
|
||||
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<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,
|
||||
},
|
||||
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<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");
|
||||
}
|
||||
}
|
||||
19
sunbeam-sdk/src/lib.rs
Normal file
19
sunbeam-sdk/src/lib.rs
Normal file
@@ -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;
|
||||
92
sunbeam-sdk/src/output.rs
Normal file
92
sunbeam-sdk/src/output.rs
Normal file
@@ -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<String>], headers: &[&str]) -> String {
|
||||
if headers.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut col_widths: Vec<usize> = 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!("{:<width$}", h, width = col_widths[i]))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let separator: String = col_widths
|
||||
.iter()
|
||||
.map(|&w| "-".repeat(w))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
let mut lines = vec![header_line, separator];
|
||||
|
||||
for row in rows {
|
||||
let cells: Vec<String> = (0..headers.len())
|
||||
.map(|i| {
|
||||
let val = row.get(i).map(|s| s.as_str()).unwrap_or("");
|
||||
format!("{:<width$}", val, width = col_widths[i])
|
||||
})
|
||||
.collect();
|
||||
lines.push(cells.join(" "));
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_table_basic() {
|
||||
let rows = vec![
|
||||
vec!["abc".to_string(), "def".to_string()],
|
||||
vec!["x".to_string(), "longer".to_string()],
|
||||
];
|
||||
let result = table(&rows, &["Col1", "Col2"]);
|
||||
assert!(result.contains("Col1"));
|
||||
assert!(result.contains("Col2"));
|
||||
assert!(result.contains("abc"));
|
||||
assert!(result.contains("longer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_empty_headers() {
|
||||
let result = table(&[], &[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_table_column_widths() {
|
||||
let rows = vec![vec!["short".to_string(), "x".to_string()]];
|
||||
let result = table(&rows, &["LongHeader", "H2"]);
|
||||
// Header should set minimum width
|
||||
for line in result.lines().skip(2) {
|
||||
// Data row: "short" should be padded to "LongHeader" width
|
||||
assert!(line.starts_with("short "));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user