use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::models::validation::{ConfigValidator, Diagnostic, Severity, YamlType};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[allow(non_snake_case)]
pub struct HelmValues {
#[serde(default)]
pub replicaCount: Option<u32>,
#[serde(default)]
pub image: Option<HelmImage>,
#[serde(default)]
pub service: Option<HelmService>,
#[serde(default)]
pub ingress: Option<HelmIngress>,
#[serde(default)]
pub resources: Option<HelmResources>,
#[serde(default)]
pub autoscaling: Option<HelmAutoscaling>,
#[serde(default)]
pub nodeSelector: Option<serde_json::Value>,
#[serde(default)]
pub tolerations: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub affinity: Option<serde_json::Value>,
#[serde(flatten)]
pub other: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmImage {
#[serde(default)]
pub repository: Option<String>,
#[serde(default)]
pub tag: Option<String>,
#[serde(default, rename = "pullPolicy")]
pub pull_policy: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmService {
#[serde(default)]
#[serde(rename = "type")]
pub service_type: Option<String>,
#[serde(default)]
pub port: Option<u16>,
#[serde(default, rename = "targetPort")]
pub target_port: Option<u16>,
#[serde(default)]
pub annotations: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmIngress {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default, rename = "className")]
pub class_name: Option<String>,
#[serde(default)]
pub annotations: Option<HashMap<String, String>>,
#[serde(default)]
pub hosts: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub tls: Option<Vec<serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmResources {
#[serde(default)]
pub limits: Option<HashMap<String, String>>,
#[serde(default)]
pub requests: Option<HashMap<String, String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelmAutoscaling {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default, rename = "minReplicas")]
pub min_replicas: Option<u32>,
#[serde(default, rename = "maxReplicas")]
pub max_replicas: Option<u32>,
#[serde(default, rename = "targetCPUUtilizationPercentage")]
pub target_cpu: Option<u32>,
}
impl HelmValues {
pub fn from_value(data: &serde_json::Value) -> Result<Self, String> {
serde_json::from_value(data.clone())
.map_err(|e| format!("Failed to parse Helm values: {e}"))
}
pub fn looks_like_helm(data: &serde_json::Value) -> bool {
let obj = match data.as_object() {
Some(o) => o,
None => return false,
};
let helm_keys = [
"replicaCount", "image", "imagePullSecrets", "service",
"ingress", "resources", "autoscaling", "nodeSelector",
"tolerations", "affinity", "podAnnotations", "podSecurityContext",
"securityContext", "serviceAccount", "fullnameOverride", "nameOverride",
];
let matches = helm_keys.iter().filter(|k| obj.contains_key(*k as &str)).count();
matches >= 2
}
}
impl ConfigValidator for HelmValues {
fn yaml_type(&self) -> YamlType {
YamlType::HelmValues
}
fn validate_structure(&self) -> Vec<Diagnostic> {
vec![] }
fn validate_semantics(&self) -> Vec<Diagnostic> {
let mut diags = Vec::new();
if let Some(img) = &self.image {
if let Some(tag) = &img.tag {
if tag == "latest" || tag.is_empty() {
diags.push(Diagnostic {
severity: Severity::Warning,
message: "image.tag is 'latest' or empty — pin a specific version for reproducibility".into(),
path: Some("image > tag".into()),
});
}
} else {
diags.push(Diagnostic {
severity: Severity::Info,
message: "image.tag not specified — chart may default to 'latest'".into(),
path: Some("image > tag".into()),
});
}
}
if let Some(replicas) = self.replicaCount {
if replicas == 0 {
diags.push(Diagnostic {
severity: Severity::Warning,
message: "replicaCount=0 — no pods will be created".into(),
path: Some("replicaCount".into()),
});
} else if replicas == 1 && self.autoscaling.as_ref().and_then(|a| a.enabled).unwrap_or(false) {
diags.push(Diagnostic {
severity: Severity::Info,
message: "replicaCount=1 with autoscaling.enabled — HPA will handle scaling".into(),
path: Some("replicaCount".into()),
});
}
}
if let Some(hpa) = &self.autoscaling
&& hpa.enabled.unwrap_or(false)
&& let (Some(min), Some(max)) = (hpa.min_replicas, hpa.max_replicas)
&& min > max
{
diags.push(Diagnostic {
severity: Severity::Error,
message: format!("autoscaling.minReplicas ({}) > maxReplicas ({})", min, max),
path: Some("autoscaling".into()),
});
}
if let Some(res) = &self.resources
&& res.requests.is_none() && res.limits.is_some()
{
diags.push(Diagnostic {
severity: Severity::Info,
message: "resources.limits set but no requests — consider setting both".into(),
path: Some("resources".into()),
});
}
if let Some(ing) = &self.ingress
&& ing.enabled.unwrap_or(false)
{
if ing.hosts.is_none() || ing.hosts.as_ref().map(|h| h.is_empty()).unwrap_or(true) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: "ingress.enabled=true but no hosts defined".into(),
path: Some("ingress > hosts".into()),
});
}
if ing.tls.is_none() {
diags.push(Diagnostic {
severity: Severity::Info,
message: "ingress enabled without TLS — traffic will be unencrypted".into(),
path: Some("ingress > tls".into()),
});
}
}
self.detect_placeholders(&mut diags);
diags
}
}
impl HelmValues {
fn detect_placeholders(&self, diags: &mut Vec<Diagnostic>) {
let placeholders = ["CHANGEME", "TODO", "FIXME", "REPLACE_ME", "YOUR_", "XXX"];
fn check_value(key: &str, val: &serde_json::Value, diags: &mut Vec<Diagnostic>, placeholders: &[&str]) {
if let Some(s) = val.as_string() {
for placeholder in placeholders {
if s.contains(placeholder) {
diags.push(Diagnostic {
severity: Severity::Warning,
message: format!("Placeholder value '{}' found at '{}'", placeholder, key),
path: Some(key.into()),
});
break;
}
}
} else if let Some(obj) = val.as_object() {
for (k, v) in obj {
check_value(&format!("{} > {}", key, k), v, diags, placeholders);
}
} else if let Some(arr) = val.as_array() {
for (i, v) in arr.iter().enumerate() {
check_value(&format!("{} > [{}]", key, i), v, diags, placeholders);
}
}
}
if let Ok(json) = serde_json::to_value(self)
&& let Some(obj) = json.as_object()
{
for (k, v) in obj {
check_value(k, v, diags, &placeholders);
}
}
}
}
trait ValueAsString {
fn as_string(&self) -> Option<&str>;
}
impl ValueAsString for serde_json::Value {
fn as_string(&self) -> Option<&str> {
self.as_str()
}
}