talos_api_rs/config/
talosconfig.rs1use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::error::{Result, TalosError};
34
35pub const ENV_TALOSCONFIG: &str = "TALOSCONFIG";
37
38pub const ENV_TALOS_CONTEXT: &str = "TALOS_CONTEXT";
40
41pub const ENV_TALOS_ENDPOINTS: &str = "TALOS_ENDPOINTS";
43
44pub const ENV_TALOS_NODES: &str = "TALOS_NODES";
46
47#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct TalosConfig {
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub context: Option<String>,
53
54 pub contexts: HashMap<String, TalosContext>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct TalosContext {
61 pub endpoints: Vec<String>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub nodes: Option<Vec<String>>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub ca: Option<String>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub crt: Option<String>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub key: Option<String>,
79}
80
81impl TalosConfig {
82 #[allow(clippy::result_large_err)]
91 pub fn load_default() -> Result<Self> {
92 let config_path = Self::default_path()?;
93 Self::load_from_path(&config_path)
94 }
95
96 #[allow(clippy::result_large_err)]
108 pub fn load_from_path<P: AsRef<Path>>(path: P) -> Result<Self> {
109 let content = fs::read_to_string(path.as_ref()).map_err(|e| {
110 TalosError::Config(format!(
111 "Failed to read config file {}: {}",
112 path.as_ref().display(),
113 e
114 ))
115 })?;
116
117 Self::from_yaml(&content)
118 }
119
120 #[allow(clippy::result_large_err)]
130 pub fn from_yaml(yaml: &str) -> Result<Self> {
131 serde_yaml::from_str(yaml)
132 .map_err(|e| TalosError::Config(format!("Failed to parse config YAML: {}", e)))
133 }
134
135 #[allow(clippy::result_large_err)]
141 pub fn default_path() -> Result<PathBuf> {
142 let home = dirs::home_dir()
143 .ok_or_else(|| TalosError::Config("Could not determine home directory".to_string()))?;
144
145 Ok(home.join(".talos").join("config"))
146 }
147
148 #[allow(clippy::result_large_err)]
154 pub fn config_path() -> Result<PathBuf> {
155 if let Ok(env_path) = std::env::var("TALOSCONFIG") {
156 Ok(PathBuf::from(env_path))
157 } else {
158 Self::default_path()
159 }
160 }
161
162 pub fn active_context(&self) -> Option<&TalosContext> {
168 self.context
169 .as_ref()
170 .and_then(|name| self.contexts.get(name))
171 }
172
173 pub fn get_context(&self, name: &str) -> Option<&TalosContext> {
179 self.contexts.get(name)
180 }
181
182 pub fn context_names(&self) -> Vec<&str> {
184 self.contexts.keys().map(|s| s.as_str()).collect()
185 }
186
187 #[allow(clippy::result_large_err)]
212 pub fn load_with_env() -> Result<Self> {
213 let config_path = Self::config_path()?;
215 let mut config = if config_path.exists() {
216 Self::load_from_path(&config_path)?
217 } else {
218 Self {
220 context: None,
221 contexts: HashMap::new(),
222 }
223 };
224
225 if let Ok(context) = std::env::var(ENV_TALOS_CONTEXT) {
227 if !context.is_empty() {
228 config.context = Some(context);
229 }
230 }
231
232 if let Ok(endpoints_str) = std::env::var(ENV_TALOS_ENDPOINTS) {
234 if !endpoints_str.is_empty() {
235 let endpoints: Vec<String> = endpoints_str
236 .split(',')
237 .map(|s| s.trim().to_string())
238 .filter(|s| !s.is_empty())
239 .collect();
240
241 if !endpoints.is_empty() {
242 let context_name = config.context.clone().unwrap_or_else(|| "env".to_string());
244
245 if let Some(ctx) = config.contexts.get_mut(&context_name) {
246 ctx.endpoints = endpoints;
247 } else {
248 config.contexts.insert(
249 context_name.clone(),
250 TalosContext {
251 endpoints,
252 nodes: None,
253 ca: None,
254 crt: None,
255 key: None,
256 },
257 );
258 config.context = Some(context_name);
259 }
260 }
261 }
262 }
263
264 if let Ok(nodes_str) = std::env::var(ENV_TALOS_NODES) {
266 if !nodes_str.is_empty() {
267 let nodes: Vec<String> = nodes_str
268 .split(',')
269 .map(|s| s.trim().to_string())
270 .filter(|s| !s.is_empty())
271 .collect();
272
273 if !nodes.is_empty() {
274 if let Some(context_name) = &config.context {
275 if let Some(ctx) = config.contexts.get_mut(context_name) {
276 ctx.nodes = Some(nodes);
277 }
278 }
279 }
280 }
281 }
282
283 Ok(config)
284 }
285
286 pub fn effective_context_name(&self) -> Option<&str> {
288 if let Ok(env_context) = std::env::var(ENV_TALOS_CONTEXT) {
290 if !env_context.is_empty() && self.contexts.contains_key(&env_context) {
291 return Some(
292 self.contexts
293 .get_key_value(&env_context)
294 .map(|(k, _)| k.as_str())
295 .unwrap_or_default(),
296 );
297 }
298 }
299 self.context.as_deref()
300 }
301}
302
303impl TalosContext {
304 pub fn first_endpoint(&self) -> Option<&String> {
306 self.endpoints.first()
307 }
308
309 pub fn first_node(&self) -> Option<&String> {
311 self.nodes.as_ref().and_then(|nodes| nodes.first())
312 }
313}
314
315#[cfg(test)]
316mod tests {
317 use super::*;
318
319 const SAMPLE_CONFIG: &str = r#"
320context: my-cluster
321contexts:
322 my-cluster:
323 endpoints:
324 - 10.0.0.2
325 - 10.0.0.3
326 ca: |
327 -----BEGIN CERTIFICATE-----
328 MIIBcDCCARegAwIBAgIRAMK1...
329 -----END CERTIFICATE-----
330 crt: |
331 -----BEGIN CERTIFICATE-----
332 MIIBbjCCAROgAwIBAgIQdB...
333 -----END CERTIFICATE-----
334 key: |
335 -----BEGIN ED25519 PRIVATE KEY-----
336 MC4CAQAwBQYDK2VwBCIEIA...
337 -----END ED25519 PRIVATE KEY-----
338 another-cluster:
339 endpoints:
340 - 192.168.1.10
341 nodes:
342 - 192.168.1.11
343 - 192.168.1.12
344"#;
345
346 #[test]
347 fn test_parse_basic_config() {
348 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
349
350 assert_eq!(config.context, Some("my-cluster".to_string()));
351 assert_eq!(config.contexts.len(), 2);
352 }
353
354 #[test]
355 fn test_active_context() {
356 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
357
358 let active = config.active_context().unwrap();
359 assert_eq!(active.endpoints, vec!["10.0.0.2", "10.0.0.3"]);
360 assert!(active.ca.is_some());
361 assert!(active.crt.is_some());
362 assert!(active.key.is_some());
363 }
364
365 #[test]
366 fn test_get_context() {
367 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
368
369 let ctx = config.get_context("another-cluster").unwrap();
370 assert_eq!(ctx.endpoints, vec!["192.168.1.10"]);
371 assert_eq!(
372 ctx.nodes,
373 Some(vec!["192.168.1.11".to_string(), "192.168.1.12".to_string()])
374 );
375 }
376
377 #[test]
378 fn test_context_names() {
379 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
380
381 let mut names = config.context_names();
382 names.sort();
383
384 assert_eq!(names, vec!["another-cluster", "my-cluster"]);
385 }
386
387 #[test]
388 fn test_first_endpoint() {
389 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
390 let ctx = config.get_context("my-cluster").unwrap();
391
392 assert_eq!(ctx.first_endpoint(), Some(&"10.0.0.2".to_string()));
393 }
394
395 #[test]
396 fn test_first_node() {
397 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
398 let ctx = config.get_context("another-cluster").unwrap();
399
400 assert_eq!(ctx.first_node(), Some(&"192.168.1.11".to_string()));
401 }
402
403 #[test]
404 fn test_missing_context() {
405 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
406 assert!(config.get_context("nonexistent").is_none());
407 }
408
409 #[test]
410 fn test_minimal_config() {
411 let minimal = r#"
412contexts:
413 minimal:
414 endpoints:
415 - 127.0.0.1:50000
416"#;
417
418 let config = TalosConfig::from_yaml(minimal).unwrap();
419 assert_eq!(config.context, None);
420 assert_eq!(config.contexts.len(), 1);
421
422 let ctx = config.get_context("minimal").unwrap();
423 assert_eq!(ctx.endpoints, vec!["127.0.0.1:50000"]);
424 assert!(ctx.ca.is_none());
425 assert!(ctx.nodes.is_none());
426 }
427
428 #[test]
429 fn test_env_constants() {
430 assert_eq!(ENV_TALOSCONFIG, "TALOSCONFIG");
432 assert_eq!(ENV_TALOS_CONTEXT, "TALOS_CONTEXT");
433 assert_eq!(ENV_TALOS_ENDPOINTS, "TALOS_ENDPOINTS");
434 assert_eq!(ENV_TALOS_NODES, "TALOS_NODES");
435 }
436
437 #[test]
438 fn test_effective_context_name() {
439 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
440
441 assert_eq!(config.context, Some("my-cluster".to_string()));
444 }
445
446 #[test]
447 fn test_effective_context_name_without_config_context() {
448 let yaml = r#"
450contexts:
451 ctx1:
452 endpoints:
453 - 10.0.0.1
454 ctx2:
455 endpoints:
456 - 10.0.0.2
457"#;
458 let config = TalosConfig::from_yaml(yaml).unwrap();
459 assert_eq!(config.effective_context_name(), None);
461 }
462
463 #[test]
464 fn test_active_context_returns_configured_context() {
465 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
466
467 let ctx = config.active_context();
469 assert!(ctx.is_some());
470 let ctx = ctx.unwrap();
471 assert!(ctx.endpoints.contains(&"10.0.0.2".to_string()));
473 }
474
475 #[test]
476 fn test_get_context_explicit_name() {
477 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
478
479 let ctx = config.get_context("another-cluster");
481 assert!(ctx.is_some());
482 let ctx = ctx.unwrap();
483 assert_eq!(ctx.endpoints, vec!["192.168.1.10"]);
484 }
485
486 #[test]
487 fn test_get_context_nonexistent() {
488 let config = TalosConfig::from_yaml(SAMPLE_CONFIG).unwrap();
489
490 let ctx = config.get_context("does-not-exist");
492 assert!(ctx.is_none());
493 }
494
495 #[test]
496 fn test_from_yaml_empty_contexts() {
497 let yaml = r#"
498contexts: {}
499"#;
500 let config = TalosConfig::from_yaml(yaml).unwrap();
501 assert!(config.contexts.is_empty());
502 }
503
504 #[test]
505 fn test_context_with_all_optional_fields() {
506 let yaml = r#"
507context: full-context
508contexts:
509 full-context:
510 endpoints:
511 - https://192.168.1.1:50000
512 nodes:
513 - 192.168.1.1
514 - 192.168.1.2
515 ca: |
516 -----BEGIN CERTIFICATE-----
517 MIIB...
518 -----END CERTIFICATE-----
519 crt: |
520 -----BEGIN CERTIFICATE-----
521 MIIB...
522 -----END CERTIFICATE-----
523 key: |
524 -----BEGIN ED25519 PRIVATE KEY-----
525 MC4...
526 -----END ED25519 PRIVATE KEY-----
527"#;
528 let config = TalosConfig::from_yaml(yaml).unwrap();
529 let ctx = config.get_context("full-context").unwrap();
530
531 assert!(ctx.ca.is_some());
532 assert!(ctx.crt.is_some());
533 assert!(ctx.key.is_some());
534 assert!(ctx.nodes.is_some());
535 assert_eq!(ctx.nodes.as_ref().unwrap().len(), 2);
536 }
537}