syncable_cli/analyzer/kubelint/
config.rs

1//! Configuration for the kubelint-rs linter.
2//!
3//! Provides configuration options matching the Go kube-linter:
4//! - Check inclusion/exclusion
5//! - Path ignoring
6//! - Custom check definitions
7//! - Failure thresholds
8
9use crate::analyzer::kubelint::types::{ObjectKindsDesc, Severity};
10use serde::{Deserialize, Serialize};
11use std::collections::HashSet;
12use std::path::Path;
13
14/// Configuration for the KubeLint linter.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct KubelintConfig {
18    /// If true, add all built-in checks regardless of defaults.
19    #[serde(default, rename = "addAllBuiltIn")]
20    pub add_all_builtin: bool,
21
22    /// If true, do not automatically add default checks.
23    #[serde(default)]
24    pub do_not_auto_add_defaults: bool,
25
26    /// List of check names to include (in addition to defaults).
27    #[serde(default)]
28    pub include: Vec<String>,
29
30    /// List of check names to exclude.
31    #[serde(default)]
32    pub exclude: Vec<String>,
33
34    /// Glob patterns for paths to ignore.
35    #[serde(default)]
36    pub ignore_paths: Vec<String>,
37
38    /// Custom check definitions.
39    #[serde(default)]
40    pub custom_checks: Vec<CheckSpec>,
41
42    /// Minimum severity to report. Checks below this threshold are filtered.
43    #[serde(default)]
44    pub failure_threshold: Severity,
45
46    /// If true, never return a non-zero exit code.
47    #[serde(default)]
48    pub no_fail: bool,
49}
50
51impl Default for KubelintConfig {
52    fn default() -> Self {
53        Self {
54            add_all_builtin: false,
55            do_not_auto_add_defaults: false,
56            include: Vec::new(),
57            exclude: Vec::new(),
58            ignore_paths: Vec::new(),
59            custom_checks: Vec::new(),
60            failure_threshold: Severity::Warning,
61            no_fail: false,
62        }
63    }
64}
65
66impl KubelintConfig {
67    /// Create a new default configuration.
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// Add a check to the include list.
73    pub fn include(mut self, check: impl Into<String>) -> Self {
74        self.include.push(check.into());
75        self
76    }
77
78    /// Add a check to the exclude list.
79    pub fn exclude(mut self, check: impl Into<String>) -> Self {
80        self.exclude.push(check.into());
81        self
82    }
83
84    /// Add a path pattern to ignore.
85    pub fn ignore_path(mut self, pattern: impl Into<String>) -> Self {
86        self.ignore_paths.push(pattern.into());
87        self
88    }
89
90    /// Set the failure threshold.
91    pub fn with_threshold(mut self, threshold: Severity) -> Self {
92        self.failure_threshold = threshold;
93        self
94    }
95
96    /// Enable all built-in checks.
97    pub fn with_all_builtin(mut self) -> Self {
98        self.add_all_builtin = true;
99        self
100    }
101
102    /// Disable automatic default checks.
103    pub fn without_defaults(mut self) -> Self {
104        self.do_not_auto_add_defaults = true;
105        self
106    }
107
108    /// Check if a check is explicitly excluded.
109    pub fn is_check_excluded(&self, check_name: &str) -> bool {
110        self.exclude.iter().any(|e| e == check_name)
111    }
112
113    /// Check if a check is explicitly included.
114    pub fn is_check_included(&self, check_name: &str) -> bool {
115        self.include.iter().any(|e| e == check_name)
116    }
117
118    /// Get the effective set of check names to run.
119    ///
120    /// This resolves includes/excludes against the available checks.
121    pub fn resolve_checks<'a>(&self, available: &'a [CheckSpec]) -> Vec<&'a CheckSpec> {
122        let default_checks: HashSet<&str> = DEFAULT_CHECKS.iter().copied().collect();
123
124        available
125            .iter()
126            .filter(|check| {
127                let name = check.name.as_str();
128
129                // Explicitly excluded checks are always skipped
130                if self.is_check_excluded(name) {
131                    return false;
132                }
133
134                // Explicitly included checks are always included
135                if self.is_check_included(name) {
136                    return true;
137                }
138
139                // If add_all_builtin is set, include all
140                if self.add_all_builtin {
141                    return true;
142                }
143
144                // If not suppressing defaults, include default checks
145                if !self.do_not_auto_add_defaults && default_checks.contains(name) {
146                    return true;
147                }
148
149                false
150            })
151            .collect()
152    }
153
154    /// Check if a file path should be ignored based on ignore_paths patterns.
155    pub fn should_ignore_path(&self, path: &Path) -> bool {
156        let path_str = path.to_string_lossy();
157
158        for pattern in &self.ignore_paths {
159            if let Ok(glob) = glob::Pattern::new(pattern) {
160                if glob.matches(&path_str) {
161                    return true;
162                }
163            }
164            // Also check simple prefix/suffix matches
165            if path_str.contains(pattern) {
166                return true;
167            }
168        }
169        false
170    }
171
172    /// Load configuration from a YAML file.
173    pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
174        let content =
175            std::fs::read_to_string(path).map_err(|e| ConfigError::IoError(e.to_string()))?;
176
177        Self::load_from_str(&content)
178    }
179
180    /// Load configuration from a YAML string.
181    pub fn load_from_str(content: &str) -> Result<Self, ConfigError> {
182        serde_yaml::from_str(content).map_err(|e| ConfigError::ParseError(e.to_string()))
183    }
184
185    /// Try to load config from default locations (.kube-linter.yaml, .kube-linter.yml).
186    pub fn load_from_default() -> Option<Self> {
187        for filename in &[".kube-linter.yaml", ".kube-linter.yml"] {
188            let path = Path::new(filename);
189            if path.exists() {
190                if let Ok(config) = Self::load_from_file(path) {
191                    return Some(config);
192                }
193            }
194        }
195        None
196    }
197}
198
199/// A check specification defining what to lint and how.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct CheckSpec {
202    /// Unique name for this check (e.g., "privileged-container").
203    pub name: String,
204
205    /// Human-readable description of what this check does.
206    pub description: String,
207
208    /// Remediation advice for fixing violations.
209    pub remediation: String,
210
211    /// The template key this check is based on.
212    pub template: String,
213
214    /// Parameters to pass to the template.
215    #[serde(default)]
216    pub params: serde_yaml::Value,
217
218    /// Which object kinds this check applies to.
219    #[serde(default)]
220    pub scope: CheckScope,
221}
222
223impl CheckSpec {
224    /// Create a new check specification.
225    pub fn new(
226        name: impl Into<String>,
227        description: impl Into<String>,
228        remediation: impl Into<String>,
229        template: impl Into<String>,
230    ) -> Self {
231        Self {
232            name: name.into(),
233            description: description.into(),
234            remediation: remediation.into(),
235            template: template.into(),
236            params: serde_yaml::Value::Null,
237            scope: CheckScope::default(),
238        }
239    }
240
241    /// Set parameters for this check.
242    pub fn with_params(mut self, params: serde_yaml::Value) -> Self {
243        self.params = params;
244        self
245    }
246
247    /// Set the scope for this check.
248    pub fn with_scope(mut self, scope: CheckScope) -> Self {
249        self.scope = scope;
250        self
251    }
252}
253
254/// Scope configuration for a check.
255#[derive(Debug, Clone, Default, Serialize, Deserialize)]
256pub struct CheckScope {
257    /// Which object kinds this check applies to.
258    #[serde(default, rename = "objectKinds")]
259    pub object_kinds: ObjectKindsDesc,
260}
261
262impl CheckScope {
263    /// Create a new scope with the given object kinds.
264    pub fn new(kinds: &[&str]) -> Self {
265        Self {
266            object_kinds: ObjectKindsDesc::new(kinds),
267        }
268    }
269}
270
271/// Configuration errors.
272#[derive(Debug, Clone)]
273pub enum ConfigError {
274    /// I/O error reading config file.
275    IoError(String),
276    /// Parse error in config file.
277    ParseError(String),
278}
279
280impl std::fmt::Display for ConfigError {
281    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
282        match self {
283            ConfigError::IoError(msg) => write!(f, "I/O error: {}", msg),
284            ConfigError::ParseError(msg) => write!(f, "Parse error: {}", msg),
285        }
286    }
287}
288
289impl std::error::Error for ConfigError {}
290
291/// Default checks that are enabled by default (matching kube-linter defaults).
292pub const DEFAULT_CHECKS: &[&str] = &[
293    "dangling-service",
294    "default-service-account",
295    "deprecated-service-account",
296    "drop-net-raw-capability",
297    "env-var-secret",
298    "host-mounts",
299    "mismatching-selector",
300    "no-anti-affinity",
301    "no-liveness-probe",
302    "no-readiness-probe",
303    "no-rolling-update-strategy",
304    "privilege-escalation",
305    "privileged-container",
306    "read-secret-from-env-var",
307    "run-as-non-root",
308    "ssh-port",
309    "unset-cpu-requirements",
310    "unset-memory-requirements",
311    "writable-host-mount",
312];
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn test_default_config() {
320        let config = KubelintConfig::default();
321        assert!(!config.add_all_builtin);
322        assert!(!config.do_not_auto_add_defaults);
323        assert!(config.include.is_empty());
324        assert!(config.exclude.is_empty());
325        assert_eq!(config.failure_threshold, Severity::Warning);
326    }
327
328    #[test]
329    fn test_config_builder() {
330        let config = KubelintConfig::new()
331            .include("custom-check")
332            .exclude("privileged-container")
333            .with_threshold(Severity::Error);
334
335        assert!(config.is_check_included("custom-check"));
336        assert!(config.is_check_excluded("privileged-container"));
337        assert_eq!(config.failure_threshold, Severity::Error);
338    }
339
340    #[test]
341    fn test_path_ignoring() {
342        let config = KubelintConfig::new()
343            .ignore_path("**/test/**")
344            .ignore_path("vendor/");
345
346        assert!(config.should_ignore_path(Path::new("vendor/k8s/deployment.yaml")));
347        // Note: glob matching behavior may vary
348    }
349
350    #[test]
351    fn test_load_from_str() {
352        let yaml = r#"
353addAllBuiltIn: true
354exclude:
355  - latest-tag
356  - privileged-container
357include:
358  - custom-check
359failureThreshold: error
360"#;
361        let config = KubelintConfig::load_from_str(yaml).unwrap();
362        assert!(config.add_all_builtin);
363        assert!(config.is_check_excluded("latest-tag"));
364        assert!(config.is_check_excluded("privileged-container"));
365        assert!(config.is_check_included("custom-check"));
366        assert_eq!(config.failure_threshold, Severity::Error);
367    }
368
369    #[test]
370    fn test_check_spec() {
371        let check = CheckSpec::new(
372            "test-check",
373            "A test check",
374            "Fix the issue",
375            "test-template",
376        )
377        .with_scope(CheckScope::new(&["Deployment", "StatefulSet"]));
378
379        assert_eq!(check.name, "test-check");
380        assert_eq!(check.template, "test-template");
381    }
382}