syncable_cli/analyzer/kubelint/
lint.rs

1//! Main linting orchestration for kubelint-rs.
2//!
3//! This module ties together parsing, checks, and pragmas to provide
4//! the main linting API.
5
6use crate::analyzer::kubelint::checks::builtin_checks;
7use crate::analyzer::kubelint::config::{CheckSpec, KubelintConfig};
8use crate::analyzer::kubelint::context::{LintContext, LintContextImpl};
9use crate::analyzer::kubelint::parser::{helm, kustomize, yaml};
10use crate::analyzer::kubelint::pragma::should_ignore_check;
11use crate::analyzer::kubelint::types::{CheckFailure, Severity};
12
13use std::path::Path;
14
15/// Result of linting Kubernetes manifests.
16#[derive(Debug, Clone)]
17pub struct LintResult {
18    /// Check violations found.
19    pub failures: Vec<CheckFailure>,
20    /// Parse errors (if any).
21    pub parse_errors: Vec<String>,
22    /// Summary of the lint run.
23    pub summary: LintSummary,
24}
25
26/// Summary of a lint run.
27#[derive(Debug, Clone)]
28pub struct LintSummary {
29    /// Number of objects analyzed.
30    pub objects_analyzed: usize,
31    /// Number of checks run.
32    pub checks_run: usize,
33    /// Whether the lint passed (no failures above threshold).
34    pub passed: bool,
35}
36
37impl LintResult {
38    /// Create a new empty result.
39    pub fn new() -> Self {
40        Self {
41            failures: Vec::new(),
42            parse_errors: Vec::new(),
43            summary: LintSummary {
44                objects_analyzed: 0,
45                checks_run: 0,
46                passed: true,
47            },
48        }
49    }
50
51    /// Check if there are any failures.
52    pub fn has_failures(&self) -> bool {
53        !self.failures.is_empty()
54    }
55
56    /// Check if there are any errors (failure with Error severity).
57    pub fn has_errors(&self) -> bool {
58        self.failures.iter().any(|f| f.severity == Severity::Error)
59    }
60
61    /// Check if there are any warnings (failure with Warning severity).
62    pub fn has_warnings(&self) -> bool {
63        self.failures
64            .iter()
65            .any(|f| f.severity == Severity::Warning)
66    }
67
68    /// Get the maximum severity in the results.
69    pub fn max_severity(&self) -> Option<Severity> {
70        self.failures.iter().map(|f| f.severity).max()
71    }
72
73    /// Check if the results should cause a non-zero exit.
74    pub fn should_fail(&self, config: &KubelintConfig) -> bool {
75        if config.no_fail {
76            return false;
77        }
78
79        if let Some(max) = self.max_severity() {
80            max >= config.failure_threshold
81        } else {
82            false
83        }
84    }
85
86    /// Filter failures by severity threshold.
87    pub fn filter_by_threshold(&mut self, threshold: Severity) {
88        self.failures.retain(|f| f.severity >= threshold);
89    }
90
91    /// Sort failures by file path and line number.
92    pub fn sort(&mut self) {
93        self.failures.sort();
94    }
95}
96
97impl Default for LintResult {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103/// Lint Kubernetes manifests from a path.
104///
105/// The path can be:
106/// - A single YAML file
107/// - A directory containing YAML files
108/// - A Helm chart directory
109/// - A Kustomize directory
110pub fn lint(path: &Path, config: &KubelintConfig) -> LintResult {
111    let mut result = LintResult::new();
112
113    // Check if path should be ignored
114    if config.should_ignore_path(path) {
115        return result;
116    }
117
118    // Load objects from the path
119    let (ctx, warning) = match load_context(path, config) {
120        Ok((ctx, warning)) => (ctx, warning),
121        Err(err) => {
122            result.parse_errors.push(err);
123            return result;
124        }
125    };
126
127    // Add warning as parse error if present (for UI to display)
128    if let Some(warn) = warning {
129        result.parse_errors.push(warn);
130    }
131
132    // Run checks
133    result = run_checks(&ctx, config);
134    result
135}
136
137/// Lint a single YAML file.
138pub fn lint_file(path: &Path, config: &KubelintConfig) -> LintResult {
139    lint(path, config)
140}
141
142/// Lint YAML content directly.
143pub fn lint_content(content: &str, config: &KubelintConfig) -> LintResult {
144    let mut result = LintResult::new();
145    let mut ctx = LintContextImpl::new();
146
147    // Parse the YAML content
148    match yaml::parse_yaml(content) {
149        Ok(objects) => {
150            for obj in objects {
151                ctx.add_object(obj);
152            }
153        }
154        Err(err) => {
155            result.parse_errors.push(err.to_string());
156            return result;
157        }
158    }
159
160    // Run checks
161    run_checks(&ctx, config)
162}
163
164/// Load a lint context from a path.
165/// Returns (context, optional_warning) - warning is set if fallback was used.
166fn load_context(
167    path: &Path,
168    _config: &KubelintConfig,
169) -> Result<(LintContextImpl, Option<String>), String> {
170    let mut ctx = LintContextImpl::new();
171    let mut warning: Option<String> = None;
172
173    if helm::is_helm_chart(path) {
174        // Load as Helm chart - try to render first
175        match helm::render_helm_chart(path, None) {
176            Ok(objects) => {
177                for obj in objects {
178                    ctx.add_object(obj);
179                }
180            }
181            Err(err) => {
182                // Helm rendering failed - fall back to parsing raw template files
183                // This allows linting broken charts that can't be rendered
184                let templates_dir = path.join("templates");
185                if templates_dir.exists() {
186                    warning = Some(format!(
187                        "Helm render failed ({}), falling back to raw template parsing",
188                        err
189                    ));
190                    // Parse template files as raw YAML (may contain Go template syntax)
191                    match yaml::parse_yaml_dir(&templates_dir) {
192                        Ok(objects) => {
193                            for obj in objects {
194                                ctx.add_object(obj);
195                            }
196                        }
197                        Err(yaml_err) => {
198                            // Both Helm render and raw YAML parsing failed
199                            return Err(format!(
200                                "Failed to render Helm chart: {}. Fallback YAML parsing also failed: {}",
201                                err, yaml_err
202                            ));
203                        }
204                    }
205                } else {
206                    return Err(format!("Failed to render Helm chart: {}", err));
207                }
208            }
209        }
210    } else if kustomize::is_kustomize_dir(path) {
211        // Load as Kustomize directory
212        match kustomize::render_kustomize(path) {
213            Ok(objects) => {
214                for obj in objects {
215                    ctx.add_object(obj);
216                }
217            }
218            Err(err) => return Err(format!("Failed to render Kustomize: {}", err)),
219        }
220    } else if path.is_dir() {
221        // Load directory - but first discover and render any Helm charts or Kustomize dirs
222        load_directory_with_rendering(&mut ctx, path)?;
223    } else {
224        // Load single file
225        match yaml::parse_yaml_file(path) {
226            Ok(objects) => {
227                for obj in objects {
228                    ctx.add_object(obj);
229                }
230            }
231            Err(err) => return Err(format!("Failed to parse YAML file: {}", err)),
232        }
233    }
234
235    Ok((ctx, warning))
236}
237
238/// Load a directory, discovering and rendering Helm charts and Kustomize dirs within.
239fn load_directory_with_rendering(ctx: &mut LintContextImpl, path: &Path) -> Result<(), String> {
240    use std::collections::HashSet;
241
242    let mut processed_dirs: HashSet<std::path::PathBuf> = HashSet::new();
243
244    // First pass: discover Helm charts and Kustomize dirs, render them
245    for entry in walkdir::WalkDir::new(path)
246        .follow_links(true)
247        .into_iter()
248        .filter_map(|e| e.ok())
249    {
250        let entry_path = entry.path();
251        if entry_path.is_dir() {
252            // Check for Helm chart
253            if helm::is_helm_chart(entry_path) {
254                if let Ok(objects) = helm::render_helm_chart(entry_path, None) {
255                    for obj in objects {
256                        ctx.add_object(obj);
257                    }
258                }
259                // Mark this directory and all subdirs as processed
260                processed_dirs.insert(entry_path.to_path_buf());
261                continue;
262            }
263
264            // Check for Kustomize dir
265            if kustomize::is_kustomize_dir(entry_path) {
266                if let Ok(objects) = kustomize::render_kustomize(entry_path) {
267                    for obj in objects {
268                        ctx.add_object(obj);
269                    }
270                }
271                // Mark this directory and all subdirs as processed
272                processed_dirs.insert(entry_path.to_path_buf());
273                continue;
274            }
275        }
276    }
277
278    // Second pass: parse regular YAML files not inside Helm/Kustomize dirs
279    for entry in walkdir::WalkDir::new(path)
280        .follow_links(true)
281        .into_iter()
282        .filter_map(|e| e.ok())
283    {
284        let entry_path = entry.path();
285        if entry_path.is_file() {
286            // Skip files inside already-processed directories
287            let should_skip = processed_dirs
288                .iter()
289                .any(|processed| entry_path.starts_with(processed));
290            if should_skip {
291                continue;
292            }
293
294            // Check for YAML file
295            let ext = entry_path.extension().and_then(|e| e.to_str());
296            if matches!(ext, Some("yaml") | Some("yml"))
297                && let Ok(objects) = yaml::parse_yaml_file(entry_path)
298            {
299                for obj in objects {
300                    ctx.add_object(obj);
301                }
302            }
303        }
304    }
305
306    Ok(())
307}
308
309/// Run all enabled checks on a lint context.
310fn run_checks(ctx: &LintContextImpl, config: &KubelintConfig) -> LintResult {
311    use crate::analyzer::kubelint::templates;
312    use crate::analyzer::kubelint::types::CheckFailure;
313
314    let mut result = LintResult::new();
315
316    // Get all available checks
317    let all_checks = builtin_checks();
318
319    // Combine with custom checks
320    let mut available_checks: Vec<&CheckSpec> = all_checks.iter().collect();
321    for custom in &config.custom_checks {
322        available_checks.push(custom);
323    }
324
325    // Resolve which checks to run
326    let checks_to_run = config.resolve_checks(&all_checks);
327
328    result.summary.objects_analyzed = ctx.objects().len();
329    result.summary.checks_run = checks_to_run.len();
330
331    // Cache instantiated check functions
332    let mut check_funcs: std::collections::HashMap<String, Box<dyn templates::CheckFunc>> =
333        std::collections::HashMap::new();
334
335    // Pre-instantiate all check functions
336    for check in &checks_to_run {
337        if let Some(template) = templates::get_template(&check.template) {
338            match template.instantiate(&check.params) {
339                Ok(func) => {
340                    check_funcs.insert(check.name.clone(), func);
341                }
342                Err(e) => {
343                    // Log template instantiation error but continue
344                    eprintln!(
345                        "Warning: Failed to instantiate check '{}': {}",
346                        check.name, e
347                    );
348                }
349            }
350        }
351    }
352
353    // Run each check on each object
354    for obj in ctx.objects() {
355        for check in &checks_to_run {
356            // Check if this check applies to this object kind
357            if !check.scope.object_kinds.matches(&obj.kind()) {
358                continue;
359            }
360
361            // Check if this check is ignored via annotation
362            if should_ignore_check(obj, &check.name) {
363                continue;
364            }
365
366            // Run the check function if we have one
367            if let Some(func) = check_funcs.get(&check.name) {
368                let diagnostics = func.check(obj);
369
370                // Convert diagnostics to CheckFailures
371                for diag in diagnostics {
372                    let mut failure = CheckFailure::new(
373                        check.name.as_str(),
374                        Severity::Warning, // Default severity
375                        &diag.message,
376                        &obj.metadata.file_path,
377                        obj.name(),
378                        obj.kind().as_str(),
379                    );
380
381                    if let Some(ns) = obj.namespace() {
382                        failure = failure.with_namespace(ns);
383                    }
384
385                    if let Some(line) = obj.metadata.line_number {
386                        failure = failure.with_line(line);
387                    }
388
389                    if let Some(remediation) = diag.remediation {
390                        failure = failure.with_remediation(remediation);
391                    }
392
393                    result.failures.push(failure);
394                }
395            }
396        }
397    }
398
399    // Filter by threshold
400    result.filter_by_threshold(config.failure_threshold);
401
402    // Sort results
403    result.sort();
404
405    // Update summary
406    result.summary.passed = !result.should_fail(config);
407
408    result
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_lint_result_new() {
417        let result = LintResult::new();
418        assert!(result.failures.is_empty());
419        assert!(result.parse_errors.is_empty());
420        assert!(result.summary.passed);
421    }
422
423    #[test]
424    fn test_lint_content_empty() {
425        let result = lint_content("", &KubelintConfig::default());
426        assert!(result.failures.is_empty());
427    }
428
429    #[test]
430    fn test_should_fail() {
431        let mut result = LintResult::new();
432        result.failures.push(CheckFailure::new(
433            "test-check",
434            Severity::Warning,
435            "test message",
436            "test.yaml",
437            "test-obj",
438            "Deployment",
439        ));
440
441        let config = KubelintConfig::default().with_threshold(Severity::Warning);
442        assert!(result.should_fail(&config));
443
444        let config = KubelintConfig::default().with_threshold(Severity::Error);
445        assert!(!result.should_fail(&config));
446
447        let mut no_fail_config = KubelintConfig::default();
448        no_fail_config.no_fail = true;
449        assert!(!result.should_fail(&no_fail_config));
450    }
451
452    #[test]
453    fn test_lint_real_file() {
454        // Test with actual test file if it exists
455        let test_file = std::path::Path::new("test-lint/k8s/insecure-deployment.yaml");
456        if !test_file.exists() {
457            eprintln!("Test file not found, skipping: {:?}", test_file);
458            return;
459        }
460
461        // Read and print the file content
462        let content = std::fs::read_to_string(test_file).unwrap();
463        println!("=== File Content ===\n{}\n", content);
464
465        // Create config with all builtin checks
466        let config = KubelintConfig::default().with_all_builtin();
467        println!("=== Config ===");
468        println!("add_all_builtin: {}", config.add_all_builtin);
469
470        // First test: lint from content
471        let result_content = lint_content(&content, &config);
472        println!("\n=== Lint Content Result ===");
473        println!(
474            "Objects analyzed: {}",
475            result_content.summary.objects_analyzed
476        );
477        println!("Checks run: {}", result_content.summary.checks_run);
478        println!("Failures: {}", result_content.failures.len());
479        for f in &result_content.failures {
480            println!("  - {} [{:?}]: {}", f.code, f.severity, f.message);
481        }
482        for e in &result_content.parse_errors {
483            println!("  Parse error: {}", e);
484        }
485
486        // Second test: lint from file
487        let result_file = lint_file(test_file, &config);
488        println!("\n=== Lint File Result ===");
489        println!("Objects analyzed: {}", result_file.summary.objects_analyzed);
490        println!("Checks run: {}", result_file.summary.checks_run);
491        println!("Failures: {}", result_file.failures.len());
492        for f in &result_file.failures {
493            println!("  - {} [{:?}]: {}", f.code, f.severity, f.message);
494        }
495        for e in &result_file.parse_errors {
496            println!("  Parse error: {}", e);
497        }
498
499        // Assert we found issues
500        assert!(
501            result_content.has_failures() || result_file.has_failures(),
502            "Expected to find security issues in the test file!"
503        );
504    }
505
506    #[test]
507    fn test_lint_content_finds_issues() {
508        // Test a deployment with multiple security issues
509        let yaml = r#"
510apiVersion: apps/v1
511kind: Deployment
512metadata:
513  name: insecure-deploy
514spec:
515  replicas: 1
516  selector:
517    matchLabels:
518      app: test
519  template:
520    spec:
521      containers:
522      - name: nginx
523        image: nginx:latest
524        securityContext:
525          privileged: true
526"#;
527        // Use a config with all built-in checks enabled
528        let config = KubelintConfig::default().with_all_builtin();
529        let result = lint_content(yaml, &config);
530
531        // Should find issues: privileged container, latest tag, no probes, no resources, etc.
532        assert!(
533            result.has_failures(),
534            "Expected linting failures for insecure deployment"
535        );
536
537        // Verify we found the privileged container issue
538        let privileged_failures: Vec<_> = result
539            .failures
540            .iter()
541            .filter(|f| f.code.as_str() == "privileged-container")
542            .collect();
543        assert!(
544            !privileged_failures.is_empty(),
545            "Should detect privileged container"
546        );
547
548        // Verify we found the latest tag issue
549        let latest_tag_failures: Vec<_> = result
550            .failures
551            .iter()
552            .filter(|f| f.code.as_str() == "latest-tag")
553            .collect();
554        assert!(!latest_tag_failures.is_empty(), "Should detect latest tag");
555    }
556
557    #[test]
558    fn test_lint_content_secure_deployment() {
559        // Test a secure deployment
560        let yaml = r#"
561apiVersion: apps/v1
562kind: Deployment
563metadata:
564  name: secure-deploy
565spec:
566  replicas: 1
567  selector:
568    matchLabels:
569      app: test
570  template:
571    spec:
572      serviceAccountName: my-service-account
573      securityContext:
574        runAsNonRoot: true
575      containers:
576      - name: nginx
577        image: nginx:1.21.0
578        securityContext:
579          privileged: false
580          allowPrivilegeEscalation: false
581          readOnlyRootFilesystem: true
582          capabilities:
583            drop:
584            - ALL
585        resources:
586          requests:
587            cpu: 100m
588            memory: 128Mi
589          limits:
590            cpu: 200m
591            memory: 256Mi
592        livenessProbe:
593          httpGet:
594            path: /healthz
595            port: 8080
596        readinessProbe:
597          httpGet:
598            path: /ready
599            port: 8080
600"#;
601        // Only include a subset of checks that this deployment should pass
602        let config = KubelintConfig::default()
603            .include("privileged-container")
604            .include("latest-tag");
605
606        let result = lint_content(yaml, &config);
607
608        // Should not find privileged or latest-tag issues
609        let critical_failures: Vec<_> = result
610            .failures
611            .iter()
612            .filter(|f| {
613                f.code.as_str() == "privileged-container" || f.code.as_str() == "latest-tag"
614            })
615            .collect();
616        assert!(
617            critical_failures.is_empty(),
618            "Secure deployment should not have privileged/latest-tag failures: {:?}",
619            critical_failures
620        );
621    }
622
623    #[test]
624    fn test_lint_content_with_ignore_annotation() {
625        // Test that ignore annotations work
626        let yaml = r#"
627apiVersion: apps/v1
628kind: Deployment
629metadata:
630  name: ignored-deploy
631  annotations:
632    ignore-check.kube-linter.io/privileged-container: "intentionally privileged"
633spec:
634  replicas: 1
635  selector:
636    matchLabels:
637      app: test
638  template:
639    spec:
640      containers:
641      - name: nginx
642        image: nginx:1.21.0
643        securityContext:
644          privileged: true
645"#;
646        let config = KubelintConfig::default().include("privileged-container");
647        let result = lint_content(yaml, &config);
648
649        // Should NOT find privileged container issue due to ignore annotation
650        let privileged_failures: Vec<_> = result
651            .failures
652            .iter()
653            .filter(|f| f.code.as_str() == "privileged-container")
654            .collect();
655        assert!(
656            privileged_failures.is_empty(),
657            "Ignored check should not produce failures"
658        );
659    }
660}