use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeHealthcheck {
#[serde(default)]
pub test: Option<serde_json::Value>,
#[serde(default)]
pub interval: Option<String>,
#[serde(default)]
pub timeout: Option<String>,
#[serde(default)]
pub retries: Option<u32>,
#[serde(default)]
pub start_period: Option<String>,
#[serde(default)]
pub start_interval: Option<String>,
#[serde(default)]
pub disable: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeBuild {
#[serde(default)]
pub context: Option<String>,
#[serde(default)]
pub dockerfile: Option<String>,
#[serde(default)]
pub args: Option<serde_json::Value>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub cache_from: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeDeploy {
#[serde(default)]
pub replicas: Option<u32>,
#[serde(default)]
pub resources: Option<serde_json::Value>,
#[serde(default)]
pub restart_policy: Option<serde_json::Value>,
#[serde(default)]
pub update_config: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComposeService {
#[serde(default)]
pub image: Option<String>,
#[serde(default)]
pub build: Option<serde_json::Value>,
#[serde(default)]
pub ports: Vec<serde_json::Value>,
#[serde(default)]
pub volumes: Vec<serde_json::Value>,
#[serde(default)]
pub environment: Option<serde_json::Value>,
#[serde(default)]
pub env_file: Option<serde_json::Value>,
#[serde(default)]
pub depends_on: Option<serde_json::Value>,
#[serde(default)]
pub networks: Option<serde_json::Value>,
#[serde(default)]
pub command: Option<serde_json::Value>,
#[serde(default)]
pub entrypoint: Option<serde_json::Value>,
#[serde(default)]
pub restart: Option<String>,
#[serde(default)]
pub deploy: Option<ComposeDeploy>,
#[serde(default)]
pub healthcheck: Option<ComposeHealthcheck>,
#[serde(default)]
pub container_name: Option<String>,
#[serde(default)]
pub hostname: Option<String>,
#[serde(default)]
pub labels: Option<serde_json::Value>,
#[serde(default)]
pub logging: Option<serde_json::Value>,
#[serde(default)]
pub expose: Vec<serde_json::Value>,
#[serde(default)]
pub extra_hosts: Option<serde_json::Value>,
#[serde(default)]
pub working_dir: Option<String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub privileged: Option<bool>,
#[serde(default)]
pub cap_add: Vec<String>,
#[serde(default)]
pub cap_drop: Vec<String>,
#[serde(default)]
pub security_opt: Vec<String>,
#[serde(default)]
pub tmpfs: Option<serde_json::Value>,
#[serde(default)]
pub stdin_open: Option<bool>,
#[serde(default)]
pub tty: Option<bool>,
#[serde(default)]
pub secrets: Option<serde_json::Value>,
#[serde(default)]
pub configs: Option<serde_json::Value>,
#[serde(default)]
pub profiles: Vec<String>,
#[serde(default)]
pub platform: Option<String>,
#[serde(default)]
pub init: Option<bool>,
#[serde(default)]
pub stop_grace_period: Option<String>,
#[serde(default)]
pub sysctls: Option<serde_json::Value>,
#[serde(default)]
pub ulimits: Option<serde_json::Value>,
#[serde(default)]
pub pull_policy: Option<String>,
#[serde(default)]
pub mem_limit: Option<String>,
#[serde(default)]
pub cpus: Option<serde_json::Value>,
#[serde(default)]
pub shm_size: Option<serde_json::Value>,
#[serde(default)]
pub pid: Option<String>,
#[serde(default)]
pub network_mode: Option<String>,
#[serde(default)]
pub links: Vec<String>,
#[serde(default)]
pub external_links: Vec<String>,
#[serde(default)]
pub dns: Option<serde_json::Value>,
#[serde(default)]
pub dns_search: Option<serde_json::Value>,
#[serde(default)]
pub domainname: Option<String>,
#[serde(default)]
pub ipc: Option<String>,
#[serde(default)]
pub mac_address: Option<String>,
#[serde(default)]
pub extends: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerCompose {
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub services: HashMap<String, ComposeService>,
#[serde(default)]
pub volumes: Option<serde_json::Value>,
#[serde(default)]
pub networks: Option<serde_json::Value>,
#[serde(default)]
pub secrets: Option<serde_json::Value>,
#[serde(default)]
pub configs: Option<serde_json::Value>,
#[serde(default, rename = "x-common")]
pub x_common: Option<serde_json::Value>,
}
impl DockerCompose {
pub fn from_value(data: serde_json::Value) -> Result<Self, String> {
serde_json::from_value(data)
.map_err(|e| format!("Failed to parse Docker Compose: {e}"))
}
}
impl ConfigValidator for DockerCompose {
fn yaml_type(&self) -> YamlType { YamlType::DockerCompose }
fn validate_structure(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if self.services.is_empty() {
diags.push(Diagnostic {
severity: Severity::Error,
message: "No services defined".into(),
path: Some("services".into()),
});
}
for (name, svc) in &self.services {
if svc.image.is_none() && svc.build.is_none() {
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("Service '{}': must specify either 'image' or 'build'", name),
path: Some(format!("services > {}", name)),
});
}
}
diags
}
fn validate_semantics(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if let Some(ver) = &self.version {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("'version: \"{}\"' is deprecated in modern Docker Compose — it can be removed", ver),
path: Some("version".into()),
});
}
let mut host_ports: HashMap<String, Vec<String>> = HashMap::new();
for (name, svc) in &self.services {
if let Some(img) = &svc.image
&& (img.ends_with(":latest") || !img.contains(':')) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Service '{}': using ':latest' or untagged image '{}' — pin a specific version", name, img),
path: Some(format!("services > {} > image", name)),
});
}
match svc.restart.as_deref() {
Some("no") | None => {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Service '{}': no restart policy — container won't restart automatically", name),
path: Some(format!("services > {} > restart", name)),
});
}
_ => {}
}
if svc.healthcheck.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: format!("Service '{}': no healthcheck defined", name),
path: Some(format!("services > {} > healthcheck", name)),
});
}
if svc.privileged == Some(true) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Service '{}': running in privileged mode — security risk", name),
path: Some(format!("services > {} > privileged", name)),
});
}
for port_val in &svc.ports {
if let Some(port_str) = port_val.as_str() {
if let Some(host_port) = extract_host_port(port_str) {
host_ports
.entry(host_port.clone())
.or_default()
.push(name.clone());
}
}
}
for vol in &svc.volumes {
if let Some(vol_str) = vol.as_str() {
let path = vol_str.split(':').next().unwrap_or("");
if path == "/" || path == "/etc" || path == "/var/run/docker.sock" {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Service '{}': bind mounting '{}' is a security risk", name, path),
path: Some(format!("services > {} > volumes", name)),
});
}
}
}
}
for (port, services) in &host_ports {
if services.len() > 1 {
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("Host port {} is mapped by multiple services: {}", port, services.join(", ")),
path: Some("services > ports".into()),
});
}
}
diags
}
}
fn extract_host_port(port_mapping: &str) -> Option<String> {
let parts: Vec<&str> = port_mapping.split(':').collect();
match parts.len() {
2 => Some(parts[0].to_string()),
3 => Some(format!("{}:{}", parts[0], parts[1])),
_ => None,
}
}