1use std::collections::HashMap;
2use std::fs;
3
4use petgraph::Graph;
5use petgraph::graph::NodeIndex;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
9pub struct Config {
10 pub project: Project,
11}
12
13#[derive(Debug, Default, Clone, PartialEq, Eq)]
14pub enum Node {
15 #[default]
17 Root,
18 Resource(Service),
20}
21
22impl std::fmt::Display for Node {
23 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24 match self {
25 Node::Root => write!(f, "Root"),
26 Node::Resource(service) => write!(f, "service: {service:?}"),
27 }
28 }
29}
30
31impl Config {
32 const DEFAULT_CONFIG_PATH: &'static str = "oct.toml";
33
34 pub fn new(path: Option<&str>) -> Result<Self, Box<dyn std::error::Error>> {
35 let config =
36 fs::read_to_string(path.unwrap_or(Self::DEFAULT_CONFIG_PATH)).map_err(|e| {
37 format!(
38 "Failed to read config file {}: {}",
39 Self::DEFAULT_CONFIG_PATH,
40 e
41 )
42 })?;
43
44 let config_with_injected_envs = Self::render_system_envs(config);
45
46 let toml_data: Config = toml::from_str(&config_with_injected_envs)?;
47
48 Ok(toml_data)
49 }
50
51 pub fn to_graph(&self) -> Result<Graph<Node, String>, Box<dyn std::error::Error>> {
53 let mut graph = Graph::<Node, String>::new();
54 let mut edges = Vec::new();
55 let root = graph.add_node(Node::Root);
56
57 let mut services_map: HashMap<String, NodeIndex> = HashMap::new();
58 for service in &self.project.services {
59 if services_map.contains_key(&service.name) {
60 return Err(format!("Duplicate service name: '{}'", service.name).into());
61 }
62 let node = graph.add_node(Node::Resource(service.clone()));
63
64 services_map.insert(service.name.clone(), node);
65 }
66
67 for service in &self.project.services {
68 let resource = services_map
69 .get(&service.name)
70 .expect("Missed resource value in resource_map");
71
72 if service.depends_on.is_empty() {
73 edges.push((root, *resource, String::new()));
74 } else {
75 for dependency_name in &service.depends_on {
76 let dependency_resource = services_map.get(dependency_name);
77
78 match dependency_resource {
79 Some(dependency_resource) => {
80 edges.push((*dependency_resource, *resource, String::new()));
81 }
82 None => {
83 return Err(format!(
84 "Missed resource with name '{dependency_name}' referenced as dependency in '{}' service",
85 service.name
86 )
87 .into());
88 }
89 }
90 }
91 }
92 }
93
94 graph.extend_with_edges(&edges);
95
96 Ok(graph)
97 }
98
99 fn render_system_envs(config: String) -> String {
102 let mut context = tera::Context::new();
103 context.insert("env", &std::env::vars().collect::<HashMap<_, _>>());
104
105 let render_result = tera::Tera::one_off(&config, &context, true);
106
107 match render_result {
108 Ok(render_result) => {
109 log::info!("Config with injected env vars:\n{render_result}");
110
111 render_result
112 }
113 Err(e) => {
114 log::warn!("Failed to render string: '{config}', error: {e}, context: {context:?}");
115
116 config
117 }
118 }
119 }
120}
121
122#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
123pub enum StateBackend {
124 #[serde(rename = "local")]
125 Local {
126 path: String,
128 },
129
130 #[serde(rename = "s3")]
131 S3 {
132 region: String,
134 bucket: String,
136 key: String,
138 },
139}
140
141#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
142pub struct Project {
143 pub name: String,
144
145 pub state_backend: StateBackend,
146 pub user_state_backend: StateBackend,
147
148 pub services: Vec<Service>,
149
150 pub domain: Option<String>,
151}
152
153#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
156pub struct Service {
157 pub name: String,
159 pub image: String,
161 pub dockerfile_path: Option<String>,
163 pub command: Option<String>,
165 pub internal_port: Option<u32>,
167 pub external_port: Option<u32>,
169 pub cpus: u32,
171 pub memory: u64,
173 #[serde(default)]
175 pub depends_on: Vec<String>,
176 #[serde(default)]
179 pub envs: HashMap<String, String>,
180}
181
182#[cfg(test)]
183mod tests {
184 use std::io::Write;
185
186 use super::*;
187
188 #[test]
189 fn test_config_new_success_path_privided() {
190 let config_file_content = r#"
192[project]
193name = "example"
194domain = "opencloudtool.com"
195
196[project.state_backend.local]
197path = "./state.json"
198
199[project.user_state_backend.local]
200path = "./user_state.json"
201
202[[project.services]]
203name = "app_1"
204image = ""
205dockerfile_path = "Dockerfile"
206command = "echo Hello World!"
207internal_port = 80
208external_port = 80
209cpus = 250
210memory = 64
211
212[project.services.envs]
213KEY1 = "VALUE1"
214KEY2 = """
215Multiline
216string"""
217KEY_WITH_INJECTED_ENV = "{{ env.CARGO_PKG_NAME }}"
218KEY_WITH_OTHER_TEMPLATE_VARIABLE = "{{ other_vars.some_var }}"
219
220[[project.services]]
221name = "app_2"
222image = "nginx:latest"
223cpus = 250
224memory = 64
225depends_on = ["app_1"]
226"#;
227
228 let mut config_file = tempfile::NamedTempFile::new().expect("Failed to create a temp file");
229 config_file
230 .write_all(config_file_content.as_bytes())
231 .expect("Failed to write to file");
232
233 let config =
235 Config::new(config_file.path().to_str()).expect("Failed to create a new config");
236
237 assert_eq!(
239 config,
240 Config {
241 project: Project {
242 name: String::from("example"),
243 state_backend: StateBackend::Local {
244 path: String::from("./state.json")
245 },
246 user_state_backend: StateBackend::Local {
247 path: String::from("./user_state.json")
248 },
249 services: vec![
250 Service {
251 name: String::from("app_1"),
252 image: String::new(),
253 dockerfile_path: Some(String::from("Dockerfile")),
254 command: Some(String::from("echo Hello World!")),
255 internal_port: Some(80),
256 external_port: Some(80),
257 cpus: 250,
258 memory: 64,
259 depends_on: vec![],
260 envs: HashMap::from([
261 (String::from("KEY1"), String::from("VALUE1")),
262 (String::from("KEY2"), String::from("Multiline\nstring")),
263 (
264 String::from("KEY_WITH_INJECTED_ENV"),
265 String::from("oct-config")
270 ),
271 (
272 String::from("KEY_WITH_OTHER_TEMPLATE_VARIABLE"),
273 String::from("{{ other_vars.some_var }}")
274 ),
275 ]),
276 },
277 Service {
278 name: String::from("app_2"),
279 image: String::from("nginx:latest"),
280 dockerfile_path: None,
281 command: None,
282 internal_port: None,
283 external_port: None,
284 cpus: 250,
285 memory: 64,
286 depends_on: vec![String::from("app_1")],
287 envs: HashMap::new(),
288 }
289 ],
290 domain: Some(String::from("opencloudtool.com")),
291 }
292 }
293 );
294 }
295
296 #[test]
297 fn test_config_to_graph_empty() {
298 let config = Config {
300 project: Project {
301 name: String::from("test"),
302 state_backend: StateBackend::Local {
303 path: String::from("state.json"),
304 },
305 user_state_backend: StateBackend::Local {
306 path: String::from("user_state.json"),
307 },
308 services: Vec::new(),
309 domain: None,
310 },
311 };
312
313 let graph = config.to_graph().expect("Failed to get graph");
315
316 assert_eq!(graph.node_count(), 1); assert_eq!(graph.edge_count(), 0);
319 }
320
321 #[test]
322 fn test_config_to_graph_single_node() {
323 let service = Service {
325 name: String::from("app_1"),
326 image: String::from("nginx:latest"),
327 dockerfile_path: None,
328 command: None,
329 internal_port: None,
330 external_port: None,
331 cpus: 250,
332 memory: 64,
333 depends_on: vec![],
334 envs: HashMap::new(),
335 };
336 let config = Config {
337 project: Project {
338 name: String::from("test"),
339 state_backend: StateBackend::Local {
340 path: String::from("state.json"),
341 },
342 user_state_backend: StateBackend::Local {
343 path: String::from("user_state.json"),
344 },
345 services: vec![service],
346 domain: None,
347 },
348 };
349
350 let graph = config.to_graph().expect("Failed to get graph");
352
353 assert_eq!(graph.node_count(), 2);
355 assert_eq!(graph.edge_count(), 1);
356
357 let root_node_index = graph
358 .node_indices()
359 .find(|i| matches!(graph[*i], Node::Root))
360 .expect("Root node not found");
361 let service_node_index = graph
362 .node_indices()
363 .find(|i| matches!(graph[*i], Node::Resource(_)))
364 .expect("Service node not found");
365
366 assert!(graph.contains_edge(root_node_index, service_node_index));
367 }
368
369 #[test]
370 fn test_config_to_graph_with_dependencies() {
371 let service1 = Service {
373 name: String::from("app_1"),
374 image: String::from("nginx:latest"),
375 dockerfile_path: None,
376 command: None,
377 internal_port: None,
378 external_port: None,
379 cpus: 250,
380 memory: 64,
381 depends_on: vec![],
382 envs: HashMap::new(),
383 };
384 let service2 = Service {
385 name: String::from("app_2"),
386 image: String::from("nginx:latest"),
387 dockerfile_path: None,
388 command: None,
389 internal_port: None,
390 external_port: None,
391 cpus: 250,
392 memory: 64,
393 depends_on: vec![String::from("app_1")],
394 envs: HashMap::new(),
395 };
396 let config = Config {
397 project: Project {
398 name: String::from("test"),
399 state_backend: StateBackend::Local {
400 path: String::from("state.json"),
401 },
402 user_state_backend: StateBackend::Local {
403 path: String::from("user_state.json"),
404 },
405 services: vec![service1.clone(), service2.clone()],
406 domain: None,
407 },
408 };
409
410 let graph = config.to_graph().expect("Failed to get graph");
412
413 assert_eq!(graph.node_count(), 3);
415 assert_eq!(graph.edge_count(), 2);
416
417 let root_node_index = graph
418 .node_indices()
419 .find(|i| matches!(graph[*i], Node::Root))
420 .expect("Root node not found");
421 let service1_node_index = graph
422 .node_indices()
423 .find(|i| graph[*i] == Node::Resource(service1.clone()))
424 .expect("Service 1 node not found");
425 let service2_node_index = graph
426 .node_indices()
427 .find(|i| graph[*i] == Node::Resource(service2.clone()))
428 .expect("Service 2 node not found");
429
430 assert!(graph.contains_edge(root_node_index, service1_node_index));
431 assert!(graph.contains_edge(service1_node_index, service2_node_index));
432 }
433
434 #[test]
435 fn test_config_to_graph_failed_to_get_dependency() {
436 let service = Service {
438 name: String::from("app_1"),
439 image: String::from("nginx:latest"),
440 dockerfile_path: None,
441 command: None,
442 internal_port: None,
443 external_port: None,
444 cpus: 250,
445 memory: 64,
446 depends_on: vec![String::from("INCORRECT_SERVICE_NAME")],
447 envs: HashMap::new(),
448 };
449 let config = Config {
450 project: Project {
451 name: String::from("test"),
452 state_backend: StateBackend::Local {
453 path: String::from("state.json"),
454 },
455 user_state_backend: StateBackend::Local {
456 path: String::from("user_state.json"),
457 },
458 services: vec![service],
459 domain: None,
460 },
461 };
462
463 let graph = config.to_graph();
465
466 assert!(graph.is_err());
468 assert_eq!(
469 graph.expect_err("Expected error").to_string(),
470 "Missed resource with name 'INCORRECT_SERVICE_NAME' referenced as dependency in 'app_1' service"
471 );
472 }
473
474 #[test]
475 fn test_config_to_graph_duplicate_service_names() {
476 let service1 = Service {
478 name: String::from("app_1"),
479 image: String::from("nginx:latest"),
480 dockerfile_path: None,
481 command: None,
482 internal_port: None,
483 external_port: None,
484 cpus: 250,
485 memory: 64,
486 depends_on: vec![],
487 envs: HashMap::new(),
488 };
489 let service2 = Service {
490 name: String::from("app_1"),
491 image: String::from("nginx:latest"),
492 dockerfile_path: None,
493 command: None,
494 internal_port: None,
495 external_port: None,
496 cpus: 250,
497 memory: 64,
498 depends_on: vec![],
499 envs: HashMap::new(),
500 };
501 let config = Config {
502 project: Project {
503 name: String::from("test"),
504 state_backend: StateBackend::Local {
505 path: String::from("state.json"),
506 },
507 user_state_backend: StateBackend::Local {
508 path: String::from("user_state.json"),
509 },
510 services: vec![service1, service2],
511 domain: None,
512 },
513 };
514
515 let graph = config.to_graph();
517
518 assert!(graph.is_err());
520 assert_eq!(
521 graph.expect_err("Expected error").to_string(),
522 "Duplicate service name: 'app_1'"
523 );
524 }
525}