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 && let Some(ref backup) = backup_path {
286            let backup_file = backup.join(file_path.file_name().unwrap_or_default());
287            if let Err(e) = fs::write(&backup_file, &content) {
288                errors.push(format!("Failed to backup {}: {}", file_path.display(), e));
289            }
290        }
291
292        let mut modified_content = content.clone();
293        let mut line_offset: i32 = 0;
294
295        // Sort fixes by line number (descending) to avoid offset issues
296        file_fixes.sort_by(|a, b| b.line_number.cmp(&a.line_number));
297
298        for fix in file_fixes.iter_mut() {
299            // Check confidence threshold
300            if fix.confidence < min_confidence {
301                fix.status = FixStatus::Skipped;
302                skipped += 1;
303                continue;
304            }
305
306            // Check risk level
307            if fix.impact.risk == FixRisk::Critical {
308                fix.status = FixStatus::Skipped;
309                skipped += 1;
310                continue;
311            }
312
313            // Apply the fix
314            match apply_single_fix(&mut modified_content, fix, &mut line_offset) {
315                Ok(_) => {
316                    fix.status = if dry_run {
317                        FixStatus::Pending
318                    } else {
319                        FixStatus::Applied
320                    };
321                    applied += 1;
322                }
323                Err(e) => {
324                    fix.status = FixStatus::Failed;
325                    errors.push(format!("Fix {} failed: {}", fix.id, e));
326                    failed += 1;
327                }
328            }
329        }
330
331        // Write modified content if not dry run
332        if !dry_run
333            && applied > 0
334            && let Err(e) = fs::write(file_path, &modified_content)
335        {
336            errors.push(format!("Failed to write {}: {}", file_path.display(), e));
337        }
338    }
339
340    FixApplicationResult {
341        total_fixes: fixes.len(),
342        applied,
343        skipped,
344        failed,
345        backup_path,
346        fixes: fixes.to_vec(),
347        errors,
348    }
349}
350
351/// Apply a single fix to the content.
352fn apply_single_fix(
353    content: &mut String,
354    fix: &PreciseFix,
355    _line_offset: &mut i32,
356) -> Result<(), String> {
357    let lines: Vec<&str> = content.lines().collect();
358
359    // Find the resources section for this container
360    let target_line = fix.line_number as usize;
361
362    if target_line == 0 || target_line > lines.len() {
363        return Err(format!("Invalid line number: {}", target_line));
364    }
365
366    // Build the new resources YAML
367    let indent = detect_indent(&lines, target_line - 1);
368    let new_resources = generate_resources_yaml(fix, &indent);
369
370    // Find end of current resources section
371    let (start_idx, end_idx) = find_resources_section(&lines, target_line - 1)?;
372
373    // Replace the section
374    let mut new_lines: Vec<String> = Vec::new();
375    new_lines.extend(lines[..start_idx].iter().map(|s| s.to_string()));
376    new_lines.push(new_resources);
377    new_lines.extend(lines[end_idx..].iter().map(|s| s.to_string()));
378
379    *content = new_lines.join("\n");
380
381    Ok(())
382}
383
384/// Find the resources section boundaries.
385fn find_resources_section(lines: &[&str], start: usize) -> Result<(usize, usize), String> {
386    let base_indent = lines
387        .get(start)
388        .map(|l| l.len() - l.trim_start().len())
389        .unwrap_or(0);
390
391    // Find the end of resources section
392    let mut end = start + 1;
393    while end < lines.len() {
394        let line = lines[end];
395        let trimmed = line.trim_start();
396
397        // Empty lines are part of the section
398        if trimmed.is_empty() {
399            end += 1;
400            continue;
401        }
402
403        let current_indent = line.len() - trimmed.len();
404
405        // If we're back to base indent or less, we've exited the section
406        if current_indent <= base_indent && !trimmed.starts_with('-') {
407            break;
408        }
409
410        end += 1;
411    }
412
413    Ok((start, end))
414}
415
416/// Detect indentation at a line.
417fn detect_indent(lines: &[&str], line_idx: usize) -> String {
418    lines
419        .get(line_idx)
420        .map(|l| {
421            let trimmed = l.trim_start();
422            let indent_len = l.len() - trimmed.len();
423            " ".repeat(indent_len)
424        })
425        .unwrap_or_else(|| "        ".to_string()) // Default 8 spaces
426}
427
428/// Generate YAML for resources section.
429fn generate_resources_yaml(fix: &PreciseFix, indent: &str) -> String {
430    let child_indent = format!("{}  ", indent);
431
432    let mut yaml = format!("{}resources:\n", indent);
433    yaml.push_str(&format!("{}requests:\n", child_indent));
434
435    if let Some(ref cpu) = fix.recommended.cpu_request {
436        yaml.push_str(&format!("{}  cpu: \"{}\"\n", child_indent, cpu));
437    }
438    if let Some(ref mem) = fix.recommended.memory_request {
439        yaml.push_str(&format!("{}  memory: \"{}\"\n", child_indent, mem));
440    }
441
442    yaml.push_str(&format!("{}limits:\n", child_indent));
443
444    if let Some(ref cpu) = fix.recommended.cpu_limit {
445        yaml.push_str(&format!("{}  cpu: \"{}\"\n", child_indent, cpu));
446    }
447    if let Some(ref mem) = fix.recommended.memory_limit {
448        yaml.push_str(&format!("{}  memory: \"{}\"", child_indent, mem));
449    }
450
451    yaml
452}
453
454/// Generate a unique fix ID.
455fn generate_fix_id(workload: &str, container: &str) -> String {
456    use std::time::{SystemTime, UNIX_EPOCH};
457    let ts = SystemTime::now()
458        .duration_since(UNIX_EPOCH)
459        .map(|d| d.as_millis())
460        .unwrap_or(0);
461    format!("fix-{}-{}-{}", workload, container, ts % 10000)
462}
463
464/// Convert severity to confidence score.
465fn severity_to_confidence(severity: &Severity) -> u8 {
466    match severity {
467        Severity::Critical => 95,
468        Severity::High => 80,
469        Severity::Medium => 60,
470        Severity::Low => 40,
471        Severity::Info => 20,
472    }
473}
474
475/// Assess impact of a static recommendation.
476fn assess_impact(rec: &ResourceRecommendation) -> FixImpact {
477    let risk = match rec.severity {
478        Severity::Critical | Severity::High => FixRisk::High,
479        Severity::Medium => FixRisk::Medium,
480        _ => FixRisk::Low,
481    };
482
483    FixImpact {
484        risk,
485        monthly_savings: 0.0,
486        oom_risk: false,
487        throttle_risk: false,
488        recommendation: rec.message.clone(),
489    }
490}
491
492/// Format millicores to K8s CPU string.
493fn format_millicores(millicores: u64) -> String {
494    if millicores >= 1000 && millicores.is_multiple_of(1000) {
495        format!("{}", millicores / 1000)
496    } else {
497        format!("{}m", millicores)
498    }
499}
500
501/// Double the millicores value for limits.
502fn double_millicores(value: &str) -> String {
503    if value.ends_with('m') {
504        let m: u64 = value.trim_end_matches('m').parse().unwrap_or(100);
505        format!("{}m", m * 2)
506    } else {
507        let cores: f64 = value.parse().unwrap_or(0.5);
508        format!("{}", cores * 2.0)
509    }
510}
511
512/// Format bytes to K8s memory string.
513fn format_bytes(bytes: u64) -> String {
514    if bytes >= 1024 * 1024 * 1024 && bytes.is_multiple_of(1024 * 1024 * 1024) {
515        format!("{}Gi", bytes / (1024 * 1024 * 1024))
516    } else if bytes >= 1024 * 1024 {
517        format!("{}Mi", bytes / (1024 * 1024))
518    } else if bytes >= 1024 {
519        format!("{}Ki", bytes / 1024)
520    } else {
521        format!("{}", bytes)
522    }
523}