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};

/// Helm values.yaml configuration.
/// Since values files are free-form, we validate common patterns and placeholders.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(non_snake_case)]
pub struct HelmValues {
    // Common Helm patterns (all optional)
    #[serde(default)]
    pub replicaCount: Option<u32>,
    #[serde(default)]
    pub image: Option<HelmImage>,
    #[serde(default)]
    pub service: Option<HelmService>,
    #[serde(default)]
    pub ingress: Option<HelmIngress>,
    #[serde(default)]
    pub resources: Option<HelmResources>,
    #[serde(default)]
    pub autoscaling: Option<HelmAutoscaling>,
    #[serde(default)]
    pub nodeSelector: Option<serde_json::Value>,
    #[serde(default)]
    pub tolerations: Option<Vec<serde_json::Value>>,
    #[serde(default)]
    pub affinity: Option<serde_json::Value>,
    // Catch-all for other values
    #[serde(flatten)]
    pub other: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmImage {
    #[serde(default)]
    pub repository: Option<String>,
    #[serde(default)]
    pub tag: Option<String>,
    #[serde(default, rename = "pullPolicy")]
    pub pull_policy: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmService {
    #[serde(default)]
    #[serde(rename = "type")]
    pub service_type: Option<String>,
    #[serde(default)]
    pub port: Option<u16>,
    #[serde(default, rename = "targetPort")]
    pub target_port: Option<u16>,
    #[serde(default)]
    pub annotations: Option<HashMap<String, String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmIngress {
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default, rename = "className")]
    pub class_name: Option<String>,
    #[serde(default)]
    pub annotations: Option<HashMap<String, String>>,
    #[serde(default)]
    pub hosts: Option<Vec<serde_json::Value>>,
    #[serde(default)]
    pub tls: Option<Vec<serde_json::Value>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmResources {
    #[serde(default)]
    pub limits: Option<HashMap<String, String>>,
    #[serde(default)]
    pub requests: Option<HashMap<String, String>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmAutoscaling {
    #[serde(default)]
    pub enabled: Option<bool>,
    #[serde(default, rename = "minReplicas")]
    pub min_replicas: Option<u32>,
    #[serde(default, rename = "maxReplicas")]
    pub max_replicas: Option<u32>,
    #[serde(default, rename = "targetCPUUtilizationPercentage")]
    pub target_cpu: Option<u32>,
}

impl HelmValues {
    pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
        serde_json::from_value(data.clone())
            .map_err(|e| format!("Failed to parse Helm values: {e}"))
    }

    /// Heuristic detection: looks like a Helm values file if it has common patterns
    pub fn looks_like_helm(data: &serde_json::Value) -> bool {
        let obj = match data.as_object() {
            Some(o) => o,
            None => return false,
        };

        // Common Helm value keys
        let helm_keys = [
            "replicaCount", "image", "imagePullSecrets", "service",
            "ingress", "resources", "autoscaling", "nodeSelector",
            "tolerations", "affinity", "podAnnotations", "podSecurityContext",
            "securityContext", "serviceAccount", "fullnameOverride", "nameOverride",
        ];

        let matches = helm_keys.iter().filter(|k| obj.contains_key(*k as &str)).count();
        matches >= 2
    }
}

impl ConfigValidator for HelmValues {
    fn yaml_type(&self) -> YamlType {
        YamlType::HelmValues
    }

    fn validate_structure(&self) -> Vec<Diagnostic> {
        vec![] // Helm values are free-form, no structural requirements
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();

        // Image tag check
        if let Some(img) = &self.image {
            if let Some(tag) = &img.tag {
                if tag == "latest" || tag.is_empty() {
                    diags.push(Diagnostic {
                        severity: Severity::Warning,
                        message: "image.tag is 'latest' or empty — pin a specific version for reproducibility".into(),
                        path: Some("image > tag".into()),
                    });
                }
            } else {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: "image.tag not specified — chart may default to 'latest'".into(),
                    path: Some("image > tag".into()),
                });
            }
        }

        // replicaCount check
        if let Some(replicas) = self.replicaCount {
            if replicas == 0 {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: "replicaCount=0 — no pods will be created".into(),
                    path: Some("replicaCount".into()),
                });
            } else if replicas == 1 && self.autoscaling.as_ref().and_then(|a| a.enabled).unwrap_or(false) {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: "replicaCount=1 with autoscaling.enabled — HPA will handle scaling".into(),
                    path: Some("replicaCount".into()),
                });
            }
        }

        // Autoscaling consistency
        if let Some(hpa) = &self.autoscaling
            && hpa.enabled.unwrap_or(false)
            && let (Some(min), Some(max)) = (hpa.min_replicas, hpa.max_replicas)
            && min > max
        {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: format!("autoscaling.minReplicas ({}) > maxReplicas ({})", min, max),
                path: Some("autoscaling".into()),
            });
        }

        // Resources check
        if let Some(res) = &self.resources
            && res.requests.is_none() && res.limits.is_some()
        {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "resources.limits set but no requests — consider setting both".into(),
                path: Some("resources".into()),
            });
        }

        // Ingress enabled but no hosts
        if let Some(ing) = &self.ingress
            && ing.enabled.unwrap_or(false)
        {
            if ing.hosts.is_none() || ing.hosts.as_ref().map(|h| h.is_empty()).unwrap_or(true) {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: "ingress.enabled=true but no hosts defined".into(),
                    path: Some("ingress > hosts".into()),
                });
            }
            if ing.tls.is_none() {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: "ingress enabled without TLS — traffic will be unencrypted".into(),
                    path: Some("ingress > tls".into()),
                });
            }
        }

        // Placeholder detection in all string values
        self.detect_placeholders(&mut diags);

        diags
    }
}

impl HelmValues {
    /// Scan all string values for common placeholders
    fn detect_placeholders(&self, diags: &mut Vec<Diagnostic>) {
        let placeholders = ["CHANGEME", "TODO", "FIXME", "REPLACE_ME", "YOUR_", "XXX"];

        fn check_value(key: &str, val: &serde_json::Value, diags: &mut Vec<Diagnostic>, placeholders: &[&str]) {
            if let Some(s) = val.as_string() {
                for placeholder in placeholders {
                    if s.contains(placeholder) {
                        diags.push(Diagnostic {
                            severity: Severity::Warning,
                            message: format!("Placeholder value '{}' found at '{}'", placeholder, key),
                            path: Some(key.into()),
                        });
                        break;
                    }
                }
            } else if let Some(obj) = val.as_object() {
                for (k, v) in obj {
                    check_value(&format!("{} > {}", key, k), v, diags, placeholders);
                }
            } else if let Some(arr) = val.as_array() {
                for (i, v) in arr.iter().enumerate() {
                    check_value(&format!("{} > [{}]", key, i), v, diags, placeholders);
                }
            }
        }

        // Serialize self to JSON to traverse all values
        if let Ok(json) = serde_json::to_value(self)
            && let Some(obj) = json.as_object()
        {
            for (k, v) in obj {
                check_value(k, v, diags, &placeholders);
            }
        }
    }
}

// Helper trait for serde_json::Value
trait ValueAsString {
    fn as_string(&self) -> Option<&str>;
}

impl ValueAsString for serde_json::Value {
    fn as_string(&self) -> Option<&str> {
        self.as_str()
    }
}