Skip to main content

talos_api_rs/config/
talosconfig.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Talosctl configuration file parser
4//!
5//! This module provides functionality to parse the talosctl config file
6//! (typically `~/.talos/config`) which contains connection information for
7//! multiple Talos clusters.
8//!
9//! # Example
10//!
11//! ```no_run
12//! use talos_api_rs::config::TalosConfig;
13//!
14//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
15//! // Load from default location (~/.talos/config)
16//! let config = TalosConfig::load_default()?;
17//!
18//! // Get the active context
19//! if let Some(context_name) = &config.context {
20//!     if let Some(ctx) = config.contexts.get(context_name) {
21//!         println!("Endpoints: {:?}", ctx.endpoints);
22//!     }
23//! }
24//! # Ok(())
25//! # }
26//! ```
27
28use serde::{Deserialize, Serialize};
29use std::collections::HashMap;
30use std::fs;
31use std::path::{Path, PathBuf};
32
33use crate::error::{Result, TalosError};
34
35/// Environment variable for overriding the config file path
36pub const ENV_TALOSCONFIG: &str = "TALOSCONFIG";
37
38/// Environment variable for overriding the context
39pub const ENV_TALOS_CONTEXT: &str = "TALOS_CONTEXT";
40
41/// Environment variable for overriding endpoints (comma-separated)
42pub const ENV_TALOS_ENDPOINTS: &str = "TALOS_ENDPOINTS";
43
44/// Environment variable for specifying target nodes (comma-separated)
45pub const ENV_TALOS_NODES: &str = "TALOS_NODES";
46
47/// Represents the entire talosctl configuration file structure
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
49pub struct TalosConfig {
50    /// The currently active context name
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub context: Option<String>,
53
54    /// Map of context names to their configurations
55    pub contexts: HashMap<String, TalosContext>,
56}
57
58/// Configuration for a single Talos cluster context
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct TalosContext {
61    /// List of control plane endpoints (IP addresses or DNS names)
62    pub endpoints: Vec<String>,
63
64    /// Optional list of specific node targets
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub nodes: Option<Vec<String>>,
67
68    /// CA certificate in PEM format
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub ca: Option<String>,
71
72    /// Client certificate in PEM format
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub crt: Option<String>,
75
76    /// Client private key in PEM format
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub key: Option<String>,
79}
80
81impl TalosConfig {
82    /// Load configuration from the default location (~/.talos/config)
83    ///
84    /// # Errors
85    ///
86    /// Returns an error if:
87    /// - The home directory cannot be determined
88    /// - The config file cannot be read
89    /// - The config file is malformed
90    #[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    /// Load configuration from a specific path
97    ///
98    /// # Arguments
99    ///
100    /// * `path` - Path to the talosconfig file
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if:
105    /// - The file cannot be read
106    /// - The file is malformed YAML
107    #[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    /// Parse configuration from YAML string
121    ///
122    /// # Arguments
123    ///
124    /// * `yaml` - YAML content as string
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the YAML is malformed
129    #[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    /// Get the default config file path (~/.talos/config)
136    ///
137    /// # Errors
138    ///
139    /// Returns an error if the home directory cannot be determined
140    #[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    /// Get the path to the config file, respecting TALOSCONFIG environment variable
149    ///
150    /// # Errors
151    ///
152    /// Returns an error if the home directory cannot be determined when TALOSCONFIG is not set
153    #[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    /// Get the currently active context
163    ///
164    /// # Returns
165    ///
166    /// Returns `None` if no active context is set or if the context doesn't exist
167    pub fn active_context(&self) -> Option<&TalosContext> {
168        self.context
169            .as_ref()
170            .and_then(|name| self.contexts.get(name))
171    }
172
173    /// Get a context by name
174    ///
175    /// # Arguments
176    ///
177    /// * `name` - The context name to retrieve
178    pub fn get_context(&self, name: &str) -> Option<&TalosContext> {
179        self.contexts.get(name)
180    }
181
182    /// List all available context names
183    pub fn context_names(&self) -> Vec<&str> {
184        self.contexts.keys().map(|s| s.as_str()).collect()
185    }
186
187    /// Load configuration with environment variable overrides
188    ///
189    /// This method respects the following environment variables:
190    /// - `TALOSCONFIG`: Path to the config file (default: `~/.talos/config`)
191    /// - `TALOS_CONTEXT`: Override the active context
192    /// - `TALOS_ENDPOINTS`: Override endpoints (comma-separated)
193    /// - `TALOS_NODES`: Override target nodes (comma-separated)
194    ///
195    /// # Example
196    ///
197    /// ```no_run
198    /// use talos_api_rs::config::TalosConfig;
199    ///
200    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
201    /// // Load config with env overrides
202    /// let config = TalosConfig::load_with_env()?;
203    ///
204    /// // Get effective context (may be overridden by TALOS_CONTEXT)
205    /// if let Some(ctx) = config.active_context() {
206    ///     println!("Using endpoints: {:?}", ctx.endpoints);
207    /// }
208    /// # Ok(())
209    /// # }
210    /// ```
211    #[allow(clippy::result_large_err)]
212    pub fn load_with_env() -> Result<Self> {
213        // Load base config
214        let config_path = Self::config_path()?;
215        let mut config = if config_path.exists() {
216            Self::load_from_path(&config_path)?
217        } else {
218            // Create empty config if file doesn't exist
219            Self {
220                context: None,
221                contexts: HashMap::new(),
222            }
223        };
224
225        // Override context from TALOS_CONTEXT
226        if let Ok(context) = std::env::var(ENV_TALOS_CONTEXT) {
227            if !context.is_empty() {
228                config.context = Some(context);
229            }
230        }
231
232        // Override endpoints from TALOS_ENDPOINTS
233        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                    // Create or update context named "env" for env-based endpoints
243                    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        // Override nodes from TALOS_NODES
265        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    /// Get the effective context name (respects TALOS_CONTEXT env var)
287    pub fn effective_context_name(&self) -> Option<&str> {
288        // Check env var first
289        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    /// Get the first endpoint, if any
305    pub fn first_endpoint(&self) -> Option<&String> {
306        self.endpoints.first()
307    }
308
309    /// Get the first node, if any
310    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        // Just verify the constants are defined correctly
431        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        // Without env override, should return the config's context
442        // Note: We can't easily test with env vars in unit tests without affecting other tests
443        assert_eq!(config.context, Some("my-cluster".to_string()));
444    }
445
446    #[test]
447    fn test_effective_context_name_without_config_context() {
448        // Config without a context field set
449        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        // effective_context_name returns None when no context is set
460        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        // active_context should return the configured context (my-cluster)
468        let ctx = config.active_context();
469        assert!(ctx.is_some());
470        let ctx = ctx.unwrap();
471        // my-cluster's first endpoint is 10.0.0.2
472        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        // Can get another-cluster by explicit name
480        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        // Nonexistent context should return None
491        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}