Skip to main content

devops_models/models/
helm.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5/// Helm values.yaml configuration.
6/// Since values files are free-form, we validate common patterns and placeholders.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8#[allow(non_snake_case)]
9pub struct HelmValues {
10    // Common Helm patterns (all optional)
11    #[serde(default)]
12    pub replicaCount: Option<u32>,
13    #[serde(default)]
14    pub image: Option<HelmImage>,
15    #[serde(default)]
16    pub service: Option<HelmService>,
17    #[serde(default)]
18    pub ingress: Option<HelmIngress>,
19    #[serde(default)]
20    pub resources: Option<HelmResources>,
21    #[serde(default)]
22    pub autoscaling: Option<HelmAutoscaling>,
23    #[serde(default)]
24    pub nodeSelector: Option<serde_json::Value>,
25    #[serde(default)]
26    pub tolerations: Option<Vec<serde_json::Value>>,
27    #[serde(default)]
28    pub affinity: Option<serde_json::Value>,
29    // Catch-all for other values
30    #[serde(flatten)]
31    pub other: HashMap<String, serde_json::Value>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct HelmImage {
36    #[serde(default)]
37    pub repository: Option<String>,
38    #[serde(default)]
39    pub tag: Option<String>,
40    #[serde(default, rename = "pullPolicy")]
41    pub pull_policy: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct HelmService {
46    #[serde(default)]
47    #[serde(rename = "type")]
48    pub service_type: Option<String>,
49    #[serde(default)]
50    pub port: Option<u16>,
51    #[serde(default, rename = "targetPort")]
52    pub target_port: Option<u16>,
53    #[serde(default)]
54    pub annotations: Option<HashMap<String, String>>,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct HelmIngress {
59    #[serde(default)]
60    pub enabled: Option<bool>,
61    #[serde(default, rename = "className")]
62    pub class_name: Option<String>,
63    #[serde(default)]
64    pub annotations: Option<HashMap<String, String>>,
65    #[serde(default)]
66    pub hosts: Option<Vec<serde_json::Value>>,
67    #[serde(default)]
68    pub tls: Option<Vec<serde_json::Value>>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct HelmResources {
73    #[serde(default)]
74    pub limits: Option<HashMap<String, String>>,
75    #[serde(default)]
76    pub requests: Option<HashMap<String, String>>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct HelmAutoscaling {
81    #[serde(default)]
82    pub enabled: Option<bool>,
83    #[serde(default, rename = "minReplicas")]
84    pub min_replicas: Option<u32>,
85    #[serde(default, rename = "maxReplicas")]
86    pub max_replicas: Option<u32>,
87    #[serde(default, rename = "targetCPUUtilizationPercentage")]
88    pub target_cpu: Option<u32>,
89}
90
91impl HelmValues {
92    pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
93        serde_json::from_value(data.clone())
94            .map_err(|e| format!("Failed to parse Helm values: {e}"))
95    }
96
97    /// Heuristic detection: looks like a Helm values file if it has common patterns
98    pub fn looks_like_helm(data: &serde_json::Value) -> bool {
99        let obj = match data.as_object() {
100            Some(o) => o,
101            None => return false,
102        };
103
104        // Common Helm value keys
105        let helm_keys = [
106            "replicaCount", "image", "imagePullSecrets", "service",
107            "ingress", "resources", "autoscaling", "nodeSelector",
108            "tolerations", "affinity", "podAnnotations", "podSecurityContext",
109            "securityContext", "serviceAccount", "fullnameOverride", "nameOverride",
110        ];
111
112        let matches = helm_keys.iter().filter(|k| obj.contains_key(*k as &str)).count();
113        matches >= 2
114    }
115}
116
117impl ConfigValidator for HelmValues {
118    fn yaml_type(&self) -> YamlType {
119        YamlType::HelmValues
120    }
121
122    fn validate_structure(&self) -> Vec<Diagnostic> {
123        vec![] // Helm values are free-form, no structural requirements
124    }
125
126    fn validate_semantics(&self) -> Vec<Diagnostic> {
127        let mut diags = Vec::new();
128
129        // Image tag check
130        if let Some(img) = &self.image {
131            if let Some(tag) = &img.tag {
132                if tag == "latest" || tag.is_empty() {
133                    diags.push(Diagnostic {
134                        severity: Severity::Warning,
135                        message: "image.tag is 'latest' or empty — pin a specific version for reproducibility".into(),
136                        path: Some("image > tag".into()),
137                    });
138                }
139            } else {
140                diags.push(Diagnostic {
141                    severity: Severity::Info,
142                    message: "image.tag not specified — chart may default to 'latest'".into(),
143                    path: Some("image > tag".into()),
144                });
145            }
146        }
147
148        // replicaCount check
149        if let Some(replicas) = self.replicaCount {
150            if replicas == 0 {
151                diags.push(Diagnostic {
152                    severity: Severity::Warning,
153                    message: "replicaCount=0 — no pods will be created".into(),
154                    path: Some("replicaCount".into()),
155                });
156            } else if replicas == 1 && self.autoscaling.as_ref().and_then(|a| a.enabled).unwrap_or(false) {
157                diags.push(Diagnostic {
158                    severity: Severity::Info,
159                    message: "replicaCount=1 with autoscaling.enabled — HPA will handle scaling".into(),
160                    path: Some("replicaCount".into()),
161                });
162            }
163        }
164
165        // Autoscaling consistency
166        if let Some(hpa) = &self.autoscaling
167            && hpa.enabled.unwrap_or(false)
168            && let (Some(min), Some(max)) = (hpa.min_replicas, hpa.max_replicas)
169            && min > max
170        {
171            diags.push(Diagnostic {
172                severity: Severity::Error,
173                message: format!("autoscaling.minReplicas ({}) > maxReplicas ({})", min, max),
174                path: Some("autoscaling".into()),
175            });
176        }
177
178        // Resources check
179        if let Some(res) = &self.resources
180            && res.requests.is_none() && res.limits.is_some()
181        {
182            diags.push(Diagnostic {
183                severity: Severity::Info,
184                message: "resources.limits set but no requests — consider setting both".into(),
185                path: Some("resources".into()),
186            });
187        }
188
189        // Ingress enabled but no hosts
190        if let Some(ing) = &self.ingress
191            && ing.enabled.unwrap_or(false)
192        {
193            if ing.hosts.is_none() || ing.hosts.as_ref().map(|h| h.is_empty()).unwrap_or(true) {
194                diags.push(Diagnostic {
195                    severity: Severity::Warning,
196                    message: "ingress.enabled=true but no hosts defined".into(),
197                    path: Some("ingress > hosts".into()),
198                });
199            }
200            if ing.tls.is_none() {
201                diags.push(Diagnostic {
202                    severity: Severity::Info,
203                    message: "ingress enabled without TLS — traffic will be unencrypted".into(),
204                    path: Some("ingress > tls".into()),
205                });
206            }
207        }
208
209        // Placeholder detection in all string values
210        self.detect_placeholders(&mut diags);
211
212        diags
213    }
214}
215
216impl HelmValues {
217    /// Scan all string values for common placeholders
218    fn detect_placeholders(&self, diags: &mut Vec<Diagnostic>) {
219        let placeholders = ["CHANGEME", "TODO", "FIXME", "REPLACE_ME", "YOUR_", "XXX"];
220
221        fn check_value(key: &str, val: &serde_json::Value, diags: &mut Vec<Diagnostic>, placeholders: &[&str]) {
222            if let Some(s) = val.as_string() {
223                for placeholder in placeholders {
224                    if s.contains(placeholder) {
225                        diags.push(Diagnostic {
226                            severity: Severity::Warning,
227                            message: format!("Placeholder value '{}' found at '{}'", placeholder, key),
228                            path: Some(key.into()),
229                        });
230                        break;
231                    }
232                }
233            } else if let Some(obj) = val.as_object() {
234                for (k, v) in obj {
235                    check_value(&format!("{} > {}", key, k), v, diags, placeholders);
236                }
237            } else if let Some(arr) = val.as_array() {
238                for (i, v) in arr.iter().enumerate() {
239                    check_value(&format!("{} > [{}]", key, i), v, diags, placeholders);
240                }
241            }
242        }
243
244        // Serialize self to JSON to traverse all values
245        if let Ok(json) = serde_json::to_value(self)
246            && let Some(obj) = json.as_object()
247        {
248            for (k, v) in obj {
249                check_value(k, v, diags, &placeholders);
250            }
251        }
252    }
253}
254
255// Helper trait for serde_json::Value
256trait ValueAsString {
257    fn as_string(&self) -> Option<&str>;
258}
259
260impl ValueAsString for serde_json::Value {
261    fn as_string(&self) -> Option<&str> {
262        self.as_str()
263    }
264}