feat: kubectl-style contexts with per-domain auth tokens
Config now supports named contexts (like kubectl), each bundling
domain, kube-context, ssh-host, infra-dir, and acme-email. Legacy
flat config auto-migrates to a "production" context on load.
- sunbeam config set --domain sunbeam.pt --host user@server
- sunbeam config use-context production
- sunbeam config get (shows all contexts)
Auth tokens stored per-domain (~/.local/share/sunbeam/auth/{domain}.json)
so local and production don't clobber each other. pm and auth commands
read domain from active context instead of K8s cluster discovery.
This commit is contained in:
56
src/auth.rs
56
src/auth.rs
@@ -29,15 +29,28 @@ const DEFAULT_CLIENT_ID: &str = "sunbeam-cli";
|
|||||||
// Cache file helpers
|
// Cache file helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
fn cache_path() -> PathBuf {
|
/// Cache path for auth tokens — per-domain so multiple environments work.
|
||||||
dirs::data_dir()
|
fn cache_path_for_domain(domain: &str) -> PathBuf {
|
||||||
|
let dir = dirs::data_dir()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
.join(".local/share")
|
.join(".local/share")
|
||||||
})
|
})
|
||||||
.join("sunbeam")
|
.join("sunbeam")
|
||||||
.join("auth.json")
|
.join("auth");
|
||||||
|
if domain.is_empty() {
|
||||||
|
dir.join("default.json")
|
||||||
|
} else {
|
||||||
|
// Sanitize domain for filename
|
||||||
|
let safe = domain.replace(['/', '\\', ':'], "_");
|
||||||
|
dir.join(format!("{safe}.json"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_path() -> PathBuf {
|
||||||
|
let domain = crate::config::domain();
|
||||||
|
cache_path_for_domain(domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_cache() -> Result<AuthTokens> {
|
fn read_cache() -> Result<AuthTokens> {
|
||||||
@@ -112,7 +125,13 @@ async fn resolve_domain(explicit: Option<&str>) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Cached token domain (already logged in to a domain)
|
// 2. Active context domain (set by cli::dispatch from config)
|
||||||
|
let ctx_domain = crate::config::domain();
|
||||||
|
if !ctx_domain.is_empty() {
|
||||||
|
return Ok(ctx_domain.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cached token domain (already logged in)
|
||||||
if let Ok(tokens) = read_cache() {
|
if let Ok(tokens) = read_cache() {
|
||||||
if !tokens.domain.is_empty() {
|
if !tokens.domain.is_empty() {
|
||||||
crate::output::ok(&format!("Using cached domain: {}", tokens.domain));
|
crate::output::ok(&format!("Using cached domain: {}", tokens.domain));
|
||||||
@@ -120,29 +139,15 @@ async fn resolve_domain(explicit: Option<&str>) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Config: derive from production_host
|
|
||||||
let config = crate::config::load_config();
|
|
||||||
if !config.production_host.is_empty() {
|
|
||||||
let host = &config.production_host;
|
|
||||||
let raw = host.split('@').last().unwrap_or(host);
|
|
||||||
let raw = raw.split(':').next().unwrap_or(raw);
|
|
||||||
// Take the last 2+ segments as the domain (e.g. admin.sunbeam.pt -> sunbeam.pt)
|
|
||||||
let parts: Vec<&str> = raw.split('.').collect();
|
|
||||||
if parts.len() >= 2 {
|
|
||||||
let domain = format!("{}.{}", parts[parts.len() - 2], parts[parts.len() - 1]);
|
|
||||||
return Ok(domain);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Try cluster discovery (may fail if not connected)
|
// 4. Try cluster discovery (may fail if not connected)
|
||||||
match crate::kube::get_domain().await {
|
match crate::kube::get_domain().await {
|
||||||
Ok(d) if !d.is_empty() && !d.starts_with(".") => return Ok(d),
|
Ok(d) if !d.is_empty() && !d.starts_with('.') => return Ok(d),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(SunbeamError::config(
|
Err(SunbeamError::config(
|
||||||
"Could not determine domain. Use --domain flag, or configure with:\n \
|
"Could not determine domain. Use --domain flag, or configure with:\n \
|
||||||
sunbeam config set --host user@your-server.example.com"
|
sunbeam config set --host user@your-server.example.com",
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,9 +808,16 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_cache_path_is_under_sunbeam() {
|
fn test_cache_path_is_under_sunbeam() {
|
||||||
let path = cache_path();
|
let path = cache_path_for_domain("sunbeam.pt");
|
||||||
let path_str = path.to_string_lossy();
|
let path_str = path.to_string_lossy();
|
||||||
assert!(path_str.contains("sunbeam"));
|
assert!(path_str.contains("sunbeam"));
|
||||||
assert!(path_str.ends_with("auth.json"));
|
assert!(path_str.contains("auth"));
|
||||||
|
assert!(path_str.ends_with("sunbeam.pt.json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_cache_path_default_domain() {
|
||||||
|
let path = cache_path_for_domain("");
|
||||||
|
assert!(path.to_string_lossy().ends_with("default.json"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
132
src/cli.rs
132
src/cli.rs
@@ -278,8 +278,11 @@ impl std::fmt::Display for BuildTarget {
|
|||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
pub enum ConfigAction {
|
pub enum ConfigAction {
|
||||||
/// Set configuration values.
|
/// Set configuration values for the current context.
|
||||||
Set {
|
Set {
|
||||||
|
/// Domain suffix (e.g. sunbeam.pt).
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
domain: String,
|
||||||
/// Production SSH host (e.g. user@server.example.com).
|
/// Production SSH host (e.g. user@server.example.com).
|
||||||
#[arg(long, default_value = "")]
|
#[arg(long, default_value = "")]
|
||||||
host: String,
|
host: String,
|
||||||
@@ -289,11 +292,19 @@ pub enum ConfigAction {
|
|||||||
/// ACME email for Let's Encrypt certificates.
|
/// ACME email for Let's Encrypt certificates.
|
||||||
#[arg(long, default_value = "")]
|
#[arg(long, default_value = "")]
|
||||||
acme_email: String,
|
acme_email: String,
|
||||||
|
/// Context name to configure (default: current context).
|
||||||
|
#[arg(long, default_value = "")]
|
||||||
|
context_name: String,
|
||||||
},
|
},
|
||||||
/// Get current configuration.
|
/// Get current configuration.
|
||||||
Get,
|
Get,
|
||||||
/// Clear configuration.
|
/// Clear configuration.
|
||||||
Clear,
|
Clear,
|
||||||
|
/// Switch the active context.
|
||||||
|
UseContext {
|
||||||
|
/// Context name to switch to.
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Subcommand, Debug)]
|
#[derive(Subcommand, Debug)]
|
||||||
@@ -751,28 +762,25 @@ mod tests {
|
|||||||
pub async fn dispatch() -> Result<()> {
|
pub async fn dispatch() -> Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
|
|
||||||
let ctx = cli
|
// Resolve the active context from config + CLI flags
|
||||||
.context
|
let config = crate::config::load_config();
|
||||||
.as_deref()
|
let active = crate::config::resolve_context(
|
||||||
.unwrap_or_else(|| default_context(&cli.env));
|
&config,
|
||||||
|
&cli.env.to_string(),
|
||||||
|
cli.context.as_deref(),
|
||||||
|
&cli.domain,
|
||||||
|
);
|
||||||
|
|
||||||
// For production, resolve SSH host
|
// Initialize kube context from the resolved context
|
||||||
let ssh_host = match cli.env {
|
let kube_ctx = if active.kube_context.is_empty() {
|
||||||
Env::Production => {
|
default_context(&cli.env)
|
||||||
let host = crate::config::get_production_host();
|
} else {
|
||||||
if host.is_empty() {
|
&active.kube_context
|
||||||
return Err(SunbeamError::config(
|
|
||||||
"Production host not configured. \
|
|
||||||
Use `sunbeam config set --host` or set SUNBEAM_SSH_HOST.",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Some(host)
|
|
||||||
}
|
|
||||||
Env::Local => None,
|
|
||||||
};
|
};
|
||||||
|
crate::kube::set_context(kube_ctx, &active.ssh_host);
|
||||||
|
|
||||||
// Initialize kube context
|
// Store active context globally for other modules to read
|
||||||
crate::kube::set_context(ctx, ssh_host.as_deref().unwrap_or(""));
|
crate::config::set_active_context(active);
|
||||||
|
|
||||||
match cli.verb {
|
match cli.verb {
|
||||||
None => {
|
None => {
|
||||||
@@ -867,50 +875,84 @@ pub async fn dispatch() -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Some(ConfigAction::Set {
|
Some(ConfigAction::Set {
|
||||||
|
domain: set_domain,
|
||||||
host,
|
host,
|
||||||
infra_dir,
|
infra_dir,
|
||||||
acme_email,
|
acme_email,
|
||||||
|
context_name,
|
||||||
}) => {
|
}) => {
|
||||||
let mut config = crate::config::load_config();
|
let mut config = crate::config::load_config();
|
||||||
|
// Determine which context to modify
|
||||||
|
let ctx_name = if context_name.is_empty() {
|
||||||
|
if !config.current_context.is_empty() {
|
||||||
|
config.current_context.clone()
|
||||||
|
} else {
|
||||||
|
"production".to_string()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
context_name
|
||||||
|
};
|
||||||
|
|
||||||
|
let ctx = config.contexts.entry(ctx_name.clone()).or_default();
|
||||||
|
if !set_domain.is_empty() {
|
||||||
|
ctx.domain = set_domain;
|
||||||
|
}
|
||||||
if !host.is_empty() {
|
if !host.is_empty() {
|
||||||
config.production_host = host;
|
ctx.ssh_host = host.clone();
|
||||||
|
config.production_host = host; // keep legacy field in sync
|
||||||
}
|
}
|
||||||
if !infra_dir.is_empty() {
|
if !infra_dir.is_empty() {
|
||||||
|
ctx.infra_dir = infra_dir.clone();
|
||||||
config.infra_directory = infra_dir;
|
config.infra_directory = infra_dir;
|
||||||
}
|
}
|
||||||
if !acme_email.is_empty() {
|
if !acme_email.is_empty() {
|
||||||
|
ctx.acme_email = acme_email.clone();
|
||||||
config.acme_email = acme_email;
|
config.acme_email = acme_email;
|
||||||
}
|
}
|
||||||
|
if config.current_context.is_empty() {
|
||||||
|
config.current_context = ctx_name;
|
||||||
|
}
|
||||||
crate::config::save_config(&config)
|
crate::config::save_config(&config)
|
||||||
}
|
}
|
||||||
|
Some(ConfigAction::UseContext { name }) => {
|
||||||
|
let mut config = crate::config::load_config();
|
||||||
|
if !config.contexts.contains_key(&name) {
|
||||||
|
crate::output::warn(&format!("Context '{name}' does not exist. Creating empty context."));
|
||||||
|
config.contexts.insert(name.clone(), crate::config::Context::default());
|
||||||
|
}
|
||||||
|
config.current_context = name.clone();
|
||||||
|
crate::config::save_config(&config)?;
|
||||||
|
crate::output::ok(&format!("Switched to context '{name}'."));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Some(ConfigAction::Get) => {
|
Some(ConfigAction::Get) => {
|
||||||
let config = crate::config::load_config();
|
let config = crate::config::load_config();
|
||||||
let host_display = if config.production_host.is_empty() {
|
let current = if config.current_context.is_empty() {
|
||||||
"(not set)"
|
"(none)"
|
||||||
} else {
|
} else {
|
||||||
&config.production_host
|
&config.current_context
|
||||||
};
|
};
|
||||||
let infra_display = if config.infra_directory.is_empty() {
|
crate::output::ok(&format!("Current context: {current}"));
|
||||||
"(not set)"
|
println!();
|
||||||
} else {
|
for (name, ctx) in &config.contexts {
|
||||||
&config.infra_directory
|
let marker = if name == current { " *" } else { "" };
|
||||||
};
|
crate::output::ok(&format!("Context: {name}{marker}"));
|
||||||
let email_display = if config.acme_email.is_empty() {
|
if !ctx.domain.is_empty() {
|
||||||
"(not set)"
|
crate::output::ok(&format!(" domain: {}", ctx.domain));
|
||||||
} else {
|
}
|
||||||
&config.acme_email
|
if !ctx.kube_context.is_empty() {
|
||||||
};
|
crate::output::ok(&format!(" kube-context: {}", ctx.kube_context));
|
||||||
crate::output::ok(&format!("Production host: {host_display}"));
|
}
|
||||||
crate::output::ok(&format!(
|
if !ctx.ssh_host.is_empty() {
|
||||||
"Infrastructure directory: {infra_display}"
|
crate::output::ok(&format!(" ssh-host: {}", ctx.ssh_host));
|
||||||
));
|
}
|
||||||
crate::output::ok(&format!("ACME email: {email_display}"));
|
if !ctx.infra_dir.is_empty() {
|
||||||
|
crate::output::ok(&format!(" infra-dir: {}", ctx.infra_dir));
|
||||||
let effective = crate::config::get_production_host();
|
}
|
||||||
if !effective.is_empty() {
|
if !ctx.acme_email.is_empty() {
|
||||||
crate::output::ok(&format!(
|
crate::output::ok(&format!(" acme-email: {}", ctx.acme_email));
|
||||||
"Effective production host: {effective}"
|
}
|
||||||
));
|
println!();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
284
src/config.rs
284
src/config.rs
@@ -1,18 +1,89 @@
|
|||||||
use crate::error::{Result, ResultExt};
|
use crate::error::{Result, ResultExt, SunbeamError};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Config data model
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Sunbeam configuration stored at ~/.sunbeam.json.
|
/// 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)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct SunbeamConfig {
|
pub struct SunbeamConfig {
|
||||||
|
/// The active context name. If empty, uses "default".
|
||||||
|
#[serde(default, rename = "current-context")]
|
||||||
|
pub current_context: String,
|
||||||
|
|
||||||
|
/// Named contexts.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub contexts: HashMap<String, Context>,
|
||||||
|
|
||||||
|
// --- Legacy fields (migrated on load) ---
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub production_host: String,
|
pub production_host: String,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub infra_directory: String,
|
pub infra_directory: String,
|
||||||
#[serde(default)]
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
pub acme_email: String,
|
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 {
|
fn config_path() -> PathBuf {
|
||||||
dirs::home_dir()
|
dirs::home_dir()
|
||||||
.unwrap_or_else(|| PathBuf::from("."))
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
@@ -20,12 +91,13 @@ fn config_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load configuration from ~/.sunbeam.json, return default if not found.
|
/// Load configuration from ~/.sunbeam.json, return default if not found.
|
||||||
|
/// Migrates legacy flat config to context-based format.
|
||||||
pub fn load_config() -> SunbeamConfig {
|
pub fn load_config() -> SunbeamConfig {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return SunbeamConfig::default();
|
return SunbeamConfig::default();
|
||||||
}
|
}
|
||||||
match std::fs::read_to_string(&path) {
|
let mut config: SunbeamConfig = match std::fs::read_to_string(&path) {
|
||||||
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||||
crate::output::warn(&format!(
|
crate::output::warn(&format!(
|
||||||
"Failed to parse config from {}: {e}",
|
"Failed to parse config from {}: {e}",
|
||||||
@@ -40,18 +112,35 @@ pub fn load_config() -> SunbeamConfig {
|
|||||||
));
|
));
|
||||||
SunbeamConfig::default()
|
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.
|
/// Save configuration to ~/.sunbeam.json.
|
||||||
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
|
pub fn save_config(config: &SunbeamConfig) -> Result<()> {
|
||||||
let path = config_path();
|
let path = config_path();
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
std::fs::create_dir_all(parent).with_ctx(|| {
|
std::fs::create_dir_all(parent).with_ctx(|| {
|
||||||
format!(
|
format!("Failed to create config directory: {}", parent.display())
|
||||||
"Failed to create config directory: {}",
|
|
||||||
parent.display()
|
|
||||||
)
|
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
let content = serde_json::to_string_pretty(config)?;
|
let content = serde_json::to_string_pretty(config)?;
|
||||||
@@ -61,30 +150,103 @@ pub fn save_config(config: &SunbeamConfig) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the context to use, given CLI flags and config.
|
||||||
|
pub fn resolve_context(
|
||||||
|
config: &SunbeamConfig,
|
||||||
|
env_flag: &str,
|
||||||
|
context_override: Option<&str>,
|
||||||
|
domain_override: &str,
|
||||||
|
) -> Context {
|
||||||
|
// Start from the named context (CLI --env or current-context)
|
||||||
|
let context_name = context_override
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
if env_flag == "production" {
|
||||||
|
"production".to_string()
|
||||||
|
} else if env_flag == "local" {
|
||||||
|
"local".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.
|
/// Get production host from config or SUNBEAM_SSH_HOST environment variable.
|
||||||
pub fn get_production_host() -> String {
|
pub fn get_production_host() -> String {
|
||||||
let config = load_config();
|
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() {
|
if !config.production_host.is_empty() {
|
||||||
return config.production_host;
|
return config.production_host;
|
||||||
}
|
}
|
||||||
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
|
std::env::var("SUNBEAM_SSH_HOST").unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get infrastructure directory from config.
|
|
||||||
pub fn get_infra_directory() -> String {
|
|
||||||
load_config().infra_directory
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Infrastructure manifests directory as a Path.
|
/// Infrastructure manifests directory as a Path.
|
||||||
///
|
|
||||||
/// Prefers the configured infra_directory; falls back to a path relative to
|
|
||||||
/// the current executable (works when running from the development checkout).
|
|
||||||
pub fn get_infra_dir() -> PathBuf {
|
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;
|
let configured = load_config().infra_directory;
|
||||||
if !configured.is_empty() {
|
if !configured.is_empty() {
|
||||||
return PathBuf::from(configured);
|
return PathBuf::from(configured);
|
||||||
}
|
}
|
||||||
// Dev fallback: walk up from the executable to find monorepo root
|
// Dev fallback
|
||||||
std::env::current_exe()
|
std::env::current_exe()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.canonicalize().ok())
|
.and_then(|p| p.canonicalize().ok())
|
||||||
@@ -115,10 +277,7 @@ pub fn clear_config() -> Result<()> {
|
|||||||
if path.exists() {
|
if path.exists() {
|
||||||
std::fs::remove_file(&path)
|
std::fs::remove_file(&path)
|
||||||
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
|
.with_ctx(|| format!("Failed to remove {}", path.display()))?;
|
||||||
crate::output::ok(&format!(
|
crate::output::ok(&format!("Configuration cleared from {}", path.display()));
|
||||||
"Configuration cleared from {}",
|
|
||||||
path.display()
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
crate::output::warn("No configuration file found to clear");
|
crate::output::warn("No configuration file found to clear");
|
||||||
}
|
}
|
||||||
@@ -132,22 +291,81 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_default_config() {
|
fn test_default_config() {
|
||||||
let config = SunbeamConfig::default();
|
let config = SunbeamConfig::default();
|
||||||
assert!(config.production_host.is_empty());
|
assert!(config.current_context.is_empty());
|
||||||
assert!(config.infra_directory.is_empty());
|
assert!(config.contexts.is_empty());
|
||||||
assert!(config.acme_email.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_config_roundtrip() {
|
fn test_derive_domain_from_host() {
|
||||||
let config = SunbeamConfig {
|
assert_eq!(derive_domain_from_host("sienna@admin.sunbeam.pt"), "sunbeam.pt");
|
||||||
production_host: "user@example.com".to_string(),
|
assert_eq!(derive_domain_from_host("user@62.210.145.138"), "145.138");
|
||||||
infra_directory: "/path/to/infra".to_string(),
|
assert_eq!(derive_domain_from_host("sunbeam.pt"), "sunbeam.pt");
|
||||||
acme_email: "ops@example.com".to_string(),
|
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 json = serde_json::to_string(&config).unwrap();
|
||||||
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
|
let loaded: SunbeamConfig = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(loaded.production_host, "user@example.com");
|
assert_eq!(loaded.current_context, "production");
|
||||||
assert_eq!(loaded.infra_directory, "/path/to/infra");
|
let ctx = loaded.contexts.get("production").unwrap();
|
||||||
assert_eq!(loaded.acme_email, "ops@example.com");
|
assert_eq!(ctx.domain, "sunbeam.pt");
|
||||||
|
assert_eq!(ctx.ssh_host, "sienna@server.sunbeam.pt");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_context_from_env_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()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let ctx = resolve_context(&config, "production", None, "");
|
||||||
|
assert_eq!(ctx.domain, "sunbeam.pt");
|
||||||
|
assert_eq!(ctx.kube_context, "production");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_context_domain_override() {
|
||||||
|
let config = SunbeamConfig::default();
|
||||||
|
let ctx = resolve_context(&config, "local", None, "custom.example.com");
|
||||||
|
assert_eq!(ctx.domain, "custom.example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_context_defaults_local() {
|
||||||
|
let config = SunbeamConfig::default();
|
||||||
|
let ctx = resolve_context(&config, "local", None, "");
|
||||||
|
assert_eq!(ctx.kube_context, "sunbeam");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
src/pm.rs
18
src/pm.rs
@@ -1000,7 +1000,8 @@ fn display_ticket_detail(t: &Ticket) {
|
|||||||
/// When `source` is `None`, both Planka and Gitea are queried in parallel.
|
/// When `source` is `None`, both Planka and Gitea are queried in parallel.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
|
pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
|
|
||||||
let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p"));
|
let fetch_planka = source.is_none() || matches!(source, Some("planka" | "p"));
|
||||||
let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g"));
|
let fetch_gitea = source.is_none() || matches!(source, Some("gitea" | "g"));
|
||||||
@@ -1052,7 +1053,8 @@ pub async fn cmd_pm_list(source: Option<&str>, state: &str) -> Result<()> {
|
|||||||
/// Show details for a single ticket by ID.
|
/// Show details for a single ticket by ID.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
let ticket_ref = parse_ticket_id(id)?;
|
let ticket_ref = parse_ticket_id(id)?;
|
||||||
|
|
||||||
let ticket = match ticket_ref {
|
let ticket = match ticket_ref {
|
||||||
@@ -1077,7 +1079,8 @@ pub async fn cmd_pm_show(id: &str) -> Result<()> {
|
|||||||
/// for Gitea it is `"org/repo"`.
|
/// for Gitea it is `"org/repo"`.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> {
|
pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
|
|
||||||
let ticket = match source {
|
let ticket = match source {
|
||||||
"planka" | "p" => {
|
"planka" | "p" => {
|
||||||
@@ -1120,7 +1123,8 @@ pub async fn cmd_pm_create(title: &str, body: &str, source: &str, target: &str)
|
|||||||
/// Add a comment to a ticket.
|
/// Add a comment to a ticket.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
|
pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
let ticket_ref = parse_ticket_id(id)?;
|
let ticket_ref = parse_ticket_id(id)?;
|
||||||
|
|
||||||
match ticket_ref {
|
match ticket_ref {
|
||||||
@@ -1141,7 +1145,8 @@ pub async fn cmd_pm_comment(id: &str, text: &str) -> Result<()> {
|
|||||||
/// Close a ticket.
|
/// Close a ticket.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
let ticket_ref = parse_ticket_id(id)?;
|
let ticket_ref = parse_ticket_id(id)?;
|
||||||
|
|
||||||
match ticket_ref {
|
match ticket_ref {
|
||||||
@@ -1175,7 +1180,8 @@ pub async fn cmd_pm_close(id: &str) -> Result<()> {
|
|||||||
/// Assign a user to a ticket.
|
/// Assign a user to a ticket.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
|
pub async fn cmd_pm_assign(id: &str, user: &str) -> Result<()> {
|
||||||
let domain = crate::kube::get_domain().await?;
|
let domain = crate::config::domain();
|
||||||
|
if domain.is_empty() { return Err(crate::error::SunbeamError::config("No domain configured. Run: sunbeam config set --domain sunbeam.pt")); }
|
||||||
let ticket_ref = parse_ticket_id(id)?;
|
let ticket_ref = parse_ticket_id(id)?;
|
||||||
|
|
||||||
match ticket_ref {
|
match ticket_ref {
|
||||||
|
|||||||
Reference in New Issue
Block a user