1use std::collections::{BTreeSet, HashMap, HashSet};
15
16use crate::inventory::{HostGroup, Inventory};
17use crate::run::{Run, RunStatus};
18use crate::RunId;
19
20const NON_RESOURCE_GROUPS: [&str; 2] = ["all", "ungrouped"];
24
25pub fn glob_match(pattern: &str, text: &str) -> bool {
29 let p = pattern.as_bytes();
30 let t = text.as_bytes();
31 let (mut pi, mut ti) = (0usize, 0usize);
32 let (mut star, mut mark) = (None, 0usize);
33 while ti < t.len() {
34 if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
35 pi += 1;
36 ti += 1;
37 } else if pi < p.len() && p[pi] == b'*' {
38 star = Some(pi);
39 mark = ti;
40 pi += 1;
41 } else if let Some(s) = star {
42 pi = s + 1;
43 mark += 1;
44 ti = mark;
45 } else {
46 return false;
47 }
48 }
49 while pi < p.len() && p[pi] == b'*' {
50 pi += 1;
51 }
52 pi == p.len()
53}
54
55fn collect_group_hosts(
58 index: &HashMap<&str, &HostGroup>,
59 group: &str,
60 out: &mut HashSet<String>,
61 seen: &mut HashSet<String>,
62) {
63 if !seen.insert(group.to_string()) {
64 return;
65 }
66 if let Some(g) = index.get(group) {
67 for h in &g.hosts {
68 out.insert(h.clone());
69 }
70 for child in &g.children {
71 collect_group_hosts(index, child, out, seen);
72 }
73 }
74}
75
76fn group_host_closures(inv: &Inventory) -> HashMap<String, HashSet<String>> {
78 let index: HashMap<&str, &HostGroup> =
79 inv.groups.iter().map(|g| (g.name.as_str(), g)).collect();
80 let mut closures = HashMap::new();
81 for g in &inv.groups {
82 let mut hosts = HashSet::new();
83 let mut seen = HashSet::new();
84 collect_group_hosts(&index, &g.name, &mut hosts, &mut seen);
85 closures.insert(g.name.clone(), hosts);
86 }
87 closures
88}
89
90fn resolve_target_hosts(
100 limit: Option<&str>,
101 all_hosts: &HashSet<String>,
102 group_hosts: &HashMap<String, HashSet<String>>,
103) -> HashSet<String> {
104 let limit = match limit {
105 Some(s) if !s.trim().is_empty() => s,
106 _ => return all_hosts.clone(),
107 };
108
109 let mut target = HashSet::new();
110 for raw in limit.split([':', ',']) {
111 let tok = raw.trim().trim_start_matches(|c| c == '&' || c == '!').trim();
112 if tok.is_empty() {
113 continue;
114 }
115 if tok == "all" || tok == "*" {
116 return all_hosts.clone();
117 }
118 if tok.contains('*') || tok.contains('?') {
119 for (gname, hosts) in group_hosts {
120 if glob_match(tok, gname) {
121 target.extend(hosts.iter().cloned());
122 }
123 }
124 for h in all_hosts {
125 if glob_match(tok, h) {
126 target.insert(h.clone());
127 }
128 }
129 continue;
130 }
131 if let Some(hosts) = group_hosts.get(tok) {
132 target.extend(hosts.iter().cloned());
133 continue;
134 }
135 if all_hosts.contains(tok) {
136 target.insert(tok.to_string());
137 }
138 }
140 target
141}
142
143pub fn derive_resources(limit: Option<&str>, inv: &Inventory) -> Vec<String> {
147 let group_hosts = group_host_closures(inv);
148 let all_hosts: HashSet<String> = inv.hosts.iter().map(|h| h.name.clone()).collect();
149 let target = resolve_target_hosts(limit, &all_hosts, &group_hosts);
150
151 let mut resources = BTreeSet::new();
152 for g in &inv.groups {
153 if NON_RESOURCE_GROUPS.contains(&g.name.as_str()) {
154 continue;
155 }
156 if let Some(hosts) = group_hosts.get(&g.name) {
157 if !hosts.is_disjoint(&target) {
158 resources.insert(g.name.clone());
159 }
160 }
161 }
162 resources.into_iter().collect()
163}
164
165#[derive(Debug, Clone, Default)]
175pub struct LeaseFilter {
176 patterns: Vec<String>,
177}
178
179impl LeaseFilter {
180 pub fn new(patterns: Vec<String>) -> Self {
181 Self { patterns }
182 }
183
184 pub fn apply(&self, resources: &[String]) -> BTreeSet<String> {
186 if self.patterns.is_empty() {
187 return resources.iter().cloned().collect();
188 }
189 resources
190 .iter()
191 .filter(|r| self.patterns.iter().any(|p| glob_match(p, r)))
192 .cloned()
193 .collect()
194 }
195}
196
197pub fn select_admissions(runs: &[Run], lease: &LeaseFilter) -> Vec<RunId> {
205 let mut held: BTreeSet<String> = BTreeSet::new();
206 for r in runs.iter().filter(|r| r.status == RunStatus::Running) {
207 held.extend(lease.apply(&r.resources));
208 }
209
210 let mut queued: Vec<&Run> = runs
211 .iter()
212 .filter(|r| r.status == RunStatus::Queued)
213 .collect();
214 queued.sort_by(|a, b| {
216 a.started_at
217 .cmp(&b.started_at)
218 .then_with(|| a.id.0.to_string().cmp(&b.id.0.to_string()))
219 });
220
221 let mut admit = Vec::new();
222 for r in queued {
223 let res = lease.apply(&r.resources);
224 if res.is_disjoint(&held) {
225 held.extend(res);
226 admit.push(r.id.clone());
227 }
228 }
229 admit
230}
231
232#[derive(Debug, Clone, Default)]
252pub struct ResourcePolicy {
253 pub allow: Vec<String>,
254 pub deny: Vec<String>,
255 pub significant: Vec<String>,
256}
257
258impl ResourcePolicy {
259 pub fn new(allow: Vec<String>, deny: Vec<String>) -> Self {
260 Self {
261 allow,
262 deny,
263 significant: Vec::new(),
264 }
265 }
266
267 pub fn with_significant(mut self, significant: Vec<String>) -> Self {
269 self.significant = significant;
270 self
271 }
272
273 pub fn evaluate(&self, resources: &[String]) -> Result<(), String> {
275 for r in resources {
278 if let Some(p) = self.deny.iter().find(|p| glob_match(p, r)) {
279 return Err(format!(
280 "target resolves to resource `{r}`, denied by policy (deny pattern `{p}`)"
281 ));
282 }
283 }
284
285 if self.allow.is_empty() {
286 return Ok(());
287 }
288
289 if self.significant.is_empty() {
292 for r in resources {
293 if !self.allow.iter().any(|p| glob_match(p, r)) {
294 return Err(format!(
295 "target resolves to resource `{r}`, which is outside the allowed set"
296 ));
297 }
298 }
299 return Ok(());
300 }
301
302 let significant: Vec<&String> = resources
306 .iter()
307 .filter(|r| self.significant.iter().any(|p| glob_match(p, r)))
308 .collect();
309 if significant.is_empty() {
310 return Err(
311 "target resolves to no policy-significant resource; denied by default-deny policy"
312 .to_string(),
313 );
314 }
315 for r in significant {
316 if !self.allow.iter().any(|p| glob_match(p, r)) {
317 return Err(format!(
318 "target resolves to resource `{r}`, which is outside the allowed set"
319 ));
320 }
321 }
322 Ok(())
323 }
324}
325
326#[cfg(test)]
327mod tests {
328 use super::*;
329 use crate::inventory::{Host, HostGroup};
330 use crate::{InventoryId, PlaybookId, RunId};
331
332 fn grp(name: &str, children: &[&str], hosts: &[&str]) -> HostGroup {
333 HostGroup {
334 name: name.to_string(),
335 children: children.iter().map(|s| s.to_string()).collect(),
336 hosts: hosts.iter().map(|s| s.to_string()).collect(),
337 }
338 }
339
340 fn host(name: &str) -> Host {
341 Host {
342 name: name.to_string(),
343 ansible_host: None,
344 }
345 }
346
347 fn inventory() -> Inventory {
350 Inventory {
351 groups: vec![
352 grp("cluster_1node_a", &[], &["n1a"]),
353 grp("cluster_1node_b", &[], &["n1b"]),
354 grp(
355 "cluster_8node",
356 &[],
357 &["ue-content-01", "ue-content-02", "ue-content-03"],
358 ),
359 grp(
360 "clusters",
361 &["cluster_1node_a", "cluster_1node_b", "cluster_8node"],
362 &[],
363 ),
364 grp(
365 "all",
366 &["clusters"],
367 &[],
368 ),
369 ],
370 hosts: vec![
371 host("n1a"),
372 host("n1b"),
373 host("ue-content-01"),
374 host("ue-content-02"),
375 host("ue-content-03"),
376 ],
377 id: InventoryId::from("default".to_string()),
378 }
379 }
380
381 fn run(id: &str, status: RunStatus, started_at: &str, resources: &[&str]) -> Run {
382 Run {
383 playbook_id: PlaybookId::from("pb".to_string()),
384 claimed_by: None,
385 status,
386 started_at: started_at.to_string(),
387 finished_at: None,
388 exit_code: None,
389 extra_vars: None,
390 limit: None,
391 host_stats: Default::default(),
392 resources: resources.iter().map(|s| s.to_string()).collect(),
393 client_id: None,
394 id: RunId::from(id.to_string()),
395 }
396 }
397
398 #[test]
399 fn glob_matches() {
400 assert!(glob_match("cluster_1node_*", "cluster_1node_a"));
401 assert!(glob_match("cluster_1node_*", "cluster_1node_b"));
402 assert!(!glob_match("cluster_1node_*", "cluster_8node"));
403 assert!(!glob_match("cluster_1node_*", "clusters"));
404 assert!(glob_match("*", "anything"));
405 assert!(glob_match("ue-content-0?", "ue-content-03"));
406 assert!(!glob_match("ue-content-0?", "ue-content-10"));
407 assert!(glob_match("cluster_8node", "cluster_8node"));
408 }
409
410 #[test]
411 fn group_limit_resolves_to_group_and_parent() {
412 let inv = inventory();
413 assert_eq!(
416 derive_resources(Some("cluster_1node_a"), &inv),
417 vec!["cluster_1node_a".to_string(), "clusters".to_string()]
418 );
419 }
420
421 #[test]
422 fn single_host_limit_locks_owning_cluster() {
423 let inv = inventory();
424 assert_eq!(
427 derive_resources(Some("ue-content-03"), &inv),
428 vec!["cluster_8node".to_string(), "clusters".to_string()]
429 );
430 }
431
432 #[test]
433 fn empty_limit_locks_everything() {
434 let inv = inventory();
435 let res = derive_resources(None, &inv);
436 assert!(res.contains(&"cluster_1node_a".to_string()));
437 assert!(res.contains(&"cluster_1node_b".to_string()));
438 assert!(res.contains(&"cluster_8node".to_string()));
439 assert!(res.contains(&"clusters".to_string()));
440 assert!(!res.contains(&"all".to_string()));
441 }
442
443 #[test]
444 fn glob_and_union_limits() {
445 let inv = inventory();
446 assert_eq!(
447 derive_resources(Some("cluster_1node_*"), &inv),
448 vec![
449 "cluster_1node_a".to_string(),
450 "cluster_1node_b".to_string(),
451 "clusters".to_string()
452 ]
453 );
454 let res = derive_resources(Some("n1a:ue-content-01"), &inv);
456 assert!(res.contains(&"cluster_1node_a".to_string()));
457 assert!(res.contains(&"cluster_8node".to_string()));
458 }
459
460 #[test]
461 fn exclusion_operator_is_conservative() {
462 let inv = inventory();
463 let res = derive_resources(Some("cluster_8node:!ue-content-03"), &inv);
466 assert!(res.contains(&"cluster_8node".to_string()));
467 }
468
469 #[test]
470 fn unknown_token_locks_nothing() {
471 let inv = inventory();
472 assert!(derive_resources(Some("not-a-real-host"), &inv).is_empty());
473 }
474
475 #[test]
476 fn lease_filter_isolates_sibling_clusters() {
477 let inv = inventory();
478 let a = derive_resources(Some("cluster_1node_a"), &inv);
479 let b = derive_resources(Some("cluster_1node_b"), &inv);
480
481 let identity = LeaseFilter::default();
483 assert!(!identity.apply(&a).is_disjoint(&identity.apply(&b)));
484
485 let lease = LeaseFilter::new(vec![
487 "cluster_1node_*".to_string(),
488 "cluster_8node".to_string(),
489 ]);
490 assert!(lease.apply(&a).is_disjoint(&lease.apply(&b)));
491 }
492
493 #[test]
494 fn admission_serializes_same_resource() {
495 let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
496 let runs = vec![
497 run("r1", RunStatus::Queued, "2026-06-09T00:00:01Z", &["cluster_8node"]),
498 run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_8node"]),
499 ];
500 assert_eq!(select_admissions(&runs, &lease), vec![RunId::from("r1".to_string())]);
502 }
503
504 #[test]
505 fn admission_parallelizes_distinct_resources() {
506 let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
507 let runs = vec![
508 run("r1", RunStatus::Queued, "2026-06-09T00:00:01Z", &["cluster_1node_a"]),
509 run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_1node_b"]),
510 ];
511 let admitted = select_admissions(&runs, &lease);
512 assert_eq!(admitted.len(), 2);
513 }
514
515 #[test]
516 fn admission_respects_running_lease_holder() {
517 let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
518 let runs = vec![
519 run("r1", RunStatus::Running, "2026-06-09T00:00:01Z", &["cluster_8node"]),
520 run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_8node"]),
521 run("r3", RunStatus::Queued, "2026-06-09T00:00:03Z", &["cluster_1node_a"]),
522 ];
523 assert_eq!(select_admissions(&runs, &lease), vec![RunId::from("r3".to_string())]);
525 }
526
527 #[test]
528 fn policy_denies_derived_live_cluster() {
529 let inv = inventory();
530 let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec!["cluster_8node".to_string()]);
531
532 let live = derive_resources(Some("ue-content-03"), &inv);
535 assert!(policy.evaluate(&live).is_err());
536
537 let dev = derive_resources(Some("cluster_1node_a"), &inv);
540 let deny_only = ResourcePolicy::new(vec![], vec!["cluster_8node*".to_string()]);
541 assert!(deny_only.evaluate(&dev).is_ok());
542 }
543
544 #[test]
545 fn policy_dimension_makes_allow_a_clean_default_deny() {
546 let inv = inventory();
547 let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
551 .with_significant(vec!["cluster_*".to_string()]);
552
553 let dev = derive_resources(Some("cluster_1node_a"), &inv);
556 assert!(dev.contains(&"clusters".to_string()));
557 assert!(policy.evaluate(&dev).is_ok());
558
559 let prod = derive_resources(Some("ue-content-03"), &inv);
562 assert!(policy.evaluate(&prod).is_err());
563 }
564
565 #[test]
566 fn policy_dimension_denies_target_with_no_significant_resource() {
567 let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
570 .with_significant(vec!["cluster_*".to_string()]);
571 let ambient = vec!["ue_content".to_string(), "windows".to_string()];
573 assert!(policy.evaluate(&ambient).is_err());
574
575 let deny_only =
577 ResourcePolicy::new(vec![], vec![]).with_significant(vec!["cluster_*".to_string()]);
578 assert!(deny_only.evaluate(&ambient).is_ok());
579 }
580
581 #[test]
582 fn deny_still_spans_all_resources_under_policy_dimension() {
583 let policy = ResourcePolicy::new(vec![], vec!["ue_content".to_string()])
586 .with_significant(vec!["cluster_*".to_string()]);
587 let resources = vec!["cluster_1node_a".to_string(), "ue_content".to_string()];
588 assert!(policy.evaluate(&resources).is_err());
589 }
590}