use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::error::{Result, TalosError};
pub const ENV_TALOSCONFIG: &str = "TALOSCONFIG";
pub const ENV_TALOS_CONTEXT: &str = "TALOS_CONTEXT";
pub const ENV_TALOS_ENDPOINTS: &str = "TALOS_ENDPOINTS";
pub const ENV_TALOS_NODES: &str = "TALOS_NODES";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TalosConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
pub contexts: HashMap<String, TalosContext>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TalosContext {
pub endpoints: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub nodes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ca: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub crt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
}
impl TalosConfig {
#[allow(clippy::result_large_err)]
pub fn load_default() -> Result<Self> {
let config_path = Self::default_path()?;
Self::load_from_path(&config_path)
}
#[allow(clippy::result_large_err)]
pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path.as_ref()).map_err(|e| {
TalosError::Config(format!(
"Failed to read config file {}: {}",
path.as_ref().display(),
e
))
})?;
Self::from_yaml(&content)
}
#[allow(clippy::result_large_err)]
pub fn from_yaml(yaml: &str) -> Result<Self> {
serde_yaml::from_str(yaml)
.map_err(|e| TalosError::Config(format!("Failed to parse config YAML: {}", e)))
}
#[allow(clippy::result_large_err)]
pub fn default_path() -> Result<PathBuf> {
let home = dirs::home_dir()
.ok_or_else(|| TalosError::Config("Could not determine home directory".to_string()))?;
Ok(home.join(".talos").join("config"))
}
#[allow(clippy::result_large_err)]
pub fn config_path() -> Result<PathBuf> {
if let Ok(env_path) = std::env::var("TALOSCONFIG") {
Ok(PathBuf::from(env_path))
} else {
Self::default_path()
}
}
pub fn active_context(&self) -> Option<&TalosContext> {
self.context
.as_ref()
.and_then(|name| self.contexts.get(name))
}
pub fn get_context(&self, name: &str) -> Option<&TalosContext> {
self.contexts.get(name)
}
pub fn context_names(&self) -> Vec<&str> {
self.contexts.keys().map(|s| s.as_str()).collect()
}
#[allow(clippy::result_large_err)]
pub fn load_with_env() -> Result<Self> {
let config_path = Self::config_path()?;
let mut config = if config_path.exists() {
Self::load_from_path(&config_path)?
} else {
Self {
context: None,
contexts: HashMap::new(),
}
};
if let Ok(context) = std::env::var(ENV_TALOS_CONTEXT) {
if !context.is_empty() {
config.context = Some(context);
}
}
if let Ok(endpoints_str) = std::env::var(ENV_TALOS_ENDPOINTS) {
if !endpoints_str.is_empty() {
let endpoints: Vec<String> = endpoints_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !endpoints.is_empty() {
let context_name = config.context.clone().unwrap_or_else(|| "env".to_string());
if let Some(ctx) = config.contexts.get_mut(&context_name) {
ctx.endpoints = endpoints;
} else {
config.contexts.insert(
context_name.clone(),
TalosContext {
endpoints,
nodes: None,
ca: None,
crt: None,
key: None,
},
);
config.context = Some(context_name);
}
}
}
}
if let Ok(nodes_str) = std::env::var(ENV_TALOS_NODES) {
if !nodes_str.is_empty() {
let nodes: Vec<String> = nodes_str
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !nodes.is_empty() {
if let Some(context_name) = &config.context {
if let Some(ctx) = config.contexts.get_mut(context_name) {
ctx.nodes = Some(nodes);
}
}
}
}
}
Ok(config)
}
pub fn effective_context_name(&self) -> Option<&str> {
if let Ok(env_context) = std::env::var(ENV_TALOS_CONTEXT) {
if !env_context.is_empty() && self.contexts.contains_key(&env_context) {
return Some(
self.contexts
.get_key_value(&env_context)
.map(|(k, _)| k.as_str())
.unwrap_or_default(),
);
}
}
self.context.as_deref()
}
}
impl TalosContext {
pub fn first_endpoint(&self) -> Option<&String> {
self.endpoints.first()
}
pub fn first_node(&self) -> Option<&String> {
self.nodes.as_ref().and_then(|nodes| nodes.first())
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CONFIG: &str = r#"
context: my-cluster
contexts:
my-cluster:
endpoints:
- 10.0.0.2
- 10.0.0.3
ca: |
-----BEGIN CERTIFICATE-----
MIIBcDCCARegAwIBAgIRAMK1...
-----END CERTIFICATE-----
crt: |
-----BEGIN CERTIFICATE-----
MIIBbjCCAROgAwIBAgIQdB...
-----END CERTIFICATE-----
key: |
-----BEGIN ED25519 PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIA...
-----END ED25519 PRIVATE KEY-----
another-cluster:
endpoints:
- 192.168.1.10
nodes:
- 192.168.1.11
- 192.168.1.12
"#;
#[test]
fn test_parse_basic_config() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
assert_eq!(config.context, Some("my-cluster".to_string()));
assert_eq!(config.contexts.len(), 2);
}
#[test]
fn test_active_context() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let active = config.active_context().unwrap();
assert_eq!(active.endpoints, vec!["10.0.0.2", "10.0.0.3"]);
assert!(active.ca.is_some());
assert!(active.crt.is_some());
assert!(active.key.is_some());
}
#[test]
fn test_get_context() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.get_context("another-cluster").unwrap();
assert_eq!(ctx.endpoints, vec!["192.168.1.10"]);
assert_eq!(
ctx.nodes,
Some(vec!["192.168.1.11".to_string(), "192.168.1.12".to_string()])
);
}
#[test]
fn test_context_names() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let mut names = config.context_names();
names.sort();
assert_eq!(names, vec!["another-cluster", "my-cluster"]);
}
#[test]
fn test_first_endpoint() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.get_context("my-cluster").unwrap();
assert_eq!(ctx.first_endpoint(), Some(&"10.0.0.2".to_string()));
}
#[test]
fn test_first_node() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.get_context("another-cluster").unwrap();
assert_eq!(ctx.first_node(), Some(&"192.168.1.11".to_string()));
}
#[test]
fn test_missing_context() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
assert!(config.get_context("nonexistent").is_none());
}
#[test]
fn test_minimal_config() {
let minimal = r#"
contexts:
minimal:
endpoints:
- 127.0.0.1:50000
"#;
let config = TalosConfig::from_yaml(minimal).unwrap();
assert_eq!(config.context, None);
assert_eq!(config.contexts.len(), 1);
let ctx = config.get_context("minimal").unwrap();
assert_eq!(ctx.endpoints, vec!["127.0.0.1:50000"]);
assert!(ctx.ca.is_none());
assert!(ctx.nodes.is_none());
}
#[test]
fn test_env_constants() {
assert_eq!(ENV_TALOSCONFIG, "TALOSCONFIG");
assert_eq!(ENV_TALOS_CONTEXT, "TALOS_CONTEXT");
assert_eq!(ENV_TALOS_ENDPOINTS, "TALOS_ENDPOINTS");
assert_eq!(ENV_TALOS_NODES, "TALOS_NODES");
}
#[test]
fn test_effective_context_name() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
assert_eq!(config.context, Some("my-cluster".to_string()));
}
#[test]
fn test_effective_context_name_without_config_context() {
let yaml = r#"
contexts:
ctx1:
endpoints:
- 10.0.0.1
ctx2:
endpoints:
- 10.0.0.2
"#;
let config = TalosConfig::from_yaml(yaml).unwrap();
assert_eq!(config.effective_context_name(), None);
}
#[test]
fn test_active_context_returns_configured_context() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.active_context();
assert!(ctx.is_some());
let ctx = ctx.unwrap();
assert!(ctx.endpoints.contains(&"10.0.0.2".to_string()));
}
#[test]
fn test_get_context_explicit_name() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.get_context("another-cluster");
assert!(ctx.is_some());
let ctx = ctx.unwrap();
assert_eq!(ctx.endpoints, vec!["192.168.1.10"]);
}
#[test]
fn test_get_context_nonexistent() {
let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
let ctx = config.get_context("does-not-exist");
assert!(ctx.is_none());
}
#[test]
fn test_from_yaml_empty_contexts() {
let yaml = r#"
contexts: {}
"#;
let config = TalosConfig::from_yaml(yaml).unwrap();
assert!(config.contexts.is_empty());
}
#[test]
fn test_context_with_all_optional_fields() {
let yaml = r#"
context: full-context
contexts:
full-context:
endpoints:
- https://192.168.1.1:50000
nodes:
- 192.168.1.1
- 192.168.1.2
ca: |
-----BEGIN CERTIFICATE-----
MIIB...
-----END CERTIFICATE-----
crt: |
-----BEGIN CERTIFICATE-----
MIIB...
-----END CERTIFICATE-----
key: |
-----BEGIN ED25519 PRIVATE KEY-----
MC4...
-----END ED25519 PRIVATE KEY-----
"#;
let config = TalosConfig::from_yaml(yaml).unwrap();
let ctx = config.get_context("full-context").unwrap();
assert!(ctx.ca.is_some());
assert!(ctx.crt.is_some());
assert!(ctx.key.is_some());
assert!(ctx.nodes.is_some());
assert_eq!(ctx.nodes.as_ref().unwrap().len(), 2);
}
}