Skip to main content

isoloom_spec/
groups.rs

1//! Shared machine fields: `common:` for every machine, `groups:` for some. A spec that gives
2//! five servers the same Windows block writes it once:
3//!
4//! ```yaml
5//! common:
6//!   vm: { os: debian@12 }
7//!   resources: { cpus: 2, memory_mb: 2048 }
8//! groups:
9//!   servers:
10//!     members: [dc01, dc02, srv02]        # machine names, globs (ws*), other groups
11//!     vm: { os: windows-server@2019 }
12//! ```
13//!
14//! Any field a group may share deep-merges into its members: a machine's own value wins over
15//! its groups', a group's over `common:`, and between groups the most specific one (a group
16//! that is a member of another) wins; otherwise the later declared. `docker:` and `vm:` only
17//! complete an implementation the machine declares itself (even as `vm: {}`), so a shared
18//! `vm:` never turns a machine into a VM it wasn't. Groups also become Ansible inventory
19//! groups, which a provisioner's playbook can target.
20//!
21//! Expansion happens on the YAML before the spec is parsed, so every reader of a `Spec` sees
22//! finished machines; the typed `common` and `groups` stay in the spec for validation, the
23//! inventory and the snapshot.
24
25use indexmap::IndexMap;
26use serde_yaml_ng::{Mapping, Value};
27
28use crate::model::Spec;
29use crate::syntax::{Code, FieldPath, Problem};
30use crate::validate;
31
32/// Group names Isoloom fills itself in inventories.
33pub(crate) const RESERVED: &[&str] = &["all", "linux", "windows"];
34
35/// Whether `pattern` (a name, or a glob with `*`) matches `name`.
36#[must_use]
37pub fn matches(pattern: &str, name: &str) -> bool {
38    let mut parts = pattern.split('*');
39    let Some(mut rest) = parts.next().and_then(|first| name.strip_prefix(first)) else {
40        return false;
41    };
42    let Some(last) = parts.next_back() else {
43        return rest.is_empty();
44    };
45    for middle in parts {
46        match rest.split_once(middle) {
47            Some((_, after)) => rest = after,
48            None => return false,
49        }
50    }
51    rest.ends_with(last)
52}
53
54/// The groups of a spec and its machines' names: what membership is resolved against, whether
55/// the groups come from the parsed spec or from its YAML.
56struct Membership<'a> {
57    /// Each group's members as written: machine names, globs, other groups.
58    groups: IndexMap<&'a str, Vec<&'a str>>,
59    /// The machines, in spec order.
60    machines: Vec<&'a str>,
61}
62
63impl<'a> Membership<'a> {
64    /// The groups and machines of a parsed spec.
65    fn of_spec(spec: &'a Spec) -> Membership<'a> {
66        Membership {
67            groups: spec
68                .groups
69                .iter()
70                .map(|(name, g)| (name.as_str(), g.members.iter().map(String::as_str).collect()))
71                .collect(),
72            machines: spec.machines.keys().map(String::as_str).collect(),
73        }
74    }
75
76    /// Whether `name` is a group, and not also a machine (a machine's name wins).
77    fn is_group(&self, name: &str) -> bool {
78        self.groups.contains_key(name) && !self.machines.contains(&name)
79    }
80
81    /// The machines of `group`: its member names and globs, and the members of the groups it
82    /// names (recursively, cycles stopped), in spec order.
83    fn machines_of(&self, group: &str) -> Vec<&'a str> {
84        let mut seen = Vec::new();
85        let mut found = Vec::new();
86        self.collect(group, &mut seen, &mut found);
87        self.machines.iter().copied().filter(|m| found.contains(m)).collect()
88    }
89
90    fn collect(&self, group: &str, seen: &mut Vec<String>, found: &mut Vec<&'a str>) {
91        if seen.iter().any(|g| g == group) {
92            return;
93        }
94        seen.push(group.to_owned());
95        for pattern in self.groups.get(group).into_iter().flatten() {
96            if self.is_group(pattern) {
97                self.collect(pattern, seen, found);
98            } else {
99                found.extend(self.machines.iter().copied().filter(|m| matches(pattern, m)));
100            }
101        }
102    }
103
104    /// Whether `group` holds group `target`, directly or through other groups (a member named
105    /// like a machine is the machine).
106    fn contains(&self, group: &str, target: &str, seen: &mut Vec<String>) -> bool {
107        if seen.iter().any(|g| g == group) {
108            return false;
109        }
110        seen.push(group.to_owned());
111        self.groups
112            .get(group)
113            .into_iter()
114            .flatten()
115            .any(|m| self.is_group(m) && (*m == target || self.contains(m, target, seen)))
116    }
117
118    /// The groups a machine is in, least specific first: a group before the groups inside it,
119    /// declaration order otherwise.
120    fn layers_for(&self, machine: &str) -> Vec<&'a str> {
121        let mine: Vec<&'a str> = self.groups.keys().copied().filter(|g| self.machines_of(g).contains(&machine)).collect();
122        // A group's depth: how many of the machine's other groups hold it.
123        let depth = |g: &str| mine.iter().filter(|other| **other != g && self.contains(other, g, &mut Vec::new())).count();
124        let mut ranked: Vec<(usize, &'a str)> = mine.iter().map(|g| (depth(g), *g)).collect();
125        ranked.sort_by_key(|(d, _)| *d);
126        ranked.into_iter().map(|(_, g)| g).collect()
127    }
128}
129
130/// The machines of a group: its member names and globs, and the members of the groups it
131/// names (recursively, cycles stopped). In spec order.
132#[must_use]
133pub fn members(spec: &Spec, group: &str) -> Vec<String> {
134    Membership::of_spec(spec).machines_of(group).into_iter().map(str::to_owned).collect()
135}
136
137/// Folds `common:` and `groups:` into the machines of a spec document (the YAML, parsed): a
138/// machine's own value wins over its groups' (the most specific first), and theirs over
139/// `common:`. Unknown members are left for validation to report.
140///
141/// # Errors
142///
143/// An `invalid-value` [`Problem`] when `common:`, `groups:` or a group isn't a mapping.
144pub fn expand(doc: &mut Value) -> Result<(), Problem> {
145    let Value::Mapping(top) = doc else { return Ok(()) };
146    let common = common_of(top)?;
147    let groups = groups_of(top)?;
148    if common.is_empty() && groups.is_empty() {
149        return Ok(());
150    }
151    let Some(Value::Mapping(machines)) = top.get("machines").cloned() else {
152        return Ok(());
153    };
154    let membership = Membership {
155        groups: groups.iter().map(|(name, g)| (name.as_str(), members_written(g))).collect(),
156        machines: machines.keys().filter_map(Value::as_str).collect(),
157    };
158    let mut expanded = Mapping::new();
159    for (k, v) in &machines {
160        let merged = match (k.as_str(), v) {
161            (Some(name), Value::Mapping(own)) => merged(own, &common, &membership.layers_for(name), &groups),
162            _ => v.clone(),
163        };
164        expanded.insert(k.clone(), merged);
165    }
166    top.insert("machines".into(), Value::Mapping(expanded));
167    Ok(())
168}
169
170/// The groups of a spec document, by name (as written: members and shared fields).
171type Groups = IndexMap<String, Mapping>;
172
173/// A group's `members` as written in the YAML.
174fn members_written(group: &Mapping) -> Vec<&str> {
175    match group.get("members") {
176        Some(Value::Sequence(members)) => members.iter().filter_map(Value::as_str).collect(),
177        _ => Vec::new(),
178    }
179}
180
181/// `common:`, empty when left out.
182fn common_of(top: &Mapping) -> Result<Mapping, Problem> {
183    match top.get("common") {
184        Some(Value::Mapping(m)) => Ok(m.clone()),
185        Some(Value::Null) | None => Ok(Mapping::new()),
186        Some(_) => Err(Problem::new(
187            Code::InvalidValue,
188            FieldPath::new("common"),
189            "expected a mapping of machine fields",
190        )),
191    }
192}
193
194/// `groups:`, empty when left out.
195fn groups_of(top: &Mapping) -> Result<Groups, Problem> {
196    match top.get("groups") {
197        Some(Value::Mapping(m)) => m
198            .iter()
199            .map(|(k, v)| {
200                let name = k.as_str().unwrap_or_default();
201                match v {
202                    Value::Mapping(g) => Ok((name.to_owned(), g.clone())),
203                    _ => Err(Problem::new(
204                        Code::InvalidValue,
205                        FieldPath::new("groups").key(name),
206                        "expected a mapping with `members` and machine fields",
207                    )),
208                }
209            })
210            .collect(),
211        Some(Value::Null) | None => Ok(Groups::new()),
212        Some(_) => Err(Problem::new(
213            Code::InvalidValue,
214            FieldPath::new("groups"),
215            "expected a mapping of group name to group",
216        )),
217    }
218}
219
220/// A machine's own fields over its groups' (least specific first) over `common:`.
221fn merged(own: &Mapping, common: &Mapping, mine: &[&str], groups: &Groups) -> Value {
222    let mut merged = Value::Mapping(Mapping::new());
223    let layers = std::iter::once(common).chain(mine.iter().filter_map(|g| groups.get(*g)));
224    for layer in layers {
225        let mut shared = layer.clone();
226        shared.remove("members");
227        crate::defaults::merge(&mut merged, &Value::Mapping(shared));
228    }
229    crate::defaults::merge(&mut merged, &Value::Mapping(own.clone()));
230    merged
231}
232
233/// The groups' problems: a reserved or taken name, a member that names nothing, a cycle.
234#[must_use]
235pub fn problems(spec: &Spec) -> Vec<validate::Problem> {
236    let membership = Membership::of_spec(spec);
237    let invalid = |at: String, message: String| validate::Problem::new(Code::InvalidValue, at, message);
238    let mut out = Vec::new();
239    for (name, g) in &spec.groups {
240        let at = format!("groups.{name}");
241        if RESERVED.contains(&name.as_str()) {
242            out.push(invalid(at.clone(), "Isoloom fills this group itself; choose another name".into()));
243        }
244        if spec.machines.contains_key(name) {
245            out.push(invalid(at.clone(), format!("`{name}` is already a machine's name")));
246        }
247        if g.members.is_empty() {
248            out.push(invalid(format!("{at}.members"), "name the machines in the group".into()));
249        }
250        for (i, m) in g.members.iter().enumerate() {
251            if m == name && !spec.machines.contains_key(m) {
252                out.push(invalid(format!("{at}.members[{i}]"), "a group can't be its own member".into()));
253            } else if !spec.groups.contains_key(m) && !spec.machines.keys().any(|n| matches(m, n)) {
254                let why = format!("`{m}` names no machine or group");
255                out.push(validate::Problem::new(Code::UnknownReference, format!("{at}.members[{i}]"), why));
256            }
257        }
258        if membership.contains(name, name, &mut Vec::new()) {
259            out.push(invalid(format!("{at}.members"), "groups contain each other in a loop".into()));
260        }
261    }
262    out
263}
264
265#[cfg(test)]
266mod tests {
267    use super::*;
268
269    fn expanded(yaml: &str) -> Value {
270        let mut doc: Value = serde_yaml_ng::from_str(yaml).unwrap();
271        expand(&mut doc).unwrap();
272        doc
273    }
274
275    #[test]
276    fn a_machines_own_value_wins_over_its_groups_and_theirs_over_common() {
277        let doc = expanded(
278            "common: { resources: { cpus: 1, memory_mb: 512 }, privileged: true }\ngroups:\n  big: { members: [web], resources: { cpus: 4 } }\nmachines:\n  web: { resources: { memory_mb: 2048 } }\n  db: {}\n",
279        );
280        let web = &doc["machines"]["web"];
281        assert_eq!(
282            (web["resources"]["cpus"].as_u64(), web["resources"]["memory_mb"].as_u64()),
283            (Some(4), Some(2048))
284        );
285        assert_eq!(web["privileged"].as_bool(), Some(true));
286        assert_eq!(doc["machines"]["db"]["resources"]["cpus"].as_u64(), Some(1));
287    }
288
289    #[test]
290    fn the_most_specific_group_wins() {
291        let doc = expanded(
292            "groups:\n  inner: { members: [web], os: debian@12 }\n  outer: { members: [inner], os: alpine, privileged: true }\nmachines:\n  web: {}\n",
293        );
294        let web = &doc["machines"]["web"];
295        assert_eq!((web["os"].as_str(), web["privileged"].as_bool()), (Some("debian@12"), Some(true)));
296    }
297
298    #[test]
299    fn shared_fields_of_the_wrong_shape_are_refused_at_their_field() {
300        let mut common: Value = serde_yaml_ng::from_str("common: 5\nmachines: { a: {} }\n").unwrap();
301        assert_eq!(expand(&mut common).unwrap_err().path.as_str(), "common");
302        let mut group: Value = serde_yaml_ng::from_str("groups: { g: 5 }\nmachines: { a: {} }\n").unwrap();
303        assert_eq!(expand(&mut group).unwrap_err().path.as_str(), "groups.g");
304        let mut groups: Value = serde_yaml_ng::from_str("groups: [g]\nmachines: { a: {} }\n").unwrap();
305        assert_eq!(expand(&mut groups).unwrap_err().path.as_str(), "groups");
306    }
307
308    #[test]
309    fn a_groups_machines_follow_globs_and_nested_groups_in_spec_order() {
310        let spec = crate::testing::model(
311            "version: 1\nname: t\ngroups:\n  ws: { members: [ws*] }\n  all-hosts: { members: [ws, dc01] }\n  self: { members: [self, ws] }\nmachines:\n  dc01: {}\n  ws02: {}\n  ws01: {}\n",
312        )
313        .unwrap();
314        assert_eq!(members(&spec, "all-hosts"), ["dc01", "ws02", "ws01"]);
315        assert_eq!(members(&spec, "self"), ["ws02", "ws01"], "a group holding itself stops there");
316        assert_eq!(members(&spec, "nope"), Vec::<String>::new());
317    }
318
319    #[test]
320    fn group_loops_count_groups_not_machines_of_the_same_name() {
321        let looped = crate::testing::model("version: 1\nname: t\ngroups:\n  a: { members: [b] }\n  b: { members: [a] }\nmachines:\n  m: {}\n").unwrap();
322        assert!(problems(&looped).iter().any(|p| p.at == "groups.a.members" && p.message.contains("loop")));
323        let named = crate::testing::model("version: 1\nname: t\ngroups:\n  m: { members: [m] }\nmachines:\n  m: {}\n").unwrap();
324        let found = problems(&named);
325        assert_eq!(
326            found,
327            [validate::Problem::new(Code::InvalidValue, "groups.m", "`m` is already a machine's name")]
328        );
329    }
330
331    #[test]
332    fn a_member_is_a_machine_a_glob_or_a_group_never_the_group_itself() {
333        let spec = crate::testing::model(
334            "version: 1\nname: t\ngroups:\n  g: { members: [g, ghost, ws*, h, web] }\n  h: { members: [web] }\nmachines:\n  web: {}\n  ws01: {}\n",
335        )
336        .unwrap();
337        assert_eq!(
338            problems(&spec),
339            [
340                validate::Problem::new(Code::InvalidValue, "groups.g.members[0]", "a group can't be its own member"),
341                validate::Problem::new(Code::UnknownReference, "groups.g.members[1]", "`ghost` names no machine or group"),
342                validate::Problem::new(Code::InvalidValue, "groups.g.members", "groups contain each other in a loop"),
343            ]
344        );
345    }
346
347    #[test]
348    fn globs_match_prefixes_suffixes_and_middles() {
349        assert!(matches("ws*", "ws01") && !matches("ws*", "dc01"));
350        assert!(matches("*01", "ws01") && matches("w*1", "ws01") && !matches("w*2", "ws01"));
351        assert!(matches("dc01", "dc01") && !matches("dc0", "dc01"));
352    }
353}