Skip to main content

xianyun_deploy/deploy/
order.rs

1//! Orders the deployers declared in a config so that every deployer follows
2//! the deployers its `needs` keyword names.
3//!
4//! Ported from `internal/deploy/order.go`.
5
6use std::collections::HashMap;
7
8use thiserror::Error;
9use xianyun_core::config::container::Container;
10use xianyun_core::toposort::{self, ToposortError};
11
12/// Errors from [`list_deployers_in_order`].
13#[derive(Debug, Error, PartialEq, Eq)]
14pub enum OrderError {
15    /// A deployer's `needs` references a deployer absent from the config.
16    #[error("unknown deployer dependency: deployer {0:?} needs {1:?}")]
17    UnknownDependency(String, String),
18    /// A circular dependency was detected between deployers.
19    #[error("circular deployer dependency detected")]
20    CircularDependency,
21}
22
23/// Orders the deployers in `cfg` so that every deployer follows the
24/// deployers its `needs` keyword names. Deployers no `needs` relates to
25/// each other may come in any order.
26///
27/// # Errors
28///
29/// Returns [`OrderError::UnknownDependency`] if a deployer names a
30/// dependency absent from the config, or [`OrderError::CircularDependency`]
31/// if the dependencies form a cycle.
32pub fn list_deployers_in_order(cfg: &Container) -> Result<Vec<String>, OrderError> {
33    let Some(deployers) = cfg.caproni.deployers.as_ref() else {
34        return Ok(Vec::new());
35    };
36
37    let mut names = Vec::with_capacity(deployers.len());
38    let mut needs: HashMap<String, Vec<String>> = HashMap::with_capacity(deployers.len());
39
40    for (name, deployer_config) in deployers.iter() {
41        for need in &deployer_config.needs {
42            if !deployers.has(need) {
43                return Err(OrderError::UnknownDependency(name.clone(), need.clone()));
44            }
45        }
46
47        names.push(name.clone());
48        needs.insert(name.clone(), deployer_config.needs.clone());
49    }
50
51    toposort::sort(&names, |name| needs.get(name).cloned().unwrap_or_default())
52        .map_err(|e| match e {
53            ToposortError::Cycle(_) => OrderError::CircularDependency,
54        })
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use xianyun_core::config::container::new_container;
61    use xianyun_core::config::global::GlobalConfig;
62    use xianyun_core::config::latest::{CaproniConfig, DeployerConfig};
63    use xianyun_core::config::types::NetworkConfig;
64    use xianyun_core::orderedmap::OrderedMap;
65
66    /// Wraps a [`CaproniConfig`] in a [`Container`] for use in tests,
67    /// mirroring the Go test helper `newTestConfig`. Ensures
68    /// `cluster.network` is set so the variables resolver can compute
69    /// `CAPRONI_PRIMARY_HOSTNAME`.
70    fn new_test_config(mut caproni_config: CaproniConfig) -> Container {
71        if caproni_config.cluster.network.is_none() {
72            caproni_config.cluster.network = Some(NetworkConfig {
73                hostname: "test.example.com".to_string(),
74                additional_hostnames: Vec::new(),
75                cluster_domain: String::new(),
76            });
77        }
78
79        new_container(GlobalConfig::default(), caproni_config)
80    }
81
82    fn deployer(needs: &[&str]) -> DeployerConfig {
83        DeployerConfig {
84            needs: needs.iter().map(ToString::to_string).collect(),
85            ..Default::default()
86        }
87    }
88
89    fn deployer_typed(deployer_type: &str, needs: &[&str]) -> DeployerConfig {
90        DeployerConfig {
91            deployer_type: deployer_type.to_string(),
92            ..deployer(needs)
93        }
94    }
95
96    fn config_from(deployers: HashMap<String, DeployerConfig>) -> CaproniConfig {
97        CaproniConfig {
98            deployers: Some(OrderedMap::from_map(deployers)),
99            ..Default::default()
100        }
101    }
102
103    // Ported from internal/deploy/order_test.go:13 (TestListDeployersInOrder)
104    #[test]
105    fn test_list_deployers_in_order() {
106        struct Case {
107            name: &'static str,
108            deployers: HashMap<String, DeployerConfig>,
109            want: Option<Vec<&'static str>>,
110            want_err: Option<OrderError>,
111        }
112
113        let cases = vec![
114            Case {
115                name: "correct order",
116                deployers: HashMap::from([
117                    ("my-app".to_string(), deployer(&["namespace", "database"])),
118                    ("namespace".to_string(), deployer(&[])),
119                    ("database".to_string(), deployer(&["namespace"])),
120                    ("ai-system".to_string(), deployer(&["my-app"])),
121                ]),
122                want: Some(vec!["namespace", "database", "my-app", "ai-system"]),
123                want_err: None,
124            },
125            Case {
126                name: "fan-out: two deployers share the same dependency",
127                deployers: HashMap::from([
128                    ("base".to_string(), deployer(&[])),
129                    ("app-a".to_string(), deployer(&["base"])),
130                    ("app-b".to_string(), deployer(&["base", "app-a"])),
131                ]),
132                want: Some(vec!["base", "app-a", "app-b"]),
133                want_err: None,
134            },
135            Case {
136                name: "cyclical dependency",
137                deployers: HashMap::from([
138                    ("my-app".to_string(), deployer(&["namespace"])),
139                    ("namespace".to_string(), deployer(&["my-app"])),
140                ]),
141                want: None,
142                want_err: Some(OrderError::CircularDependency),
143            },
144            Case {
145                name: "empty config yields no order",
146                deployers: HashMap::new(),
147                want: Some(vec![]),
148                want_err: None,
149            },
150            Case {
151                name: "duplicate needs are tolerated",
152                deployers: HashMap::from([
153                    ("base".to_string(), deployer(&[])),
154                    ("app".to_string(), deployer(&["base", "base"])),
155                ]),
156                want: Some(vec!["base", "app"]),
157                want_err: None,
158            },
159            Case {
160                name: "self dependency is cyclical",
161                deployers: HashMap::from([("app".to_string(), deployer(&["app"]))]),
162                want: None,
163                want_err: Some(OrderError::CircularDependency),
164            },
165            Case {
166                name: "missing dependency is reported",
167                deployers: HashMap::from([("app".to_string(), deployer(&["does-not-exist"]))]),
168                want: None,
169                want_err: Some(OrderError::UnknownDependency(
170                    "app".to_string(),
171                    "does-not-exist".to_string(),
172                )),
173            },
174        ];
175
176        for case in cases {
177            let cfg = new_test_config(config_from(case.deployers));
178
179            let result = list_deployers_in_order(&cfg);
180
181            if let Some(want_err) = case.want_err {
182                match result {
183                    Ok(got) => panic!(
184                        "{}: expected error {want_err:?}, got Ok({got:?})",
185                        case.name
186                    ),
187                    Err(err) => assert_eq!(err, want_err, "{}", case.name),
188                }
189            } else {
190                let got = result.unwrap_or_else(|e| panic!("{}: unexpected error: {e}", case.name));
191                let want: Vec<String> = case
192                    .want
193                    .unwrap_or_default()
194                    .into_iter()
195                    .map(str::to_string)
196                    .collect();
197                assert_eq!(got, want, "{}", case.name);
198            }
199        }
200    }
201
202    // Ported from internal/deploy/order_test.go:112 (TestListDeployersInOrderDeclaredChainIsStable)
203    #[test]
204    fn test_list_deployers_in_order_declared_chain_is_stable() {
205        for i in 0..50 {
206            let mut deployers = HashMap::new();
207            deployers.insert(
208                "gitlab".to_string(),
209                deployer_typed("helm", &["caproni-dev-ca"]),
210            );
211            deployers.insert("cert-manager".to_string(), deployer_typed("helm", &[]));
212            deployers.insert(
213                "trust-manager".to_string(),
214                deployer_typed("helm", &["cert-manager"]),
215            );
216            deployers.insert(
217                "caproni-dev-ca".to_string(),
218                deployer_typed("kustomize", &["cert-manager", "trust-manager"]),
219            );
220
221            let cfg = new_test_config(config_from(deployers));
222
223            let order = list_deployers_in_order(&cfg)
224                .unwrap_or_else(|e| panic!("iteration {i}: unexpected error: {e}"));
225
226            assert_eq!(
227                order,
228                vec!["cert-manager", "trust-manager", "caproni-dev-ca", "gitlab"],
229                "iteration {i}"
230            );
231        }
232    }
233
234    // Ported from internal/deploy/order_test.go:138 (TestListDeployersInOrderBundledNameCanNeedUserDeployer)
235    #[test]
236    fn test_list_deployers_in_order_bundled_name_can_need_user_deployer() {
237        let deployers = HashMap::from([
238            (
239                "cert-manager".to_string(),
240                deployer_typed("helm", &["gitlab"]),
241            ),
242            ("gitlab".to_string(), deployer_typed("helm", &[])),
243        ]);
244
245        let cfg = new_test_config(config_from(deployers));
246
247        let order =
248            list_deployers_in_order(&cfg).unwrap_or_else(|e| panic!("unexpected error: {e}"));
249
250        assert_eq!(order, vec!["gitlab", "cert-manager"]);
251    }
252}