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

// ═══════════════════════════════════════════════════════════════════════════
// Ingress
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressTLS {
    #[serde(default)]
    pub hosts: Vec<String>,
    #[serde(default, rename = "secretName")]
    pub secret_name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressBackend {
    #[serde(default)]
    pub service: Option<IngressServiceBackend>,
    #[serde(default)]
    pub resource: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressServiceBackend {
    pub name: String,
    pub port: IngressServicePort,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressServicePort {
    #[serde(default)]
    pub number: Option<u16>,
    #[serde(default)]
    pub name: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressPath {
    pub path: String,
    #[serde(default, rename = "pathType")]
    pub path_type: Option<String>,
    pub backend: IngressBackend,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressHTTP {
    pub paths: Vec<IngressPath>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressRule {
    #[serde(default)]
    pub host: Option<String>,
    #[serde(default)]
    pub http: Option<IngressHTTP>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IngressSpec {
    #[serde(default, rename = "ingressClassName")]
    pub ingress_class_name: Option<String>,
    #[serde(default)]
    pub tls: Vec<IngressTLS>,
    #[serde(default)]
    pub rules: Vec<IngressRule>,
    #[serde(default, rename = "defaultBackend")]
    pub default_backend: Option<IngressBackend>,
}

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

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

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.spec.rules.is_empty() && self.spec.default_backend.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "Ingress has no rules and no defaultBackend — no traffic will be routed".into(),
                path: Some("spec".into()),
            });
        }
        for (i, rule) in self.spec.rules.iter().enumerate() {
            if let Some(http) = &rule.http {
                for (j, p) in http.paths.iter().enumerate() {
                    if p.backend.service.is_none() && p.backend.resource.is_none() {
                        diags.push(Diagnostic {
                            severity: Severity::Error,
                            message: format!("Rule[{}].path[{}] '{}' has no backend", i, j, p.path),
                            path: Some(format!("spec > rules > {} > http > paths > {}", i, j)),
                        });
                    }
                }
            }
        }
        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        // TLS warnings
        let hosts_with_tls: Vec<&str> = self.spec.tls.iter()
            .flat_map(|t| t.hosts.iter().map(|h| h.as_str()))
            .collect();
        for rule in &self.spec.rules {
            if let Some(host) = &rule.host
                && !hosts_with_tls.contains(&host.as_str()) && !self.spec.tls.is_empty() {
                    diags.push(Diagnostic {
                        severity: Severity::Warning,
                        message: format!("Host '{}' has no TLS configuration", host),
                        path: Some("spec > tls".into()),
                    });
                }
        }
        if self.spec.tls.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "No TLS configured — traffic will be unencrypted".into(),
                path: Some("spec > tls".into()),
            });
        }
        if self.spec.ingress_class_name.is_none() {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "No ingressClassName specified — cluster default will be used".into(),
                path: Some("spec > ingressClassName".into()),
            });
        }
        // pathType check
        for (i, rule) in self.spec.rules.iter().enumerate() {
            if let Some(http) = &rule.http {
                for (j, p) in http.paths.iter().enumerate() {
                    if p.path_type.is_none() {
                        diags.push(Diagnostic {
                            severity: Severity::Warning,
                            message: format!("Rule[{}].path[{}] has no pathType — ImplementationSpecific will be used", i, j),
                            path: Some(format!("spec > rules > {} > http > paths > {} > pathType", i, j)),
                        });
                    }
                }
            }
        }
        diags
    }
}

// ═══════════════════════════════════════════════════════════════════════════
// NetworkPolicy
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkPolicyPort {
    #[serde(default)]
    pub port: Option<serde_json::Value>,
    #[serde(default)]
    pub protocol: Option<String>,
    #[serde(default, rename = "endPort")]
    pub end_port: Option<u16>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkPolicyPeer {
    #[serde(default, rename = "podSelector")]
    pub pod_selector: Option<serde_json::Value>,
    #[serde(default, rename = "namespaceSelector")]
    pub namespace_selector: Option<serde_json::Value>,
    #[serde(default, rename = "ipBlock")]
    pub ip_block: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkPolicyIngressRule {
    #[serde(default)]
    pub from: Vec<NetworkPolicyPeer>,
    #[serde(default)]
    pub ports: Vec<NetworkPolicyPort>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkPolicyEgressRule {
    #[serde(default)]
    pub to: Vec<NetworkPolicyPeer>,
    #[serde(default)]
    pub ports: Vec<NetworkPolicyPort>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkPolicySpec {
    #[serde(rename = "podSelector")]
    pub pod_selector: serde_json::Value,
    #[serde(default, rename = "policyTypes")]
    pub policy_types: Vec<String>,
    #[serde(default)]
    pub ingress: Vec<NetworkPolicyIngressRule>,
    #[serde(default)]
    pub egress: Vec<NetworkPolicyEgressRule>,
}

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

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

    fn validate_structure(&self) -> Vec<Diagnostic> {
        vec![]
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        let has_ingress_type = self.spec.policy_types.iter().any(|t| t == "Ingress");
        let has_egress_type = self.spec.policy_types.iter().any(|t| t == "Egress");

        if has_ingress_type && self.spec.ingress.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Warning,
                message: "policyTypes includes 'Ingress' but no ingress rules — all ingress will be denied".into(),
                path: Some("spec > ingress".into()),
            });
        }
        if has_egress_type && self.spec.egress.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Warning,
                message: "policyTypes includes 'Egress' but no egress rules — all egress will be denied (including DNS!)".into(),
                path: Some("spec > egress".into()),
            });
        }
        if self.spec.policy_types.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "No policyTypes specified — only Ingress will be enforced if ingress rules exist".into(),
                path: Some("spec > policyTypes".into()),
            });
        }
        diags
    }
}