use std::collections::{BTreeSet, HashMap, HashSet};
use crate::inventory::{HostGroup, Inventory};
use crate::run::{Run, RunStatus};
use crate::RunId;
const NON_RESOURCE_GROUPS: [&str; 2] = ["all", "ungrouped"];
pub fn glob_match(pattern: &str, text: &str) -> bool {
let p = pattern.as_bytes();
let t = text.as_bytes();
let (mut pi, mut ti) = (0usize, 0usize);
let (mut star, mut mark) = (None, 0usize);
while ti < t.len() {
if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
pi += 1;
ti += 1;
} else if pi < p.len() && p[pi] == b'*' {
star = Some(pi);
mark = ti;
pi += 1;
} else if let Some(s) = star {
pi = s + 1;
mark += 1;
ti = mark;
} else {
return false;
}
}
while pi < p.len() && p[pi] == b'*' {
pi += 1;
}
pi == p.len()
}
fn collect_group_hosts(
index: &HashMap<&str, &HostGroup>,
group: &str,
out: &mut HashSet<String>,
seen: &mut HashSet<String>,
) {
if !seen.insert(group.to_string()) {
return;
}
if let Some(g) = index.get(group) {
for h in &g.hosts {
out.insert(h.clone());
}
for child in &g.children {
collect_group_hosts(index, child, out, seen);
}
}
}
fn group_host_closures(inv: &Inventory) -> HashMap<String, HashSet<String>> {
let index: HashMap<&str, &HostGroup> =
inv.groups.iter().map(|g| (g.name.as_str(), g)).collect();
let mut closures = HashMap::new();
for g in &inv.groups {
let mut hosts = HashSet::new();
let mut seen = HashSet::new();
collect_group_hosts(&index, &g.name, &mut hosts, &mut seen);
closures.insert(g.name.clone(), hosts);
}
closures
}
fn resolve_target_hosts(
limit: Option<&str>,
all_hosts: &HashSet<String>,
group_hosts: &HashMap<String, HashSet<String>>,
) -> HashSet<String> {
let limit = match limit {
Some(s) if !s.trim().is_empty() => s,
_ => return all_hosts.clone(),
};
let mut target = HashSet::new();
for raw in limit.split([':', ',']) {
let tok = raw.trim().trim_start_matches(|c| c == '&' || c == '!').trim();
if tok.is_empty() {
continue;
}
if tok == "all" || tok == "*" {
return all_hosts.clone();
}
if tok.contains('*') || tok.contains('?') {
for (gname, hosts) in group_hosts {
if glob_match(tok, gname) {
target.extend(hosts.iter().cloned());
}
}
for h in all_hosts {
if glob_match(tok, h) {
target.insert(h.clone());
}
}
continue;
}
if let Some(hosts) = group_hosts.get(tok) {
target.extend(hosts.iter().cloned());
continue;
}
if all_hosts.contains(tok) {
target.insert(tok.to_string());
}
}
target
}
pub fn derive_resources(limit: Option<&str>, inv: &Inventory) -> Vec<String> {
let group_hosts = group_host_closures(inv);
let all_hosts: HashSet<String> = inv.hosts.iter().map(|h| h.name.clone()).collect();
let target = resolve_target_hosts(limit, &all_hosts, &group_hosts);
let mut resources = BTreeSet::new();
for g in &inv.groups {
if NON_RESOURCE_GROUPS.contains(&g.name.as_str()) {
continue;
}
if let Some(hosts) = group_hosts.get(&g.name) {
if !hosts.is_disjoint(&target) {
resources.insert(g.name.clone());
}
}
}
resources.into_iter().collect()
}
#[derive(Debug, Clone, Default)]
pub struct LeaseFilter {
patterns: Vec<String>,
}
impl LeaseFilter {
pub fn new(patterns: Vec<String>) -> Self {
Self { patterns }
}
pub fn apply(&self, resources: &[String]) -> BTreeSet<String> {
if self.patterns.is_empty() {
return resources.iter().cloned().collect();
}
resources
.iter()
.filter(|r| self.patterns.iter().any(|p| glob_match(p, r)))
.cloned()
.collect()
}
}
pub fn select_admissions(runs: &[Run], lease: &LeaseFilter) -> Vec<RunId> {
let mut held: BTreeSet<String> = BTreeSet::new();
for r in runs.iter().filter(|r| r.status == RunStatus::Running) {
held.extend(lease.apply(&r.resources));
}
let mut queued: Vec<&Run> = runs
.iter()
.filter(|r| r.status == RunStatus::Queued)
.collect();
queued.sort_by(|a, b| {
a.started_at
.cmp(&b.started_at)
.then_with(|| a.id.0.to_string().cmp(&b.id.0.to_string()))
});
let mut admit = Vec::new();
for r in queued {
let res = lease.apply(&r.resources);
if res.is_disjoint(&held) {
held.extend(res);
admit.push(r.id.clone());
}
}
admit
}
#[derive(Debug, Clone, Default)]
pub struct ResourcePolicy {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub significant: Vec<String>,
}
impl ResourcePolicy {
pub fn new(allow: Vec<String>, deny: Vec<String>) -> Self {
Self {
allow,
deny,
significant: Vec::new(),
}
}
pub fn with_significant(mut self, significant: Vec<String>) -> Self {
self.significant = significant;
self
}
pub fn evaluate(&self, resources: &[String]) -> Result<(), String> {
for r in resources {
if let Some(p) = self.deny.iter().find(|p| glob_match(p, r)) {
return Err(format!(
"target resolves to resource `{r}`, denied by policy (deny pattern `{p}`)"
));
}
}
if self.allow.is_empty() {
return Ok(());
}
if self.significant.is_empty() {
for r in resources {
if !self.allow.iter().any(|p| glob_match(p, r)) {
return Err(format!(
"target resolves to resource `{r}`, which is outside the allowed set"
));
}
}
return Ok(());
}
let significant: Vec<&String> = resources
.iter()
.filter(|r| self.significant.iter().any(|p| glob_match(p, r)))
.collect();
if significant.is_empty() {
return Err(
"target resolves to no policy-significant resource; denied by default-deny policy"
.to_string(),
);
}
for r in significant {
if !self.allow.iter().any(|p| glob_match(p, r)) {
return Err(format!(
"target resolves to resource `{r}`, which is outside the allowed set"
));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inventory::{Host, HostGroup};
use crate::{InventoryId, PlaybookId, RunId};
fn grp(name: &str, children: &[&str], hosts: &[&str]) -> HostGroup {
HostGroup {
name: name.to_string(),
children: children.iter().map(|s| s.to_string()).collect(),
hosts: hosts.iter().map(|s| s.to_string()).collect(),
}
}
fn host(name: &str) -> Host {
Host {
name: name.to_string(),
ansible_host: None,
}
}
fn inventory() -> Inventory {
Inventory {
groups: vec![
grp("cluster_1node_a", &[], &["n1a"]),
grp("cluster_1node_b", &[], &["n1b"]),
grp(
"cluster_8node",
&[],
&["ue-content-01", "ue-content-02", "ue-content-03"],
),
grp(
"clusters",
&["cluster_1node_a", "cluster_1node_b", "cluster_8node"],
&[],
),
grp(
"all",
&["clusters"],
&[],
),
],
hosts: vec![
host("n1a"),
host("n1b"),
host("ue-content-01"),
host("ue-content-02"),
host("ue-content-03"),
],
id: InventoryId::from("default".to_string()),
}
}
fn run(id: &str, status: RunStatus, started_at: &str, resources: &[&str]) -> Run {
Run {
playbook_id: PlaybookId::from("pb".to_string()),
status,
started_at: started_at.to_string(),
finished_at: None,
exit_code: None,
extra_vars: None,
limit: None,
host_stats: Default::default(),
resources: resources.iter().map(|s| s.to_string()).collect(),
client_id: None,
id: RunId::from(id.to_string()),
}
}
#[test]
fn glob_matches() {
assert!(glob_match("cluster_1node_*", "cluster_1node_a"));
assert!(glob_match("cluster_1node_*", "cluster_1node_b"));
assert!(!glob_match("cluster_1node_*", "cluster_8node"));
assert!(!glob_match("cluster_1node_*", "clusters"));
assert!(glob_match("*", "anything"));
assert!(glob_match("ue-content-0?", "ue-content-03"));
assert!(!glob_match("ue-content-0?", "ue-content-10"));
assert!(glob_match("cluster_8node", "cluster_8node"));
}
#[test]
fn group_limit_resolves_to_group_and_parent() {
let inv = inventory();
assert_eq!(
derive_resources(Some("cluster_1node_a"), &inv),
vec!["cluster_1node_a".to_string(), "clusters".to_string()]
);
}
#[test]
fn single_host_limit_locks_owning_cluster() {
let inv = inventory();
assert_eq!(
derive_resources(Some("ue-content-03"), &inv),
vec!["cluster_8node".to_string(), "clusters".to_string()]
);
}
#[test]
fn empty_limit_locks_everything() {
let inv = inventory();
let res = derive_resources(None, &inv);
assert!(res.contains(&"cluster_1node_a".to_string()));
assert!(res.contains(&"cluster_1node_b".to_string()));
assert!(res.contains(&"cluster_8node".to_string()));
assert!(res.contains(&"clusters".to_string()));
assert!(!res.contains(&"all".to_string()));
}
#[test]
fn glob_and_union_limits() {
let inv = inventory();
assert_eq!(
derive_resources(Some("cluster_1node_*"), &inv),
vec![
"cluster_1node_a".to_string(),
"cluster_1node_b".to_string(),
"clusters".to_string()
]
);
let res = derive_resources(Some("n1a:ue-content-01"), &inv);
assert!(res.contains(&"cluster_1node_a".to_string()));
assert!(res.contains(&"cluster_8node".to_string()));
}
#[test]
fn exclusion_operator_is_conservative() {
let inv = inventory();
let res = derive_resources(Some("cluster_8node:!ue-content-03"), &inv);
assert!(res.contains(&"cluster_8node".to_string()));
}
#[test]
fn unknown_token_locks_nothing() {
let inv = inventory();
assert!(derive_resources(Some("not-a-real-host"), &inv).is_empty());
}
#[test]
fn lease_filter_isolates_sibling_clusters() {
let inv = inventory();
let a = derive_resources(Some("cluster_1node_a"), &inv);
let b = derive_resources(Some("cluster_1node_b"), &inv);
let identity = LeaseFilter::default();
assert!(!identity.apply(&a).is_disjoint(&identity.apply(&b)));
let lease = LeaseFilter::new(vec![
"cluster_1node_*".to_string(),
"cluster_8node".to_string(),
]);
assert!(lease.apply(&a).is_disjoint(&lease.apply(&b)));
}
#[test]
fn admission_serializes_same_resource() {
let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
let runs = vec![
run("r1", RunStatus::Queued, "2026-06-09T00:00:01Z", &["cluster_8node"]),
run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_8node"]),
];
assert_eq!(select_admissions(&runs, &lease), vec![RunId::from("r1".to_string())]);
}
#[test]
fn admission_parallelizes_distinct_resources() {
let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
let runs = vec![
run("r1", RunStatus::Queued, "2026-06-09T00:00:01Z", &["cluster_1node_a"]),
run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_1node_b"]),
];
let admitted = select_admissions(&runs, &lease);
assert_eq!(admitted.len(), 2);
}
#[test]
fn admission_respects_running_lease_holder() {
let lease = LeaseFilter::new(vec!["cluster_*".to_string()]);
let runs = vec![
run("r1", RunStatus::Running, "2026-06-09T00:00:01Z", &["cluster_8node"]),
run("r2", RunStatus::Queued, "2026-06-09T00:00:02Z", &["cluster_8node"]),
run("r3", RunStatus::Queued, "2026-06-09T00:00:03Z", &["cluster_1node_a"]),
];
assert_eq!(select_admissions(&runs, &lease), vec![RunId::from("r3".to_string())]);
}
#[test]
fn policy_denies_derived_live_cluster() {
let inv = inventory();
let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec!["cluster_8node".to_string()]);
let live = derive_resources(Some("ue-content-03"), &inv);
assert!(policy.evaluate(&live).is_err());
let dev = derive_resources(Some("cluster_1node_a"), &inv);
let deny_only = ResourcePolicy::new(vec![], vec!["cluster_8node*".to_string()]);
assert!(deny_only.evaluate(&dev).is_ok());
}
#[test]
fn policy_dimension_makes_allow_a_clean_default_deny() {
let inv = inventory();
let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
.with_significant(vec!["cluster_*".to_string()]);
let dev = derive_resources(Some("cluster_1node_a"), &inv);
assert!(dev.contains(&"clusters".to_string()));
assert!(policy.evaluate(&dev).is_ok());
let prod = derive_resources(Some("ue-content-03"), &inv);
assert!(policy.evaluate(&prod).is_err());
}
#[test]
fn policy_dimension_denies_target_with_no_significant_resource() {
let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
.with_significant(vec!["cluster_*".to_string()]);
let ambient = vec!["ue_content".to_string(), "windows".to_string()];
assert!(policy.evaluate(&ambient).is_err());
let deny_only =
ResourcePolicy::new(vec![], vec![]).with_significant(vec!["cluster_*".to_string()]);
assert!(deny_only.evaluate(&ambient).is_ok());
}
#[test]
fn deny_still_spans_all_resources_under_policy_dimension() {
let policy = ResourcePolicy::new(vec![], vec!["ue_content".to_string()])
.with_significant(vec!["cluster_*".to_string()]);
let resources = vec!["cluster_1node_a".to_string(), "ue_content".to_string()];
assert!(policy.evaluate(&resources).is_err());
}
}