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::k8s::K8sMetadata;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};

// ═══════════════════════════════════════════════════════════════════════════
// PersistentVolumeClaim
// ═══════════════════════════════════════════════════════════════════════════

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

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PVCSpec {
    #[serde(rename = "accessModes")]
    pub access_modes: Vec<String>,
    #[serde(default)]
    pub resources: Option<PVCResources>,
    #[serde(default, rename = "storageClassName")]
    pub storage_class_name: Option<String>,
    #[serde(default, rename = "volumeMode")]
    pub volume_mode: Option<String>,
    #[serde(default, rename = "volumeName")]
    pub volume_name: Option<String>,
    #[serde(default)]
    pub selector: Option<serde_json::Value>,
    #[serde(default, rename = "dataSource")]
    pub data_source: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct K8sPVC {
    #[serde(rename = "apiVersion")]
    pub api_version: String,
    pub kind: String,
    pub metadata: K8sMetadata,
    pub spec: PVCSpec,
}

const VALID_ACCESS_MODES: &[&str] = &[
    "ReadWriteOnce", "ReadOnlyMany", "ReadWriteMany", "ReadWriteOncePod",
];

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

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.spec.access_modes.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "accessModes is required and cannot be empty".into(),
                path: Some("spec > accessModes".into()),
            });
        }
        for mode in &self.spec.access_modes {
            if !VALID_ACCESS_MODES.contains(&mode.as_str()) {
                diags.push(Diagnostic {
                    severity: Severity::Error,
                    message: format!("Invalid accessMode '{}' — expected one of: {}", mode, VALID_ACCESS_MODES.join(", ")),
                    path: Some("spec > accessModes".into()),
                });
            }
        }
        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.spec.resources.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Warning,
                message: "No resources.requests.storage — PVC size is undefined".into(),
                path: Some("spec > resources".into()),
            });
        } else if let Some(res) = &self.spec.resources
            && !res.requests.contains_key("storage") {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: "No resources.requests.storage — PVC size is undefined".into(),
                    path: Some("spec > resources > requests".into()),
                });
            }
        if self.spec.storage_class_name.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "No storageClassName — cluster default StorageClass will be used".into(),
                path: Some("spec > storageClassName".into()),
            });
        }
        diags
    }
}