1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
8#[allow(non_snake_case)]
9pub struct HelmValues {
10 #[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 #[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 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 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![] }
125
126 fn validate_semantics(&self) -> Vec<Diagnostic> {
127 let mut diags = Vec::new();
128
129 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 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 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 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 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 self.detect_placeholders(&mut diags);
211
212 diags
213 }
214}
215
216impl HelmValues {
217 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 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
255trait 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}