syncable_cli/agent/tools/
helmlint.rs

1//! Helmlint tool - Native Helm chart linting using Rig's Tool trait
2//!
3//! Lints Helm **chart structure and templates** (before rendering).
4//! Validates Chart.yaml, values.yaml, Go template syntax, and Helm-specific best practices.
5//!
6//! **Use this for:** Helm chart development, template syntax issues, chart metadata validation.
7//! **Use KubelintTool for:** Security/best practice issues in rendered K8s manifests.
8//!
9//! Output is optimized for AI agent decision-making with:
10//! - Categorized issues (structure, values, template, security, best-practice)
11//! - Priority rankings (critical, high, medium, low)
12//! - Actionable fix recommendations
13//! - Rule documentation
14
15use rig::completion::ToolDefinition;
16use rig::tool::Tool;
17use serde::{Deserialize, Serialize};
18use serde_json::json;
19use std::path::PathBuf;
20
21use crate::analyzer::helmlint::types::RuleCategory;
22use crate::analyzer::helmlint::{HelmlintConfig, LintResult, Severity, lint_chart};
23
24/// Arguments for the helmlint tool
25#[derive(Debug, Deserialize)]
26pub struct HelmlintArgs {
27    /// Path to Helm chart directory (relative to project root)
28    #[serde(default)]
29    pub chart: Option<String>,
30
31    /// Rules to ignore (e.g., ["HL1007", "HL5001"])
32    #[serde(default)]
33    pub ignore: Vec<String>,
34
35    /// Minimum severity threshold: "error", "warning", "info", "style"
36    #[serde(default)]
37    pub threshold: Option<String>,
38}
39
40/// Error type for helmlint tool
41#[derive(Debug, thiserror::Error)]
42#[error("Helmlint error: {0}")]
43pub struct HelmlintError(String);
44
45/// Tool to lint Helm charts natively
46///
47/// **When to use:**
48/// - Validating Helm chart structure (Chart.yaml, values.yaml)
49/// - Checking Go template syntax issues (unclosed blocks, undefined variables)
50/// - Helm-specific best practices
51///
52/// **When to use KubelintTool instead:**
53/// - Checking security issues in the rendered K8s manifests
54/// - Validating K8s resource configurations (probes, resource limits, RBAC)
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct HelmlintTool {
57    project_path: PathBuf,
58}
59
60impl HelmlintTool {
61    pub fn new(project_path: PathBuf) -> Self {
62        Self { project_path }
63    }
64
65    fn parse_threshold(threshold: &str) -> Severity {
66        match threshold.to_lowercase().as_str() {
67            "error" => Severity::Error,
68            "warning" => Severity::Warning,
69            "info" => Severity::Info,
70            "style" => Severity::Style,
71            _ => Severity::Warning,
72        }
73    }
74
75    /// Get priority based on severity and category
76    fn get_priority(severity: Severity, category: RuleCategory) -> &'static str {
77        match (severity, category) {
78            (Severity::Error, RuleCategory::Security) => "critical",
79            (Severity::Error, _) => "high",
80            (Severity::Warning, RuleCategory::Security) => "high",
81            (Severity::Warning, RuleCategory::Template) => "high",
82            (Severity::Warning, RuleCategory::Structure) => "medium",
83            (Severity::Warning, _) => "medium",
84            (Severity::Info, _) => "low",
85            (Severity::Style, _) => "low",
86            (Severity::Ignore, _) => "info",
87        }
88    }
89
90    /// Get fix recommendation for common rules
91    fn get_fix_recommendation(code: &str) -> &'static str {
92        match code {
93            // Structure rules (HL1xxx)
94            "HL1001" => "Create a Chart.yaml file in the chart root directory.",
95            "HL1002" => "Add 'apiVersion: v2' (for Helm 3) or 'apiVersion: v1' to Chart.yaml.",
96            "HL1003" => "Add a 'name' field to Chart.yaml matching the chart directory name.",
97            "HL1004" => {
98                "Add a 'version' field with semantic versioning (e.g., '1.0.0') to Chart.yaml."
99            }
100            "HL1005" => "Use semantic versioning format (MAJOR.MINOR.PATCH) for the version field.",
101            "HL1006" => "Add a 'description' field explaining what the chart does.",
102            "HL1007" => "Add a 'maintainers' list with name and email for chart ownership.",
103            "HL1008" => "Ensure all dependencies listed in Chart.yaml are available and versioned.",
104
105            // Values rules (HL2xxx)
106            "HL2001" => "Create a values.yaml file with default configuration values.",
107            "HL2002" => "Define this value in values.yaml or provide a default in the template.",
108            "HL2003" => "Remove unused values from values.yaml or use them in templates.",
109            "HL2004" => "Use consistent naming (camelCase or snake_case) for all values.",
110            "HL2005" => "Add comments documenting the purpose and valid options for values.",
111
112            // Template rules (HL3xxx)
113            "HL3001" => "Close the unclosed template block ({{- end }}).",
114            "HL3002" => "Define this template with {{ define \"name\" }} or check for typos.",
115            "HL3003" => "Use {{ .Values.key }} or {{ .Release.Name }} for valid references.",
116            "HL3004" => "Check nesting of if/range/with blocks - each needs matching {{ end }}.",
117            "HL3005" => "Ensure the pipeline uses valid functions and proper syntax.",
118            "HL3006" => "Add whitespace control with {{- and -}} to avoid extra blank lines.",
119
120            // Security rules (HL4xxx)
121            "HL4001" => "Add 'securityContext.runAsNonRoot: true' to container specs.",
122            "HL4002" => "Remove 'privileged: true' or add explicit justification annotation.",
123            "HL4003" => "Add resource limits (cpu, memory) to prevent resource exhaustion.",
124            "HL4004" => "Use 'readOnlyRootFilesystem: true' in securityContext.",
125            "HL4005" => "Drop all capabilities and add only required ones explicitly.",
126
127            // Best practice rules (HL5xxx)
128            "HL5001" => "Add resource requests and limits for all containers.",
129            "HL5002" => "Add liveness and readiness probes for health checking.",
130            "HL5003" => "Use '{{ .Release.Namespace }}' for namespace-aware resources.",
131            "HL5004" => "Include NOTES.txt with post-install instructions.",
132            "HL5005" => "Add labels including 'app.kubernetes.io/name' and 'helm.sh/chart'.",
133            "HL5006" => "Use '{{ include \"chart.fullname\" . }}' for consistent naming.",
134            "HL5007" => "Add selector labels to connect Services with Deployments.",
135
136            _ => "Review the Helm chart best practices documentation.",
137        }
138    }
139
140    /// Format result optimized for agent decision-making
141    fn format_result(result: &LintResult) -> String {
142        // Categorize and enrich failures
143        let enriched_failures: Vec<serde_json::Value> = result
144            .failures
145            .iter()
146            .map(|f| {
147                let code = f.code.as_str();
148                let priority = Self::get_priority(f.severity, f.category);
149
150                json!({
151                    "code": code,
152                    "severity": f.severity.as_str(),
153                    "priority": priority,
154                    "category": f.category.display_name(),
155                    "message": f.message,
156                    "file": f.file.display().to_string(),
157                    "line": f.line,
158                    "column": f.column,
159                    "fixable": f.fixable,
160                    "fix": Self::get_fix_recommendation(code),
161                })
162            })
163            .collect();
164
165        // Group by priority
166        let critical: Vec<_> = enriched_failures
167            .iter()
168            .filter(|f| f["priority"] == "critical")
169            .cloned()
170            .collect();
171        let high: Vec<_> = enriched_failures
172            .iter()
173            .filter(|f| f["priority"] == "high")
174            .cloned()
175            .collect();
176        let medium: Vec<_> = enriched_failures
177            .iter()
178            .filter(|f| f["priority"] == "medium")
179            .cloned()
180            .collect();
181        let low: Vec<_> = enriched_failures
182            .iter()
183            .filter(|f| f["priority"] == "low")
184            .cloned()
185            .collect();
186
187        // Group by category
188        let mut by_category: std::collections::HashMap<&str, usize> =
189            std::collections::HashMap::new();
190        for f in &result.failures {
191            *by_category.entry(f.category.display_name()).or_default() += 1;
192        }
193
194        // Build decision context
195        let decision_context = if critical.is_empty() && high.is_empty() {
196            if medium.is_empty() && low.is_empty() {
197                "Helm chart follows best practices. No issues found."
198            } else if medium.is_empty() {
199                "Minor improvements possible. Low priority issues only."
200            } else {
201                "Good baseline. Medium priority improvements recommended."
202            }
203        } else if !critical.is_empty() {
204            "Critical issues found. Fix template/security issues before deployment."
205        } else {
206            "High priority issues found. Fix template syntax or structure issues."
207        };
208
209        // Build agent-optimized output
210        let mut output = json!({
211            "chart": result.chart_path,
212            "success": !result.has_errors(),
213            "decision_context": decision_context,
214            "tool_guidance": "Use helmlint for chart structure/template issues. Use kubelint for K8s resource security/best practices.",
215            "summary": {
216                "total": result.failures.len(),
217                "files_checked": result.files_checked,
218                "by_priority": {
219                    "critical": critical.len(),
220                    "high": high.len(),
221                    "medium": medium.len(),
222                    "low": low.len(),
223                },
224                "by_severity": {
225                    "errors": result.error_count,
226                    "warnings": result.warning_count,
227                },
228                "by_category": by_category,
229            },
230            "action_plan": {
231                "critical": critical,
232                "high": high,
233                "medium": medium,
234                "low": low,
235            },
236        });
237
238        // Add quick fixes summary
239        if !enriched_failures.is_empty() {
240            let quick_fixes: Vec<String> = enriched_failures
241                .iter()
242                .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
243                .take(5)
244                .map(|f| {
245                    format!(
246                        "{} line {}: {} - {}",
247                        f["file"].as_str().unwrap_or(""),
248                        f["line"],
249                        f["code"].as_str().unwrap_or(""),
250                        f["fix"].as_str().unwrap_or("")
251                    )
252                })
253                .collect();
254
255            if !quick_fixes.is_empty() {
256                output["quick_fixes"] = json!(quick_fixes);
257            }
258        }
259
260        if !result.parse_errors.is_empty() {
261            output["parse_errors"] = json!(result.parse_errors);
262        }
263
264        serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
265    }
266}
267
268impl Tool for HelmlintTool {
269    const NAME: &'static str = "helmlint";
270
271    type Error = HelmlintError;
272    type Args = HelmlintArgs;
273    type Output = String;
274
275    async fn definition(&self, _prompt: String) -> ToolDefinition {
276        ToolDefinition {
277            name: Self::NAME.to_string(),
278            description: "Lint Helm chart STRUCTURE and TEMPLATES (before rendering). \
279                Validates Chart.yaml, values.yaml, Go template syntax, and Helm-specific best practices. \
280                \n\n**Use helmlint for:** Chart metadata, template syntax errors, undefined values, unclosed blocks. \
281                \n**Use kubelint for:** Security/best practices in rendered K8s manifests (probes, resources, RBAC). \
282                \n\nReturns AI-optimized JSON with issues categorized by priority and type. \
283                Each issue includes an actionable fix recommendation."
284                .to_string(),
285            parameters: json!({
286                "type": "object",
287                "properties": {
288                    "chart": {
289                        "type": "string",
290                        "description": "Path to Helm chart directory relative to project root (e.g., 'charts/my-app', 'helm/production'). Must contain Chart.yaml."
291                    },
292                    "ignore": {
293                        "type": "array",
294                        "items": { "type": "string" },
295                        "description": "List of rule codes to ignore (e.g., ['HL1007', 'HL5001']). See rule categories: HL1xxx=Structure, HL2xxx=Values, HL3xxx=Template, HL4xxx=Security, HL5xxx=BestPractice"
296                    },
297                    "threshold": {
298                        "type": "string",
299                        "enum": ["error", "warning", "info", "style"],
300                        "description": "Minimum severity to report. Default is 'warning'."
301                    }
302                },
303                "required": ["chart"]
304            }),
305        }
306    }
307
308    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
309        // Build configuration
310        let mut config = HelmlintConfig::default();
311
312        // Apply ignored rules
313        for rule in &args.ignore {
314            config = config.ignore(rule.as_str());
315        }
316
317        // Apply threshold
318        if let Some(threshold) = &args.threshold {
319            config = config.with_threshold(Self::parse_threshold(threshold));
320        }
321
322        // Determine chart path
323        let chart_path = if let Some(chart) = &args.chart {
324            self.project_path.join(chart)
325        } else {
326            // Look for Chart.yaml in project root
327            if self.project_path.join("Chart.yaml").exists() {
328                self.project_path.clone()
329            } else {
330                return Err(HelmlintError(
331                    "No chart specified and no Chart.yaml found in project root. \
332                    Specify a chart directory with 'chart' parameter."
333                        .to_string(),
334                ));
335            }
336        };
337
338        // Validate it's a Helm chart
339        if !chart_path.join("Chart.yaml").exists() {
340            return Err(HelmlintError(format!(
341                "No Chart.yaml found in '{}'. This doesn't appear to be a Helm chart directory. \
342                For K8s manifest linting, use the kubelint tool instead.",
343                chart_path.display()
344            )));
345        }
346
347        // Lint the chart
348        let result = lint_chart(&chart_path, &config);
349
350        // Check for parse errors
351        if !result.parse_errors.is_empty() {
352            log::warn!("Helm chart parse errors: {:?}", result.parse_errors);
353        }
354
355        Ok(Self::format_result(&result))
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::fs;
363    use tempfile::TempDir;
364
365    fn create_test_chart(dir: &std::path::Path) {
366        fs::create_dir_all(dir.join("templates")).unwrap();
367
368        fs::write(
369            dir.join("Chart.yaml"),
370            r#"apiVersion: v2
371name: test-chart
372version: 1.0.0
373description: A test chart
374"#,
375        )
376        .unwrap();
377
378        fs::write(
379            dir.join("values.yaml"),
380            r#"replicaCount: 1
381image:
382  repository: nginx
383  tag: "1.25"
384"#,
385        )
386        .unwrap();
387
388        fs::write(
389            dir.join("templates/deployment.yaml"),
390            r#"apiVersion: apps/v1
391kind: Deployment
392metadata:
393  name: {{ .Release.Name }}
394spec:
395  replicas: {{ .Values.replicaCount }}
396"#,
397        )
398        .unwrap();
399    }
400
401    #[tokio::test]
402    async fn test_helmlint_valid_chart() {
403        let temp_dir = TempDir::new().unwrap();
404        create_test_chart(temp_dir.path());
405
406        let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
407        let args = HelmlintArgs {
408            chart: Some(".".to_string()),
409            ignore: vec![],
410            threshold: None,
411        };
412
413        let result = tool.call(args).await.unwrap();
414        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
415
416        assert!(parsed["decision_context"].is_string());
417        assert!(parsed["tool_guidance"].is_string());
418        assert!(parsed["summary"]["files_checked"].is_number());
419    }
420
421    #[tokio::test]
422    async fn test_helmlint_no_chart() {
423        let temp_dir = TempDir::new().unwrap();
424        // Don't create a chart
425
426        let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
427        let args = HelmlintArgs {
428            chart: None,
429            ignore: vec![],
430            threshold: None,
431        };
432
433        let result = tool.call(args).await;
434        assert!(result.is_err());
435        assert!(
436            result
437                .unwrap_err()
438                .to_string()
439                .contains("No chart specified")
440        );
441    }
442
443    #[tokio::test]
444    async fn test_helmlint_not_a_chart() {
445        let temp_dir = TempDir::new().unwrap();
446        // Create a directory without Chart.yaml
447        fs::create_dir_all(temp_dir.path().join("some-dir")).unwrap();
448
449        let tool = HelmlintTool::new(temp_dir.path().to_path_buf());
450        let args = HelmlintArgs {
451            chart: Some("some-dir".to_string()),
452            ignore: vec![],
453            threshold: None,
454        };
455
456        let result = tool.call(args).await;
457        assert!(result.is_err());
458        assert!(result.unwrap_err().to_string().contains("No Chart.yaml"));
459    }
460}