devops-models 0.1.0

Typed serde models for DevOps configuration formats: Kubernetes, Docker Compose, GitLab CI, GitHub Actions, Prometheus, Alertmanager, Helm, Ansible, and OpenAPI.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};

// ═══════════════════════════════════════════════════════════════════════════
// Docker Compose
// ═══════════════════════════════════════════════════════════════════════════

#[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();
        // Deprecated version field
        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()),
            });
        }

        // Collect host ports for duplicate detection
        let mut host_ports: HashMap<String, Vec<String>> = HashMap::new();

        for (name, svc) in &self.services {
            // Image tag analysis
            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)),
                    });
                }

            // No restart policy
            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)),
                    });
                }
                _ => {}
            }

            // No healthcheck
            if svc.healthcheck.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Service '{}': no healthcheck defined", name),
                    path: Some(format!("services > {} > healthcheck", name)),
                });
            }

            // Privileged mode
            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)),
                });
            }

            // Port duplicate detection
            for port_val in &svc.ports {
                if let Some(port_str) = port_val.as_str() {
                    // Extract host port (before the last :)
                    if let Some(host_port) = extract_host_port(port_str) {
                        host_ports
                            .entry(host_port.clone())
                            .or_default()
                            .push(name.clone());
                    }
                }
            }

            // Sensitive bind mounts
            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)),
                        });
                    }
                }
            }
        }

        // Check for duplicate host ports
        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> {
    // Handle formats: "8080:80", "127.0.0.1:8080:80", "8080:80/tcp"
    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,
    }
}