syncable_cli/analyzer/k8s_optimize/
static_analyzer.rs

1//! Static analysis of Kubernetes manifests for resource optimization.
2//!
3//! Analyzes Kubernetes manifests to detect over-provisioned or under-provisioned
4//! resources without requiring cluster access.
5//!
6//! Supports:
7//! - Kubernetes YAML manifests
8//! - **Terraform HCL** files with `kubernetes_*` provider resources
9//! - **Helm charts** - Renders with `helm template` before analysis
10//! - **Kustomize directories** - Builds with `kustomize build` before analysis
11
12use super::config::K8sOptimizeConfig;
13use super::parser::{
14    detect_workload_type, extract_container_image, extract_container_name, extract_resources,
15};
16use super::recommender::{ContainerContext, generate_recommendations};
17use super::terraform_parser::parse_terraform_k8s_resources;
18use super::types::{AnalysisMode, OptimizationIssue, OptimizationResult};
19
20use std::path::Path;
21use std::process::Command;
22use std::time::Instant;
23
24// ============================================================================
25// Main Analysis Functions
26// ============================================================================
27
28/// Analyze Kubernetes manifests from a path.
29///
30/// The path can be:
31/// - A single YAML file
32/// - A single Terraform (.tf) file
33/// - A directory containing YAML and/or Terraform files
34/// - A Helm chart directory
35/// - A Kustomize directory
36pub fn analyze(path: &Path, config: &K8sOptimizeConfig) -> OptimizationResult {
37    let start = Instant::now();
38    let mut result = OptimizationResult::new(path.to_path_buf(), AnalysisMode::Static);
39
40    // Check if path should be ignored
41    if config.should_ignore_path(path) {
42        result.metadata.duration_ms = start.elapsed().as_millis() as u64;
43        return result;
44    }
45
46    // Load and parse YAML content
47    let yaml_contents = if path.is_dir() {
48        collect_yaml_files(path)
49    } else if path.is_file() {
50        if let Some(ext) = path.extension()
51            && ext == "tf"
52        {
53            // Single Terraform file - process it separately
54            analyze_terraform_resources(path, config, &mut result);
55            update_summary(&mut result);
56            result.sort();
57            result.metadata.duration_ms = start.elapsed().as_millis() as u64;
58            return result;
59        }
60        match std::fs::read_to_string(path) {
61            Ok(content) => vec![(path.to_path_buf(), content)],
62            Err(_) => {
63                result.metadata.duration_ms = start.elapsed().as_millis() as u64;
64                return result;
65            }
66        }
67    } else {
68        result.metadata.duration_ms = start.elapsed().as_millis() as u64;
69        return result;
70    };
71
72    // Analyze each YAML file
73    for (file_path, content) in yaml_contents {
74        analyze_yaml_content(&content, &file_path, config, &mut result);
75    }
76
77    // Also analyze Terraform files in the directory
78    if path.is_dir() {
79        analyze_terraform_resources(path, config, &mut result);
80    }
81
82    // Update summary
83    update_summary(&mut result);
84
85    // Sort recommendations by severity
86    result.sort();
87
88    result.metadata.duration_ms = start.elapsed().as_millis() as u64;
89    result
90}
91
92/// Analyze a single YAML file.
93pub fn analyze_file(path: &Path, config: &K8sOptimizeConfig) -> OptimizationResult {
94    analyze(path, config)
95}
96
97/// Analyze YAML content directly.
98pub fn analyze_content(content: &str, config: &K8sOptimizeConfig) -> OptimizationResult {
99    let start = Instant::now();
100    let mut result =
101        OptimizationResult::new(std::path::PathBuf::from("<content>"), AnalysisMode::Static);
102
103    analyze_yaml_content(content, Path::new("<content>"), config, &mut result);
104    update_summary(&mut result);
105    result.sort();
106
107    result.metadata.duration_ms = start.elapsed().as_millis() as u64;
108    result
109}
110
111// ============================================================================
112// Internal Analysis
113// ============================================================================
114
115/// Analyze YAML content and add recommendations to result.
116fn analyze_yaml_content(
117    content: &str,
118    file_path: &Path,
119    config: &K8sOptimizeConfig,
120    result: &mut OptimizationResult,
121) {
122    // Track line numbers as we split multi-document YAML
123    let mut line_offset = 1u32;
124
125    // Split multi-document YAML
126    for doc in content.split("\n---") {
127        let doc_line_count = doc.lines().count() as u32;
128        let doc = doc.trim();
129        if doc.is_empty() {
130            line_offset += doc_line_count.max(1); // At least 1 for the separator
131            continue;
132        }
133
134        // Strip leading YAML comments (like Helm's # Source: comments)
135        // but keep the actual YAML content
136        let yaml_start = doc.lines().position(|line| {
137            let trimmed = line.trim();
138            !trimmed.is_empty() && !trimmed.starts_with('#')
139        });
140
141        // Calculate the actual line where YAML content starts
142        let content_line_offset = line_offset + yaml_start.unwrap_or(0) as u32;
143
144        let doc = match yaml_start {
145            Some(start) => doc.lines().skip(start).collect::<Vec<_>>().join("\n"),
146            None => {
147                line_offset += doc_line_count.max(1);
148                continue; // All lines are comments
149            }
150        };
151
152        if doc.is_empty() {
153            line_offset += doc_line_count.max(1);
154            continue;
155        }
156
157        // Parse YAML document
158        let yaml: serde_yaml::Value = match serde_yaml::from_str(&doc) {
159            Ok(v) => v,
160            Err(_) => {
161                line_offset += doc_line_count.max(1);
162                continue;
163            }
164        };
165
166        // Extract kind and metadata
167        let kind = match yaml.get("kind").and_then(|v| v.as_str()) {
168            Some(k) => k,
169            None => continue,
170        };
171
172        // Only analyze workload kinds
173        if !is_workload_kind(kind) {
174            continue;
175        }
176
177        result.summary.resources_analyzed += 1;
178
179        let name = yaml
180            .get("metadata")
181            .and_then(|m| m.get("name"))
182            .and_then(|n| n.as_str())
183            .unwrap_or("unknown")
184            .to_string();
185
186        let namespace = yaml
187            .get("metadata")
188            .and_then(|m| m.get("namespace"))
189            .and_then(|n| n.as_str())
190            .map(String::from);
191
192        // Check if namespace should be excluded
193        if let Some(ref ns) = namespace
194            && config.should_exclude_namespace(ns)
195        {
196            continue;
197        }
198
199        // Extract containers from pod spec
200        let containers = extract_containers(&yaml, kind);
201
202        for container in containers {
203            result.summary.containers_analyzed += 1;
204
205            let container_name =
206                extract_container_name(&container).unwrap_or_else(|| "unknown".to_string());
207            let container_image = extract_container_image(&container);
208            let resources = extract_resources(&container);
209
210            let workload_type =
211                detect_workload_type(container_image.as_deref(), Some(&container_name), kind);
212
213            let ctx = ContainerContext {
214                resource_kind: kind.to_string(),
215                resource_name: name.clone(),
216                namespace: namespace.clone(),
217                container_name,
218                file_path: file_path.to_path_buf(),
219                line: Some(content_line_offset), // Line where this K8s object starts
220                current: resources,
221                workload_type,
222            };
223
224            let recommendations = generate_recommendations(&ctx, config);
225            result.recommendations.extend(recommendations);
226        }
227
228        // Update line offset for next document (add 1 for the --- separator)
229        line_offset += doc_line_count.max(1);
230    }
231}
232
233/// Check if a kind is a workload that has containers.
234fn is_workload_kind(kind: &str) -> bool {
235    matches!(
236        kind,
237        "Deployment"
238            | "StatefulSet"
239            | "DaemonSet"
240            | "ReplicaSet"
241            | "Pod"
242            | "Job"
243            | "CronJob"
244            | "DeploymentConfig" // OpenShift
245    )
246}
247
248/// Extract containers from a workload YAML.
249fn extract_containers(yaml: &serde_yaml::Value, kind: &str) -> Vec<serde_yaml::Value> {
250    let mut containers = Vec::new();
251
252    // Get pod spec path based on kind
253    let pod_spec = match kind {
254        "Pod" => yaml.get("spec"),
255        "CronJob" => yaml
256            .get("spec")
257            .and_then(|s| s.get("jobTemplate"))
258            .and_then(|j| j.get("spec"))
259            .and_then(|s| s.get("template"))
260            .and_then(|t| t.get("spec")),
261        _ => yaml
262            .get("spec")
263            .and_then(|s| s.get("template"))
264            .and_then(|t| t.get("spec")),
265    };
266
267    if let Some(spec) = pod_spec {
268        // Regular containers
269        if let Some(serde_yaml::Value::Sequence(ctrs)) = spec.get("containers") {
270            containers.extend(ctrs.iter().cloned());
271        }
272
273        // Init containers
274        if let Some(serde_yaml::Value::Sequence(ctrs)) = spec.get("initContainers") {
275            containers.extend(ctrs.iter().cloned());
276        }
277    }
278
279    containers
280}
281
282// ============================================================================
283// Helm and Kustomize Rendering
284// ============================================================================
285
286/// Check if helm binary is available.
287fn is_helm_available() -> bool {
288    Command::new("helm")
289        .arg("version")
290        .arg("--short")
291        .output()
292        .map(|o| o.status.success())
293        .unwrap_or(false)
294}
295
296/// Check if kustomize binary is available.
297fn is_kustomize_available() -> bool {
298    Command::new("kustomize")
299        .arg("version")
300        .output()
301        .map(|o| o.status.success())
302        .unwrap_or(false)
303}
304
305/// Render a Helm chart using `helm template`.
306/// Returns the rendered YAML content.
307fn render_helm_chart(chart_path: &Path) -> Option<String> {
308    if !is_helm_available() {
309        log::warn!(
310            "helm not found in PATH, skipping Helm chart rendering for {}",
311            chart_path.display()
312        );
313        return None;
314    }
315
316    let output = Command::new("helm")
317        .arg("template")
318        .arg("release-name")
319        .arg(chart_path)
320        .output();
321
322    match output {
323        Ok(o) if o.status.success() => Some(String::from_utf8_lossy(&o.stdout).to_string()),
324        Ok(o) => {
325            let stderr = String::from_utf8_lossy(&o.stderr);
326            log::warn!(
327                "Helm template failed for {}: {}",
328                chart_path.display(),
329                stderr
330            );
331            None
332        }
333        Err(e) => {
334            log::warn!(
335                "Failed to run helm template for {}: {}",
336                chart_path.display(),
337                e
338            );
339            None
340        }
341    }
342}
343
344/// Render a Kustomize directory using `kustomize build`.
345/// Returns the rendered YAML content.
346fn render_kustomize(kustomize_path: &Path) -> Option<String> {
347    // Try kubectl kustomize first (more commonly available)
348    let kubectl_output = Command::new("kubectl")
349        .arg("kustomize")
350        .arg(kustomize_path)
351        .output();
352
353    if let Ok(o) = kubectl_output
354        && o.status.success()
355    {
356        return Some(String::from_utf8_lossy(&o.stdout).to_string());
357    }
358
359    // Fall back to standalone kustomize
360    if !is_kustomize_available() {
361        log::warn!(
362            "kustomize not found in PATH, skipping Kustomize rendering for {}",
363            kustomize_path.display()
364        );
365        return None;
366    }
367
368    let output = Command::new("kustomize")
369        .arg("build")
370        .arg(kustomize_path)
371        .output();
372
373    match output {
374        Ok(o) if o.status.success() => Some(String::from_utf8_lossy(&o.stdout).to_string()),
375        Ok(o) => {
376            let stderr = String::from_utf8_lossy(&o.stderr);
377            log::warn!(
378                "Kustomize build failed for {}: {}",
379                kustomize_path.display(),
380                stderr
381            );
382            None
383        }
384        Err(e) => {
385            log::warn!(
386                "Failed to run kustomize build for {}: {}",
387                kustomize_path.display(),
388                e
389            );
390            None
391        }
392    }
393}
394
395/// Collect all YAML files from a directory.
396/// For Helm charts, renders with `helm template`.
397/// For Kustomize directories, builds with `kustomize build`.
398fn collect_yaml_files(dir: &Path) -> Vec<(std::path::PathBuf, String)> {
399    let mut files = Vec::new();
400
401    // Check if this is a Helm chart
402    let chart_yaml = dir.join("Chart.yaml");
403    if chart_yaml.exists() {
404        // Render the Helm chart
405        if let Some(rendered) = render_helm_chart(dir) {
406            files.push((dir.to_path_buf(), rendered));
407            return files;
408        }
409        // Fallback: just read templates directly (won't parse {{ }} syntax well)
410        let templates_dir = dir.join("templates");
411        if templates_dir.exists() {
412            log::info!("Falling back to raw template parsing for {}", dir.display());
413            collect_yaml_files_recursive(&templates_dir, &mut files);
414        }
415        return files;
416    }
417
418    // Check if this is a Kustomize directory
419    let kustomization = dir.join("kustomization.yaml");
420    let kustomization_alt = dir.join("kustomization.yml");
421    if kustomization.exists() || kustomization_alt.exists() {
422        // Render the Kustomize directory
423        if let Some(rendered) = render_kustomize(dir) {
424            files.push((dir.to_path_buf(), rendered));
425            return files;
426        }
427        // Fallback: collect YAML files directly
428        log::info!("Falling back to raw YAML parsing for {}", dir.display());
429        collect_yaml_files_recursive(dir, &mut files);
430        return files;
431    }
432
433    // Check for nested Helm charts and Kustomize directories
434    find_and_render_nested(dir, &mut files);
435
436    // Also collect regular YAML files
437    collect_yaml_files_recursive(dir, &mut files);
438    files
439}
440
441/// Find and render nested Helm charts and Kustomize directories.
442fn find_and_render_nested(dir: &Path, files: &mut Vec<(std::path::PathBuf, String)>) {
443    let entries = match std::fs::read_dir(dir) {
444        Ok(e) => e,
445        Err(_) => return,
446    };
447
448    for entry in entries.flatten() {
449        let path = entry.path();
450        if !path.is_dir() {
451            continue;
452        }
453
454        // Check for Helm chart
455        if path.join("Chart.yaml").exists() {
456            if let Some(rendered) = render_helm_chart(&path) {
457                files.push((path.clone(), rendered));
458            }
459            continue; // Don't recurse into rendered charts
460        }
461
462        // Check for Kustomize
463        if path.join("kustomization.yaml").exists() || path.join("kustomization.yml").exists() {
464            if let Some(rendered) = render_kustomize(&path) {
465                files.push((path.clone(), rendered));
466            }
467            continue; // Don't recurse into rendered kustomize dirs
468        }
469
470        // Recurse into subdirectories
471        find_and_render_nested(&path, files);
472    }
473}
474
475fn collect_yaml_files_recursive(dir: &Path, files: &mut Vec<(std::path::PathBuf, String)>) {
476    let entries = match std::fs::read_dir(dir) {
477        Ok(e) => e,
478        Err(_) => return,
479    };
480
481    for entry in entries.flatten() {
482        let path = entry.path();
483        if path.is_dir() {
484            collect_yaml_files_recursive(&path, files);
485        } else if let Some(ext) = path.extension() {
486            if (ext == "yaml" || ext == "yml")
487                && let Ok(content) = std::fs::read_to_string(&path)
488            {
489                files.push((path, content));
490            }
491        }
492    }
493}
494
495/// Update the summary statistics based on recommendations.
496fn update_summary(result: &mut OptimizationResult) {
497    for rec in &result.recommendations {
498        match rec.issue {
499            OptimizationIssue::OverProvisioned => result.summary.over_provisioned += 1,
500            OptimizationIssue::UnderProvisioned => result.summary.under_provisioned += 1,
501            OptimizationIssue::NoRequestsDefined => result.summary.missing_requests += 1,
502            OptimizationIssue::NoLimitsDefined => result.summary.missing_limits += 1,
503            _ => {}
504        }
505    }
506
507    // Calculate optimal count
508    if result.summary.containers_analyzed > 0 {
509        let issue_count = result.summary.over_provisioned
510            + result.summary.under_provisioned
511            + result.summary.missing_requests;
512        result.summary.optimal = result
513            .summary
514            .containers_analyzed
515            .saturating_sub(issue_count);
516    }
517
518    // Calculate waste percentage (simplified - based on over-provisioned count)
519    if result.summary.containers_analyzed > 0 {
520        result.summary.total_waste_percentage = (result.summary.over_provisioned as f32
521            / result.summary.containers_analyzed as f32)
522            * 100.0;
523    }
524}
525
526// ============================================================================
527// Terraform Analysis
528// ============================================================================
529
530/// Format bytes to K8s memory format (Mi, Gi, etc.)
531fn format_bytes_to_k8s(bytes: u64) -> String {
532    const GI: u64 = 1024 * 1024 * 1024;
533    const MI: u64 = 1024 * 1024;
534    const KI: u64 = 1024;
535
536    if bytes >= GI && bytes.is_multiple_of(GI) {
537        format!("{}Gi", bytes / GI)
538    } else if bytes >= MI && bytes.is_multiple_of(MI) {
539        format!("{}Mi", bytes / MI)
540    } else if bytes >= KI && bytes.is_multiple_of(KI) {
541        format!("{}Ki", bytes / KI)
542    } else {
543        format!("{}", bytes)
544    }
545}
546
547/// Analyze Terraform files for Kubernetes resources.
548fn analyze_terraform_resources(
549    path: &Path,
550    config: &K8sOptimizeConfig,
551    result: &mut OptimizationResult,
552) {
553    use super::types::ResourceSpec;
554
555    let tf_resources = parse_terraform_k8s_resources(path);
556
557    for tf_res in tf_resources {
558        // Skip system namespaces if not included
559        if let Some(ref ns) = tf_res.namespace
560            && config.should_exclude_namespace(ns)
561        {
562            continue;
563        }
564
565        result.summary.resources_analyzed += 1;
566
567        // Map Terraform resource type to K8s kind
568        let kind = match tf_res.resource_type.as_str() {
569            t if t.contains("deployment") => "Deployment",
570            t if t.contains("stateful_set") => "StatefulSet",
571            t if t.contains("daemon_set") => "DaemonSet",
572            t if t.contains("job") && !t.contains("cron") => "Job",
573            t if t.contains("cron_job") => "CronJob",
574            t if t.contains("pod") => "Pod",
575            _ => "Deployment",
576        };
577
578        let resource_name = tf_res
579            .k8s_name
580            .clone()
581            .unwrap_or_else(|| tf_res.tf_name.clone());
582
583        for container in &tf_res.containers {
584            result.summary.containers_analyzed += 1;
585
586            // Build ResourceSpec from Terraform container
587            // Convert millicores/bytes back to K8s format strings
588            let cpu_req = container
589                .requests
590                .as_ref()
591                .and_then(|r| r.cpu)
592                .map(|c| format!("{}m", c));
593            let mem_req = container
594                .requests
595                .as_ref()
596                .and_then(|r| r.memory)
597                .map(format_bytes_to_k8s);
598            let cpu_lim = container
599                .limits
600                .as_ref()
601                .and_then(|l| l.cpu)
602                .map(|c| format!("{}m", c));
603            let mem_lim = container
604                .limits
605                .as_ref()
606                .and_then(|l| l.memory)
607                .map(format_bytes_to_k8s);
608
609            let current = ResourceSpec {
610                cpu_request: cpu_req,
611                memory_request: mem_req,
612                cpu_limit: cpu_lim,
613                memory_limit: mem_lim,
614            };
615
616            let ctx = ContainerContext {
617                resource_kind: kind.to_string(),
618                resource_name: resource_name.clone(),
619                namespace: tf_res.namespace.clone(),
620                container_name: container.name.clone(),
621                file_path: std::path::PathBuf::from(&tf_res.source_file),
622                line: None,
623                current,
624                workload_type: tf_res.workload_type,
625            };
626
627            let recommendations = generate_recommendations(&ctx, config);
628            result.recommendations.extend(recommendations);
629        }
630    }
631}
632
633// ============================================================================
634// Tests
635// ============================================================================
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn test_analyze_simple_deployment() {
643        let yaml = r#"
644apiVersion: apps/v1
645kind: Deployment
646metadata:
647  name: nginx-deployment
648  namespace: default
649spec:
650  replicas: 3
651  selector:
652    matchLabels:
653      app: nginx
654  template:
655    spec:
656      containers:
657      - name: nginx
658        image: nginx:1.21
659        resources:
660          requests:
661            cpu: 100m
662            memory: 128Mi
663          limits:
664            cpu: 500m
665            memory: 512Mi
666"#;
667        let config = K8sOptimizeConfig::default().with_system();
668        let result = analyze_content(yaml, &config);
669
670        assert_eq!(result.summary.resources_analyzed, 1);
671        assert_eq!(result.summary.containers_analyzed, 1);
672    }
673
674    #[test]
675    fn test_analyze_no_resources() {
676        let yaml = r#"
677apiVersion: apps/v1
678kind: Deployment
679metadata:
680  name: no-resources
681spec:
682  replicas: 1
683  selector:
684    matchLabels:
685      app: test
686  template:
687    spec:
688      containers:
689      - name: app
690        image: myapp:v1
691"#;
692        let config = K8sOptimizeConfig::default().with_system();
693        let result = analyze_content(yaml, &config);
694
695        assert_eq!(result.summary.containers_analyzed, 1);
696        assert!(result.has_recommendations());
697        assert!(
698            result
699                .recommendations
700                .iter()
701                .any(|r| { r.issue == OptimizationIssue::NoRequestsDefined })
702        );
703    }
704
705    #[test]
706    fn test_analyze_over_provisioned() {
707        let yaml = r#"
708apiVersion: apps/v1
709kind: Deployment
710metadata:
711  name: over-provisioned
712spec:
713  replicas: 1
714  selector:
715    matchLabels:
716      app: test
717  template:
718    spec:
719      containers:
720      - name: nginx
721        image: nginx:1.21
722        resources:
723          requests:
724            cpu: 4000m
725            memory: 8Gi
726          limits:
727            cpu: 8000m
728            memory: 16Gi
729"#;
730        let config = K8sOptimizeConfig::default().with_system();
731        let result = analyze_content(yaml, &config);
732
733        assert!(result.has_recommendations());
734        assert!(
735            result
736                .recommendations
737                .iter()
738                .any(|r| { r.issue == OptimizationIssue::OverProvisioned })
739        );
740    }
741
742    #[test]
743    fn test_analyze_multi_container() {
744        let yaml = r#"
745apiVersion: apps/v1
746kind: Deployment
747metadata:
748  name: multi-container
749spec:
750  replicas: 1
751  selector:
752    matchLabels:
753      app: test
754  template:
755    spec:
756      initContainers:
757      - name: init
758        image: busybox
759      containers:
760      - name: app
761        image: myapp:v1
762      - name: sidecar
763        image: envoy:v1
764"#;
765        let config = K8sOptimizeConfig::default().with_system();
766        let result = analyze_content(yaml, &config);
767
768        assert_eq!(result.summary.containers_analyzed, 3);
769    }
770
771    #[test]
772    #[ignore] // TODO: Fix test - cronjob getting unexpected OverProvisioned recommendations
773    fn test_analyze_cronjob() {
774        let yaml = r#"
775apiVersion: batch/v1
776kind: CronJob
777metadata:
778  name: batch-job
779spec:
780  schedule: "0 * * * *"
781  jobTemplate:
782    spec:
783      template:
784        spec:
785          containers:
786          - name: job
787            image: batch:v1
788            resources:
789              requests:
790                cpu: 2000m
791                memory: 4Gi
792          restartPolicy: Never
793"#;
794        let config = K8sOptimizeConfig::default().with_system();
795        let result = analyze_content(yaml, &config);
796
797        assert_eq!(result.summary.containers_analyzed, 1);
798        // CronJobs should be detected as Batch workload and not trigger over-provisioned warnings
799        assert!(
800            !result
801                .recommendations
802                .iter()
803                .any(|r| { r.issue == OptimizationIssue::OverProvisioned })
804        );
805    }
806
807    #[test]
808    fn test_exclude_kube_system() {
809        let yaml = r#"
810apiVersion: apps/v1
811kind: Deployment
812metadata:
813  name: coredns
814  namespace: kube-system
815spec:
816  replicas: 2
817  selector:
818    matchLabels:
819      app: coredns
820  template:
821    spec:
822      containers:
823      - name: coredns
824        image: coredns:1.10
825"#;
826        let config = K8sOptimizeConfig::default(); // include_system = false by default
827        let result = analyze_content(yaml, &config);
828
829        // kube-system should be excluded
830        assert_eq!(result.summary.containers_analyzed, 0);
831    }
832
833    #[test]
834    fn test_is_workload_kind() {
835        assert!(is_workload_kind("Deployment"));
836        assert!(is_workload_kind("StatefulSet"));
837        assert!(is_workload_kind("DaemonSet"));
838        assert!(is_workload_kind("Job"));
839        assert!(is_workload_kind("CronJob"));
840        assert!(is_workload_kind("Pod"));
841        assert!(!is_workload_kind("Service"));
842        assert!(!is_workload_kind("ConfigMap"));
843        assert!(!is_workload_kind("Secret"));
844    }
845}