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 all YAML files in directory
222        match yaml::parse_yaml_dir(path) {
223            Ok(objects) => {
224                for obj in objects {
225                    ctx.add_object(obj);
226                }
227            }
228            Err(err) => return Err(format!("Failed to parse YAML directory: {}", err)),
229        }
230    } else {
231        // Load single file
232        match yaml::parse_yaml_file(path) {
233            Ok(objects) => {
234                for obj in objects {
235                    ctx.add_object(obj);
236                }
237            }
238            Err(err) => return Err(format!("Failed to parse YAML file: {}", err)),
239        }
240    }
241
242    Ok((ctx, warning))
243}
244
245/// Run all enabled checks on a lint context.
246fn run_checks(ctx: &LintContextImpl, config: &KubelintConfig) -> LintResult {
247    use crate::analyzer::kubelint::templates;
248    use crate::analyzer::kubelint::types::CheckFailure;
249
250    let mut result = LintResult::new();
251
252    // Get all available checks
253    let all_checks = builtin_checks();
254
255    // Combine with custom checks
256    let mut available_checks: Vec<&CheckSpec> = all_checks.iter().collect();
257    for custom in &config.custom_checks {
258        available_checks.push(custom);
259    }
260
261    // Resolve which checks to run
262    let checks_to_run = config.resolve_checks(&all_checks);
263
264    result.summary.objects_analyzed = ctx.objects().len();
265    result.summary.checks_run = checks_to_run.len();
266
267    // Cache instantiated check functions
268    let mut check_funcs: std::collections::HashMap<String, Box<dyn templates::CheckFunc>> =
269        std::collections::HashMap::new();
270
271    // Pre-instantiate all check functions
272    for check in &checks_to_run {
273        if let Some(template) = templates::get_template(&check.template) {
274            match template.instantiate(&check.params) {
275                Ok(func) => {
276                    check_funcs.insert(check.name.clone(), func);
277                }
278                Err(e) => {
279                    // Log template instantiation error but continue
280                    eprintln!(
281                        "Warning: Failed to instantiate check '{}': {}",
282                        check.name, e
283                    );
284                }
285            }
286        }
287    }
288
289    // Run each check on each object
290    for obj in ctx.objects() {
291        for check in &checks_to_run {
292            // Check if this check applies to this object kind
293            if !check.scope.object_kinds.matches(&obj.kind()) {
294                continue;
295            }
296
297            // Check if this check is ignored via annotation
298            if should_ignore_check(obj, &check.name) {
299                continue;
300            }
301
302            // Run the check function if we have one
303            if let Some(func) = check_funcs.get(&check.name) {
304                let diagnostics = func.check(obj);
305
306                // Convert diagnostics to CheckFailures
307                for diag in diagnostics {
308                    let mut failure = CheckFailure::new(
309                        check.name.as_str(),
310                        Severity::Warning, // Default severity
311                        &diag.message,
312                        &obj.metadata.file_path,
313                        obj.name(),
314                        obj.kind().as_str(),
315                    );
316
317                    if let Some(ns) = obj.namespace() {
318                        failure = failure.with_namespace(ns);
319                    }
320
321                    if let Some(line) = obj.metadata.line_number {
322                        failure = failure.with_line(line);
323                    }
324
325                    if let Some(remediation) = diag.remediation {
326                        failure = failure.with_remediation(remediation);
327                    }
328
329                    result.failures.push(failure);
330                }
331            }
332        }
333    }
334
335    // Filter by threshold
336    result.filter_by_threshold(config.failure_threshold);
337
338    // Sort results
339    result.sort();
340
341    // Update summary
342    result.summary.passed = !result.should_fail(config);
343
344    result
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    #[test]
352    fn test_lint_result_new() {
353        let result = LintResult::new();
354        assert!(result.failures.is_empty());
355        assert!(result.parse_errors.is_empty());
356        assert!(result.summary.passed);
357    }
358
359    #[test]
360    fn test_lint_content_empty() {
361        let result = lint_content("", &KubelintConfig::default());
362        assert!(result.failures.is_empty());
363    }
364
365    #[test]
366    fn test_should_fail() {
367        let mut result = LintResult::new();
368        result.failures.push(CheckFailure::new(
369            "test-check",
370            Severity::Warning,
371            "test message",
372            "test.yaml",
373            "test-obj",
374            "Deployment",
375        ));
376
377        let config = KubelintConfig::default().with_threshold(Severity::Warning);
378        assert!(result.should_fail(&config));
379
380        let config = KubelintConfig::default().with_threshold(Severity::Error);
381        assert!(!result.should_fail(&config));
382
383        let mut no_fail_config = KubelintConfig::default();
384        no_fail_config.no_fail = true;
385        assert!(!result.should_fail(&no_fail_config));
386    }
387
388    #[test]
389    fn test_lint_real_file() {
390        // Test with actual test file if it exists
391        let test_file = std::path::Path::new("test-lint/k8s/insecure-deployment.yaml");
392        if !test_file.exists() {
393            eprintln!("Test file not found, skipping: {:?}", test_file);
394            return;
395        }
396
397        // Read and print the file content
398        let content = std::fs::read_to_string(test_file).unwrap();
399        println!("=== File Content ===\n{}\n", content);
400
401        // Create config with all builtin checks
402        let config = KubelintConfig::default().with_all_builtin();
403        println!("=== Config ===");
404        println!("add_all_builtin: {}", config.add_all_builtin);
405
406        // First test: lint from content
407        let result_content = lint_content(&content, &config);
408        println!("\n=== Lint Content Result ===");
409        println!(
410            "Objects analyzed: {}",
411            result_content.summary.objects_analyzed
412        );
413        println!("Checks run: {}", result_content.summary.checks_run);
414        println!("Failures: {}", result_content.failures.len());
415        for f in &result_content.failures {
416            println!("  - {} [{:?}]: {}", f.code, f.severity, f.message);
417        }
418        for e in &result_content.parse_errors {
419            println!("  Parse error: {}", e);
420        }
421
422        // Second test: lint from file
423        let result_file = lint_file(test_file, &config);
424        println!("\n=== Lint File Result ===");
425        println!("Objects analyzed: {}", result_file.summary.objects_analyzed);
426        println!("Checks run: {}", result_file.summary.checks_run);
427        println!("Failures: {}", result_file.failures.len());
428        for f in &result_file.failures {
429            println!("  - {} [{:?}]: {}", f.code, f.severity, f.message);
430        }
431        for e in &result_file.parse_errors {
432            println!("  Parse error: {}", e);
433        }
434
435        // Assert we found issues
436        assert!(
437            result_content.has_failures() || result_file.has_failures(),
438            "Expected to find security issues in the test file!"
439        );
440    }
441
442    #[test]
443    fn test_lint_content_finds_issues() {
444        // Test a deployment with multiple security issues
445        let yaml = r#"
446apiVersion: apps/v1
447kind: Deployment
448metadata:
449  name: insecure-deploy
450spec:
451  replicas: 1
452  selector:
453    matchLabels:
454      app: test
455  template:
456    spec:
457      containers:
458      - name: nginx
459        image: nginx:latest
460        securityContext:
461          privileged: true
462"#;
463        // Use a config with all built-in checks enabled
464        let config = KubelintConfig::default().with_all_builtin();
465        let result = lint_content(yaml, &config);
466
467        // Should find issues: privileged container, latest tag, no probes, no resources, etc.
468        assert!(
469            result.has_failures(),
470            "Expected linting failures for insecure deployment"
471        );
472
473        // Verify we found the privileged container issue
474        let privileged_failures: Vec<_> = result
475            .failures
476            .iter()
477            .filter(|f| f.code.as_str() == "privileged-container")
478            .collect();
479        assert!(
480            !privileged_failures.is_empty(),
481            "Should detect privileged container"
482        );
483
484        // Verify we found the latest tag issue
485        let latest_tag_failures: Vec<_> = result
486            .failures
487            .iter()
488            .filter(|f| f.code.as_str() == "latest-tag")
489            .collect();
490        assert!(!latest_tag_failures.is_empty(), "Should detect latest tag");
491    }
492
493    #[test]
494    fn test_lint_content_secure_deployment() {
495        // Test a secure deployment
496        let yaml = r#"
497apiVersion: apps/v1
498kind: Deployment
499metadata:
500  name: secure-deploy
501spec:
502  replicas: 1
503  selector:
504    matchLabels:
505      app: test
506  template:
507    spec:
508      serviceAccountName: my-service-account
509      securityContext:
510        runAsNonRoot: true
511      containers:
512      - name: nginx
513        image: nginx:1.21.0
514        securityContext:
515          privileged: false
516          allowPrivilegeEscalation: false
517          readOnlyRootFilesystem: true
518          capabilities:
519            drop:
520            - ALL
521        resources:
522          requests:
523            cpu: 100m
524            memory: 128Mi
525          limits:
526            cpu: 200m
527            memory: 256Mi
528        livenessProbe:
529          httpGet:
530            path: /healthz
531            port: 8080
532        readinessProbe:
533          httpGet:
534            path: /ready
535            port: 8080
536"#;
537        // Only include a subset of checks that this deployment should pass
538        let config = KubelintConfig::default()
539            .include("privileged-container")
540            .include("latest-tag");
541
542        let result = lint_content(yaml, &config);
543
544        // Should not find privileged or latest-tag issues
545        let critical_failures: Vec<_> = result
546            .failures
547            .iter()
548            .filter(|f| {
549                f.code.as_str() == "privileged-container" || f.code.as_str() == "latest-tag"
550            })
551            .collect();
552        assert!(
553            critical_failures.is_empty(),
554            "Secure deployment should not have privileged/latest-tag failures: {:?}",
555            critical_failures
556        );
557    }
558
559    #[test]
560    fn test_lint_content_with_ignore_annotation() {
561        // Test that ignore annotations work
562        let yaml = r#"
563apiVersion: apps/v1
564kind: Deployment
565metadata:
566  name: ignored-deploy
567  annotations:
568    ignore-check.kube-linter.io/privileged-container: "intentionally privileged"
569spec:
570  replicas: 1
571  selector:
572    matchLabels:
573      app: test
574  template:
575    spec:
576      containers:
577      - name: nginx
578        image: nginx:1.21.0
579        securityContext:
580          privileged: true
581"#;
582        let config = KubelintConfig::default().include("privileged-container");
583        let result = lint_content(yaml, &config);
584
585        // Should NOT find privileged container issue due to ignore annotation
586        let privileged_failures: Vec<_> = result
587            .failures
588            .iter()
589            .filter(|f| f.code.as_str() == "privileged-container")
590            .collect();
591        assert!(
592            privileged_failures.is_empty(),
593            "Ignored check should not produce failures"
594        );
595    }
596}