1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5#[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 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 let mut host_ports: HashMap<String, Vec<String>> = HashMap::new();
221
222 for (name, svc) in &self.services {
223 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 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 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 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 for port_val in &svc.ports {
265 if let Some(port_str) = port_val.as_str() {
266 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 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 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 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}