Skip to main content

tachyon_types/
resource.rs

1//! Resource derivation and lease / gating policy for the run queue.
2//!
3//! A run targets a set of inventory groups — its *resources*. The lease queue
4//! serializes runs that share a resource; the scoped MCP gateway rejects runs
5//! whose resources fall outside an allowed set. Both operate on the **derived**
6//! resource set, never the raw `--limit` string, so a single-host limit that
7//! lands inside a managed cluster is correctly treated as touching that
8//! cluster (the slip-through the SOW calls out explicitly).
9//!
10//! Everything here is pure: it takes an [`Inventory`] snapshot plus plain
11//! patterns and returns plain data, so the queue/admission logic is
12//! unit-testable without ansible or a running server.
13
14use std::collections::{BTreeSet, HashMap, HashSet};
15
16use crate::inventory::{HostGroup, Inventory};
17use crate::run::{Run, RunStatus};
18use crate::RunId;
19
20/// Inventory groups that never represent a lease resource: `all` spans every
21/// host (locking it would make every run conflict) and `ungrouped` is
22/// ansible's catch-all for hosts with no group.
23const NON_RESOURCE_GROUPS: [&str; 2] = ["all", "ungrouped"];
24
25/// Minimal shell-style glob: `*` matches any run of characters (including
26/// empty), `?` matches exactly one. Case-sensitive, no character classes —
27/// enough for matching inventory group names like `cluster_1node_*`.
28pub 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
55/// Transitively collect every host reachable from `group`, following children.
56/// `seen` guards against cyclic group graphs.
57fn 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
76/// Map every group name to its transitive host set.
77fn 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
90/// Resolve an ansible `--limit` expression to the set of hosts it targets.
91///
92/// Handles the realistic cases — a bare group name, a bare host name, and
93/// `:`/`,`-separated unions of those, plus `*`/`?` globs over group and host
94/// names. The intersection (`:&`) and exclusion (`:!`) operators are treated
95/// as unions (the leading operator char is stripped): this can only *widen*
96/// the resolved set, so a lease never under-locks. Unknown tokens (not a group
97/// or host in inventory) are ignored — ansible would not target them either.
98/// A missing/empty limit means "all hosts".
99fn 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        // else: token names neither a known group nor host — ignore.
139    }
140    target
141}
142
143/// Derive the resource set a run touches: every inventory group (except the
144/// non-resource `all`/`ungrouped`) whose transitive host set intersects the
145/// hosts the run's `limit` targets. Returned sorted and de-duplicated.
146pub 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/// Narrows a run's derived resources to the groups that actually carry a lease.
166///
167/// The command handler stores *every* touched group on the run (including
168/// umbrella parents like an `all`-of-clusters group). Leasing at that raw
169/// granularity would make sibling clusters contend through their shared parent.
170/// Configuring `lease_groups` (glob patterns naming the real cluster groups)
171/// restricts contention to those. An empty filter is the identity — every
172/// touched group leases — which is the structural fallback when no patterns are
173/// configured.
174#[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    /// The lease-significant subset of `resources`.
185    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
197/// Pure admission step. Given the current set of runs and the lease filter,
198/// return the ids of `Queued` runs that may be promoted to `Running` now:
199/// processed oldest-first, a queued run is admitted only when its
200/// lease-significant resources are disjoint from those already held by a
201/// `Running` run *and* from those claimed by an earlier-admitted run in this
202/// same pass. The lease is implicit — "the Running run holding the resource" —
203/// and releases when the run finishes (its status leaves `Running`).
204pub 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    // FIFO by submission time, with a stable id tiebreak for same-instant runs.
215    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/// Allow/deny policy for the scoped MCP gateway, evaluated over a run's
233/// *derived* resources. `deny` wins: a run is rejected if any resource matches
234/// a deny pattern. If `allow` is non-empty, every resource must additionally
235/// match some allow pattern. Empty allow + empty deny is allow-all.
236///
237/// `significant` is the **policy dimension**: when set, the `allow` check is
238/// evaluated only over the derived resources whose group names match a
239/// significant pattern, and the ambient co-membership umbrella groups (`ue`,
240/// `ue_content`, `windows`, a `clusters` parent…) are filtered out before the
241/// check — so they never have to be enumerated in `allow`. This makes
242/// `allow=cluster_1node_*` over `significant=cluster_*` a true default-deny: a
243/// target that resolves to *no* significant resource cannot be proven in scope
244/// and is rejected. `deny` is unaffected — it still spans every derived
245/// resource, which is the conservative direction. Empty `significant` preserves
246/// the legacy behavior (allow is checked over every derived resource).
247///
248/// It mirrors the lease's [`LeaseFilter`] (which names the groups that carry a
249/// *lease*); here the patterns name the groups that are *significant for
250/// policy*. The two dimensions are configured independently.
251#[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    /// Set the policy-significant group patterns (the policy dimension).
268    pub fn with_significant(mut self, significant: Vec<String>) -> Self {
269        self.significant = significant;
270        self
271    }
272
273    /// `Ok(())` if the resource set is permitted, `Err(reason)` otherwise.
274    pub fn evaluate(&self, resources: &[String]) -> Result<(), String> {
275        // Deny wins, and spans every derived resource (conservative — an
276        // ambient umbrella group naming a denied cluster still rejects).
277        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        // Legacy mode (no policy dimension configured): every derived resource
290        // must match an allow pattern.
291        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        // Policy-dimension mode: evaluate `allow` only over the significant
303        // resources, and require at least one — a target touching nothing
304        // significant cannot be proven in scope, so default-deny.
305        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    /// Inventory with two single-node clusters and one 8-node cluster, all under
348    /// a shared `clusters` parent plus the conventional `all` group.
349    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        // A group limit touches the group itself and its umbrella parent, but
414        // never the non-resource `all` group.
415        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        // SOW acceptance: a single host inside the live cluster locks the whole
425        // cluster group (and parent), not just the one host.
426        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        // `:` union of two host names → both owning clusters.
455        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        // `cluster_8node:!ue-content-03` still locks the whole cluster (the
464        // exclusion is ignored → never under-locks).
465        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        // Without a lease filter the shared `clusters` parent makes siblings contend.
482        let identity = LeaseFilter::default();
483        assert!(!identity.apply(&a).is_disjoint(&identity.apply(&b)));
484
485        // Naming the real cluster groups isolates them.
486        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        // Only the oldest queued run on a contended resource is admitted.
501        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        // r2 blocked by the running r1; r3 on a free resource is admitted.
524        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        // A single live host slips past a raw-string check but its derived
533        // resources include cluster_8node → denied.
534        let live = derive_resources(Some("ue-content-03"), &inv);
535        assert!(policy.evaluate(&live).is_err());
536
537        // A dev single-node cluster is allowed. (Its parent `clusters` is not in
538        // the allow set, so deny-only policy is the robust config here.)
539        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        // significant=cluster_*  allow=cluster_1node_*  : the umbrella `clusters`
548        // parent is filtered out before the allow check, so it need not appear in
549        // the allow set. A pure allow-list with no deny is now sufficient.
550        let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
551            .with_significant(vec!["cluster_*".to_string()]);
552
553        // Dev single-node cluster → allowed, despite `clusters` being a derived
554        // resource (it's not significant).
555        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        // Prod 8-node cluster (even via a single live host) → denied, no blocklist
560        // needed: cluster_8node is significant but outside the allow set.
561        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        // A target that resolves only to ambient (non-significant) groups can't be
568        // proven in scope under a configured policy dimension → default-deny.
569        let policy = ResourcePolicy::new(vec!["cluster_1node_*".to_string()], vec![])
570            .with_significant(vec!["cluster_*".to_string()]);
571        // No cluster_* resource present.
572        let ambient = vec!["ue_content".to_string(), "windows".to_string()];
573        assert!(policy.evaluate(&ambient).is_err());
574
575        // With an empty allow list there's nothing to default-deny against.
576        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        // deny is conservative: it matches across every derived resource, not just
584        // the significant ones, so an ambient-group deny still rejects.
585        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}