Skip to main content

xbp_deploy/
graph.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2
3use crate::error::{DeployError, Result};
4use crate::types::ServiceConfigView;
5
6#[derive(Debug, Clone)]
7pub struct ServiceNode {
8    pub name: String,
9    pub depends_on: Vec<String>,
10}
11
12#[derive(Debug, Clone, Default)]
13pub struct ServiceGraph {
14    pub nodes: HashMap<String, ServiceNode>,
15}
16
17impl ServiceGraph {
18    pub fn from_services(services: &[ServiceConfigView]) -> Self {
19        let mut nodes = HashMap::new();
20        for svc in services {
21            nodes.insert(
22                svc.name.clone(),
23                ServiceNode {
24                    name: svc.name.clone(),
25                    depends_on: svc.depends_on.clone(),
26                },
27            );
28        }
29        Self { nodes }
30    }
31}
32
33/// Compute apply order for `selected` service names.
34///
35/// 1. If `group_order` is non-empty → authoritative (filtered to selected).
36/// 2. Else DAG topological sort from `depends_on`.
37/// 3. Stable fallback: preserve `selected` order for unconstrained nodes.
38pub fn order_services(
39    selected: &[String],
40    group_order: Option<&[String]>,
41    graph: &ServiceGraph,
42) -> Result<Vec<String>> {
43    if let Some(order) = group_order {
44        if !order.is_empty() {
45            let selected_set: HashSet<&str> = selected.iter().map(String::as_str).collect();
46            let mut out = Vec::new();
47            for name in order {
48                if selected_set.contains(name.as_str()) {
49                    out.push(name.clone());
50                }
51            }
52            // Append any selected not listed in order (end).
53            for name in selected {
54                if !out.iter().any(|n| n == name) {
55                    out.push(name.clone());
56                }
57            }
58            return Ok(out);
59        }
60    }
61
62    // Validate missing deps among selected.
63    let selected_set: HashSet<String> = selected.iter().cloned().collect();
64    for name in selected {
65        let Some(node) = graph.nodes.get(name) else {
66            continue;
67        };
68        for dep in &node.depends_on {
69            if !graph.nodes.contains_key(dep) {
70                return Err(DeployError::Graph(format!(
71                    "service `{name}` depends on missing service `{dep}`"
72                )));
73            }
74        }
75    }
76
77    // Kahn topological sort restricted to selected (+ their selected deps).
78    let mut indegree: HashMap<String, usize> = selected
79        .iter()
80        .map(|n| (n.clone(), 0usize))
81        .collect();
82    let mut adj: HashMap<String, Vec<String>> = HashMap::new();
83
84    for name in selected {
85        let deps = graph
86            .nodes
87            .get(name)
88            .map(|n| n.depends_on.clone())
89            .unwrap_or_default();
90        for dep in deps {
91            if !selected_set.contains(&dep) {
92                continue;
93            }
94            adj.entry(dep.clone()).or_default().push(name.clone());
95            *indegree.entry(name.clone()).or_default() += 1;
96        }
97    }
98
99    let mut queue: VecDeque<String> = selected
100        .iter()
101        .filter(|n| indegree.get(*n).copied().unwrap_or(0) == 0)
102        .cloned()
103        .collect();
104
105    // Preserve original relative order for zero-indegree nodes.
106    queue.make_contiguous().sort_by_key(|n| {
107        selected.iter().position(|s| s == n).unwrap_or(usize::MAX)
108    });
109
110    let mut ordered = Vec::new();
111    while let Some(node) = queue.pop_front() {
112        ordered.push(node.clone());
113        if let Some(children) = adj.get(&node) {
114            for child in children {
115                if let Some(d) = indegree.get_mut(child) {
116                    *d = d.saturating_sub(1);
117                    if *d == 0 {
118                        queue.push_back(child.clone());
119                    }
120                }
121            }
122        }
123    }
124
125    if ordered.len() != selected.len() {
126        return Err(DeployError::Graph(
127            "dependency cycle detected among selected services".into(),
128        ));
129    }
130    Ok(ordered)
131}
132
133/// Athena group guardrail: operator before runtime when both present.
134pub fn enforce_athena_operator_before_runtime(order: &mut Vec<String>) {
135    let op = order.iter().position(|s| s == "athena-operator");
136    let rt = order.iter().position(|s| s == "athena-runtime");
137    if let (Some(i), Some(j)) = (op, rt) {
138        if i > j {
139            order.swap(i, j);
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn svc(name: &str, deps: &[&str]) -> ServiceConfigView {
149        ServiceConfigView {
150            name: name.into(),
151            root_directory: None,
152            version: None,
153            depends_on: deps.iter().map(|s| (*s).into()).collect(),
154            oci: None,
155            deploy: None,
156        }
157    }
158
159    #[test]
160    fn group_order_authoritative() {
161        let services = vec![svc("a", &[]), svc("b", &[]), svc("c", &[])];
162        let graph = ServiceGraph::from_services(&services);
163        let order = order_services(
164            &["a".into(), "b".into(), "c".into()],
165            Some(&["c".into(), "a".into(), "b".into()]),
166            &graph,
167        )
168        .unwrap();
169        assert_eq!(order, vec!["c", "a", "b"]);
170    }
171
172    #[test]
173    fn topo_respects_depends_on() {
174        let services = vec![svc("a", &[]), svc("b", &["a"]), svc("c", &["b"])];
175        let graph = ServiceGraph::from_services(&services);
176        let order = order_services(
177            &["c".into(), "b".into(), "a".into()],
178            None,
179            &graph,
180        )
181        .unwrap();
182        assert_eq!(order, vec!["a", "b", "c"]);
183    }
184
185    #[test]
186    fn cycle_errors() {
187        let services = vec![svc("a", &["b"]), svc("b", &["a"])];
188        let graph = ServiceGraph::from_services(&services);
189        let err = order_services(&["a".into(), "b".into()], None, &graph).unwrap_err();
190        assert!(err.to_string().contains("cycle"));
191    }
192
193    #[test]
194    fn athena_guardrail() {
195        let mut order = vec!["athena-runtime".into(), "athena-operator".into()];
196        enforce_athena_operator_before_runtime(&mut order);
197        assert_eq!(order[0], "athena-operator");
198    }
199}