blob: e966dfd0f754d897f9f013bbee37bb9f324d5413 [file]
use crate::config::environment::{Environment, EnvironmentConfig, EnvironmentCredentials};
use crate::error::{DorisError, DorisResult};
use crate::product::ProductProfile;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
/// Top-level config file structure (<product-config-dir>/config.toml).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ConfigFile {
#[serde(default)]
pub default_env: Option<String>,
#[serde(default)]
pub environments: BTreeMap<String, EnvironmentConfig>,
}
/// Top-level credentials file structure (<product-config-dir>/credentials.toml).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CredentialsFile {
#[serde(flatten)]
pub environments: BTreeMap<String, EnvironmentCredentials>,
}
/// The config store manages reading/writing config + credentials.
#[derive(Debug)]
pub struct Store {
pub product: &'static ProductProfile,
pub config: ConfigFile,
pub credentials: CredentialsFile,
config_dir: Option<PathBuf>,
}
impl Store {
/// True when `<PREFIX>_HOST` + `<PREFIX>_USER` are set. In this mode the CLI must
/// avoid all filesystem side effects — the bastion is multi-tenant and
/// writable home dirs are not guaranteed.
pub fn is_stateless(product: &ProductProfile) -> bool {
std::env::var(product.env_key("HOST")).is_ok()
&& std::env::var(product.env_key("USER")).is_ok()
}
/// Build an in-memory Store with no backing files. `save()` refuses to
/// write. Used when `is_stateless()` is true.
pub fn stateless(product: &'static ProductProfile) -> Self {
Store {
product,
config: ConfigFile::default(),
credentials: CredentialsFile::default(),
config_dir: None,
}
}
/// Resolve an Environment purely from env vars. Never touches disk.
pub fn resolve_stateless(product: &'static ProductProfile, name: &str) -> Environment {
let host = std::env::var(product.env_key("HOST")).unwrap_or_default();
let user = std::env::var(product.env_key("USER")).unwrap_or_default();
let password = std::env::var(product.env_key("PASSWORD")).unwrap_or_default();
let mysql_port = std::env::var(product.env_key("PORT"))
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(9030);
let http_port = std::env::var(product.env_key("HTTP_PORT"))
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8030);
Environment {
product,
name: name.to_string(),
host,
mysql_port,
http_port,
user,
password,
socks5: crate::config::Socks5Config::from_env(product),
cluster_routing_directive: None,
}
}
/// Load config from the product config directory (creates directory if needed).
/// In stateless mode, returns an in-memory Store with no filesystem I/O.
pub fn load(product: &'static ProductProfile) -> DorisResult<Self> {
if Self::is_stateless(product) {
return Ok(Self::stateless(product));
}
let config_dir = Self::config_dir(product)?;
std::fs::create_dir_all(&config_dir)
.map_err(|e| DorisError::config(format!("Failed to create config directory: {e}")))?;
let config_path = config_dir.join("config.toml");
let creds_path = config_dir.join("credentials.toml");
let config: ConfigFile = if config_path.exists() {
let text = std::fs::read_to_string(&config_path)
.map_err(|e| DorisError::config(format!("Failed to read config: {e}")))?;
toml::from_str(&text)
.map_err(|e| DorisError::config(format!("Invalid config.toml: {e}")))?
} else {
ConfigFile::default()
};
let credentials: CredentialsFile = if creds_path.exists() {
let text = std::fs::read_to_string(&creds_path)
.map_err(|e| DorisError::config(format!("Failed to read credentials: {e}")))?;
toml::from_str(&text)
.map_err(|e| DorisError::config(format!("Invalid credentials.toml: {e}")))?
} else {
CredentialsFile::default()
};
Ok(Store {
product,
config,
credentials,
config_dir: Some(config_dir),
})
}
/// Resolve an environment by name, applying env var overrides.
pub fn resolve_env(&self, name: &str) -> DorisResult<Environment> {
// Stateless shortcut — never touch config files.
if Self::is_stateless(self.product) {
return Ok(Self::resolve_stateless(self.product, name));
}
let env_config = self.config.environments.get(name).ok_or_else(|| {
if self.config.environments.is_empty() {
DorisError::AuthRequired
} else {
DorisError::EnvNotFound {
name: name.to_string(),
}
}
})?;
let creds = self
.credentials
.environments
.get(name)
.cloned()
.unwrap_or_default();
let env = Environment::from_config(self.product, name.to_string(), env_config, &creds);
Ok(env.with_env_overrides())
}
/// Get the effective environment name (--env flag > <PREFIX>_ENV > config default).
pub fn effective_env_name(&self, flag: &str) -> String {
if flag != "default" {
return flag.to_string();
}
if let Ok(env_name) = std::env::var(self.product.env_key("ENV")) {
return env_name;
}
self.config
.default_env
.clone()
.unwrap_or_else(|| "default".to_string())
}
/// Get the effective environment source for diagnostics.
/// Returns (source_type, source_detail).
pub fn effective_env_source(&self, flag: &str) -> (&'static str, String) {
if flag != "default" {
return ("flag", format!("--env {flag}"));
}
let env_key = self.product.env_key("ENV");
if let Ok(name) = std::env::var(&env_key) {
return ("env_var", format!("{env_key}={name}"));
}
if let Some(ref name) = self.config.default_env {
return ("config", format!("default_env={name}"));
}
("fallback", "no environment configured".to_string())
}
/// Add a new environment.
pub fn add_env(
&mut self,
name: &str,
config: EnvironmentConfig,
creds: EnvironmentCredentials,
) -> DorisResult<()> {
self.config.environments.insert(name.to_string(), config);
self.credentials
.environments
.insert(name.to_string(), creds);
// Set as default if it's the first environment
if self.config.environments.len() == 1 {
self.config.default_env = Some(name.to_string());
}
self.save()
}
/// Set the default environment.
pub fn set_default(&mut self, name: &str) -> DorisResult<()> {
if !self.config.environments.contains_key(name) {
return Err(DorisError::EnvNotFound {
name: name.to_string(),
});
}
self.config.default_env = Some(name.to_string());
self.save()
}
/// List all environment names with their configs.
pub fn list_envs(&self) -> Vec<(String, &EnvironmentConfig, bool)> {
let default_name = self.config.default_env.as_deref().unwrap_or("default");
self.config
.environments
.iter()
.map(|(name, config)| (name.clone(), config, name == default_name))
.collect()
}
/// Remove an environment.
pub fn remove_env(&mut self, name: &str) -> DorisResult<()> {
self.config.environments.remove(name);
self.credentials.environments.remove(name);
if self.config.default_env.as_deref() == Some(name) {
self.config.default_env = self.config.environments.keys().next().cloned();
}
self.save()
}
/// Save config and credentials to disk.
fn save(&self) -> DorisResult<()> {
let config_dir = self.config_dir.as_ref().ok_or_else(|| {
DorisError::config(format!(
"Cannot modify config in stateless mode ({} + {} set). \
Unset those env vars to use {}/ config files.",
self.product.env_key("HOST"),
self.product.env_key("USER"),
self.product.config_dir,
))
})?;
let config_text = toml::to_string_pretty(&self.config)
.map_err(|e| DorisError::config(format!("Failed to serialize config: {e}")))?;
let creds_text = toml::to_string_pretty(&self.credentials)
.map_err(|e| DorisError::config(format!("Failed to serialize credentials: {e}")))?;
let config_path = config_dir.join("config.toml");
let creds_path = config_dir.join("credentials.toml");
std::fs::write(&config_path, config_text)
.map_err(|e| DorisError::config(format!("Failed to write config: {e}")))?;
std::fs::write(&creds_path, creds_text)
.map_err(|e| DorisError::config(format!("Failed to write credentials: {e}")))?;
// Set restrictive permissions on credentials file (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
let _ = std::fs::set_permissions(&creds_path, perms);
}
Ok(())
}
fn config_dir(product: &ProductProfile) -> DorisResult<PathBuf> {
let home =
dirs::home_dir().ok_or_else(|| DorisError::config("Cannot determine home directory"))?;
Ok(home.join(&product.config_dir))
}
}