syncable_cli/analyzer/k8s_optimize/
fix_applicator.rs

1//! Precise Fix Locator and Applicator
2//!
3//! Locates exact positions of resource definitions in YAML files and applies
4//! targeted fixes with safety measures (backups, dry-run, validation).
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10use super::live_analyzer::LiveRecommendation;
11use super::types::{
12    FixApplicationResult, FixImpact, FixResourceValues, FixRisk, FixSource, FixStatus, PreciseFix,
13    ResourceRecommendation, Severity,
14};
15
16/// YAML location info for a resource.
17#[derive(Debug, Clone)]
18pub struct YamlLocation {
19    /// Line number where the resource starts (1-indexed)
20    pub start_line: u32,
21    /// Line number where the resources section starts
22    pub resources_line: Option<u32>,
23    /// Column where resources starts
24    pub resources_column: Option<u32>,
25    /// Full path within the YAML (for nested resources like Helm)
26    pub yaml_path: String,
27}
28
29/// Locate resources in a YAML file and return precise fix locations.
30pub fn locate_resources_in_file(
31    file_path: &Path,
32    recommendations: &[LiveRecommendation],
33) -> Vec<PreciseFix> {
34    let content = match fs::read_to_string(file_path) {
35        Ok(c) => c,
36        Err(_) => return vec![],
37    };
38
39    let mut fixes = Vec::new();
40
41    // Parse YAML documents
42    for doc in yaml_rust2::YamlLoader::load_from_str(&content).unwrap_or_default() {
43        // Find workloads in the document
44        let locations = find_workload_locations(&content, &doc);
45
46        for rec in recommendations {
47            if let Some(loc) =
48                locations.get(&(rec.workload_name.clone(), rec.container_name.clone()))
49            {
50                let fix = create_precise_fix(file_path, rec, loc);
51                fixes.push(fix);
52            }
53        }
54    }
55
56    fixes
57}
58
59/// Locate resources from static analysis recommendations.
60pub fn locate_resources_from_static(recommendations: &[ResourceRecommendation]) -> Vec<PreciseFix> {
61    let mut fixes = Vec::new();
62
63    for rec in recommendations {
64        // Static recommendations include file path
65        let fix = PreciseFix {
66            id: generate_fix_id(&rec.resource_name, &rec.container),
67            file_path: rec.file_path.clone(),
68            line_number: rec.line.unwrap_or(0),
69            column: None,
70            resource_kind: rec.resource_kind.clone(),
71            resource_name: rec.resource_name.clone(),
72            container_name: rec.container.clone(),
73            namespace: rec.namespace.clone(),
74            current: FixResourceValues {
75                cpu_request: rec.current.cpu_request.clone(),
76                cpu_limit: rec.current.cpu_limit.clone(),
77                memory_request: rec.current.memory_request.clone(),
78                memory_limit: rec.current.memory_limit.clone(),
79            },
80            recommended: FixResourceValues {
81                cpu_request: rec.recommended.cpu_request.clone(),
82                cpu_limit: rec.recommended.cpu_limit.clone(),
83                memory_request: rec.recommended.memory_request.clone(),
84                memory_limit: rec.recommended.memory_limit.clone(),
85            },
86            confidence: severity_to_confidence(&rec.severity),
87            source: FixSource::StaticAnalysis,
88            impact: assess_impact(rec),
89            status: FixStatus::Pending,
90        };
91        fixes.push(fix);
92    }
93
94    fixes
95}
96
97/// Find workload locations in YAML content.
98fn find_workload_locations(
99    content: &str,
100    _doc: &yaml_rust2::Yaml,
101) -> HashMap<(String, String), YamlLocation> {
102    let mut locations = HashMap::new();
103
104    let lines: Vec<&str> = content.lines().collect();
105    let mut current_kind = String::new();
106    let mut current_name = String::new();
107    let mut current_container = String::new();
108    let mut workload_start_line: u32 = 0;
109    let mut in_containers = false;
110    let mut resources_line: Option<u32> = None;
111
112    for (idx, line) in lines.iter().enumerate() {
113        let line_num = (idx + 1) as u32;
114        let trimmed = line.trim();
115
116        // Detect kind
117        if trimmed.starts_with("kind:") {
118            current_kind = trimmed.trim_start_matches("kind:").trim().to_string();
119            workload_start_line = line_num;
120            current_name.clear();
121            current_container.clear();
122            in_containers = false;
123            resources_line = None;
124        }
125
126        // Detect metadata name
127        if trimmed.starts_with("name:") && !in_containers {
128            current_name = trimmed.trim_start_matches("name:").trim().to_string();
129        }
130
131        // Detect containers section
132        if trimmed == "containers:" {
133            in_containers = true;
134        }
135
136        // Detect container name
137        if in_containers && trimmed.starts_with("- name:") {
138            current_container = trimmed.trim_start_matches("- name:").trim().to_string();
139        }
140
141        // Detect resources section
142        if in_containers && trimmed == "resources:" {
143            resources_line = Some(line_num);
144
145            // Only add if we have all the info
146            if !current_name.is_empty() && !current_container.is_empty() {
147                let key = (current_name.clone(), current_container.clone());
148                locations.insert(
149                    key,
150                    YamlLocation {
151                        start_line: workload_start_line,
152                        resources_line,
153                        resources_column: Some(line.len() as u32 - trimmed.len() as u32),
154                        yaml_path: format!(
155                            "{}/{}/containers/{}/resources",
156                            current_kind, current_name, current_container
157                        ),
158                    },
159                );
160            }
161        }
162    }
163
164    locations
165}
166
167/// Create a precise fix from a live recommendation.
168fn create_precise_fix(
169    file_path: &Path,
170    rec: &LiveRecommendation,
171    loc: &YamlLocation,
172) -> PreciseFix {
173    let cpu_str = format_millicores(rec.recommended_cpu_millicores);
174    let mem_str = format_bytes(rec.recommended_memory_bytes);
175
176    // Current values
177    let current_cpu = rec.current_cpu_millicores.map(format_millicores);
178    let current_mem = rec.current_memory_bytes.map(format_bytes);
179
180    PreciseFix {
181        id: generate_fix_id(&rec.workload_name, &rec.container_name),
182        file_path: file_path.to_path_buf(),
183        line_number: loc.resources_line.unwrap_or(loc.start_line),
184        column: loc.resources_column,
185        resource_kind: rec.workload_kind.clone(),
186        resource_name: rec.workload_name.clone(),
187        container_name: rec.container_name.clone(),
188        namespace: Some(rec.namespace.clone()),
189        current: FixResourceValues {
190            cpu_request: current_cpu.clone(),
191            cpu_limit: current_cpu.map(|c| double_millicores(&c)),
192            memory_request: current_mem.clone(),
193            memory_limit: current_mem.clone(),
194        },
195        recommended: FixResourceValues {
196            cpu_request: Some(cpu_str.clone()),
197            cpu_limit: Some(double_millicores(&cpu_str)),
198            memory_request: Some(mem_str.clone()),
199            memory_limit: Some(mem_str),
200        },
201        confidence: rec.confidence,
202        source: match rec.data_source {
203            super::live_analyzer::DataSource::Prometheus => FixSource::PrometheusP95,
204            super::live_analyzer::DataSource::MetricsServer => FixSource::MetricsServer,
205            super::live_analyzer::DataSource::Combined => FixSource::Combined,
206            super::live_analyzer::DataSource::Static => FixSource::StaticAnalysis,
207        },
208        impact: FixImpact {
209            risk: if rec.confidence >= 80 {
210                FixRisk::Low
211            } else if rec.confidence >= 60 {
212                FixRisk::Medium
213            } else {
214                FixRisk::High
215            },
216            monthly_savings: 0.0, // Will be calculated by cost estimator
217            oom_risk: rec.memory_waste_pct < -10.0, // Reducing memory below current usage
218            throttle_risk: rec.cpu_waste_pct < -10.0, // Reducing CPU below current usage
219            recommendation: if rec.confidence >= 80 {
220                "Safe to apply - high confidence based on observed usage".to_string()
221            } else if rec.confidence >= 60 {
222                "Review before applying - moderate confidence".to_string()
223            } else {
224                "Manual review required - limited data available".to_string()
225            },
226        },
227        status: FixStatus::Pending,
228    }
229}
230
231/// Apply fixes to files.
232pub fn apply_fixes(
233    fixes: &mut [PreciseFix],
234    backup_dir: Option<&Path>,
235    dry_run: bool,
236    min_confidence: u8,
237) -> FixApplicationResult {
238    let mut applied = 0;
239    let mut skipped = 0;
240    let mut failed = 0;
241    let mut errors = Vec::new();
242
243    // Create backup directory if requested
244    let backup_path = if !dry_run {
245        if let Some(dir) = backup_dir {
246            match fs::create_dir_all(dir) {
247                Ok(_) => Some(dir.to_path_buf()),
248                Err(e) => {
249                    errors.push(format!("Failed to create backup dir: {}", e));
250                    None
251                }
252            }
253        } else {
254            None
255        }
256    } else {
257        None
258    };
259
260    // Group fixes by file
261    let mut fixes_by_file: HashMap<PathBuf, Vec<&mut PreciseFix>> = HashMap::new();
262    for fix in fixes.iter_mut() {
263        fixes_by_file
264            .entry(fix.file_path.clone())
265            .or_default()
266            .push(fix);
267    }
268
269    // Process each file
270    for (file_path, file_fixes) in fixes_by_file.iter_mut() {
271        // Read file content
272        let content = match fs::read_to_string(file_path) {
273            Ok(c) => c,
274            Err(e) => {
275                errors.push(format!("Failed to read {}: {}", file_path.display(), e));
276                for fix in file_fixes.iter_mut() {
277                    fix.status = FixStatus::Failed;
278                    failed += 1;
279                }
280                continue;
281            }
282        };
283
284        // Create backup if not dry run
285        if !dry_run {
286            if let Some(ref backup) = backup_path {
287                let backup_file = backup.join(file_path.file_name().unwrap_or_default());
288                if let Err(e) = fs::write(&backup_file, &content) {
289                    errors.push(format!("Failed to backup {}: {}", file_path.display(), e));
290                }
291            }
292        }
293
294        let mut modified_content = content.clone();
295        let mut line_offset: i32 = 0;
296
297        // Sort fixes by line number (descending) to avoid offset issues
298        file_fixes.sort_by(|a, b| b.line_number.cmp(&a.line_number));
299
300        for fix in file_fixes.iter_mut() {
301            // Check confidence threshold
302            if fix.confidence < min_confidence {
303                fix.status = FixStatus::Skipped;
304                skipped += 1;
305                continue;
306            }
307
308            // Check risk level
309            if fix.impact.risk == FixRisk::Critical {
310                fix.status = FixStatus::Skipped;
311                skipped += 1;
312                continue;
313            }
314
315            // Apply the fix
316            match apply_single_fix(&mut modified_content, fix, &mut line_offset) {
317                Ok(_) => {
318                    fix.status = if dry_run {
319                        FixStatus::Pending
320                    } else {
321                        FixStatus::Applied
322                    };
323                    applied += 1;
324                }
325                Err(e) => {
326                    fix.status = FixStatus::Failed;
327                    errors.push(format!("Fix {} failed: {}", fix.id, e));
328                    failed += 1;
329                }
330            }
331        }
332
333        // Write modified content if not dry run
334        if !dry_run && applied > 0 {
335            if let Err(e) = fs::write(file_path, &modified_content) {
336                errors.push(format!("Failed to write {}: {}", file_path.display(), e));
337            }
338        }
339    }
340
341    FixApplicationResult {
342        total_fixes: fixes.len(),
343        applied,
344        skipped,
345        failed,
346        backup_path,
347        fixes: fixes.to_vec(),
348        errors,
349    }
350}
351
352/// Apply a single fix to the content.
353fn apply_single_fix(
354    content: &mut String,
355    fix: &PreciseFix,
356    _line_offset: &mut i32,
357) -> Result<(), String> {
358    let lines: Vec<&str> = content.lines().collect();
359
360    // Find the resources section for this container
361    let target_line = fix.line_number as usize;
362
363    if target_line == 0 || target_line > lines.len() {
364        return Err(format!("Invalid line number: {}", target_line));
365    }
366
367    // Build the new resources YAML
368    let indent = detect_indent(&lines, target_line - 1);
369    let new_resources = generate_resources_yaml(fix, &indent);
370
371    // Find end of current resources section
372    let (start_idx, end_idx) = find_resources_section(&lines, target_line - 1)?;
373
374    // Replace the section
375    let mut new_lines: Vec<String> = Vec::new();
376    new_lines.extend(lines[..start_idx].iter().map(|s| s.to_string()));
377    new_lines.push(new_resources);
378    new_lines.extend(lines[end_idx..].iter().map(|s| s.to_string()));
379
380    *content = new_lines.join("\n");
381
382    Ok(())
383}
384
385/// Find the resources section boundaries.
386fn find_resources_section(lines: &[&str], start: usize) -> Result<(usize, usize), String> {
387    let base_indent = lines
388        .get(start)
389        .map(|l| l.len() - l.trim_start().len())
390        .unwrap_or(0);
391
392    // Find the end of resources section
393    let mut end = start + 1;
394    while end < lines.len() {
395        let line = lines[end];
396        let trimmed = line.trim_start();
397
398        // Empty lines are part of the section
399        if trimmed.is_empty() {
400            end += 1;
401            continue;
402        }
403
404        let current_indent = line.len() - trimmed.len();
405
406        // If we're back to base indent or less, we've exited the section
407        if current_indent <= base_indent && !trimmed.starts_with('-') {
408            break;
409        }
410
411        end += 1;
412    }
413
414    Ok((start, end))
415}
416
417/// Detect indentation at a line.
418fn detect_indent(lines: &[&str], line_idx: usize) -> String {
419    lines
420        .get(line_idx)
421        .map(|l| {
422            let trimmed = l.trim_start();
423            let indent_len = l.len() - trimmed.len();
424            " ".repeat(indent_len)
425        })
426        .unwrap_or_else(|| "        ".to_string()) // Default 8 spaces
427}
428
429/// Generate YAML for resources section.
430fn generate_resources_yaml(fix: &PreciseFix, indent: &str) -> String {
431    let child_indent = format!("{}  ", indent);
432
433    let mut yaml = format!("{}resources:\n", indent);
434    yaml.push_str(&format!("{}requests:\n", child_indent));
435
436    if let Some(ref cpu) = fix.recommended.cpu_request {
437        yaml.push_str(&format!("{}  cpu: \"{}\"\n", child_indent, cpu));
438    }
439    if let Some(ref mem) = fix.recommended.memory_request {
440        yaml.push_str(&format!("{}  memory: \"{}\"\n", child_indent, mem));
441    }
442
443    yaml.push_str(&format!("{}limits:\n", child_indent));
444
445    if let Some(ref cpu) = fix.recommended.cpu_limit {
446        yaml.push_str(&format!("{}  cpu: \"{}\"\n", child_indent, cpu));
447    }
448    if let Some(ref mem) = fix.recommended.memory_limit {
449        yaml.push_str(&format!("{}  memory: \"{}\"", child_indent, mem));
450    }
451
452    yaml
453}
454
455/// Generate a unique fix ID.
456fn generate_fix_id(workload: &str, container: &str) -> String {
457    use std::time::{SystemTime, UNIX_EPOCH};
458    let ts = SystemTime::now()
459        .duration_since(UNIX_EPOCH)
460        .map(|d| d.as_millis())
461        .unwrap_or(0);
462    format!("fix-{}-{}-{}", workload, container, ts % 10000)
463}
464
465/// Convert severity to confidence score.
466fn severity_to_confidence(severity: &Severity) -> u8 {
467    match severity {
468        Severity::Critical => 95,
469        Severity::High => 80,
470        Severity::Medium => 60,
471        Severity::Low => 40,
472        Severity::Info => 20,
473    }
474}
475
476/// Assess impact of a static recommendation.
477fn assess_impact(rec: &ResourceRecommendation) -> FixImpact {
478    let risk = match rec.severity {
479        Severity::Critical | Severity::High => FixRisk::High,
480        Severity::Medium => FixRisk::Medium,
481        _ => FixRisk::Low,
482    };
483
484    FixImpact {
485        risk,
486        monthly_savings: 0.0,
487        oom_risk: false,
488        throttle_risk: false,
489        recommendation: rec.message.clone(),
490    }
491}
492
493/// Format millicores to K8s CPU string.
494fn format_millicores(millicores: u64) -> String {
495    if millicores >= 1000 && millicores.is_multiple_of(1000) {
496        format!("{}", millicores / 1000)
497    } else {
498        format!("{}m", millicores)
499    }
500}
501
502/// Double the millicores value for limits.
503fn double_millicores(value: &str) -> String {
504    if value.ends_with('m') {
505        let m: u64 = value.trim_end_matches('m').parse().unwrap_or(100);
506        format!("{}m", m * 2)
507    } else {
508        let cores: f64 = value.parse().unwrap_or(0.5);
509        format!("{}", cores * 2.0)
510    }
511}
512
513/// Format bytes to K8s memory string.
514fn format_bytes(bytes: u64) -> String {
515    if bytes >= 1024 * 1024 * 1024 && bytes.is_multiple_of(1024 * 1024 * 1024) {
516        format!("{}Gi", bytes / (1024 * 1024 * 1024))
517    } else if bytes >= 1024 * 1024 {
518        format!("{}Mi", bytes / (1024 * 1024))
519    } else if bytes >= 1024 {
520        format!("{}Ki", bytes / 1024)
521    } else {
522        format!("{}", bytes)
523    }
524}