1use 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
54pub trait GroupSource: Send + Sync {
59 fn resolve_map(&self, group: &str) -> Result<String>;
63
64 fn resolve_all(&self) -> Result<String> {
68 Err(GroupSourceError::GroupSourceNoUpcall.into())
69 }
70
71 fn resolve_list(&self) -> Result<Vec<String>> {
73 Err(GroupSourceError::GroupSourceNoUpcall.into())
74 }
75
76 fn resolve_reverse(&self, _node: &str) -> Result<Vec<String>> {
78 Err(GroupSourceError::GroupSourceNoUpcall.into())
79 }
80
81 fn has_reverse(&self) -> bool {
83 false
84 }
85
86 fn cache_time(&self) -> Option<f64> {
88 None
89 }
90}
91
92pub 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 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 pub fn with_all(mut self, all: impl Into<String>) -> Self {
116 self.all = Some(all.into());
117 self
118 }
119
120 pub fn with_list(mut self, list: Vec<String>) -> Self {
122 self.list = Some(list);
123 self
124 }
125
126 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
165const DEFAULT_CACHE_TIME: f64 = 3600.0;
168
169#[derive(Debug, Default)]
174struct UpcallCache {
175 map: HashMap<String, (String, Instant)>,
178 list: Option<(Vec<String>, Instant)>,
180 mapall_expiry: Option<Instant>,
182}
183
184pub 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 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 pub fn with_mapall_upcall(mut self, mapall_upcall: Option<String>) -> Self {
228 self.mapall_upcall = mapall_upcall;
229 self
230 }
231
232 pub fn with_cache_time(mut self, cache_time: f64) -> Self {
234 self.cache_time = cache_time;
235 self
236 }
237
238 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 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 let content = self.run_command(mapall_tpl, "")?;
272 let expiry = Instant::now() + self.cache_duration();
273
274 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 Some(nodes) if !group.is_empty() && group.split_whitespace().count() == 1 => nodes,
289 _ => {
290 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 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 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
348fn 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 self.populate_from_mapall()?;
362 let mut cache = self.cache.lock().unwrap();
363 if self.map_upcall.is_none() {
364 return Ok(cache
367 .map
368 .get(group)
369 .map(|(nodes, _)| nodes.clone())
370 .unwrap_or_default());
371 }
372 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 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 self.populate_from_mapall()?;
404 let mut cache = self.cache.lock().unwrap();
405 if self.list_upcall.is_none() {
406 return Ok(cache
410 .list
411 .as_ref()
412 .map(|(groups, _)| groups.clone())
413 .unwrap_or_default());
414 }
415 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
454pub 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 pub fn new() -> Self {
466 Self {
467 sources: HashMap::new(),
468 default_source_name: None,
469 illegal_chars: HashSet::new(),
470 }
471 }
472
473 pub fn add_source(&mut self, name: impl Into<String>, source: Box<dyn GroupSource>) {
475 self.sources.insert(name.into(), source);
476 }
477
478 pub fn set_default(&mut self, name: impl Into<String>) {
480 self.default_source_name = Some(name.into());
481 }
482
483 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 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 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 pub fn grouplist(&self, namespace: Option<&str>) -> Result<Vec<String>> {
524 let source = self.get_source(namespace)?;
525 source.resolve_list()
526 }
527
528 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 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 pub fn set_illegal_chars(&mut self, chars: HashSet<char>) {
543 self.illegal_chars = chars;
544 }
545
546 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
569pub struct GroupResolverConfig {
575 filenames: Vec<PathBuf>,
576 illegal_chars: HashSet<char>,
577 initialized: bool,
578 resolver: Option<GroupResolver>,
579}
580
581impl GroupResolverConfig {
582 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 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 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 ini.load(filename.to_string_lossy().as_ref())
614 .map_err(|e| GroupResolverConfigError::ParseError(e.to_string()))?;
615
616 if let Some(default) = ini.get("main", "default") {
618 resolver.set_default(default);
619 }
620
621 for section_name in ini.sections() {
623 if section_name == "main" || section_name == "default" {
624 continue;
625 }
626
627 let map_upcall = ini.get(§ion_name, "map");
628 let mapall_upcall = ini.get(§ion_name, "mapall");
629 let all_upcall = ini.get(§ion_name, "all");
630 let list_upcall = ini.get(§ion_name, "list");
631 let reverse_upcall = ini.get(§ion_name, "reverse");
632 let cache_time = ini
633 .get(§ion_name, "cache_time")
634 .and_then(|v| v.parse::<f64>().ok());
635
636 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 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 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 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 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 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 let mut groups = res.grouplist(None).unwrap();
896 groups.sort();
897 assert_eq!(groups, vec!["a", "b", "c"]);
898 assert!(res.group_nodes("nope", None).unwrap().is_empty());
900 }
901
902 #[test]
903 fn test_mapall_with_map_fallback() {
904 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 assert_eq!(res.group_nodes("a", None).unwrap(), vec!["foo1"]);
912 assert_eq!(res.group_nodes("b", None).unwrap(), vec!["foo2"]);
914 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 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 assert_eq!(res.grouplist(None).unwrap(), vec!["a"]);
932 }
933
934 #[test]
935 fn test_mapall_expiry() {
936 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 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 assert!(res.group_nodes("old", None).unwrap().is_empty());
952 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let data = tempfile::NamedTempFile::new().unwrap(); 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 assert!(resolver.grouplist(None).is_err());
1138 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 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 let err = config.resolver().err().unwrap();
1157 assert!(format!("{:?}", err).contains("map' or 'mapall'"));
1158 }
1159}