1use indexmap::IndexMap;
26use serde_yaml_ng::{Mapping, Value};
27
28use crate::model::Spec;
29use crate::syntax::{Code, FieldPath, Problem};
30use crate::validate;
31
32pub(crate) const RESERVED: &[&str] = &["all", "linux", "windows"];
34
35#[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
54struct Membership<'a> {
57 groups: IndexMap<&'a str, Vec<&'a str>>,
59 machines: Vec<&'a str>,
61}
62
63impl<'a> Membership<'a> {
64 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 fn is_group(&self, name: &str) -> bool {
78 self.groups.contains_key(name) && !self.machines.contains(&name)
79 }
80
81 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 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 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 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#[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
137pub 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
170type Groups = IndexMap<String, Mapping>;
172
173fn 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
181fn 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
194fn 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
220fn 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#[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}