Skip to main content

devops_models/models/
docker_compose.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5// ═══════════════════════════════════════════════════════════════════════════
6// Docker Compose
7// ═══════════════════════════════════════════════════════════════════════════
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct ComposeHealthcheck {
11    #[serde(default)]
12    pub test: Option<serde_json::Value>,
13    #[serde(default)]
14    pub interval: Option<String>,
15    #[serde(default)]
16    pub timeout: Option<String>,
17    #[serde(default)]
18    pub retries: Option<u32>,
19    #[serde(default)]
20    pub start_period: Option<String>,
21    #[serde(default)]
22    pub start_interval: Option<String>,
23    #[serde(default)]
24    pub disable: Option<bool>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ComposeBuild {
29    #[serde(default)]
30    pub context: Option<String>,
31    #[serde(default)]
32    pub dockerfile: Option<String>,
33    #[serde(default)]
34    pub args: Option<serde_json::Value>,
35    #[serde(default)]
36    pub target: Option<String>,
37    #[serde(default)]
38    pub cache_from: Option<Vec<String>>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct ComposeDeploy {
43    #[serde(default)]
44    pub replicas: Option<u32>,
45    #[serde(default)]
46    pub resources: Option<serde_json::Value>,
47    #[serde(default)]
48    pub restart_policy: Option<serde_json::Value>,
49    #[serde(default)]
50    pub update_config: Option<serde_json::Value>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct ComposeService {
55    #[serde(default)]
56    pub image: Option<String>,
57    #[serde(default)]
58    pub build: Option<serde_json::Value>,
59    #[serde(default)]
60    pub ports: Vec<serde_json::Value>,
61    #[serde(default)]
62    pub volumes: Vec<serde_json::Value>,
63    #[serde(default)]
64    pub environment: Option<serde_json::Value>,
65    #[serde(default)]
66    pub env_file: Option<serde_json::Value>,
67    #[serde(default)]
68    pub depends_on: Option<serde_json::Value>,
69    #[serde(default)]
70    pub networks: Option<serde_json::Value>,
71    #[serde(default)]
72    pub command: Option<serde_json::Value>,
73    #[serde(default)]
74    pub entrypoint: Option<serde_json::Value>,
75    #[serde(default)]
76    pub restart: Option<String>,
77    #[serde(default)]
78    pub deploy: Option<ComposeDeploy>,
79    #[serde(default)]
80    pub healthcheck: Option<ComposeHealthcheck>,
81    #[serde(default)]
82    pub container_name: Option<String>,
83    #[serde(default)]
84    pub hostname: Option<String>,
85    #[serde(default)]
86    pub labels: Option<serde_json::Value>,
87    #[serde(default)]
88    pub logging: Option<serde_json::Value>,
89    #[serde(default)]
90    pub expose: Vec<serde_json::Value>,
91    #[serde(default)]
92    pub extra_hosts: Option<serde_json::Value>,
93    #[serde(default)]
94    pub working_dir: Option<String>,
95    #[serde(default)]
96    pub user: Option<String>,
97    #[serde(default)]
98    pub privileged: Option<bool>,
99    #[serde(default)]
100    pub cap_add: Vec<String>,
101    #[serde(default)]
102    pub cap_drop: Vec<String>,
103    #[serde(default)]
104    pub security_opt: Vec<String>,
105    #[serde(default)]
106    pub tmpfs: Option<serde_json::Value>,
107    #[serde(default)]
108    pub stdin_open: Option<bool>,
109    #[serde(default)]
110    pub tty: Option<bool>,
111    #[serde(default)]
112    pub secrets: Option<serde_json::Value>,
113    #[serde(default)]
114    pub configs: Option<serde_json::Value>,
115    #[serde(default)]
116    pub profiles: Vec<String>,
117    #[serde(default)]
118    pub platform: Option<String>,
119    #[serde(default)]
120    pub init: Option<bool>,
121    #[serde(default)]
122    pub stop_grace_period: Option<String>,
123    #[serde(default)]
124    pub sysctls: Option<serde_json::Value>,
125    #[serde(default)]
126    pub ulimits: Option<serde_json::Value>,
127    #[serde(default)]
128    pub pull_policy: Option<String>,
129    #[serde(default)]
130    pub mem_limit: Option<String>,
131    #[serde(default)]
132    pub cpus: Option<serde_json::Value>,
133    #[serde(default)]
134    pub shm_size: Option<serde_json::Value>,
135    #[serde(default)]
136    pub pid: Option<String>,
137    #[serde(default)]
138    pub network_mode: Option<String>,
139    #[serde(default)]
140    pub links: Vec<String>,
141    #[serde(default)]
142    pub external_links: Vec<String>,
143    #[serde(default)]
144    pub dns: Option<serde_json::Value>,
145    #[serde(default)]
146    pub dns_search: Option<serde_json::Value>,
147    #[serde(default)]
148    pub domainname: Option<String>,
149    #[serde(default)]
150    pub ipc: Option<String>,
151    #[serde(default)]
152    pub mac_address: Option<String>,
153    #[serde(default)]
154    pub extends: Option<serde_json::Value>,
155}
156
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct DockerCompose {
159    #[serde(default)]
160    pub version: Option<String>,
161    #[serde(default)]
162    pub name: Option<String>,
163    #[serde(default)]
164    pub services: HashMap<String, ComposeService>,
165    #[serde(default)]
166    pub volumes: Option<serde_json::Value>,
167    #[serde(default)]
168    pub networks: Option<serde_json::Value>,
169    #[serde(default)]
170    pub secrets: Option<serde_json::Value>,
171    #[serde(default)]
172    pub configs: Option<serde_json::Value>,
173    #[serde(default, rename = "x-common")]
174    pub x_common: Option<serde_json::Value>,
175}
176
177impl DockerCompose {
178    pub fn from_value(data: serde_json::Value) -> Result<Self, String> {
179        serde_json::from_value(data)
180            .map_err(|e| format!("Failed to parse Docker Compose: {e}"))
181    }
182}
183
184impl ConfigValidator for DockerCompose {
185    fn yaml_type(&self) -> YamlType { YamlType::DockerCompose }
186
187    fn validate_structure(&self) -> Vec<Diagnostic> {
188        let mut diags = Vec::new();
189        if self.services.is_empty() {
190            diags.push(Diagnostic {
191                severity: Severity::Error,
192                message: "No services defined".into(),
193                path: Some("services".into()),
194            });
195        }
196        for (name, svc) in &self.services {
197            if svc.image.is_none() && svc.build.is_none() {
198                diags.push(Diagnostic {
199                    severity: Severity::Error,
200                    message: format!("Service '{}': must specify either 'image' or 'build'", name),
201                    path: Some(format!("services > {}", name)),
202                });
203            }
204        }
205        diags
206    }
207
208    fn validate_semantics(&self) -> Vec<Diagnostic> {
209        let mut diags = Vec::new();
210        // Deprecated version field
211        if let Some(ver) = &self.version {
212            diags.push(Diagnostic {
213                severity: Severity::Info,
214                message: format!("'version: \"{}\"' is deprecated in modern Docker Compose — it can be removed", ver),
215                path: Some("version".into()),
216            });
217        }
218
219        // Collect host ports for duplicate detection
220        let mut host_ports: HashMap<String, Vec<String>> = HashMap::new();
221
222        for (name, svc) in &self.services {
223            // Image tag analysis
224            if let Some(img) = &svc.image
225                && (img.ends_with(":latest") || !img.contains(':')) {
226                    diags.push(Diagnostic {
227                        severity: Severity::Warning,
228                        message: format!("Service '{}': using ':latest' or untagged image '{}' — pin a specific version", name, img),
229                        path: Some(format!("services > {} > image", name)),
230                    });
231                }
232
233            // No restart policy
234            match svc.restart.as_deref() {
235                Some("no") | None => {
236                    diags.push(Diagnostic {
237                        severity: Severity::Info,
238                        message: format!("Service '{}': no restart policy — container won't restart automatically", name),
239                        path: Some(format!("services > {} > restart", name)),
240                    });
241                }
242                _ => {}
243            }
244
245            // No healthcheck
246            if svc.healthcheck.is_none() {
247                diags.push(Diagnostic {
248                    severity: Severity::Info,
249                    message: format!("Service '{}': no healthcheck defined", name),
250                    path: Some(format!("services > {} > healthcheck", name)),
251                });
252            }
253
254            // Privileged mode
255            if svc.privileged == Some(true) {
256                diags.push(Diagnostic {
257                    severity: Severity::Warning,
258                    message: format!("Service '{}': running in privileged mode — security risk", name),
259                    path: Some(format!("services > {} > privileged", name)),
260                });
261            }
262
263            // Port duplicate detection
264            for port_val in &svc.ports {
265                if let Some(port_str) = port_val.as_str() {
266                    // Extract host port (before the last :)
267                    if let Some(host_port) = extract_host_port(port_str) {
268                        host_ports
269                            .entry(host_port.clone())
270                            .or_default()
271                            .push(name.clone());
272                    }
273                }
274            }
275
276            // Sensitive bind mounts
277            for vol in &svc.volumes {
278                if let Some(vol_str) = vol.as_str() {
279                    let path = vol_str.split(':').next().unwrap_or("");
280                    if path == "/" || path == "/etc" || path == "/var/run/docker.sock" {
281                        diags.push(Diagnostic {
282                            severity: Severity::Warning,
283                            message: format!("Service '{}': bind mounting '{}' is a security risk", name, path),
284                            path: Some(format!("services > {} > volumes", name)),
285                        });
286                    }
287                }
288            }
289        }
290
291        // Check for duplicate host ports
292        for (port, services) in &host_ports {
293            if services.len() > 1 {
294                diags.push(Diagnostic {
295                    severity: Severity::Error,
296                    message: format!("Host port {} is mapped by multiple services: {}", port, services.join(", ")),
297                    path: Some("services > ports".into()),
298                });
299            }
300        }
301
302        diags
303    }
304}
305
306fn extract_host_port(port_mapping: &str) -> Option<String> {
307    // Handle formats: "8080:80", "127.0.0.1:8080:80", "8080:80/tcp"
308    let parts: Vec<&str> = port_mapping.split(':').collect();
309    match parts.len() {
310        2 => Some(parts[0].to_string()),
311        3 => Some(format!("{}:{}", parts[0], parts[1])),
312        _ => None,
313    }
314}