Skip to main content

consortium_cli/
groups.rs

1//! ClusterShell node-group lookup shared by `claw` and `cast`.
2//!
3//! Two sources, in order:
4//!
5//! 1. `groups.conf` (`$XDG_CONFIG_HOME/clustershell/groups.conf`, default
6//!    `~/.config/clustershell/groups.conf`, then `/etc/clustershell/groups.conf`)
7//!    through [`GroupResolverConfig`] — the same upcall-driven resolver
8//!    `claw -g` uses.
9//! 2. Flat `groups.d` files (`$XDG_CONFIG_HOME/clustershell/groups.d/*`,
10//!    then `/etc/clustershell/groups.d/*`; YAML files are skipped) with one
11//!    `group: nodeset` per line, where the nodeset is a whitespace- or
12//!    comma-separated list of node patterns.
13//!
14//! `@group` and `@source:group` spellings are accepted; with flat files the
15//! source selects the file stem inside `groups.d`.
16
17use std::collections::HashSet;
18use std::path::{Path, PathBuf};
19
20use consortium::node_set::NodeSet;
21use consortium::node_utils::GroupResolverConfig;
22
23/// The user's ClusterShell config directory:
24/// `$XDG_CONFIG_HOME/clustershell` or `~/.config/clustershell`.
25pub fn user_config_dir() -> Option<PathBuf> {
26    if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
27        return Some(PathBuf::from(xdg).join("clustershell"));
28    }
29    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config/clustershell"))
30}
31
32/// Find groups.conf config files in standard locations.
33pub fn find_groups_conf() -> Vec<PathBuf> {
34    let mut paths = Vec::new();
35
36    if let Some(dir) = user_config_dir() {
37        let user_conf = dir.join("groups.conf");
38        if user_conf.exists() {
39            paths.push(user_conf);
40        }
41    }
42
43    // System config: /etc/clustershell/groups.conf
44    let sys_conf = PathBuf::from("/etc/clustershell/groups.conf");
45    if sys_conf.exists() {
46        paths.push(sys_conf);
47    }
48
49    paths
50}
51
52/// Load a GroupResolverConfig from standard config file locations.
53pub fn load_group_resolver() -> anyhow::Result<GroupResolverConfig> {
54    let paths = find_groups_conf();
55    if paths.is_empty() {
56        anyhow::bail!(
57            "no groups.conf found (checked ~/.config/clustershell/ and /etc/clustershell/)"
58        );
59    }
60    Ok(GroupResolverConfig::new(paths, HashSet::new()))
61}
62
63/// The `groups.d` directories holding flat group files, user first.
64pub fn groups_d_dirs() -> Vec<PathBuf> {
65    let mut dirs = Vec::new();
66    if let Some(dir) = user_config_dir() {
67        dirs.push(dir.join("groups.d"));
68    }
69    dirs.push(PathBuf::from("/etc/clustershell/groups.d"));
70    dirs
71}
72
73/// `(group, nodeset)` pairs from a flat `group: nodeset` file. Blank lines
74/// and `#` comments are ignored.
75pub fn parse_flat_groups(content: &str) -> Vec<(String, String)> {
76    content
77        .lines()
78        .map(str::trim)
79        .filter(|l| !l.is_empty() && !l.starts_with('#'))
80        .filter_map(|l| {
81            let (name, nodes) = l.split_once(':')?;
82            let name = name.trim();
83            if name.is_empty() {
84                return None;
85            }
86            Some((name.to_string(), nodes.trim().to_string()))
87        })
88        .collect()
89}
90
91/// Look `group` up in the flat files under `dirs`. `source` restricts the
92/// search to files whose stem equals it. The first match wins.
93pub fn lookup_flat_group(dirs: &[PathBuf], source: Option<&str>, group: &str) -> Option<String> {
94    for dir in dirs {
95        let Ok(entries) = std::fs::read_dir(dir) else {
96            continue;
97        };
98        let mut files: Vec<PathBuf> = entries
99            .filter_map(|e| e.ok().map(|e| e.path()))
100            .filter(|p| p.is_file() && !is_yaml(p))
101            .filter(|p| {
102                source.map_or(true, |s| {
103                    p.file_stem().and_then(|st| st.to_str()) == Some(s)
104                })
105            })
106            .collect();
107        files.sort();
108        for file in files {
109            let Ok(content) = std::fs::read_to_string(&file) else {
110                continue;
111            };
112            if let Some((_, nodes)) = parse_flat_groups(&content)
113                .into_iter()
114                .find(|(g, _)| g == group)
115            {
116                return Some(nodes);
117            }
118        }
119    }
120    None
121}
122
123fn is_yaml(path: &Path) -> bool {
124    matches!(
125        path.extension().and_then(|e| e.to_str()),
126        Some("yaml") | Some("yml")
127    )
128}
129
130/// Resolve a group spelled `group` or `source:group` (without the leading
131/// `@`) to node patterns: groups.conf first, then flat `groups.d` files.
132pub fn group_nodes(spec: &str) -> anyhow::Result<Vec<String>> {
133    let (source, group) = match spec.split_once(':') {
134        Some((s, g)) => (Some(s), g),
135        None => (None, spec),
136    };
137    if group.is_empty() {
138        anyhow::bail!("empty group name in '@{}'", spec);
139    }
140
141    if let Ok(mut config) = load_group_resolver() {
142        if let Ok(resolver) = config.resolver() {
143            if let Ok(nodes) = resolver.group_nodes(group, source) {
144                if !nodes.is_empty() {
145                    return Ok(nodes);
146                }
147            }
148        }
149    }
150
151    let dirs = groups_d_dirs();
152    if let Some(nodes) = lookup_flat_group(&dirs, source, group) {
153        return Ok(split_patterns(&nodes));
154    }
155
156    let mut checked: Vec<String> = find_groups_conf()
157        .iter()
158        .map(|p| p.display().to_string())
159        .collect();
160    checked.extend(dirs.iter().map(|d| format!("{}/*", d.display())));
161    anyhow::bail!(
162        "group '@{}' not found (checked: {})",
163        spec,
164        checked.join(", ")
165    )
166}
167
168/// Split a flat-file nodeset value into individual patterns.
169fn split_patterns(nodes: &str) -> Vec<String> {
170    nodes
171        .split(|c: char| c == ',' || c.is_whitespace())
172        .filter(|s| !s.is_empty())
173        .map(str::to_string)
174        .collect()
175}
176
177/// Expand a `--on` pattern whose top-level comma-separated tokens may be
178/// `@group` references, resolving each through `lookup`, into a
179/// [`NodeSet`]. Plain tokens keep full bracket/range syntax.
180pub fn expand_pattern(
181    pattern: &str,
182    lookup: &dyn Fn(&str) -> anyhow::Result<Vec<String>>,
183) -> anyhow::Result<NodeSet> {
184    let mut ns = NodeSet::new();
185    let mut plain: Vec<&str> = Vec::new();
186    for token in split_top_level(pattern) {
187        if let Some(group) = token.strip_prefix('@') {
188            for node_pattern in lookup(group)? {
189                let parsed = NodeSet::parse(&node_pattern).map_err(|e| {
190                    anyhow::anyhow!(
191                        "invalid node pattern '{}' in group '@{}': {}",
192                        node_pattern,
193                        group,
194                        e
195                    )
196                })?;
197                ns.update(&parsed);
198            }
199        } else if !token.is_empty() {
200            plain.push(token);
201        }
202    }
203    if !plain.is_empty() {
204        let joined = plain.join(",");
205        let parsed = NodeSet::parse(&joined)
206            .map_err(|e| anyhow::anyhow!("invalid node pattern '{}': {}", joined, e))?;
207        ns.update(&parsed);
208    }
209    Ok(ns)
210}
211
212/// Split on commas outside `[...]`.
213fn split_top_level(pattern: &str) -> Vec<&str> {
214    let mut out = Vec::new();
215    let mut depth = 0usize;
216    let mut start = 0;
217    for (i, c) in pattern.char_indices() {
218        match c {
219            '[' => depth += 1,
220            ']' => depth = depth.saturating_sub(1),
221            ',' if depth == 0 => {
222                out.push(pattern[start..i].trim());
223                start = i + 1;
224            }
225            _ => {}
226        }
227    }
228    out.push(pattern[start..].trim());
229    out
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn parses_flat_group_lines_and_skips_comments() {
238        let groups = parse_flat_groups(
239            "# fleet\n\ndarwin: mac01.local mac02.local\nlinux: box[01-03],gpu01\nbad line\n",
240        );
241        assert_eq!(
242            groups,
243            vec![
244                ("darwin".to_string(), "mac01.local mac02.local".to_string()),
245                ("linux".to_string(), "box[01-03],gpu01".to_string()),
246            ]
247        );
248    }
249
250    #[test]
251    fn flat_lookup_prefers_first_dir_and_honours_source_stem() {
252        let user = tempfile::tempdir().unwrap();
253        let sys = tempfile::tempdir().unwrap();
254        std::fs::write(user.path().join("cluster.cfg"), "web: u[1-2]\n").unwrap();
255        std::fs::write(user.path().join("lab.cfg"), "web: lab1\ndb: lab-db\n").unwrap();
256        std::fs::write(user.path().join("cluster.yaml"), "web: yaml-only\n").unwrap();
257        std::fs::write(sys.path().join("cluster.cfg"), "web: s[1-2]\nsys: s9\n").unwrap();
258        let dirs = vec![user.path().to_path_buf(), sys.path().to_path_buf()];
259
260        assert_eq!(
261            lookup_flat_group(&dirs, None, "web").as_deref(),
262            Some("u[1-2]")
263        );
264        assert_eq!(
265            lookup_flat_group(&dirs, Some("lab"), "web").as_deref(),
266            Some("lab1")
267        );
268        assert_eq!(
269            lookup_flat_group(&dirs, None, "db").as_deref(),
270            Some("lab-db")
271        );
272        assert_eq!(lookup_flat_group(&dirs, None, "sys").as_deref(), Some("s9"));
273        assert_eq!(lookup_flat_group(&dirs, None, "nope"), None);
274    }
275
276    #[test]
277    fn expand_pattern_mixes_groups_and_bracket_ranges() {
278        let lookup = |g: &str| -> anyhow::Result<Vec<String>> {
279            match g {
280                "darwin" => Ok(vec!["mac01.local".into(), "mac02.local".into()]),
281                "gpu" => Ok(vec!["gpu[1-2]".into()]),
282                other => anyhow::bail!("group '@{}' not found", other),
283            }
284        };
285        let ns = expand_pattern("@darwin,box[01-02],@gpu", &lookup).unwrap();
286        let mut nodes: Vec<String> = ns.iter().collect();
287        nodes.sort();
288        assert_eq!(
289            nodes,
290            vec![
291                "box01",
292                "box02",
293                "gpu1",
294                "gpu2",
295                "mac01.local",
296                "mac02.local"
297            ]
298        );
299
300        let err = expand_pattern("@nope", &lookup).unwrap_err();
301        assert!(err.to_string().contains("'@nope' not found"), "{err}");
302    }
303
304    #[test]
305    fn split_top_level_keeps_bracket_commas() {
306        assert_eq!(split_top_level("a[1,3],@g, b"), vec!["a[1,3]", "@g", "b"]);
307    }
308}