1use std::collections::HashMap;
7
8use thiserror::Error;
9use xianyun_core::config::container::Container;
10use xianyun_core::toposort::{self, ToposortError};
11
12#[derive(Debug, Error, PartialEq, Eq)]
14pub enum OrderError {
15 #[error("unknown deployer dependency: deployer {0:?} needs {1:?}")]
17 UnknownDependency(String, String),
18 #[error("circular deployer dependency detected")]
20 CircularDependency,
21}
22
23pub 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 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 #[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 #[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 #[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}