syncable_cli/agent/tools/
k8s_optimize.rs

1//! K8s Optimize tool - Native Kubernetes resource optimization using Rig's Tool trait
2//!
3//! Analyzes Kubernetes manifests for over-provisioned or under-provisioned
4//! resources and suggests right-sized values.
5//!
6//! Output is optimized for AI agent decision-making with:
7//! - Categorized issues (over-provisioned, under-provisioned, missing resources)
8//! - Priority rankings (critical, high, medium, low)
9//! - Actionable fix recommendations with YAML snippets
10//! - Cost savings estimates (when available)
11//! - Live cluster analysis (optional, via Prometheus)
12//!
13//! # Prometheus Integration
14//!
15//! For data-driven recommendations based on actual usage:
16//! 1. Use `prometheus_discover` to find Prometheus in cluster
17//! 2. Use `prometheus_connect` to establish connection (port-forward or URL)
18//! 3. Use `k8s_optimize` with the prometheus URL from step 2
19
20use super::compression::{CompressionConfig, compress_tool_output};
21use rig::completion::ToolDefinition;
22use rig::tool::Tool;
23use serde::{Deserialize, Serialize};
24use serde_json::json;
25use std::path::PathBuf;
26
27use crate::analyzer::k8s_optimize::{
28    K8sOptimizeConfig, OptimizationResult, PrometheusAuth, PrometheusClient, Severity, analyze,
29    analyze_content, bytes_to_memory_string, millicores_to_cpu_string, parse_cpu_to_millicores,
30    parse_memory_to_bytes, rule_codes, rule_description,
31};
32
33/// Arguments for the k8s-optimize tool
34#[derive(Debug, Deserialize)]
35pub struct K8sOptimizeArgs {
36    /// Path to K8s manifest file or directory (relative to project root)
37    #[serde(default)]
38    pub path: Option<String>,
39
40    /// Inline YAML content to analyze (alternative to path)
41    #[serde(default)]
42    pub content: Option<String>,
43
44    /// Minimum severity to report: "critical", "high", "medium", "low", "info"
45    #[serde(default)]
46    pub severity: Option<String>,
47
48    /// Minimum waste percentage to report (default: 10)
49    #[serde(default)]
50    pub threshold: Option<u8>,
51
52    /// Include info-level suggestions
53    #[serde(default)]
54    pub include_info: bool,
55
56    /// Include system namespaces (kube-system, etc.)
57    #[serde(default)]
58    pub include_system: bool,
59
60    /// Run FULL comprehensive analysis (optimize + kubelint security + helmlint)
61    #[serde(default)]
62    pub full: bool,
63
64    // ========== Live Analysis Options (Phase 2) ==========
65    /// Connect to a Kubernetes cluster (kubeconfig context name)
66    #[serde(default)]
67    pub cluster: Option<String>,
68
69    /// Prometheus URL for historical metrics (e.g., "http://localhost:9090" from port-forward)
70    /// Use prometheus_discover and prometheus_connect tools to get this URL
71    #[serde(default)]
72    pub prometheus: Option<String>,
73
74    /// Prometheus authentication type: "none", "basic", "bearer" (default: "none")
75    /// Only needed for externally exposed Prometheus, NOT for port-forward connections
76    #[serde(default)]
77    pub prometheus_auth_type: Option<String>,
78
79    /// Username for Prometheus basic auth (only for external Prometheus)
80    #[serde(default)]
81    pub prometheus_username: Option<String>,
82
83    /// Password for Prometheus basic auth (only for external Prometheus)
84    #[serde(default)]
85    pub prometheus_password: Option<String>,
86
87    /// Bearer token for Prometheus auth (only for external Prometheus)
88    #[serde(default)]
89    pub prometheus_token: Option<String>,
90
91    /// Analysis period for live metrics (e.g., "7d", "24h", "1h")
92    #[serde(default)]
93    pub period: Option<String>,
94
95    // ========== Cost Estimation Options (Phase 3) ==========
96    /// Cloud provider for cost estimation: "aws", "gcp", "azure", "onprem"
97    #[serde(default)]
98    pub cloud_provider: Option<String>,
99
100    /// Cloud region for pricing (e.g., "us-east-1", "us-central1")
101    #[serde(default)]
102    pub region: Option<String>,
103}
104
105/// Error type for k8s-optimize tool
106#[derive(Debug, thiserror::Error)]
107#[error("K8s optimize error: {0}")]
108pub struct K8sOptimizeError(String);
109
110/// Result of Prometheus enhancement
111struct PrometheusEnhancement {
112    /// Number of recommendations enhanced with live data
113    enhanced_count: usize,
114    /// Number of workloads with no Prometheus data
115    no_data_count: usize,
116    /// Raw Prometheus data for each workload
117    prometheus_data: Vec<serde_json::Value>,
118}
119
120/// Find Helm charts in a directory.
121fn find_helm_charts(path: &std::path::Path) -> Vec<PathBuf> {
122    let mut charts = Vec::new();
123
124    if path.join("Chart.yaml").exists() {
125        charts.push(path.to_path_buf());
126        return charts;
127    }
128
129    if let Ok(entries) = std::fs::read_dir(path) {
130        for entry in entries.flatten() {
131            let entry_path = entry.path();
132            if entry_path.is_dir() {
133                if entry_path.join("Chart.yaml").exists() {
134                    charts.push(entry_path);
135                } else if let Ok(sub_entries) = std::fs::read_dir(&entry_path) {
136                    for sub_entry in sub_entries.flatten() {
137                        let sub_path = sub_entry.path();
138                        if sub_path.is_dir() && sub_path.join("Chart.yaml").exists() {
139                            charts.push(sub_path);
140                        }
141                    }
142                }
143            }
144        }
145    }
146
147    charts
148}
149
150/// Tool for analyzing Kubernetes resource configurations
151#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct K8sOptimizeTool {
153    project_root: PathBuf,
154}
155
156impl K8sOptimizeTool {
157    /// Create a new K8sOptimizeTool with the given project root.
158    pub fn new(project_root: PathBuf) -> Self {
159        Self { project_root }
160    }
161
162    /// Build PrometheusAuth from arguments (optional, only for external URLs)
163    fn build_prometheus_auth(args: &K8sOptimizeArgs) -> PrometheusAuth {
164        match args.prometheus_auth_type.as_deref() {
165            Some("basic") => {
166                if let (Some(username), Some(password)) =
167                    (&args.prometheus_username, &args.prometheus_password)
168                {
169                    PrometheusAuth::Basic {
170                        username: username.clone(),
171                        password: password.clone(),
172                    }
173                } else {
174                    PrometheusAuth::None
175                }
176            }
177            Some("bearer") => {
178                if let Some(token) = &args.prometheus_token {
179                    PrometheusAuth::Bearer(token.clone())
180                } else {
181                    PrometheusAuth::None
182                }
183            }
184            _ => PrometheusAuth::None,
185        }
186    }
187
188    /// Enhance recommendations with live Prometheus data.
189    ///
190    /// For each workload in the static analysis, query Prometheus for historical
191    /// CPU/memory usage and replace heuristic recommendations with data-driven ones.
192    async fn enhance_with_prometheus(
193        &self,
194        result: &mut OptimizationResult,
195        client: &PrometheusClient,
196        period: &str,
197    ) -> PrometheusEnhancement {
198        let mut enhanced_count = 0;
199        let mut no_data_count = 0;
200        let mut prometheus_data: Vec<serde_json::Value> = Vec::new();
201
202        for rec in &mut result.recommendations {
203            let namespace = rec.namespace.as_deref().unwrap_or("default");
204            let workload_name = &rec.resource_name;
205            let container = &rec.container;
206
207            // Parse current resource values from String to u64
208            let current_cpu_millicores = rec
209                .current
210                .cpu_request
211                .as_ref()
212                .and_then(|s| parse_cpu_to_millicores(s));
213            let current_memory_bytes = rec
214                .current
215                .memory_request
216                .as_ref()
217                .and_then(|s| parse_memory_to_bytes(s));
218
219            // Query Prometheus for historical data
220            match client
221                .get_container_history(namespace, workload_name, container, period)
222                .await
223            {
224                Ok(history) => {
225                    // Generate data-driven recommendation
226                    let historical_rec = PrometheusClient::generate_recommendation(
227                        &history,
228                        current_cpu_millicores,
229                        current_memory_bytes,
230                        20, // 20% safety margin
231                    );
232
233                    // Convert recommended values back to strings
234                    let cpu_str = millicores_to_cpu_string(historical_rec.recommended_cpu_request);
235                    let mem_str = bytes_to_memory_string(historical_rec.recommended_memory_request);
236                    let cpu_limit_str =
237                        millicores_to_cpu_string(historical_rec.recommended_cpu_request * 2);
238
239                    // Store the prometheus data for output
240                    prometheus_data.push(serde_json::json!({
241                        "workload": format!("{}/{}", namespace, workload_name),
242                        "container": container,
243                        "period": period,
244                        "samples": history.sample_count,
245                        "cpu_usage": {
246                            "min": history.cpu_min,
247                            "p50": history.cpu_p50,
248                            "p95": history.cpu_p95,
249                            "p99": history.cpu_p99,
250                            "max": history.cpu_max,
251                            "avg": history.cpu_avg,
252                        },
253                        "memory_usage": {
254                            "min_bytes": history.memory_min,
255                            "p50_bytes": history.memory_p50,
256                            "p95_bytes": history.memory_p95,
257                            "p99_bytes": history.memory_p99,
258                            "max_bytes": history.memory_max,
259                            "avg_bytes": history.memory_avg,
260                        },
261                        "recommendation": {
262                            "cpu_request": cpu_str,
263                            "memory_request": mem_str,
264                            "cpu_savings_pct": historical_rec.cpu_savings_pct,
265                            "memory_savings_pct": historical_rec.memory_savings_pct,
266                            "confidence": historical_rec.confidence,
267                        }
268                    }));
269
270                    // Update the recommendation with data-driven values (as strings)
271                    rec.recommended.cpu_request = Some(cpu_str.clone());
272                    rec.recommended.memory_request = Some(mem_str.clone());
273
274                    // Update fix_yaml with data-driven values
275                    rec.fix_yaml = format!(
276                        "resources:\n  requests:\n    cpu: \"{}\"\n    memory: \"{}\"\n  limits:\n    cpu: \"{}\"  # 2x request\n    memory: \"{}\"",
277                        cpu_str, mem_str, cpu_limit_str, mem_str,
278                    );
279
280                    // Update message to indicate data-driven
281                    rec.message = format!(
282                        "{} [DATA-DRIVEN: P99 usage CPU={}m, Memory={}Mi over {}, confidence={}%]",
283                        rec.message,
284                        history.cpu_p99,
285                        history.memory_p99 / (1024 * 1024),
286                        period,
287                        historical_rec.confidence
288                    );
289
290                    enhanced_count += 1;
291                }
292                Err(_) => {
293                    // No Prometheus data for this workload, keep heuristic
294                    no_data_count += 1;
295                }
296            }
297        }
298
299        PrometheusEnhancement {
300            enhanced_count,
301            no_data_count,
302            prometheus_data,
303        }
304    }
305
306    /// Build config from arguments.
307    fn build_config(&self, args: &K8sOptimizeArgs) -> K8sOptimizeConfig {
308        let mut config = K8sOptimizeConfig::default();
309
310        if let Some(severity_str) = &args.severity {
311            if let Some(severity) = Severity::parse(severity_str) {
312                config = config.with_severity(severity);
313            }
314        }
315
316        if let Some(threshold) = args.threshold {
317            config = config.with_threshold(threshold);
318        }
319
320        if args.include_info {
321            config = config.with_info();
322        }
323
324        if args.include_system {
325            config = config.with_system();
326        }
327
328        config
329    }
330
331    /// Format result for AI agent consumption.
332    fn format_for_agent(
333        &self,
334        result: &OptimizationResult,
335        args: &K8sOptimizeArgs,
336    ) -> serde_json::Value {
337        // Create a summary for the agent
338        let mut response = json!({
339            "summary": {
340                "resources_analyzed": result.summary.resources_analyzed,
341                "containers_analyzed": result.summary.containers_analyzed,
342                "over_provisioned": result.summary.over_provisioned,
343                "under_provisioned": result.summary.under_provisioned,
344                "missing_requests": result.summary.missing_requests,
345                "missing_limits": result.summary.missing_limits,
346                "optimal": result.summary.optimal,
347                "total_waste_percentage": result.summary.total_waste_percentage,
348                "mode": result.metadata.mode.to_string(),
349            },
350            "recommendations": result.recommendations.iter().map(|r| {
351                json!({
352                    "resource": format!("{}/{}", r.resource_kind, r.resource_name),
353                    "container": r.container,
354                    "namespace": r.namespace,
355                    "file": r.file_path.display().to_string(),
356                    "line": r.line,
357                    "issue": r.issue.to_string(),
358                    "severity": r.severity.as_str(),
359                    "message": r.message,
360                    "workload_type": r.workload_type.as_str(),
361                    "rule_code": r.rule_code.as_str(),
362                    "rule_description": rule_description(r.rule_code.as_str()),
363                    "current": {
364                        "cpu_request": r.current.cpu_request,
365                        "cpu_limit": r.current.cpu_limit,
366                        "memory_request": r.current.memory_request,
367                        "memory_limit": r.current.memory_limit,
368                    },
369                    "recommended": {
370                        "cpu_request": r.recommended.cpu_request,
371                        "cpu_limit": r.recommended.cpu_limit,
372                        "memory_request": r.recommended.memory_request,
373                        "memory_limit": r.recommended.memory_limit,
374                    },
375                    "fix_yaml": r.fix_yaml,
376                    // Quick fix for agent to apply
377                    "quick_fix": {
378                        "action": "replace_resources",
379                        "file": r.file_path.display().to_string(),
380                        "container": r.container.clone(),
381                        "yaml": r.fix_yaml.clone(),
382                    }
383                })
384            }).collect::<Vec<_>>(),
385            "analysis_metadata": {
386                "duration_ms": result.metadata.duration_ms,
387                "path": result.metadata.path.display().to_string(),
388                "version": result.metadata.version.clone(),
389                "timestamp": result.metadata.timestamp.clone(),
390            }
391        });
392
393        // Add warnings if any
394        if !result.warnings.is_empty() {
395            response["warnings"] = json!(
396                result
397                    .warnings
398                    .iter()
399                    .map(|w| {
400                        json!({
401                            "resource": w.resource,
402                            "issue": w.issue.to_string(),
403                            "severity": w.severity.as_str(),
404                            "message": w.message,
405                        })
406                    })
407                    .collect::<Vec<_>>()
408            );
409        }
410
411        // Add savings estimate if available
412        if let Some(savings) = result.summary.estimated_monthly_savings_usd {
413            response["estimated_savings"] = json!({
414                "monthly_usd": savings,
415                "annual_usd": savings * 12.0,
416            });
417        }
418
419        // Add rule reference for agent
420        response["rule_codes"] = json!({
421            rule_codes::NO_CPU_REQUEST: rule_description(rule_codes::NO_CPU_REQUEST),
422            rule_codes::NO_MEMORY_REQUEST: rule_description(rule_codes::NO_MEMORY_REQUEST),
423            rule_codes::NO_CPU_LIMIT: rule_description(rule_codes::NO_CPU_LIMIT),
424            rule_codes::NO_MEMORY_LIMIT: rule_description(rule_codes::NO_MEMORY_LIMIT),
425            rule_codes::HIGH_CPU_REQUEST: rule_description(rule_codes::HIGH_CPU_REQUEST),
426            rule_codes::HIGH_MEMORY_REQUEST: rule_description(rule_codes::HIGH_MEMORY_REQUEST),
427            rule_codes::EXCESSIVE_CPU_RATIO: rule_description(rule_codes::EXCESSIVE_CPU_RATIO),
428            rule_codes::EXCESSIVE_MEMORY_RATIO: rule_description(rule_codes::EXCESSIVE_MEMORY_RATIO),
429            rule_codes::REQUESTS_EQUAL_LIMITS: rule_description(rule_codes::REQUESTS_EQUAL_LIMITS),
430            rule_codes::UNBALANCED_RESOURCES: rule_description(rule_codes::UNBALANCED_RESOURCES),
431        });
432
433        // Add live analysis info if cluster or prometheus was specified
434        if args.cluster.is_some() || args.prometheus.is_some() {
435            response["live_analysis"] = json!({
436                "enabled": args.prometheus.is_some(),
437                "cluster": args.cluster.clone(),
438                "prometheus": args.prometheus.clone(),
439                "prometheus_auth": if args.prometheus_auth_type.is_some() {
440                    args.prometheus_auth_type.clone()
441                } else {
442                    Some("none".to_string())
443                },
444                "period": args.period.clone().unwrap_or_else(|| "7d".to_string()),
445                "note": if args.prometheus.is_some() {
446                    "Historical metrics analysis using Prometheus data."
447                } else {
448                    "Live analysis requires Prometheus. Use prometheus_discover and prometheus_connect to set up."
449                },
450            });
451        }
452
453        // Add cost estimation info if provider was specified
454        if args.cloud_provider.is_some() {
455            response["cost_estimation"] = json!({
456                "enabled": true,
457                "provider": args.cloud_provider.clone(),
458                "region": args.region.clone().unwrap_or_else(|| "us-east-1".to_string()),
459                "note": "Cost estimation uses approximate on-demand pricing. Actual costs may vary.",
460            });
461        }
462
463        // Add actionable summary for agent
464        let action_items: Vec<String> = result
465            .recommendations
466            .iter()
467            .filter(|r| r.severity >= Severity::Medium)
468            .map(|r| {
469                format!(
470                    "[{}] {} in {}/{}",
471                    r.rule_code.as_str(),
472                    r.message,
473                    r.resource_kind,
474                    r.resource_name
475                )
476            })
477            .collect();
478
479        if !action_items.is_empty() {
480            response["action_items"] = json!(action_items);
481        }
482
483        response
484    }
485}
486
487impl Tool for K8sOptimizeTool {
488    const NAME: &'static str = "k8s_optimize";
489
490    type Args = K8sOptimizeArgs;
491    type Output = String;
492    type Error = K8sOptimizeError;
493
494    async fn definition(&self, _prompt: String) -> ToolDefinition {
495        ToolDefinition {
496            name: Self::NAME.to_string(),
497            description: r#"Analyze Kubernetes manifests for resource optimization.
498
499**IMPORTANT: Only use when user EXPLICITLY asks about:**
500- "optimize my K8s resources" / "right-size my pods"
501- "full analysis" / "comprehensive check" (use full=true)
502- Over-provisioned or under-provisioned resources
503- Cost optimization for Kubernetes
504
505**DO NOT use for:**
506- General K8s linting without optimization focus (use kubelint)
507- Tasks where user didn't ask about optimization
508
509## For Live Cluster Analysis with Historical Metrics
510
511**RECOMMENDED FLOW when user wants data-driven optimization:**
5121. First use `prometheus_discover` to find Prometheus in cluster
5132. Use `prometheus_connect` to establish connection (starts port-forward)
5143. Call `k8s_optimize` with the prometheus URL from step 2
515
516Port-forward is preferred (no auth needed). Auth is only needed for external Prometheus URLs.
517
518## Modes
519- **Standard**: Resource optimization analysis only
520- **Full** (full=true): Comprehensive analysis including:
521  - Resource optimization (CPU/memory waste)
522  - Security checks (kubelint - privileged, RBAC, etc.)
523  - Helm validation (if charts present)
524- **Live**: With prometheus URL for historical metrics (data-driven recommendations)
525
526## Returns (analysis only - does NOT apply changes)
527- Summary with issue counts and waste percentage
528- Recommendations with suggested values (based on actual usage if Prometheus provided)
529- Security findings (if full=true)
530- Does NOT automatically modify files"#
531                .to_string(),
532            parameters: json!({
533                "type": "object",
534                "properties": {
535                    "path": {
536                        "type": "string",
537                        "description": "Path to K8s manifest file or directory (relative to project root). Examples: 'k8s/', 'deployments/api.yaml', 'charts/myapp/', 'terraform/'"
538                    },
539                    "content": {
540                        "type": "string",
541                        "description": "Inline YAML content to analyze (alternative to path)"
542                    },
543                    "severity": {
544                        "type": "string",
545                        "description": "Minimum severity to report: 'critical', 'high', 'medium', 'low', 'info'. Default: 'medium'",
546                        "enum": ["critical", "high", "medium", "low", "info"]
547                    },
548                    "threshold": {
549                        "type": "integer",
550                        "description": "Minimum waste percentage to report (default: 10)"
551                    },
552                    "include_info": {
553                        "type": "boolean",
554                        "description": "Include info-level suggestions (default: false)"
555                    },
556                    "include_system": {
557                        "type": "boolean",
558                        "description": "Include system namespaces like kube-system (default: false)"
559                    },
560                    "full": {
561                        "type": "boolean",
562                        "description": "Run FULL comprehensive analysis: optimize + kubelint security + helmlint. Use when user asks for 'full analysis' or 'check everything'."
563                    },
564                    "cluster": {
565                        "type": "string",
566                        "description": "Connect to a Kubernetes cluster for live analysis (kubeconfig context name). Requires cluster connectivity."
567                    },
568                    "prometheus": {
569                        "type": "string",
570                        "description": "Prometheus URL for historical metrics (from prometheus_connect tool, e.g., 'http://localhost:52431')"
571                    },
572                    "prometheus_auth_type": {
573                        "type": "string",
574                        "description": "Prometheus auth type (only for external URL, NOT for port-forward): 'none', 'basic', 'bearer'",
575                        "enum": ["none", "basic", "bearer"]
576                    },
577                    "prometheus_username": {
578                        "type": "string",
579                        "description": "Username for Prometheus basic auth (only for external URL)"
580                    },
581                    "prometheus_password": {
582                        "type": "string",
583                        "description": "Password for Prometheus basic auth (only for external URL)"
584                    },
585                    "prometheus_token": {
586                        "type": "string",
587                        "description": "Bearer token for Prometheus auth (only for external URL)"
588                    },
589                    "period": {
590                        "type": "string",
591                        "description": "Analysis period for live metrics (e.g., '7d', '24h', '1h'). Default: '7d'"
592                    },
593                    "cloud_provider": {
594                        "type": "string",
595                        "description": "Cloud provider for cost estimation: 'aws', 'gcp', 'azure', 'onprem'",
596                        "enum": ["aws", "gcp", "azure", "onprem"]
597                    },
598                    "region": {
599                        "type": "string",
600                        "description": "Cloud region for pricing (e.g., 'us-east-1', 'us-central1')"
601                    }
602                }
603            }),
604        }
605    }
606
607    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
608        let config = self.build_config(&args);
609
610        // IMPORTANT: Treat empty content as None - fixes AI agents passing empty strings
611        let mut result = if args.content.as_ref().is_some_and(|c| !c.trim().is_empty()) {
612            // Analyze non-empty inline content
613            analyze_content(args.content.as_ref().unwrap(), &config)
614        } else {
615            // Analyze path
616            let path = args.path.as_deref().unwrap_or(".");
617            let full_path = if std::path::Path::new(path).is_absolute() {
618                PathBuf::from(path)
619            } else {
620                self.project_root.join(path)
621            };
622
623            if !full_path.exists() {
624                return Err(K8sOptimizeError(format!(
625                    "Path not found: {}",
626                    full_path.display()
627                )));
628            }
629
630            analyze(&full_path, &config)
631        };
632
633        // If prometheus URL provided, enhance recommendations with live data
634        let prometheus_enhancement = if let Some(prometheus_url) = &args.prometheus {
635            let auth = Self::build_prometheus_auth(&args);
636            match PrometheusClient::with_auth(prometheus_url, auth) {
637                Ok(client) => {
638                    if client.is_available().await {
639                        let period = args.period.as_deref().unwrap_or("7d");
640                        Some(
641                            self.enhance_with_prometheus(&mut result, &client, period)
642                                .await,
643                        )
644                    } else {
645                        None
646                    }
647                }
648                Err(_) => None,
649            }
650        } else {
651            None
652        };
653
654        // If full mode, also run kubelint and helmlint
655        let mut output = self.format_for_agent(&result, &args);
656
657        if args.full {
658            let path = args.path.as_deref().unwrap_or(".");
659            let full_path = if std::path::Path::new(path).is_absolute() {
660                PathBuf::from(path)
661            } else {
662                self.project_root.join(path)
663            };
664
665            // Run kubelint for security
666            let kubelint_config =
667                crate::analyzer::kubelint::KubelintConfig::default().with_all_builtin();
668            let kubelint_result = crate::analyzer::kubelint::lint(&full_path, &kubelint_config);
669
670            output["security_analysis"] = json!({
671                "objects_analyzed": kubelint_result.summary.objects_analyzed,
672                "checks_run": kubelint_result.summary.checks_run,
673                "issues_found": kubelint_result.failures.len(),
674                "findings": kubelint_result.failures.iter().take(20).map(|f| {
675                    json!({
676                        "code": f.code.to_string(),
677                        "severity": format!("{:?}", f.severity).to_lowercase(),
678                        "object": format!("{}/{}", f.object_kind, f.object_name),
679                        "message": f.message,
680                        "remediation": f.remediation,
681                    })
682                }).collect::<Vec<_>>(),
683            });
684
685            // Run helmlint on Helm charts if any
686            let helm_charts = find_helm_charts(&full_path);
687            if !helm_charts.is_empty() {
688                let helmlint_config = crate::analyzer::helmlint::HelmlintConfig::default();
689                let mut chart_results: Vec<serde_json::Value> = Vec::new();
690
691                for chart_path in &helm_charts {
692                    let chart_name = chart_path
693                        .file_name()
694                        .map(|n| n.to_string_lossy().to_string())
695                        .unwrap_or_else(|| "unknown".to_string());
696                    let helmlint_result =
697                        crate::analyzer::helmlint::lint_chart(chart_path, &helmlint_config);
698
699                    chart_results.push(json!({
700                        "chart": chart_name,
701                        "issues": helmlint_result.failures.iter().map(|f| {
702                            json!({
703                                "code": f.code.to_string(),
704                                "severity": format!("{:?}", f.severity).to_lowercase(),
705                                "message": f.message,
706                            })
707                        }).collect::<Vec<_>>(),
708                    }));
709                }
710
711                output["helm_validation"] = json!({
712                    "charts_analyzed": helm_charts.len(),
713                    "results": chart_results,
714                });
715            }
716
717            output["analysis_mode"] = json!("full");
718        }
719
720        // Add Prometheus enhancement data if available
721        if let Some(enhancement) = prometheus_enhancement {
722            output["prometheus_analysis"] = json!({
723                "enabled": true,
724                "url": args.prometheus,
725                "period": args.period.clone().unwrap_or_else(|| "7d".to_string()),
726                "workloads_enhanced": enhancement.enhanced_count,
727                "workloads_no_data": enhancement.no_data_count,
728                "mode": if enhancement.enhanced_count > 0 { "data-driven" } else { "static" },
729                "historical_data": enhancement.prometheus_data,
730                "note": if enhancement.enhanced_count > 0 {
731                    format!(
732                        "Recommendations for {} workloads are based on actual P99 usage from Prometheus. {} workloads had no historical data.",
733                        enhancement.enhanced_count,
734                        enhancement.no_data_count
735                    )
736                } else {
737                    "No historical data found in Prometheus for the analyzed workloads. Recommendations are heuristic-based.".to_string()
738                }
739            });
740
741            // Update summary mode
742            if enhancement.enhanced_count > 0 {
743                output["summary"]["mode"] = json!("prometheus");
744            }
745        }
746
747        // Use smart compression with RAG retrieval pattern
748        // This preserves all data while keeping context size manageable
749        let config = CompressionConfig::default();
750        Ok(compress_tool_output(&output, "k8s_optimize", &config))
751    }
752}
753
754#[cfg(test)]
755mod tests {
756    use super::*;
757
758    #[test]
759    fn test_tool_name() {
760        assert_eq!(K8sOptimizeTool::NAME, "k8s_optimize");
761    }
762
763    #[tokio::test]
764    async fn test_analyze_content() {
765        let tool = K8sOptimizeTool::new(PathBuf::from("."));
766
767        let yaml = r#"
768apiVersion: apps/v1
769kind: Deployment
770metadata:
771  name: test-app
772spec:
773  replicas: 1
774  selector:
775    matchLabels:
776      app: test
777  template:
778    spec:
779      containers:
780      - name: app
781        image: myapp:v1
782"#;
783
784        let args = K8sOptimizeArgs {
785            path: None,
786            content: Some(yaml.to_string()),
787            severity: None,
788            threshold: None,
789            include_info: false,
790            include_system: true,
791            full: false,
792            cluster: None,
793            prometheus: None,
794            prometheus_auth_type: None,
795            prometheus_username: None,
796            prometheus_password: None,
797            prometheus_token: None,
798            period: None,
799            cloud_provider: None,
800            region: None,
801        };
802
803        let result = tool.call(args).await.unwrap();
804        assert!(result.contains("summary"));
805        assert!(result.contains("recommendations"));
806        assert!(result.contains("rule_codes"));
807    }
808
809    #[tokio::test]
810    async fn test_build_config() {
811        let tool = K8sOptimizeTool::new(PathBuf::from("."));
812
813        let args = K8sOptimizeArgs {
814            path: None,
815            content: None,
816            severity: Some("high".to_string()),
817            threshold: Some(20),
818            include_info: true,
819            include_system: true,
820            full: false,
821            cluster: None,
822            prometheus: None,
823            prometheus_auth_type: None,
824            prometheus_username: None,
825            prometheus_password: None,
826            prometheus_token: None,
827            period: None,
828            cloud_provider: None,
829            region: None,
830        };
831
832        let config = tool.build_config(&args);
833        assert_eq!(config.waste_threshold_percent, 20);
834        assert!(config.include_info);
835        assert!(config.include_system);
836    }
837
838    #[tokio::test]
839    async fn test_output_format() {
840        let tool = K8sOptimizeTool::new(PathBuf::from("."));
841
842        let yaml = r#"
843apiVersion: apps/v1
844kind: Deployment
845metadata:
846  name: over-provisioned
847spec:
848  replicas: 1
849  selector:
850    matchLabels:
851      app: test
852  template:
853    spec:
854      containers:
855      - name: nginx
856        image: nginx:1.21
857        resources:
858          requests:
859            cpu: 4000m
860            memory: 8Gi
861          limits:
862            cpu: 8000m
863            memory: 16Gi
864"#;
865
866        let args = K8sOptimizeArgs {
867            path: None,
868            content: Some(yaml.to_string()),
869            severity: None,
870            threshold: None,
871            include_info: false,
872            include_system: true,
873            full: false,
874            cluster: None,
875            prometheus: None,
876            prometheus_auth_type: None,
877            prometheus_username: None,
878            prometheus_password: None,
879            prometheus_token: None,
880            period: None,
881            cloud_provider: Some("aws".to_string()),
882            region: Some("us-east-1".to_string()),
883        };
884
885        let result = tool.call(args).await.unwrap();
886
887        // Parse and verify structure
888        let json: serde_json::Value = serde_json::from_str(&result).unwrap();
889
890        assert!(json.get("summary").is_some());
891        assert!(json.get("recommendations").is_some());
892        assert!(json.get("rule_codes").is_some());
893        assert!(json.get("cost_estimation").is_some());
894    }
895
896    #[test]
897    fn test_build_prometheus_auth_none() {
898        let args = K8sOptimizeArgs {
899            path: None,
900            content: None,
901            severity: None,
902            threshold: None,
903            include_info: false,
904            include_system: false,
905            full: false,
906            cluster: None,
907            prometheus: Some("http://localhost:9090".to_string()),
908            prometheus_auth_type: None,
909            prometheus_username: None,
910            prometheus_password: None,
911            prometheus_token: None,
912            period: None,
913            cloud_provider: None,
914            region: None,
915        };
916
917        let auth = K8sOptimizeTool::build_prometheus_auth(&args);
918        assert!(matches!(auth, PrometheusAuth::None));
919    }
920
921    #[test]
922    fn test_build_prometheus_auth_basic() {
923        let args = K8sOptimizeArgs {
924            path: None,
925            content: None,
926            severity: None,
927            threshold: None,
928            include_info: false,
929            include_system: false,
930            full: false,
931            cluster: None,
932            prometheus: Some("https://prometheus.example.com".to_string()),
933            prometheus_auth_type: Some("basic".to_string()),
934            prometheus_username: Some("admin".to_string()),
935            prometheus_password: Some("secret".to_string()),
936            prometheus_token: None,
937            period: None,
938            cloud_provider: None,
939            region: None,
940        };
941
942        let auth = K8sOptimizeTool::build_prometheus_auth(&args);
943        match auth {
944            PrometheusAuth::Basic { username, password } => {
945                assert_eq!(username, "admin");
946                assert_eq!(password, "secret");
947            }
948            _ => panic!("Expected Basic auth"),
949        }
950    }
951}