syncable_cli/analyzer/k8s_optimize/
static_analyzer.rs1use 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
24pub 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 if config.should_ignore_path(path) {
42 result.metadata.duration_ms = start.elapsed().as_millis() as u64;
43 return result;
44 }
45
46 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 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 for (file_path, content) in yaml_contents {
74 analyze_yaml_content(&content, &file_path, config, &mut result);
75 }
76
77 if path.is_dir() {
79 analyze_terraform_resources(path, config, &mut result);
80 }
81
82 update_summary(&mut result);
84
85 result.sort();
87
88 result.metadata.duration_ms = start.elapsed().as_millis() as u64;
89 result
90}
91
92pub fn analyze_file(path: &Path, config: &K8sOptimizeConfig) -> OptimizationResult {
94 analyze(path, config)
95}
96
97pub 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
111fn analyze_yaml_content(
117 content: &str,
118 file_path: &Path,
119 config: &K8sOptimizeConfig,
120 result: &mut OptimizationResult,
121) {
122 let mut line_offset = 1u32;
124
125 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); continue;
132 }
133
134 let yaml_start = doc.lines().position(|line| {
137 let trimmed = line.trim();
138 !trimmed.is_empty() && !trimmed.starts_with('#')
139 });
140
141 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; }
150 };
151
152 if doc.is_empty() {
153 line_offset += doc_line_count.max(1);
154 continue;
155 }
156
157 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 let kind = match yaml.get("kind").and_then(|v| v.as_str()) {
168 Some(k) => k,
169 None => continue,
170 };
171
172 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 if let Some(ref ns) = namespace
194 && config.should_exclude_namespace(ns)
195 {
196 continue;
197 }
198
199 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), current: resources,
221 workload_type,
222 };
223
224 let recommendations = generate_recommendations(&ctx, config);
225 result.recommendations.extend(recommendations);
226 }
227
228 line_offset += doc_line_count.max(1);
230 }
231}
232
233fn 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" )
246}
247
248fn extract_containers(yaml: &serde_yaml::Value, kind: &str) -> Vec<serde_yaml::Value> {
250 let mut containers = Vec::new();
251
252 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 if let Some(serde_yaml::Value::Sequence(ctrs)) = spec.get("containers") {
270 containers.extend(ctrs.iter().cloned());
271 }
272
273 if let Some(serde_yaml::Value::Sequence(ctrs)) = spec.get("initContainers") {
275 containers.extend(ctrs.iter().cloned());
276 }
277 }
278
279 containers
280}
281
282fn 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
296fn 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
305fn 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
344fn render_kustomize(kustomize_path: &Path) -> Option<String> {
347 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 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
395fn collect_yaml_files(dir: &Path) -> Vec<(std::path::PathBuf, String)> {
399 let mut files = Vec::new();
400
401 let chart_yaml = dir.join("Chart.yaml");
403 if chart_yaml.exists() {
404 if let Some(rendered) = render_helm_chart(dir) {
406 files.push((dir.to_path_buf(), rendered));
407 return files;
408 }
409 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 let kustomization = dir.join("kustomization.yaml");
420 let kustomization_alt = dir.join("kustomization.yml");
421 if kustomization.exists() || kustomization_alt.exists() {
422 if let Some(rendered) = render_kustomize(dir) {
424 files.push((dir.to_path_buf(), rendered));
425 return files;
426 }
427 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 find_and_render_nested(dir, &mut files);
435
436 collect_yaml_files_recursive(dir, &mut files);
438 files
439}
440
441fn 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 if path.join("Chart.yaml").exists() {
456 if let Some(rendered) = render_helm_chart(&path) {
457 files.push((path.clone(), rendered));
458 }
459 continue; }
461
462 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; }
469
470 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
495fn 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 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 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
526fn 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
547fn 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 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 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 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#[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] 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 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(); let result = analyze_content(yaml, &config);
828
829 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}