Skip to main content

consortium/
node_utils.rs

1//! Group resolution backends.
2//!
3//! Rust implementation of `ClusterShell.NodeUtils`.
4
5use std::collections::{HashMap, HashSet};
6use std::path::PathBuf;
7
8use configparser::ini::Ini;
9use thiserror::Error;
10
11#[derive(Debug, Error)]
12pub enum GroupSourceError {
13    #[error("no upcall configured for group source")]
14    GroupSourceNoUpcall,
15
16    #[error("group source query failed: {0}")]
17    GroupSourceQueryFailed(String),
18}
19
20#[derive(Debug, Error)]
21pub enum GroupResolverSourceError {
22    #[error("source not found: {0}")]
23    SourceNotFound(String),
24
25    #[error("source error: {0}")]
26    SourceError(#[from] GroupSourceError),
27}
28
29#[derive(Debug, Error)]
30pub enum GroupResolverConfigError {
31    #[error("config file not found: {0}")]
32    FileNotFound(PathBuf),
33
34    #[error("config parsing error: {0}")]
35    ParseError(String),
36}
37
38#[derive(Debug, Error)]
39pub enum GroupError {
40    #[error("group source error: {0}")]
41    SourceError(#[from] GroupSourceError),
42
43    #[error("group resolver error: {0}")]
44    ResolverError(#[from] GroupResolverSourceError),
45
46    #[error("group resolver config error: {0}")]
47    ConfigError(#[from] GroupResolverConfigError),
48}
49
50pub type Result<T> = std::result::Result<T, GroupError>;
51
52/// A source of node group definitions.
53///
54/// Group sources can be backed by various backends: static in-memory data,
55/// shell scripts, configuration files, etc.
56pub trait GroupSource: Send + Sync {
57    /// Resolve a group name to a node pattern string.
58    ///
59    /// Returns a comma-separated list of node names or a node pattern.
60    fn resolve_map(&self, group: &str) -> Result<String>;
61
62    /// Get all nodes from all groups.
63    ///
64    /// Returns a comma-separated list of all nodes.
65    fn resolve_all(&self) -> Result<String> {
66        Err(GroupSourceError::GroupSourceNoUpcall.into())
67    }
68
69    /// List all available group names.
70    fn resolve_list(&self) -> Result<Vec<String>> {
71        Err(GroupSourceError::GroupSourceNoUpcall.into())
72    }
73
74    /// Find all groups that contain a given node.
75    fn resolve_reverse(&self, _node: &str) -> Result<Vec<String>> {
76        Err(GroupSourceError::GroupSourceNoUpcall.into())
77    }
78
79    /// Returns true if this source supports reverse lookups.
80    fn has_reverse(&self) -> bool {
81        false
82    }
83
84    /// Returns the cache time in seconds, if any.
85    fn cache_time(&self) -> Option<f64> {
86        None
87    }
88}
89
90/// In-memory group source backed by a HashMap.
91///
92/// Supports basic resolve_map, and optionally resolve_all, resolve_list,
93/// and resolve_reverse if provided.
94pub struct StaticGroupSource {
95    map: HashMap<String, String>,
96    all: Option<String>,
97    list: Option<Vec<String>>,
98    reverse: Option<HashMap<String, Vec<String>>>,
99}
100
101impl StaticGroupSource {
102    /// Create a new StaticGroupSource with the given map.
103    pub fn new(map: HashMap<String, String>) -> Self {
104        Self {
105            map,
106            all: None,
107            list: None,
108            reverse: None,
109        }
110    }
111
112    /// Set the all nodes string.
113    pub fn with_all(mut self, all: impl Into<String>) -> Self {
114        self.all = Some(all.into());
115        self
116    }
117
118    /// Set the list of group names.
119    pub fn with_list(mut self, list: Vec<String>) -> Self {
120        self.list = Some(list);
121        self
122    }
123
124    /// Set the reverse lookup map (node -> groups containing it).
125    pub fn with_reverse(mut self, reverse: HashMap<String, Vec<String>>) -> Self {
126        self.reverse = Some(reverse);
127        self
128    }
129}
130
131impl GroupSource for StaticGroupSource {
132    fn resolve_map(&self, group: &str) -> Result<String> {
133        self.map
134            .get(group)
135            .cloned()
136            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
137    }
138
139    fn resolve_all(&self) -> Result<String> {
140        self.all
141            .clone()
142            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
143    }
144
145    fn resolve_list(&self) -> Result<Vec<String>> {
146        self.list
147            .clone()
148            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
149    }
150
151    fn resolve_reverse(&self, node: &str) -> Result<Vec<String>> {
152        self.reverse
153            .as_ref()
154            .and_then(|r| r.get(node).cloned())
155            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
156    }
157
158    fn has_reverse(&self) -> bool {
159        self.reverse.is_some()
160    }
161}
162
163/// Shell-based group source that runs commands for lookups.
164///
165/// Commands can use %s as a placeholder for the group name.
166pub struct UpcallGroupSource {
167    map_upcall: Option<String>,
168    all_upcall: Option<String>,
169    list_upcall: Option<String>,
170    reverse_upcall: Option<String>,
171}
172
173impl UpcallGroupSource {
174    /// Create a new UpcallGroupSource with the given command templates.
175    ///
176    /// Command templates can use %s as a placeholder for the group name.
177    pub fn new(
178        map_upcall: Option<String>,
179        all_upcall: Option<String>,
180        list_upcall: Option<String>,
181        reverse_upcall: Option<String>,
182    ) -> Self {
183        Self {
184            map_upcall,
185            all_upcall,
186            list_upcall,
187            reverse_upcall,
188        }
189    }
190
191    fn run_command(&self, template: &str, arg: &str) -> Result<String> {
192        let command = template.replace("%s", arg);
193        let output = std::process::Command::new("sh")
194            .arg("-c")
195            .arg(&command)
196            .env("GROUP", arg)
197            .output()
198            .map_err(|e| GroupSourceError::GroupSourceQueryFailed(e.to_string()))?;
199
200        if output.status.success() {
201            Ok(String::from_utf8(output.stdout)
202                .map_err(|e| GroupSourceError::GroupSourceQueryFailed(e.to_string()))?
203                .trim()
204                .to_string())
205        } else {
206            let stderr = String::from_utf8_lossy(&output.stderr);
207            Err(GroupSourceError::GroupSourceQueryFailed(stderr.to_string()).into())
208        }
209    }
210}
211
212impl GroupSource for UpcallGroupSource {
213    fn resolve_map(&self, group: &str) -> Result<String> {
214        match self.map_upcall.as_ref() {
215            Some(t) => self.run_command(t, group),
216            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
217        }
218    }
219
220    fn resolve_all(&self) -> Result<String> {
221        match self.all_upcall.as_ref() {
222            Some(t) => self.run_command(t, ""),
223            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
224        }
225    }
226
227    fn resolve_list(&self) -> Result<Vec<String>> {
228        match self.list_upcall.as_ref() {
229            Some(t) => {
230                let s = self.run_command(t, "")?;
231                Ok(s.split(|c: char| c == ',' || c.is_whitespace())
232                    .filter(|s| !s.is_empty())
233                    .map(|s| s.to_string())
234                    .collect())
235            }
236            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
237        }
238    }
239
240    fn resolve_reverse(&self, node: &str) -> Result<Vec<String>> {
241        match self.reverse_upcall.as_ref() {
242            Some(t) => {
243                let s = self.run_command(t, node)?;
244                Ok(s.split(|c: char| c == ',' || c.is_whitespace())
245                    .filter(|s| !s.is_empty())
246                    .map(|s| s.to_string())
247                    .collect())
248            }
249            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
250        }
251    }
252
253    fn has_reverse(&self) -> bool {
254        self.reverse_upcall.is_some()
255    }
256}
257
258/// Manages named group sources with a default source.
259///
260/// Resolves group references like `@group` to node sets.
261pub struct GroupResolver {
262    sources: HashMap<String, Box<dyn GroupSource>>,
263    default_source_name: Option<String>,
264    illegal_chars: HashSet<char>,
265}
266
267impl GroupResolver {
268    /// Create a new GroupResolver.
269    pub fn new() -> Self {
270        Self {
271            sources: HashMap::new(),
272            default_source_name: None,
273            illegal_chars: HashSet::new(),
274        }
275    }
276
277    /// Add a named source to the resolver.
278    pub fn add_source(&mut self, name: impl Into<String>, source: Box<dyn GroupSource>) {
279        self.sources.insert(name.into(), source);
280    }
281
282    /// Set the default source name.
283    pub fn set_default(&mut self, name: impl Into<String>) {
284        self.default_source_name = Some(name.into());
285    }
286
287    /// Get the source by name, or the default if name is None.
288    fn get_source(&self, namespace: Option<&str>) -> Result<&dyn GroupSource> {
289        let name = match namespace {
290            Some(n) => n,
291            None => self.default_source_name.as_ref().ok_or(
292                GroupResolverSourceError::SourceNotFound("no default source set".to_string()),
293            )?,
294        };
295
296        self.sources
297            .get(name)
298            .map(|s| s.as_ref())
299            .ok_or(GroupResolverSourceError::SourceNotFound(name.to_string()).into())
300    }
301
302    /// Resolve a group to a list of nodes.
303    ///
304    /// If namespace is Some, uses that source; otherwise uses the default.
305    pub fn group_nodes(&self, group: &str, namespace: Option<&str>) -> Result<Vec<String>> {
306        let source = self.get_source(namespace)?;
307        let pattern = source.resolve_map(group)?;
308        Ok(pattern
309            .split(|c: char| c == ',' || c.is_whitespace())
310            .filter(|s| !s.is_empty())
311            .map(|s| s.to_string())
312            .collect())
313    }
314
315    /// Get all nodes from all groups in the specified namespace.
316    pub fn all_nodes(&self, namespace: Option<&str>) -> Result<Vec<String>> {
317        let source = self.get_source(namespace)?;
318        let all = source.resolve_all()?;
319        Ok(all
320            .split(|c: char| c == ',' || c.is_whitespace())
321            .filter(|s| !s.is_empty())
322            .map(|s| s.to_string())
323            .collect())
324    }
325
326    /// List all group names in the specified namespace.
327    pub fn grouplist(&self, namespace: Option<&str>) -> Result<Vec<String>> {
328        let source = self.get_source(namespace)?;
329        source.resolve_list()
330    }
331
332    /// Find all groups containing a given node.
333    pub fn node_groups(&self, node: &str, namespace: Option<&str>) -> Result<Vec<String>> {
334        let source = self.get_source(namespace)?;
335        source.resolve_reverse(node)
336    }
337
338    /// Check if the namespace supports node group lookups.
339    pub fn has_node_groups(&self, namespace: Option<&str>) -> bool {
340        self.get_source(namespace)
341            .map(|s| s.has_reverse())
342            .unwrap_or(false)
343    }
344
345    /// Set illegal characters for group names.
346    pub fn set_illegal_chars(&mut self, chars: HashSet<char>) {
347        self.illegal_chars = chars;
348    }
349
350    /// Check if a group name contains illegal characters.
351    pub fn is_valid_group_name(&self, name: &str) -> Result<()> {
352        for c in name.chars() {
353            if self.illegal_chars.contains(&c) {
354                return Err(GroupResolverSourceError::SourceError(
355                    GroupSourceError::GroupSourceQueryFailed(format!(
356                        "group name contains illegal character: '{}'",
357                        c
358                    )),
359                )
360                .into());
361            }
362        }
363        Ok(())
364    }
365}
366
367impl Default for GroupResolver {
368    fn default() -> Self {
369        Self::new()
370    }
371}
372
373/// Loads group sources from INI configuration files.
374///
375/// Supports the groups.conf format with:
376/// - `[Main]` section for groupsdir, confdir, autodir, default
377/// - Other sections as UpcallGroupSource definitions
378pub struct GroupResolverConfig {
379    filenames: Vec<PathBuf>,
380    illegal_chars: HashSet<char>,
381    initialized: bool,
382    resolver: Option<GroupResolver>,
383}
384
385impl GroupResolverConfig {
386    /// Create a new GroupResolverConfig with the given filenames.
387    pub fn new(filenames: Vec<PathBuf>, illegal_chars: HashSet<char>) -> Self {
388        Self {
389            filenames,
390            illegal_chars,
391            initialized: false,
392            resolver: None,
393        }
394    }
395
396    /// Initialize the config if not already done.
397    fn ensure_initialized(&mut self) -> Result<&mut GroupResolver> {
398        if !self.initialized {
399            self.initialize()?;
400            self.initialized = true;
401        }
402        Ok(self.resolver.as_mut().unwrap())
403    }
404
405    /// Initialize the resolver from config files.
406    fn initialize(&mut self) -> Result<()> {
407        let mut resolver = GroupResolver::new();
408        resolver.set_illegal_chars(self.illegal_chars.clone());
409
410        for filename in &self.filenames {
411            if !filename.exists() {
412                return Err(GroupResolverConfigError::FileNotFound(filename.clone()).into());
413            }
414
415            let mut ini = Ini::new();
416            // configparser lowercases keys/sections by default
417            ini.load(filename.to_string_lossy().as_ref())
418                .map_err(|e| GroupResolverConfigError::ParseError(e.to_string()))?;
419
420            // Process [Main] section (lowercased by configparser)
421            if let Some(default) = ini.get("main", "default") {
422                resolver.set_default(default);
423            }
424
425            // Process other sections as UpcallGroupSource
426            for section_name in ini.sections() {
427                if section_name == "main" || section_name == "default" {
428                    continue;
429                }
430
431                let map_upcall = ini.get(&section_name, "map");
432                let all_upcall = ini.get(&section_name, "all");
433                let list_upcall = ini.get(&section_name, "list");
434                let reverse_upcall = ini.get(&section_name, "reverse");
435
436                // Only add source if at least map is defined
437                if map_upcall.is_some() {
438                    let source =
439                        UpcallGroupSource::new(map_upcall, all_upcall, list_upcall, reverse_upcall);
440                    resolver.add_source(section_name.clone(), Box::new(source));
441                }
442            }
443        }
444
445        self.resolver = Some(resolver);
446        Ok(())
447    }
448
449    /// Get the underlying resolver.
450    pub fn resolver(&mut self) -> Result<&mut GroupResolver> {
451        self.ensure_initialized()
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::collections::HashSet;
459
460    #[test]
461    fn test_static_group_source_basic_resolve() {
462        let mut map = HashMap::new();
463        map.insert("web".to_string(), "web1,web2,web3".to_string());
464        map.insert("db".to_string(), "db1,db2".to_string());
465
466        let source = StaticGroupSource::new(map);
467
468        assert_eq!(source.resolve_map("web").unwrap(), "web1,web2,web3");
469        assert_eq!(source.resolve_map("db").unwrap(), "db1,db2");
470    }
471
472    #[test]
473    fn test_static_group_source_missing_group() {
474        let source = StaticGroupSource::new(HashMap::new());
475        assert!(source.resolve_map("nonexistent").is_err());
476    }
477
478    #[test]
479    fn test_static_group_source_with_all_and_list() {
480        let mut map = HashMap::new();
481        map.insert("web".to_string(), "web1,web2".to_string());
482        map.insert("db".to_string(), "db1,db2".to_string());
483
484        let source = StaticGroupSource::new(map)
485            .with_all("web1,web2,db1,db2".to_string())
486            .with_list(vec!["web".to_string(), "db".to_string()]);
487
488        assert_eq!(source.resolve_all().unwrap(), "web1,web2,db1,db2");
489        assert_eq!(source.resolve_list().unwrap(), vec!["web", "db"]);
490    }
491
492    #[test]
493    fn test_static_group_source_reverse_lookup() {
494        let mut map = HashMap::new();
495        map.insert("web".to_string(), "web1,web2".to_string());
496        map.insert("db".to_string(), "db1,db2".to_string());
497
498        let mut reverse = HashMap::new();
499        reverse.insert("web1".to_string(), vec!["web".to_string()]);
500        reverse.insert("web2".to_string(), vec!["web".to_string()]);
501        reverse.insert("db1".to_string(), vec!["db".to_string()]);
502        reverse.insert("db2".to_string(), vec!["db".to_string()]);
503
504        let source = StaticGroupSource::new(map).with_reverse(reverse);
505
506        assert!(source.has_reverse());
507        assert_eq!(
508            source.resolve_reverse("web1").unwrap(),
509            vec!["web".to_string()]
510        );
511        assert_eq!(
512            source.resolve_reverse("db2").unwrap(),
513            vec!["db".to_string()]
514        );
515    }
516
517    #[test]
518    fn test_group_resolver_with_multiple_sources() {
519        let mut resolver = GroupResolver::new();
520
521        let mut map1 = HashMap::new();
522        map1.insert("web".to_string(), "web1,web2".to_string());
523        resolver.add_source("cluster1", Box::new(StaticGroupSource::new(map1)));
524
525        let mut map2 = HashMap::new();
526        map2.insert("db".to_string(), "db1,db2".to_string());
527        resolver.add_source("cluster2", Box::new(StaticGroupSource::new(map2)));
528
529        let web_nodes = resolver.group_nodes("web", Some("cluster1")).unwrap();
530        assert_eq!(web_nodes, vec!["web1", "web2"]);
531
532        let db_nodes = resolver.group_nodes("db", Some("cluster2")).unwrap();
533        assert_eq!(db_nodes, vec!["db1", "db2"]);
534    }
535
536    #[test]
537    fn test_group_resolver_default_source() {
538        let mut resolver = GroupResolver::new();
539
540        let mut map1 = HashMap::new();
541        map1.insert("web".to_string(), "web1,web2".to_string());
542        resolver.add_source("default", Box::new(StaticGroupSource::new(map1)));
543
544        resolver.set_default("default");
545
546        // Use default source (no namespace)
547        let web_nodes = resolver.group_nodes("web", None).unwrap();
548        assert_eq!(web_nodes, vec!["web1", "web2"]);
549    }
550
551    #[test]
552    fn test_group_resolver_missing_source() {
553        let resolver = GroupResolver::new();
554        assert!(resolver.group_nodes("web", Some("nonexistent")).is_err());
555    }
556
557    #[test]
558    fn test_group_resolver_missing_default() {
559        let resolver = GroupResolver::new();
560        assert!(resolver.group_nodes("web", None).is_err());
561    }
562
563    #[test]
564    fn test_illegal_chars_validation() {
565        let mut resolver = GroupResolver::new();
566        let mut illegal = HashSet::new();
567        illegal.insert('$');
568        illegal.insert(' ');
569
570        resolver.set_illegal_chars(illegal);
571
572        assert!(resolver.is_valid_group_name("web").is_ok());
573        assert!(resolver.is_valid_group_name("web-group").is_ok());
574        assert!(resolver.is_valid_group_name("web_group").is_ok());
575        assert!(resolver.is_valid_group_name("web$group").is_err());
576        assert!(resolver.is_valid_group_name("web group").is_err());
577    }
578
579    #[test]
580    fn test_upcall_source_no_upcall() {
581        let source = UpcallGroupSource::new(None, None, None, None);
582        assert!(source.resolve_map("test").is_err());
583        assert!(source.resolve_all().is_err());
584        assert!(source.resolve_list().is_err());
585        assert!(source.resolve_reverse("node").is_err());
586        assert!(!source.has_reverse());
587    }
588
589    #[test]
590    fn test_upcall_source_with_map() {
591        let source = UpcallGroupSource::new(Some("echo web1,web2".to_string()), None, None, None);
592        assert_eq!(source.resolve_map("test").unwrap(), "web1,web2");
593    }
594
595    #[test]
596    fn test_group_resolver_sources_from_ini() {
597        use std::io::Write;
598        use tempfile::NamedTempFile;
599
600        let mut file = NamedTempFile::new().unwrap();
601        writeln!(
602            file,
603            r#"[Main]
604default = cluster1
605
606[cluster1]
607map = echo web1,web2
608
609[cluster2]
610map = echo db1,db2
611list = echo web db
612all = echo web1,web2,db1,db2
613reverse = echo web
614"#
615        )
616        .unwrap();
617
618        let mut config = GroupResolverConfig::new(vec![file.path().to_path_buf()], HashSet::new());
619        let resolver = config.resolver().unwrap();
620
621        let web_nodes = resolver.group_nodes("test", Some("cluster1")).unwrap();
622        assert_eq!(web_nodes, vec!["web1", "web2"]);
623
624        let db_nodes = resolver.group_nodes("test", Some("cluster2")).unwrap();
625        assert_eq!(db_nodes, vec!["db1", "db2"]);
626
627        let list = resolver.grouplist(Some("cluster2")).unwrap();
628        assert_eq!(list, vec!["web", "db"]);
629    }
630
631    #[test]
632    fn test_group_resolver_config_file_not_found() {
633        let mut config = GroupResolverConfig::new(
634            vec![PathBuf::from("/nonexistent/path/groups.conf")],
635            HashSet::new(),
636        );
637        assert!(config.resolver().is_err());
638    }
639
640    #[test]
641    fn test_whitespace_splitting() {
642        let mut map = HashMap::new();
643        // Test with various whitespace
644        map.insert(
645            "web".to_string(),
646            "web1, web2  ,web3\tweb4\nweb5".to_string(),
647        );
648
649        let source = StaticGroupSource::new(map);
650        let nodes = source.resolve_map("web").unwrap();
651
652        let resolver = GroupResolver::new();
653        let parsed = nodes
654            .split(|c: char| c == ',' || c.is_whitespace())
655            .filter(|s| !s.is_empty())
656            .collect::<Vec<_>>();
657
658        assert_eq!(parsed, vec!["web1", "web2", "web3", "web4", "web5"]);
659    }
660}