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;
7use std::sync::Mutex;
8use std::time::{Duration, Instant};
9
10use configparser::ini::Ini;
11use thiserror::Error;
12
13#[derive(Debug, Error)]
14pub enum GroupSourceError {
15    #[error("no upcall configured for group source")]
16    GroupSourceNoUpcall,
17
18    #[error("group source query failed: {0}")]
19    GroupSourceQueryFailed(String),
20}
21
22#[derive(Debug, Error)]
23pub enum GroupResolverSourceError {
24    #[error("source not found: {0}")]
25    SourceNotFound(String),
26
27    #[error("source error: {0}")]
28    SourceError(#[from] GroupSourceError),
29}
30
31#[derive(Debug, Error)]
32pub enum GroupResolverConfigError {
33    #[error("config file not found: {0}")]
34    FileNotFound(PathBuf),
35
36    #[error("config parsing error: {0}")]
37    ParseError(String),
38}
39
40#[derive(Debug, Error)]
41pub enum GroupError {
42    #[error("group source error: {0}")]
43    SourceError(#[from] GroupSourceError),
44
45    #[error("group resolver error: {0}")]
46    ResolverError(#[from] GroupResolverSourceError),
47
48    #[error("group resolver config error: {0}")]
49    ConfigError(#[from] GroupResolverConfigError),
50}
51
52pub type Result<T> = std::result::Result<T, GroupError>;
53
54/// A source of node group definitions.
55///
56/// Group sources can be backed by various backends: static in-memory data,
57/// shell scripts, configuration files, etc.
58pub trait GroupSource: Send + Sync {
59    /// Resolve a group name to a node pattern string.
60    ///
61    /// Returns a comma-separated list of node names or a node pattern.
62    fn resolve_map(&self, group: &str) -> Result<String>;
63
64    /// Get all nodes from all groups.
65    ///
66    /// Returns a comma-separated list of all nodes.
67    fn resolve_all(&self) -> Result<String> {
68        Err(GroupSourceError::GroupSourceNoUpcall.into())
69    }
70
71    /// List all available group names.
72    fn resolve_list(&self) -> Result<Vec<String>> {
73        Err(GroupSourceError::GroupSourceNoUpcall.into())
74    }
75
76    /// Find all groups that contain a given node.
77    fn resolve_reverse(&self, _node: &str) -> Result<Vec<String>> {
78        Err(GroupSourceError::GroupSourceNoUpcall.into())
79    }
80
81    /// Returns true if this source supports reverse lookups.
82    fn has_reverse(&self) -> bool {
83        false
84    }
85
86    /// Returns the cache time in seconds, if any.
87    fn cache_time(&self) -> Option<f64> {
88        None
89    }
90}
91
92/// In-memory group source backed by a HashMap.
93///
94/// Supports basic resolve_map, and optionally resolve_all, resolve_list,
95/// and resolve_reverse if provided.
96pub struct StaticGroupSource {
97    map: HashMap<String, String>,
98    all: Option<String>,
99    list: Option<Vec<String>>,
100    reverse: Option<HashMap<String, Vec<String>>>,
101}
102
103impl StaticGroupSource {
104    /// Create a new StaticGroupSource with the given map.
105    pub fn new(map: HashMap<String, String>) -> Self {
106        Self {
107            map,
108            all: None,
109            list: None,
110            reverse: None,
111        }
112    }
113
114    /// Set the all nodes string.
115    pub fn with_all(mut self, all: impl Into<String>) -> Self {
116        self.all = Some(all.into());
117        self
118    }
119
120    /// Set the list of group names.
121    pub fn with_list(mut self, list: Vec<String>) -> Self {
122        self.list = Some(list);
123        self
124    }
125
126    /// Set the reverse lookup map (node -> groups containing it).
127    pub fn with_reverse(mut self, reverse: HashMap<String, Vec<String>>) -> Self {
128        self.reverse = Some(reverse);
129        self
130    }
131}
132
133impl GroupSource for StaticGroupSource {
134    fn resolve_map(&self, group: &str) -> Result<String> {
135        self.map
136            .get(group)
137            .cloned()
138            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
139    }
140
141    fn resolve_all(&self) -> Result<String> {
142        self.all
143            .clone()
144            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
145    }
146
147    fn resolve_list(&self) -> Result<Vec<String>> {
148        self.list
149            .clone()
150            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
151    }
152
153    fn resolve_reverse(&self, node: &str) -> Result<Vec<String>> {
154        self.reverse
155            .as_ref()
156            .and_then(|r| r.get(node).cloned())
157            .ok_or(GroupSourceError::GroupSourceNoUpcall.into())
158    }
159
160    fn has_reverse(&self) -> bool {
161        self.reverse.is_some()
162    }
163}
164
165/// Default upcall cache lifetime in seconds (matches Python's
166/// `UpcallGroupSource` default of 3600).
167const DEFAULT_CACHE_TIME: f64 = 3600.0;
168
169/// Cache for upcall results, mirroring Python `UpcallGroupSource._cache`.
170///
171/// Only engaged when a `mapall` upcall is configured; without `mapall`
172/// every upcall is executed on demand, exactly as before.
173#[derive(Debug, Default)]
174struct UpcallCache {
175    /// group -> (nodes string, expiry), filled from mapall output or by
176    /// per-group `map` fallback calls
177    map: HashMap<String, (String, Instant)>,
178    /// (group names in mapall output order, expiry)
179    list: Option<(Vec<String>, Instant)>,
180    /// freshness marker of the last successful mapall call
181    mapall_expiry: Option<Instant>,
182}
183
184/// Shell-based group source that runs commands for lookups.
185///
186/// Commands can use %s as a placeholder for the group name.
187///
188/// The optional `mapall` upcall returns all group-to-nodes mappings in a
189/// single call (upstream commit 68e5df9). Its output is a list of
190/// `group: nodes` lines and is used to serve both `map` and `list`
191/// queries from the cache. When a group is missing from the mapall
192/// output, resolution falls back to the per-group `map` upcall if one is
193/// defined; otherwise the group resolves to an empty node set.
194pub struct UpcallGroupSource {
195    map_upcall: Option<String>,
196    all_upcall: Option<String>,
197    list_upcall: Option<String>,
198    reverse_upcall: Option<String>,
199    mapall_upcall: Option<String>,
200    cache_time: f64,
201    cache: Mutex<UpcallCache>,
202}
203
204impl UpcallGroupSource {
205    /// Create a new UpcallGroupSource with the given command templates.
206    ///
207    /// Command templates can use %s as a placeholder for the group name.
208    pub fn new(
209        map_upcall: Option<String>,
210        all_upcall: Option<String>,
211        list_upcall: Option<String>,
212        reverse_upcall: Option<String>,
213    ) -> Self {
214        Self {
215            map_upcall,
216            all_upcall,
217            list_upcall,
218            reverse_upcall,
219            mapall_upcall: None,
220            cache_time: DEFAULT_CACHE_TIME,
221            cache: Mutex::new(UpcallCache::default()),
222        }
223    }
224
225    /// Set the optional `mapall` upcall command template, returning all
226    /// group-to-nodes mappings as `group: nodes` lines in one call.
227    pub fn with_mapall_upcall(mut self, mapall_upcall: Option<String>) -> Self {
228        self.mapall_upcall = mapall_upcall;
229        self
230    }
231
232    /// Set the upcall cache lifetime in seconds (0 disables caching).
233    pub fn with_cache_time(mut self, cache_time: f64) -> Self {
234        self.cache_time = cache_time;
235        self
236    }
237
238    /// Remove all previously cached upcall results whatever their
239    /// lifetime is (mirrors Python `UpcallGroupSource.clear_cache()`).
240    pub fn clear_cache(&self) {
241        let mut cache = self.cache.lock().unwrap();
242        *cache = UpcallCache::default();
243    }
244
245    fn cache_duration(&self) -> Duration {
246        Duration::from_secs_f64(self.cache_time.max(0.0))
247    }
248
249    /// Run the optional `mapall` upcall and fill the map and list caches
250    /// from its output. No-op if `mapall` is not defined or its last
251    /// result is still fresh. Mirrors Python `_populate_from_mapall()`:
252    /// the map cache is swapped (not merged), upcall failures and parse
253    /// errors are not cached (the next query retries), and duplicate
254    /// group lines are unioned.
255    fn populate_from_mapall(&self) -> Result<()> {
256        let mapall_tpl = match &self.mapall_upcall {
257            Some(tpl) => tpl,
258            None => return Ok(()),
259        };
260        {
261            let cache = self.cache.lock().unwrap();
262            if let Some(expiry) = cache.mapall_expiry {
263                if Instant::now() < expiry {
264                    return Ok(());
265                }
266            }
267        }
268
269        // Command failure (eg. non-zero exit) propagates and is not
270        // cached, so the next query retries the upcall.
271        let content = self.run_command(mapall_tpl, "")?;
272        let expiry = Instant::now() + self.cache_duration();
273
274        // Parse 'group: nodes' lines, preserving first-seen group order.
275        let mut order: Vec<String> = Vec::new();
276        let mut values: Vec<String> = Vec::new();
277        let mut seen: HashMap<String, usize> = HashMap::new();
278        for raw_line in content.lines() {
279            let line = raw_line.trim();
280            if line.is_empty() {
281                continue;
282            }
283            let mut parts = line.splitn(2, ':');
284            let group = parts.next().unwrap_or("").trim();
285            let nodes = parts.next().map(|n| n.trim());
286            let nodes = match nodes {
287                // every line must contain a ':' separator
288                Some(nodes) if !group.is_empty() && group.split_whitespace().count() == 1 => nodes,
289                _ => {
290                    // do not keep unusable output: leave no freshness
291                    // marker so the next query retries the upcall
292                    return Err(GroupSourceError::GroupSourceQueryFailed(format!(
293                        "mapall: invalid line {:?} (expected 'group: nodes')",
294                        line
295                    ))
296                    .into());
297                }
298            };
299            if let Some(&idx) = seen.get(group) {
300                // union duplicate group lines, like multi-line map output
301                let old = values[idx].clone();
302                values[idx] = [old.as_str(), nodes]
303                    .into_iter()
304                    .filter(|s| !s.is_empty())
305                    .collect::<Vec<_>>()
306                    .join(",");
307            } else {
308                seen.insert(group.to_string(), order.len());
309                order.push(group.to_string());
310                values.push(nodes.to_string());
311            }
312        }
313
314        let mut cache = self.cache.lock().unwrap();
315        cache.mapall_expiry = Some(expiry);
316        // swap the map cache, do not merge: removed groups are gone
317        cache.map = order
318            .iter()
319            .cloned()
320            .zip(values)
321            .map(|(group, nodes)| (group, (nodes, expiry)))
322            .collect();
323        cache.list = Some((order, expiry));
324        Ok(())
325    }
326
327    fn run_command(&self, template: &str, arg: &str) -> Result<String> {
328        let command = template.replace("%s", arg);
329        let output = std::process::Command::new("sh")
330            .arg("-c")
331            .arg(&command)
332            .env("GROUP", arg)
333            .output()
334            .map_err(|e| GroupSourceError::GroupSourceQueryFailed(e.to_string()))?;
335
336        if output.status.success() {
337            Ok(String::from_utf8(output.stdout)
338                .map_err(|e| GroupSourceError::GroupSourceQueryFailed(e.to_string()))?
339                .trim()
340                .to_string())
341        } else {
342            let stderr = String::from_utf8_lossy(&output.stderr);
343            Err(GroupSourceError::GroupSourceQueryFailed(stderr.to_string()).into())
344        }
345    }
346}
347
348/// Split an upcall output string into individual names (comma or
349/// whitespace separated).
350fn split_upcall_output(s: &str) -> Vec<String> {
351    s.split(|c: char| c == ',' || c.is_whitespace())
352        .filter(|s| !s.is_empty())
353        .map(|s| s.to_string())
354        .collect()
355}
356
357impl GroupSource for UpcallGroupSource {
358    fn resolve_map(&self, group: &str) -> Result<String> {
359        if self.mapall_upcall.is_some() {
360            // mapall configured: serve from the mapall-populated cache
361            self.populate_from_mapall()?;
362            let mut cache = self.cache.lock().unwrap();
363            if self.map_upcall.is_none() {
364                // no map fallback: unknown group resolves to an empty
365                // node set (freshness only gates the mapall re-run)
366                return Ok(cache
367                    .map
368                    .get(group)
369                    .map(|(nodes, _)| nodes.clone())
370                    .unwrap_or_default());
371            }
372            // map fallback: cached entries (from mapall or an earlier
373            // fallback call) win while fresh
374            if let Some((nodes, expiry)) = cache.map.get(group) {
375                if Instant::now() < *expiry {
376                    return Ok(nodes.clone());
377                }
378            }
379            let nodes = self.run_command(self.map_upcall.as_ref().unwrap(), group)?;
380            cache.map.insert(
381                group.to_string(),
382                (nodes.clone(), Instant::now() + self.cache_duration()),
383            );
384            return Ok(nodes);
385        }
386        match self.map_upcall.as_ref() {
387            Some(t) => self.run_command(t, group),
388            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
389        }
390    }
391
392    fn resolve_all(&self) -> Result<String> {
393        // note: 'all' is NOT derived from mapall output
394        match self.all_upcall.as_ref() {
395            Some(t) => self.run_command(t, ""),
396            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
397        }
398    }
399
400    fn resolve_list(&self) -> Result<Vec<String>> {
401        if self.mapall_upcall.is_some() {
402            // mapall configured: its output takes precedence for listing
403            self.populate_from_mapall()?;
404            let mut cache = self.cache.lock().unwrap();
405            if self.list_upcall.is_none() {
406                // no list fallback: read mapall result directly (the
407                // freshness check only gates the mapall re-run, so this
408                // also works with cache_time 0)
409                return Ok(cache
410                    .list
411                    .as_ref()
412                    .map(|(groups, _)| groups.clone())
413                    .unwrap_or_default());
414            }
415            // list fallback: a fresh mapall-derived list wins; otherwise
416            // the real list upcall runs and is cached
417            if let Some((groups, expiry)) = &cache.list {
418                if Instant::now() < *expiry {
419                    return Ok(groups.clone());
420                }
421            }
422            let s = self.run_command(self.list_upcall.as_ref().unwrap(), "")?;
423            let groups = split_upcall_output(&s);
424            cache.list = Some((groups.clone(), Instant::now() + self.cache_duration()));
425            return Ok(groups);
426        }
427        match self.list_upcall.as_ref() {
428            Some(t) => {
429                let s = self.run_command(t, "")?;
430                Ok(split_upcall_output(&s))
431            }
432            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
433        }
434    }
435
436    fn resolve_reverse(&self, node: &str) -> Result<Vec<String>> {
437        match self.reverse_upcall.as_ref() {
438            Some(t) => {
439                let s = self.run_command(t, node)?;
440                Ok(s.split(|c: char| c == ',' || c.is_whitespace())
441                    .filter(|s| !s.is_empty())
442                    .map(|s| s.to_string())
443                    .collect())
444            }
445            None => Err(GroupSourceError::GroupSourceNoUpcall.into()),
446        }
447    }
448
449    fn has_reverse(&self) -> bool {
450        self.reverse_upcall.is_some()
451    }
452}
453
454/// Manages named group sources with a default source.
455///
456/// Resolves group references like `@group` to node sets.
457pub struct GroupResolver {
458    sources: HashMap<String, Box<dyn GroupSource>>,
459    default_source_name: Option<String>,
460    illegal_chars: HashSet<char>,
461}
462
463impl GroupResolver {
464    /// Create a new GroupResolver.
465    pub fn new() -> Self {
466        Self {
467            sources: HashMap::new(),
468            default_source_name: None,
469            illegal_chars: HashSet::new(),
470        }
471    }
472
473    /// Add a named source to the resolver.
474    pub fn add_source(&mut self, name: impl Into<String>, source: Box<dyn GroupSource>) {
475        self.sources.insert(name.into(), source);
476    }
477
478    /// Set the default source name.
479    pub fn set_default(&mut self, name: impl Into<String>) {
480        self.default_source_name = Some(name.into());
481    }
482
483    /// Get the source by name, or the default if name is None.
484    fn get_source(&self, namespace: Option<&str>) -> Result<&dyn GroupSource> {
485        let name = match namespace {
486            Some(n) => n,
487            None => self.default_source_name.as_ref().ok_or(
488                GroupResolverSourceError::SourceNotFound("no default source set".to_string()),
489            )?,
490        };
491
492        self.sources
493            .get(name)
494            .map(|s| s.as_ref())
495            .ok_or(GroupResolverSourceError::SourceNotFound(name.to_string()).into())
496    }
497
498    /// Resolve a group to a list of nodes.
499    ///
500    /// If namespace is Some, uses that source; otherwise uses the default.
501    pub fn group_nodes(&self, group: &str, namespace: Option<&str>) -> Result<Vec<String>> {
502        let source = self.get_source(namespace)?;
503        let pattern = source.resolve_map(group)?;
504        Ok(pattern
505            .split(|c: char| c == ',' || c.is_whitespace())
506            .filter(|s| !s.is_empty())
507            .map(|s| s.to_string())
508            .collect())
509    }
510
511    /// Get all nodes from all groups in the specified namespace.
512    pub fn all_nodes(&self, namespace: Option<&str>) -> Result<Vec<String>> {
513        let source = self.get_source(namespace)?;
514        let all = source.resolve_all()?;
515        Ok(all
516            .split(|c: char| c == ',' || c.is_whitespace())
517            .filter(|s| !s.is_empty())
518            .map(|s| s.to_string())
519            .collect())
520    }
521
522    /// List all group names in the specified namespace.
523    pub fn grouplist(&self, namespace: Option<&str>) -> Result<Vec<String>> {
524        let source = self.get_source(namespace)?;
525        source.resolve_list()
526    }
527
528    /// Find all groups containing a given node.
529    pub fn node_groups(&self, node: &str, namespace: Option<&str>) -> Result<Vec<String>> {
530        let source = self.get_source(namespace)?;
531        source.resolve_reverse(node)
532    }
533
534    /// Check if the namespace supports node group lookups.
535    pub fn has_node_groups(&self, namespace: Option<&str>) -> bool {
536        self.get_source(namespace)
537            .map(|s| s.has_reverse())
538            .unwrap_or(false)
539    }
540
541    /// Set illegal characters for group names.
542    pub fn set_illegal_chars(&mut self, chars: HashSet<char>) {
543        self.illegal_chars = chars;
544    }
545
546    /// Check if a group name contains illegal characters.
547    pub fn is_valid_group_name(&self, name: &str) -> Result<()> {
548        for c in name.chars() {
549            if self.illegal_chars.contains(&c) {
550                return Err(GroupResolverSourceError::SourceError(
551                    GroupSourceError::GroupSourceQueryFailed(format!(
552                        "group name contains illegal character: '{}'",
553                        c
554                    )),
555                )
556                .into());
557            }
558        }
559        Ok(())
560    }
561}
562
563impl Default for GroupResolver {
564    fn default() -> Self {
565        Self::new()
566    }
567}
568
569/// Loads group sources from INI configuration files.
570///
571/// Supports the groups.conf format with:
572/// - `[Main]` section for groupsdir, confdir, autodir, default
573/// - Other sections as UpcallGroupSource definitions
574pub struct GroupResolverConfig {
575    filenames: Vec<PathBuf>,
576    illegal_chars: HashSet<char>,
577    initialized: bool,
578    resolver: Option<GroupResolver>,
579}
580
581impl GroupResolverConfig {
582    /// Create a new GroupResolverConfig with the given filenames.
583    pub fn new(filenames: Vec<PathBuf>, illegal_chars: HashSet<char>) -> Self {
584        Self {
585            filenames,
586            illegal_chars,
587            initialized: false,
588            resolver: None,
589        }
590    }
591
592    /// Initialize the config if not already done.
593    fn ensure_initialized(&mut self) -> Result<&mut GroupResolver> {
594        if !self.initialized {
595            self.initialize()?;
596            self.initialized = true;
597        }
598        Ok(self.resolver.as_mut().unwrap())
599    }
600
601    /// Initialize the resolver from config files.
602    fn initialize(&mut self) -> Result<()> {
603        let mut resolver = GroupResolver::new();
604        resolver.set_illegal_chars(self.illegal_chars.clone());
605
606        for filename in &self.filenames {
607            if !filename.exists() {
608                return Err(GroupResolverConfigError::FileNotFound(filename.clone()).into());
609            }
610
611            let mut ini = Ini::new();
612            // configparser lowercases keys/sections by default
613            ini.load(filename.to_string_lossy().as_ref())
614                .map_err(|e| GroupResolverConfigError::ParseError(e.to_string()))?;
615
616            // Process [Main] section (lowercased by configparser)
617            if let Some(default) = ini.get("main", "default") {
618                resolver.set_default(default);
619            }
620
621            // Process other sections as UpcallGroupSource
622            for section_name in ini.sections() {
623                if section_name == "main" || section_name == "default" {
624                    continue;
625                }
626
627                let map_upcall = ini.get(&section_name, "map");
628                let mapall_upcall = ini.get(&section_name, "mapall");
629                let all_upcall = ini.get(&section_name, "all");
630                let list_upcall = ini.get(&section_name, "list");
631                let reverse_upcall = ini.get(&section_name, "reverse");
632                let cache_time = ini
633                    .get(&section_name, "cache_time")
634                    .and_then(|v| v.parse::<f64>().ok());
635
636                // map or mapall is a mandatory upcall (upstream 68e5df9;
637                // Python raises GroupResolverConfigError)
638                if map_upcall.is_none() && mapall_upcall.is_none() {
639                    return Err(GroupResolverConfigError::ParseError(format!(
640                        "No option 'map' or 'mapall' in section: {:?}",
641                        section_name
642                    ))
643                    .into());
644                }
645
646                let mut source =
647                    UpcallGroupSource::new(map_upcall, all_upcall, list_upcall, reverse_upcall)
648                        .with_mapall_upcall(mapall_upcall);
649                if let Some(ctime) = cache_time {
650                    source = source.with_cache_time(ctime);
651                }
652                resolver.add_source(section_name.clone(), Box::new(source));
653            }
654        }
655
656        self.resolver = Some(resolver);
657        Ok(())
658    }
659
660    /// Get the underlying resolver.
661    pub fn resolver(&mut self) -> Result<&mut GroupResolver> {
662        self.ensure_initialized()
663    }
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669    use std::collections::HashSet;
670
671    #[test]
672    fn test_static_group_source_basic_resolve() {
673        let mut map = HashMap::new();
674        map.insert("web".to_string(), "web1,web2,web3".to_string());
675        map.insert("db".to_string(), "db1,db2".to_string());
676
677        let source = StaticGroupSource::new(map);
678
679        assert_eq!(source.resolve_map("web").unwrap(), "web1,web2,web3");
680        assert_eq!(source.resolve_map("db").unwrap(), "db1,db2");
681    }
682
683    #[test]
684    fn test_static_group_source_missing_group() {
685        let source = StaticGroupSource::new(HashMap::new());
686        assert!(source.resolve_map("nonexistent").is_err());
687    }
688
689    #[test]
690    fn test_static_group_source_with_all_and_list() {
691        let mut map = HashMap::new();
692        map.insert("web".to_string(), "web1,web2".to_string());
693        map.insert("db".to_string(), "db1,db2".to_string());
694
695        let source = StaticGroupSource::new(map)
696            .with_all("web1,web2,db1,db2".to_string())
697            .with_list(vec!["web".to_string(), "db".to_string()]);
698
699        assert_eq!(source.resolve_all().unwrap(), "web1,web2,db1,db2");
700        assert_eq!(source.resolve_list().unwrap(), vec!["web", "db"]);
701    }
702
703    #[test]
704    fn test_static_group_source_reverse_lookup() {
705        let mut map = HashMap::new();
706        map.insert("web".to_string(), "web1,web2".to_string());
707        map.insert("db".to_string(), "db1,db2".to_string());
708
709        let mut reverse = HashMap::new();
710        reverse.insert("web1".to_string(), vec!["web".to_string()]);
711        reverse.insert("web2".to_string(), vec!["web".to_string()]);
712        reverse.insert("db1".to_string(), vec!["db".to_string()]);
713        reverse.insert("db2".to_string(), vec!["db".to_string()]);
714
715        let source = StaticGroupSource::new(map).with_reverse(reverse);
716
717        assert!(source.has_reverse());
718        assert_eq!(
719            source.resolve_reverse("web1").unwrap(),
720            vec!["web".to_string()]
721        );
722        assert_eq!(
723            source.resolve_reverse("db2").unwrap(),
724            vec!["db".to_string()]
725        );
726    }
727
728    #[test]
729    fn test_group_resolver_with_multiple_sources() {
730        let mut resolver = GroupResolver::new();
731
732        let mut map1 = HashMap::new();
733        map1.insert("web".to_string(), "web1,web2".to_string());
734        resolver.add_source("cluster1", Box::new(StaticGroupSource::new(map1)));
735
736        let mut map2 = HashMap::new();
737        map2.insert("db".to_string(), "db1,db2".to_string());
738        resolver.add_source("cluster2", Box::new(StaticGroupSource::new(map2)));
739
740        let web_nodes = resolver.group_nodes("web", Some("cluster1")).unwrap();
741        assert_eq!(web_nodes, vec!["web1", "web2"]);
742
743        let db_nodes = resolver.group_nodes("db", Some("cluster2")).unwrap();
744        assert_eq!(db_nodes, vec!["db1", "db2"]);
745    }
746
747    #[test]
748    fn test_group_resolver_default_source() {
749        let mut resolver = GroupResolver::new();
750
751        let mut map1 = HashMap::new();
752        map1.insert("web".to_string(), "web1,web2".to_string());
753        resolver.add_source("default", Box::new(StaticGroupSource::new(map1)));
754
755        resolver.set_default("default");
756
757        // Use default source (no namespace)
758        let web_nodes = resolver.group_nodes("web", None).unwrap();
759        assert_eq!(web_nodes, vec!["web1", "web2"]);
760    }
761
762    #[test]
763    fn test_group_resolver_missing_source() {
764        let resolver = GroupResolver::new();
765        assert!(resolver.group_nodes("web", Some("nonexistent")).is_err());
766    }
767
768    #[test]
769    fn test_group_resolver_missing_default() {
770        let resolver = GroupResolver::new();
771        assert!(resolver.group_nodes("web", None).is_err());
772    }
773
774    #[test]
775    fn test_illegal_chars_validation() {
776        let mut resolver = GroupResolver::new();
777        let mut illegal = HashSet::new();
778        illegal.insert('$');
779        illegal.insert(' ');
780
781        resolver.set_illegal_chars(illegal);
782
783        assert!(resolver.is_valid_group_name("web").is_ok());
784        assert!(resolver.is_valid_group_name("web-group").is_ok());
785        assert!(resolver.is_valid_group_name("web_group").is_ok());
786        assert!(resolver.is_valid_group_name("web$group").is_err());
787        assert!(resolver.is_valid_group_name("web group").is_err());
788    }
789
790    #[test]
791    fn test_upcall_source_no_upcall() {
792        let source = UpcallGroupSource::new(None, None, None, None);
793        assert!(source.resolve_map("test").is_err());
794        assert!(source.resolve_all().is_err());
795        assert!(source.resolve_list().is_err());
796        assert!(source.resolve_reverse("node").is_err());
797        assert!(!source.has_reverse());
798    }
799
800    #[test]
801    fn test_upcall_source_with_map() {
802        let source = UpcallGroupSource::new(Some("echo web1,web2".to_string()), None, None, None);
803        assert_eq!(source.resolve_map("test").unwrap(), "web1,web2");
804    }
805
806    #[test]
807    fn test_group_resolver_sources_from_ini() {
808        use std::io::Write;
809        use tempfile::NamedTempFile;
810
811        let mut file = NamedTempFile::new().unwrap();
812        writeln!(
813            file,
814            r#"[Main]
815default = cluster1
816
817[cluster1]
818map = echo web1,web2
819
820[cluster2]
821map = echo db1,db2
822list = echo web db
823all = echo web1,web2,db1,db2
824reverse = echo web
825"#
826        )
827        .unwrap();
828
829        let mut config = GroupResolverConfig::new(vec![file.path().to_path_buf()], HashSet::new());
830        let resolver = config.resolver().unwrap();
831
832        let web_nodes = resolver.group_nodes("test", Some("cluster1")).unwrap();
833        assert_eq!(web_nodes, vec!["web1", "web2"]);
834
835        let db_nodes = resolver.group_nodes("test", Some("cluster2")).unwrap();
836        assert_eq!(db_nodes, vec!["db1", "db2"]);
837
838        let list = resolver.grouplist(Some("cluster2")).unwrap();
839        assert_eq!(list, vec!["web", "db"]);
840    }
841
842    #[test]
843    fn test_group_resolver_config_file_not_found() {
844        let mut config = GroupResolverConfig::new(
845            vec![PathBuf::from("/nonexistent/path/groups.conf")],
846            HashSet::new(),
847        );
848        assert!(config.resolver().is_err());
849    }
850
851    #[test]
852    fn test_whitespace_splitting() {
853        let mut map = HashMap::new();
854        // Test with various whitespace
855        map.insert(
856            "web".to_string(),
857            "web1, web2  ,web3\tweb4\nweb5".to_string(),
858        );
859
860        let source = StaticGroupSource::new(map);
861        let nodes = source.resolve_map("web").unwrap();
862
863        let resolver = GroupResolver::new();
864        let parsed = nodes
865            .split(|c: char| c == ',' || c.is_whitespace())
866            .filter(|s| !s.is_empty())
867            .collect::<Vec<_>>();
868
869        assert_eq!(parsed, vec!["web1", "web2", "web3", "web4", "web5"]);
870        drop(resolver);
871    }
872
873    // ── mapall upcall (upstream 68e5df9) ─────────────────────────────────
874    // Mirrors tests/NodeSetGroupTest.py::GroupSourceMapallTest. Shell
875    // upcalls play the role of StaticGroupSource's fake upcalls.
876
877    /// Build a GroupResolver with `source` as the default source.
878    fn resolver_with(source: UpcallGroupSource) -> GroupResolver {
879        let mut resolver = GroupResolver::new();
880        resolver.add_source("test", Box::new(source));
881        resolver.set_default("test");
882        resolver
883    }
884
885    #[test]
886    fn test_mapall_basic() {
887        // UpcallGroupSource with only a mapall upcall
888        let source = UpcallGroupSource::new(None, None, None, None)
889            .with_mapall_upcall(Some("printf 'a: foo1\nb: foo2\nc: foo[3-4]\n'".to_string()));
890        let res = resolver_with(source);
891        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["foo1"]);
892        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["foo2"]);
893        assert_eq!(res.group_nodes("c", None).unwrap(), vec!["foo[3-4]"]);
894        // list is also derived from mapall output
895        let mut groups = res.grouplist(None).unwrap();
896        groups.sort();
897        assert_eq!(groups, vec!["a", "b", "c"]);
898        // unknown group resolves to empty (no map upcall available)
899        assert!(res.group_nodes("nope", None).unwrap().is_empty());
900    }
901
902    #[test]
903    fn test_mapall_with_map_fallback() {
904        // mapall + map: missing group falls back to per-group map
905        let count = tempfile::NamedTempFile::new().unwrap();
906        let map_cmd = format!("echo foo2 && echo called >> {}", count.path().display());
907        let source = UpcallGroupSource::new(Some(map_cmd), None, None, None)
908            .with_mapall_upcall(Some("printf 'a: foo1\n'".to_string()));
909        let res = resolver_with(source);
910        // mapall output wins over the map upcall for known groups
911        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["foo1"]);
912        // 'b' not in mapall output: falls back to the map upcall
913        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["foo2"]);
914        // 'b' is now cached via map: no second map upcall
915        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["foo2"]);
916        let calls = std::fs::read_to_string(count.path()).unwrap();
917        assert_eq!(calls.lines().count(), 1);
918    }
919
920    #[test]
921    fn test_mapall_overrides_list() {
922        // mapall output takes precedence over the list upcall
923        let source =
924            UpcallGroupSource::new(None, None, Some("echo explicit_only".to_string()), None)
925                .with_mapall_upcall(Some("printf 'a: n1\n'".to_string()))
926                .with_cache_time(0.2);
927        let res = resolver_with(source);
928        assert_eq!(res.grouplist(None).unwrap(), vec!["a"]);
929        std::thread::sleep(std::time::Duration::from_millis(250));
930        // precedence holds across a cache refresh cycle
931        assert_eq!(res.grouplist(None).unwrap(), vec!["a"]);
932    }
933
934    #[test]
935    fn test_mapall_expiry() {
936        // mapall is re-run when cache_time expires
937        let data = tempfile::NamedTempFile::new().unwrap();
938        std::fs::write(data.path(), "a: v1\nold: x1\n").unwrap();
939        let source = UpcallGroupSource::new(None, None, None, None)
940            .with_mapall_upcall(Some(format!("cat {}", data.path().display())))
941            .with_cache_time(0.2);
942        let res = resolver_with(source);
943        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["v1"]);
944        assert_eq!(res.group_nodes("old", None).unwrap(), vec!["x1"]);
945        // change underlying mapall output, sleep past expiry
946        std::fs::write(data.path(), "a: v2\nb: v3\n").unwrap();
947        std::thread::sleep(std::time::Duration::from_millis(250));
948        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["v2"]);
949        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["v3"]);
950        // map cache was swapped, not merged: removed group is gone
951        assert!(res.group_nodes("old", None).unwrap().is_empty());
952        // list cache is also refreshed from mapall output
953        let mut groups = res.grouplist(None).unwrap();
954        groups.sort();
955        assert_eq!(groups, vec!["a", "b"]);
956    }
957
958    #[test]
959    fn test_mapall_clear_cache() {
960        // clear_cache() triggers a new mapall call
961        let data = tempfile::NamedTempFile::new().unwrap();
962        std::fs::write(data.path(), "a: v1\n").unwrap();
963        let source = UpcallGroupSource::new(None, None, None, None)
964            .with_mapall_upcall(Some(format!("cat {}", data.path().display())));
965        assert_eq!(source.resolve_map("a").unwrap(), "v1");
966        std::fs::write(data.path(), "a: v2\n").unwrap();
967        // still served from cache
968        assert_eq!(source.resolve_map("a").unwrap(), "v1");
969        source.clear_cache();
970        assert_eq!(source.resolve_map("a").unwrap(), "v2");
971    }
972
973    #[test]
974    fn test_mapall_cache_time_zero() {
975        // mapall-only source with cache_time set to 0 (no caching)
976        let data = tempfile::NamedTempFile::new().unwrap();
977        std::fs::write(data.path(), "a: n1\n").unwrap();
978        let source = UpcallGroupSource::new(None, None, None, None)
979            .with_mapall_upcall(Some(format!("cat {}", data.path().display())))
980            .with_cache_time(0.0);
981        assert_eq!(source.resolve_list().unwrap(), vec!["a"]);
982        assert_eq!(source.resolve_map("a").unwrap(), "n1");
983        // no caching: updated mapall output is seen right away
984        std::fs::write(data.path(), "a: n2\nb: n3\n").unwrap();
985        assert_eq!(source.resolve_map("a").unwrap(), "n2");
986        assert_eq!(source.resolve_list().unwrap(), vec!["a", "b"]);
987    }
988
989    #[test]
990    fn test_mapall_empty_group() {
991        // mapall line with no nodes resolves to empty
992        let source = UpcallGroupSource::new(None, None, None, None)
993            .with_mapall_upcall(Some("printf 'empty:\nfilled: n1\n'".to_string()));
994        let res = resolver_with(source);
995        assert!(res.group_nodes("empty", None).unwrap().is_empty());
996        assert_eq!(res.group_nodes("filled", None).unwrap(), vec!["n1"]);
997    }
998
999    #[test]
1000    fn test_mapall_empty_output() {
1001        // mapall upcall returning no groups at all
1002        let source = UpcallGroupSource::new(None, None, None, None)
1003            .with_mapall_upcall(Some("printf ''".to_string()));
1004        let res = resolver_with(source);
1005        assert!(res.grouplist(None).unwrap().is_empty());
1006        assert!(res.group_nodes("nope", None).unwrap().is_empty());
1007    }
1008
1009    #[test]
1010    fn test_mapall_blank_lines_ignored() {
1011        // mapall tolerates blank lines and surrounding whitespace
1012        let source = UpcallGroupSource::new(None, None, None, None)
1013            .with_mapall_upcall(Some("printf '\n  a: n1 \n\n  b: n2\n\n'".to_string()));
1014        let res = resolver_with(source);
1015        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["n1"]);
1016        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["n2"]);
1017    }
1018
1019    #[test]
1020    fn test_mapall_parse_error() {
1021        // mapall line without ':' raises GroupSourceQueryFailed
1022        let source = UpcallGroupSource::new(None, None, None, None)
1023            .with_mapall_upcall(Some("printf 'a: n1\nbroken_no_colon\n'".to_string()));
1024        let res = resolver_with(source);
1025        assert!(res.group_nodes("a", None).is_err());
1026    }
1027
1028    #[test]
1029    fn test_mapall_empty_group_name() {
1030        // mapall line with empty group name raises GroupSourceQueryFailed
1031        let source = UpcallGroupSource::new(None, None, None, None)
1032            .with_mapall_upcall(Some("printf ': n1\n'".to_string()));
1033        let res = resolver_with(source);
1034        assert!(res.group_nodes("a", None).is_err());
1035    }
1036
1037    #[test]
1038    fn test_mapall_whitespace_group_name() {
1039        // mapall line with multi-word group name raises GroupSourceQueryFailed
1040        let source = UpcallGroupSource::new(None, None, None, None)
1041            .with_mapall_upcall(Some("printf 'bad group: n1\n'".to_string()));
1042        let res = resolver_with(source);
1043        assert!(res.group_nodes("a", None).is_err());
1044    }
1045
1046    #[test]
1047    fn test_mapall_duplicate_groups_union() {
1048        // duplicate mapall group lines are unioned
1049        let source = UpcallGroupSource::new(None, None, None, None)
1050            .with_mapall_upcall(Some("printf 'a: n1\nb: n3\na: n2\na:\n'".to_string()));
1051        let res = resolver_with(source);
1052        let nodes = res.group_nodes("a", None).unwrap();
1053        assert_eq!(nodes, vec!["n1", "n2"]);
1054        // the unioned result folds like a nodeset
1055        assert_eq!(crate::node_set::fold(&nodes.join(",")).unwrap(), "n[1-2]");
1056        assert_eq!(res.group_nodes("b", None).unwrap(), vec!["n3"]);
1057        let mut groups = res.grouplist(None).unwrap();
1058        groups.sort();
1059        assert_eq!(groups, vec!["a", "b"]);
1060    }
1061
1062    #[test]
1063    fn test_mapall_all_nodes_single_call() {
1064        // @*-style expansion (list + map per group) served by one mapall call
1065        let count = tempfile::NamedTempFile::new().unwrap();
1066        let cmd = format!(
1067            "printf 'a: n[1-2]\nb: n[3-4]\n' && echo called >> {}",
1068            count.path().display()
1069        );
1070        let source = UpcallGroupSource::new(None, None, None, None).with_mapall_upcall(Some(cmd));
1071        let res = resolver_with(source);
1072        let mut all = Vec::new();
1073        for group in res.grouplist(None).unwrap() {
1074            all.extend(res.group_nodes(&group, None).unwrap());
1075        }
1076        assert_eq!(all, vec!["n[1-2]", "n[3-4]"]);
1077        let calls = std::fs::read_to_string(count.path()).unwrap();
1078        assert_eq!(calls.lines().count(), 1);
1079    }
1080
1081    #[test]
1082    fn test_mapall_parse_error_retry() {
1083        // mapall output is not cached on parse error
1084        let data = tempfile::NamedTempFile::new().unwrap();
1085        std::fs::write(data.path(), "broken_no_colon\n").unwrap();
1086        let source = UpcallGroupSource::new(None, None, None, None)
1087            .with_mapall_upcall(Some(format!("cat {}", data.path().display())));
1088        let res = resolver_with(source);
1089        assert!(res.group_nodes("a", None).is_err());
1090        // fixed output must be picked up by the next call, not a cached error
1091        std::fs::write(data.path(), "a: n1\n").unwrap();
1092        assert_eq!(res.group_nodes("a", None).unwrap(), vec!["n1"]);
1093    }
1094
1095    #[test]
1096    fn test_mapall_all_still_required() {
1097        // 'all' is not derived from mapall
1098        let source = UpcallGroupSource::new(None, None, None, None)
1099            .with_mapall_upcall(Some("printf 'a: n1\nb: n2\n'".to_string()));
1100        assert!(source.resolve_all().is_err());
1101    }
1102
1103    #[test]
1104    fn test_config_mapall_only() {
1105        // config with mapall upcall and no map
1106        let file = tempfile::NamedTempFile::new().unwrap();
1107        std::fs::write(
1108            file.path(),
1109            "[Main]\ndefault: bulk\n\n[bulk]\nmapall: printf 'g1: n[1-3]\\ng2: n[4-5]\\n'\n",
1110        )
1111        .unwrap();
1112        let mut config = GroupResolverConfig::new(vec![file.path().to_path_buf()], HashSet::new());
1113        let resolver = config.resolver().unwrap();
1114        assert_eq!(resolver.group_nodes("g1", None).unwrap(), vec!["n[1-3]"]);
1115        assert_eq!(resolver.group_nodes("g2", None).unwrap(), vec!["n[4-5]"]);
1116        let mut groups = resolver.grouplist(None).unwrap();
1117        groups.sort();
1118        assert_eq!(groups, vec!["g1", "g2"]);
1119    }
1120
1121    #[test]
1122    fn test_config_mapall_query_failed() {
1123        // config with failing mapall upcall (not cached, retried)
1124        let data = tempfile::NamedTempFile::new().unwrap(); // stays empty for now
1125        let file = tempfile::NamedTempFile::new().unwrap();
1126        std::fs::write(
1127            file.path(),
1128            format!(
1129                "[Main]\ndefault: bulk\n\n[bulk]\nmapall: test -s {0} && cat {0}\n",
1130                data.path().display()
1131            ),
1132        )
1133        .unwrap();
1134        let mut config = GroupResolverConfig::new(vec![file.path().to_path_buf()], HashSet::new());
1135        let resolver = config.resolver().unwrap();
1136        // empty data file: mapall upcall exits non-zero
1137        assert!(resolver.grouplist(None).is_err());
1138        // upcall failure is not cached: fix the data file and retry
1139        std::fs::write(data.path(), "a: n1\n").unwrap();
1140        assert_eq!(resolver.grouplist(None).unwrap(), vec!["a"]);
1141        assert_eq!(resolver.group_nodes("a", None).unwrap(), vec!["n1"]);
1142    }
1143
1144    #[test]
1145    fn test_config_map_or_mapall_required() {
1146        // config without map or mapall raises a config error
1147        let file = tempfile::NamedTempFile::new().unwrap();
1148        std::fs::write(
1149            file.path(),
1150            "[Main]\ndefault: nope\n\n[nope]\nlist: echo foo\n",
1151        )
1152        .unwrap();
1153        let mut config = GroupResolverConfig::new(vec![file.path().to_path_buf()], HashSet::new());
1154        // surfaces at (lazy) resolver initialization, like Python's
1155        // GroupResolverConfigError at first use
1156        let err = config.resolver().err().unwrap();
1157        assert!(format!("{:?}", err).contains("map' or 'mapall'"));
1158    }
1159}