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

// ═══════════════════════════════════════════════════════════════════════════
// Role / ClusterRole
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PolicyRule {
    #[serde(default, rename = "apiGroups")]
    pub api_groups: Vec<String>,
    #[serde(default)]
    pub resources: Vec<String>,
    pub verbs: Vec<String>,
    #[serde(default, rename = "resourceNames")]
    pub resource_names: Vec<String>,
    #[serde(default, rename = "nonResourceURLs")]
    pub non_resource_urls: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct K8sRole {
    #[serde(rename = "apiVersion")]
    pub api_version: String,
    pub kind: String,
    pub metadata: K8sMetadata,
    pub rules: Vec<PolicyRule>,
}

impl ConfigValidator for K8sRole {
    fn yaml_type(&self) -> YamlType {
        if self.kind == "ClusterRole" { YamlType::K8sClusterRole } else { YamlType::K8sRole }
    }

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        for (i, rule) in self.rules.iter().enumerate() {
            if rule.verbs.is_empty() {
                diags.push(Diagnostic {
                    severity: Severity::Error,
                    message: format!("Rule[{}]: verbs cannot be empty", i),
                    path: Some(format!("rules > {}", i)),
                });
            }
        }
        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        for (i, rule) in self.rules.iter().enumerate() {
            if rule.resources.contains(&"*".to_string()) || rule.verbs.contains(&"*".to_string()) {
                diags.push(Diagnostic {
                    severity: Severity::Warning,
                    message: format!("Rule[{}]: wildcard '*' grants broad access — follow least-privilege principle", i),
                    path: Some(format!("rules > {}", i)),
                });
            }
            let dangerous_verbs = ["delete", "deletecollection", "escalate", "impersonate"];
            for verb in &rule.verbs {
                if dangerous_verbs.contains(&verb.as_str()) {
                    diags.push(Diagnostic {
                        severity: Severity::Warning,
                        message: format!("Rule[{}]: verb '{}' is potentially dangerous", i, verb),
                        path: Some(format!("rules > {} > verbs", i)),
                    });
                }
            }
        }
        diags
    }
}

// ═══════════════════════════════════════════════════════════════════════════
// RoleBinding / ClusterRoleBinding
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RoleRef {
    #[serde(rename = "apiGroup")]
    pub api_group: String,
    pub kind: String,
    pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Subject {
    pub kind: String,
    pub name: String,
    #[serde(default)]
    pub namespace: Option<String>,
    #[serde(default, rename = "apiGroup")]
    pub api_group: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct K8sRoleBinding {
    #[serde(rename = "apiVersion")]
    pub api_version: String,
    pub kind: String,
    pub metadata: K8sMetadata,
    #[serde(rename = "roleRef")]
    pub role_ref: RoleRef,
    #[serde(default)]
    pub subjects: Vec<Subject>,
}

impl ConfigValidator for K8sRoleBinding {
    fn yaml_type(&self) -> YamlType {
        if self.kind == "ClusterRoleBinding" { YamlType::K8sClusterRoleBinding } else { YamlType::K8sRoleBinding }
    }

    fn validate_structure(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.subjects.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "No subjects defined — binding won't grant access to anyone".into(),
                path: Some("subjects".into()),
            });
        }
        if self.role_ref.name.is_empty() {
            diags.push(Diagnostic {
                severity: Severity::Error,
                message: "roleRef.name cannot be empty".into(),
                path: Some("roleRef > name".into()),
            });
        }
        diags
    }

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        // Warn about ClusterRoleBinding with cluster-admin
        if self.kind == "ClusterRoleBinding" && self.role_ref.name == "cluster-admin" {
            diags.push(Diagnostic {
                severity: Severity::Warning,
                message: "Binding to cluster-admin grants full cluster access — use with caution".into(),
                path: Some("roleRef > name".into()),
            });
        }
        for (i, subj) in self.subjects.iter().enumerate() {
            if subj.kind == "ServiceAccount" && subj.namespace.is_none() && self.kind == "ClusterRoleBinding" {
                diags.push(Diagnostic {
                    severity: Severity::Info,
                    message: format!("Subject[{}]: ServiceAccount '{}' has no namespace — defaults to 'default'", i, subj.name),
                    path: Some(format!("subjects > {}", i)),
                });
            }
        }
        diags
    }
}

// ═══════════════════════════════════════════════════════════════════════════
// ServiceAccount
// ═══════════════════════════════════════════════════════════════════════════

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceAccountSecret {
    pub name: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct K8sServiceAccount {
    #[serde(rename = "apiVersion")]
    pub api_version: String,
    pub kind: String,
    pub metadata: K8sMetadata,
    #[serde(default)]
    pub secrets: Vec<ServiceAccountSecret>,
    #[serde(default, rename = "imagePullSecrets")]
    pub image_pull_secrets: Vec<ServiceAccountSecret>,
    #[serde(default, rename = "automountServiceAccountToken")]
    pub automount_service_account_token: Option<bool>,
}

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

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

    fn validate_semantics(&self) -> Vec<Diagnostic> {
        let mut diags = Vec::new();
        if self.automount_service_account_token != Some(false) {
            diags.push(Diagnostic {
                severity: Severity::Info,
                message: "automountServiceAccountToken is not explicitly false — token will be mounted into pods".into(),
                path: Some("automountServiceAccountToken".into()),
            });
        }
        diags
    }
}