syncable_cli/analyzer/kubelint/
types.rs

1//! Core types for the kubelint-rs linter.
2//!
3//! These types match the Go kube-linter implementation for compatibility:
4//! - `Severity` - Check violation severity levels
5//! - `RuleCode` - Check identifiers (e.g., "privileged-container")
6//! - `CheckFailure` - A single check violation
7//! - `Diagnostic` - A diagnostic message from a check
8
9use serde::{Deserialize, Serialize};
10use std::cmp::Ordering;
11use std::fmt;
12use std::path::PathBuf;
13
14/// Severity levels for check violations.
15///
16/// Ordered from most severe to least severe:
17/// `Error > Warning > Info`
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Severity {
21    /// Critical issues that must be fixed
22    Error,
23    /// Important issues that should be addressed
24    #[default]
25    Warning,
26    /// Informational suggestions
27    Info,
28}
29
30impl Severity {
31    /// Parse a severity from a string (case-insensitive).
32    pub fn parse(s: &str) -> Option<Self> {
33        match s.to_lowercase().as_str() {
34            "error" => Some(Self::Error),
35            "warning" => Some(Self::Warning),
36            "info" => Some(Self::Info),
37            _ => None,
38        }
39    }
40
41    /// Get the string representation.
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            Self::Error => "error",
45            Self::Warning => "warning",
46            Self::Info => "info",
47        }
48    }
49}
50
51impl fmt::Display for Severity {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.as_str())
54    }
55}
56
57impl Ord for Severity {
58    fn cmp(&self, other: &Self) -> Ordering {
59        // Higher severity = lower numeric value for Ord
60        let self_val = match self {
61            Self::Error => 0,
62            Self::Warning => 1,
63            Self::Info => 2,
64        };
65        let other_val = match other {
66            Self::Error => 0,
67            Self::Warning => 1,
68            Self::Info => 2,
69        };
70        // Reverse so Error > Warning > Info
71        other_val.cmp(&self_val)
72    }
73}
74
75impl PartialOrd for Severity {
76    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
77        Some(self.cmp(other))
78    }
79}
80
81/// A rule/check code identifier (e.g., "privileged-container", "latest-tag").
82#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
83pub struct RuleCode(pub String);
84
85impl RuleCode {
86    /// Create a new rule code.
87    pub fn new(code: impl Into<String>) -> Self {
88        Self(code.into())
89    }
90
91    /// Get the code as a string slice.
92    pub fn as_str(&self) -> &str {
93        &self.0
94    }
95
96    /// Check if this is a security-related check.
97    pub fn is_security_check(&self) -> bool {
98        const SECURITY_CHECKS: &[&str] = &[
99            "privileged-container",
100            "privilege-escalation",
101            "run-as-non-root",
102            "read-only-root-fs",
103            "drop-net-raw-capability",
104            "hostnetwork",
105            "hostpid",
106            "hostipc",
107            "host-mounts",
108            "writable-host-mount",
109            "docker-sock",
110            "unsafe-proc-mount",
111            "access-to-secrets",
112            "access-to-create-pods",
113            "cluster-admin-role-binding",
114            "wildcard-in-rules",
115        ];
116        SECURITY_CHECKS.contains(&self.0.as_str())
117    }
118
119    /// Check if this is a best practice check.
120    pub fn is_best_practice_check(&self) -> bool {
121        const BEST_PRACTICE_CHECKS: &[&str] = &[
122            "latest-tag",
123            "no-liveness-probe",
124            "no-readiness-probe",
125            "unset-cpu-requirements",
126            "unset-memory-requirements",
127            "minimum-replicas",
128            "no-anti-affinity",
129            "no-rolling-update-strategy",
130            "default-service-account",
131        ];
132        BEST_PRACTICE_CHECKS.contains(&self.0.as_str())
133    }
134}
135
136impl fmt::Display for RuleCode {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        write!(f, "{}", self.0)
139    }
140}
141
142impl From<&str> for RuleCode {
143    fn from(s: &str) -> Self {
144        Self::new(s)
145    }
146}
147
148impl From<String> for RuleCode {
149    fn from(s: String) -> Self {
150        Self(s)
151    }
152}
153
154impl AsRef<str> for RuleCode {
155    fn as_ref(&self) -> &str {
156        &self.0
157    }
158}
159
160/// A diagnostic message produced by a check.
161///
162/// This is the raw output from a check function before it's
163/// enriched with context information.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct Diagnostic {
166    /// The diagnostic message describing the issue.
167    pub message: String,
168    /// Optional remediation advice.
169    pub remediation: Option<String>,
170}
171
172impl Diagnostic {
173    /// Create a new diagnostic with just a message.
174    pub fn new(message: impl Into<String>) -> Self {
175        Self {
176            message: message.into(),
177            remediation: None,
178        }
179    }
180
181    /// Create a diagnostic with message and remediation.
182    pub fn with_remediation(message: impl Into<String>, remediation: impl Into<String>) -> Self {
183        Self {
184            message: message.into(),
185            remediation: Some(remediation.into()),
186        }
187    }
188}
189
190impl From<String> for Diagnostic {
191    fn from(message: String) -> Self {
192        Self::new(message)
193    }
194}
195
196impl From<&str> for Diagnostic {
197    fn from(message: &str) -> Self {
198        Self::new(message)
199    }
200}
201
202/// A check failure (rule violation) found during linting.
203///
204/// This is the enriched form of a diagnostic, including context
205/// about which object and file triggered the failure.
206#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
207pub struct CheckFailure {
208    /// The check code that was violated.
209    pub code: RuleCode,
210    /// The severity of the violation.
211    pub severity: Severity,
212    /// A human-readable message describing the violation.
213    pub message: String,
214    /// The file path where the violation occurred.
215    pub file_path: PathBuf,
216    /// The name of the Kubernetes object.
217    pub object_name: String,
218    /// The kind of the Kubernetes object (e.g., "Deployment", "Service").
219    pub object_kind: String,
220    /// The namespace of the object (if applicable).
221    pub object_namespace: Option<String>,
222    /// Optional line number (1-indexed).
223    pub line: Option<u32>,
224    /// Optional remediation advice.
225    pub remediation: Option<String>,
226}
227
228impl CheckFailure {
229    /// Create a new check failure.
230    pub fn new(
231        code: impl Into<RuleCode>,
232        severity: Severity,
233        message: impl Into<String>,
234        file_path: impl Into<PathBuf>,
235        object_name: impl Into<String>,
236        object_kind: impl Into<String>,
237    ) -> Self {
238        Self {
239            code: code.into(),
240            severity,
241            message: message.into(),
242            file_path: file_path.into(),
243            object_name: object_name.into(),
244            object_kind: object_kind.into(),
245            object_namespace: None,
246            line: None,
247            remediation: None,
248        }
249    }
250
251    /// Set the namespace.
252    pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
253        self.object_namespace = Some(namespace.into());
254        self
255    }
256
257    /// Set the line number.
258    pub fn with_line(mut self, line: u32) -> Self {
259        self.line = Some(line);
260        self
261    }
262
263    /// Set remediation advice.
264    pub fn with_remediation(mut self, remediation: impl Into<String>) -> Self {
265        self.remediation = Some(remediation.into());
266        self
267    }
268
269    /// Get a full identifier for the object (namespace/name or just name).
270    pub fn object_identifier(&self) -> String {
271        match &self.object_namespace {
272            Some(ns) => format!("{}/{}", ns, self.object_name),
273            None => self.object_name.clone(),
274        }
275    }
276}
277
278impl Ord for CheckFailure {
279    fn cmp(&self, other: &Self) -> Ordering {
280        // Sort by file path, then by line number, then by severity
281        match self.file_path.cmp(&other.file_path) {
282            Ordering::Equal => match (self.line, other.line) {
283                (Some(a), Some(b)) => match a.cmp(&b) {
284                    Ordering::Equal => self.severity.cmp(&other.severity),
285                    other => other,
286                },
287                (Some(_), None) => Ordering::Less,
288                (None, Some(_)) => Ordering::Greater,
289                (None, None) => self.severity.cmp(&other.severity),
290            },
291            other => other,
292        }
293    }
294}
295
296impl PartialOrd for CheckFailure {
297    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
298        Some(self.cmp(other))
299    }
300}
301
302/// Object kinds that kube-linter can analyze.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
304pub enum ObjectKind {
305    // Core workloads
306    Deployment,
307    StatefulSet,
308    DaemonSet,
309    ReplicaSet,
310    Pod,
311    Job,
312    CronJob,
313
314    // Services & Networking
315    Service,
316    Ingress,
317    NetworkPolicy,
318
319    // RBAC
320    Role,
321    ClusterRole,
322    RoleBinding,
323    ClusterRoleBinding,
324    ServiceAccount,
325
326    // Scaling & Disruption
327    HorizontalPodAutoscaler,
328    PodDisruptionBudget,
329
330    // Storage
331    PersistentVolumeClaim,
332
333    // OpenShift specific
334    DeploymentConfig,
335    SecurityContextConstraints,
336
337    // Monitoring
338    ServiceMonitor,
339
340    // KEDA
341    ScaledObject,
342
343    // Any/Unknown
344    Any,
345}
346
347impl ObjectKind {
348    /// Get the string representation matching Kubernetes kind names.
349    pub fn as_str(&self) -> &'static str {
350        match self {
351            Self::Deployment => "Deployment",
352            Self::StatefulSet => "StatefulSet",
353            Self::DaemonSet => "DaemonSet",
354            Self::ReplicaSet => "ReplicaSet",
355            Self::Pod => "Pod",
356            Self::Job => "Job",
357            Self::CronJob => "CronJob",
358            Self::Service => "Service",
359            Self::Ingress => "Ingress",
360            Self::NetworkPolicy => "NetworkPolicy",
361            Self::Role => "Role",
362            Self::ClusterRole => "ClusterRole",
363            Self::RoleBinding => "RoleBinding",
364            Self::ClusterRoleBinding => "ClusterRoleBinding",
365            Self::ServiceAccount => "ServiceAccount",
366            Self::HorizontalPodAutoscaler => "HorizontalPodAutoscaler",
367            Self::PodDisruptionBudget => "PodDisruptionBudget",
368            Self::PersistentVolumeClaim => "PersistentVolumeClaim",
369            Self::DeploymentConfig => "DeploymentConfig",
370            Self::SecurityContextConstraints => "SecurityContextConstraints",
371            Self::ServiceMonitor => "ServiceMonitor",
372            Self::ScaledObject => "ScaledObject",
373            Self::Any => "Any",
374        }
375    }
376
377    /// Parse from a Kubernetes kind string.
378    pub fn from_kind(kind: &str) -> Option<Self> {
379        match kind {
380            "Deployment" => Some(Self::Deployment),
381            "StatefulSet" => Some(Self::StatefulSet),
382            "DaemonSet" => Some(Self::DaemonSet),
383            "ReplicaSet" => Some(Self::ReplicaSet),
384            "Pod" => Some(Self::Pod),
385            "Job" => Some(Self::Job),
386            "CronJob" => Some(Self::CronJob),
387            "Service" => Some(Self::Service),
388            "Ingress" => Some(Self::Ingress),
389            "NetworkPolicy" => Some(Self::NetworkPolicy),
390            "Role" => Some(Self::Role),
391            "ClusterRole" => Some(Self::ClusterRole),
392            "RoleBinding" => Some(Self::RoleBinding),
393            "ClusterRoleBinding" => Some(Self::ClusterRoleBinding),
394            "ServiceAccount" => Some(Self::ServiceAccount),
395            "HorizontalPodAutoscaler" => Some(Self::HorizontalPodAutoscaler),
396            "PodDisruptionBudget" => Some(Self::PodDisruptionBudget),
397            "PersistentVolumeClaim" => Some(Self::PersistentVolumeClaim),
398            "DeploymentConfig" => Some(Self::DeploymentConfig),
399            "SecurityContextConstraints" => Some(Self::SecurityContextConstraints),
400            "ServiceMonitor" => Some(Self::ServiceMonitor),
401            "ScaledObject" => Some(Self::ScaledObject),
402            _ => None,
403        }
404    }
405
406    /// Check if this kind is "DeploymentLike" (has a PodSpec).
407    pub fn is_deployment_like(&self) -> bool {
408        matches!(
409            self,
410            Self::Deployment
411                | Self::StatefulSet
412                | Self::DaemonSet
413                | Self::ReplicaSet
414                | Self::Pod
415                | Self::Job
416                | Self::CronJob
417                | Self::DeploymentConfig
418        )
419    }
420
421    /// Check if this kind is "JobLike".
422    pub fn is_job_like(&self) -> bool {
423        matches!(self, Self::Job | Self::CronJob)
424    }
425}
426
427impl fmt::Display for ObjectKind {
428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429        write!(f, "{}", self.as_str())
430    }
431}
432
433/// Describes which object kinds a check applies to.
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct ObjectKindsDesc {
436    /// List of object kind identifiers.
437    /// Can include specific kinds or group names like "DeploymentLike".
438    pub object_kinds: Vec<String>,
439}
440
441impl ObjectKindsDesc {
442    /// Create a new object kinds description.
443    pub fn new(kinds: &[&str]) -> Self {
444        Self {
445            object_kinds: kinds.iter().map(|s| (*s).to_string()).collect(),
446        }
447    }
448
449    /// Check if the given kind matches this description.
450    pub fn matches(&self, kind: &ObjectKind) -> bool {
451        // Empty list means "DeploymentLike" (for DEPLOYMENT_LIKE const)
452        if self.object_kinds.is_empty() {
453            return kind.is_deployment_like();
454        }
455
456        for k in &self.object_kinds {
457            match k.as_str() {
458                "DeploymentLike" if kind.is_deployment_like() => return true,
459                "JobLike" if kind.is_job_like() => return true,
460                "Any" => return true,
461                _ if k == kind.as_str() => return true,
462                _ => continue,
463            }
464        }
465        false
466    }
467}
468
469impl Default for ObjectKindsDesc {
470    fn default() -> Self {
471        Self {
472            object_kinds: vec!["DeploymentLike".to_string()],
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480
481    #[test]
482    fn test_severity_ordering() {
483        assert!(Severity::Error > Severity::Warning);
484        assert!(Severity::Warning > Severity::Info);
485    }
486
487    #[test]
488    fn test_severity_from_str() {
489        assert_eq!(Severity::parse("error"), Some(Severity::Error));
490        assert_eq!(Severity::parse("WARNING"), Some(Severity::Warning));
491        assert_eq!(Severity::parse("Info"), Some(Severity::Info));
492        assert_eq!(Severity::parse("invalid"), None);
493    }
494
495    #[test]
496    fn test_rule_code() {
497        let code = RuleCode::new("privileged-container");
498        assert!(code.is_security_check());
499        assert!(!code.is_best_practice_check());
500
501        let code = RuleCode::new("latest-tag");
502        assert!(!code.is_security_check());
503        assert!(code.is_best_practice_check());
504    }
505
506    #[test]
507    fn test_check_failure_ordering() {
508        let f1 = CheckFailure::new(
509            "check1",
510            Severity::Warning,
511            "msg1",
512            "a.yaml",
513            "obj1",
514            "Deployment",
515        )
516        .with_line(10);
517        let f2 = CheckFailure::new(
518            "check2",
519            Severity::Error,
520            "msg2",
521            "a.yaml",
522            "obj2",
523            "Service",
524        )
525        .with_line(5);
526        let f3 = CheckFailure::new("check3", Severity::Info, "msg3", "b.yaml", "obj3", "Pod");
527
528        let mut failures = vec![f1.clone(), f2.clone(), f3.clone()];
529        failures.sort();
530
531        // Should be sorted by file, then line
532        assert_eq!(failures[0].file_path.to_str(), Some("a.yaml"));
533        assert_eq!(failures[0].line, Some(5));
534        assert_eq!(failures[1].file_path.to_str(), Some("a.yaml"));
535        assert_eq!(failures[1].line, Some(10));
536        assert_eq!(failures[2].file_path.to_str(), Some("b.yaml"));
537    }
538
539    #[test]
540    fn test_object_kind_matching() {
541        let desc = ObjectKindsDesc::new(&["DeploymentLike"]);
542        assert!(desc.matches(&ObjectKind::Deployment));
543        assert!(desc.matches(&ObjectKind::StatefulSet));
544        assert!(desc.matches(&ObjectKind::DaemonSet));
545        assert!(desc.matches(&ObjectKind::Job));
546        assert!(!desc.matches(&ObjectKind::Service));
547
548        let desc = ObjectKindsDesc::new(&["Service", "Ingress"]);
549        assert!(desc.matches(&ObjectKind::Service));
550        assert!(desc.matches(&ObjectKind::Ingress));
551        assert!(!desc.matches(&ObjectKind::Deployment));
552    }
553
554    #[test]
555    fn test_diagnostic() {
556        let d = Diagnostic::new("container is privileged");
557        assert_eq!(d.message, "container is privileged");
558        assert!(d.remediation.is_none());
559
560        let d = Diagnostic::with_remediation("issue", "fix it");
561        assert_eq!(d.message, "issue");
562        assert_eq!(d.remediation, Some("fix it".to_string()));
563    }
564}