Skip to main content

oct_config/
lib.rs

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    /// The synthetic root node.
16    #[default]
17    Root,
18    /// A user service in the dependency graph.
19    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    /// Converts user services to a graph
52    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    /// Renders environment variables using [tera](https://docs.rs/tera/latest/tera/)
100    /// All system environment variables are available under the `env` context variable
101    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        /// Local path to the state file
127        path: String,
128    },
129
130    #[serde(rename = "s3")]
131    S3 {
132        /// Bucket region
133        region: String,
134        /// Bucket name
135        bucket: String,
136        /// Path to the file inside the S3 bucket
137        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/// Configuration for a service
154/// This configuration is managed by the user and used to deploy the service
155#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
156pub struct Service {
157    /// Service name
158    pub name: String,
159    /// Image to use for the container
160    pub image: String,
161    /// Path to the Dockerfile
162    pub dockerfile_path: Option<String>,
163    /// Command to run in the container
164    pub command: Option<String>,
165    /// Internal port exposed from the container
166    pub internal_port: Option<u32>,
167    /// External port exposed to the public internet
168    pub external_port: Option<u32>,
169    /// CPU millicores
170    pub cpus: u32,
171    /// Memory in MB
172    pub memory: u64,
173    /// List of services that this service depends on
174    #[serde(default)]
175    pub depends_on: Vec<String>,
176    /// Raw environment variables to set in the container
177    /// All values are rendered using in `render_envs` method
178    #[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        // Arrange
191        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        // Act
234        let config =
235            Config::new(config_file.path().to_str()).expect("Failed to create a new config");
236
237        // Assert
238        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                                    // "oct-orchestrator" was the previous value because it was in that crate.
266                                    // Now it's in oct-config, so CARGO_PKG_NAME will be oct-config.
267                                    // Wait, the test uses {{ env.CARGO_PKG_NAME }}.
268                                    // When running tests for oct-config, CARGO_PKG_NAME is oct-config.
269                                    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        // Arrange
299        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        // Act
314        let graph = config.to_graph().expect("Failed to get graph");
315
316        // Assert
317        assert_eq!(graph.node_count(), 1); // Root node
318        assert_eq!(graph.edge_count(), 0);
319    }
320
321    #[test]
322    fn test_config_to_graph_single_node() {
323        // Arrange
324        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        // Act
351        let graph = config.to_graph().expect("Failed to get graph");
352
353        // Assert
354        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        // Arrange
372        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        // Act
411        let graph = config.to_graph().expect("Failed to get graph");
412
413        // Assert
414        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        // Arrange
437        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        // Act
464        let graph = config.to_graph();
465
466        // Assert
467        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        // Arrange
477        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        // Act
516        let graph = config.to_graph();
517
518        // Assert
519        assert!(graph.is_err());
520        assert_eq!(
521            graph.expect_err("Expected error").to_string(),
522            "Duplicate service name: 'app_1'"
523        );
524    }
525}