tftsr-devops_investigation/src-tauri/src/metrics/client.rs
Shaun Arman 9ae89bf487
All checks were successful
Test / frontend-typecheck (pull_request) Successful in 1m49s
Test / frontend-tests (pull_request) Successful in 1m46s
PR Review Automation / review (pull_request) Successful in 4m24s
Test / rust-fmt-check (pull_request) Successful in 12m1s
Test / rust-clippy (pull_request) Successful in 13m46s
Test / rust-tests (pull_request) Successful in 15m8s
fix(security): address automated code review findings
BLOCKER fixes:
- Implement create_azuredevops_workitem instead of returning a stub error,
  reusing the existing create_work_item integration helper and writing an
  audit-log entry on success.
- Log kill failures in PtySession::Drop so leaked child processes surface
  in tracing rather than being silently swallowed.
- Add explicit PTY cleanup on every exit path of run_session_io (process
  exit, read error, write error, resize error, terminate command).
- Treat PTY resize failures as fatal: emit terminal-error to the frontend
  and break the session loop instead of just warning.

WARNING fixes:
- Remove the dead extract_json_path_value helper from commands/kube.rs.
- Wrap temp kubeconfig files in commands/metrics.rs in an RAII guard
  (TempKubeconfig) so they're removed on early-return / panic paths.
- Wrap temp kubeconfig files in commands/shell.rs PTY-session starters
  in a disarmable RAII guard (KubeconfigGuard); if kubectl resolution
  fails we no longer leak the file.
- Drop the `clear;` prefix from the kubectl-exec shell fallback so
  containers without `clear`/`tput` don't print a confusing error.

SUGGESTION fixes:
- Document why node CPU/memory percentages are 0.0 in metrics::client
  and link the gap to future work fetching node capacity.
- Add a module-level doc comment to AppState describing the
  synchronization expectations (std vs tokio Mutex) for each public
  field, and warn against holding std::sync MutexGuards across .await.

Verified: cargo fmt --check, cargo clippy -- -D warnings, and
cargo test (377 passed, 6 ignored) all pass.
2026-06-09 18:08:58 -05:00

247 lines
7.6 KiB
Rust

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PodMetrics {
pub name: String,
pub namespace: String,
pub containers: Vec<ContainerMetrics>,
pub cpu: String, // e.g., "100m"
pub memory: String, // e.g., "256Mi"
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ContainerMetrics {
pub name: String,
pub cpu: String,
pub memory: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct NodeMetrics {
pub name: String,
pub cpu: String,
pub memory: String,
pub cpu_percent: f64,
pub memory_percent: f64,
}
/// Parse kubectl top pods output (JSON format)
pub fn parse_pod_metrics(json_output: &str) -> Result<Vec<PodMetrics>> {
let value: serde_json::Value =
serde_json::from_str(json_output).context("Failed to parse kubectl top pods JSON")?;
let items = value
.get("items")
.and_then(|v| v.as_array())
.context("Missing items array")?;
let mut metrics = Vec::new();
for item in items {
let name = item
.get("metadata")
.and_then(|m| m.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let namespace = item
.get("metadata")
.and_then(|m| m.get("namespace"))
.and_then(|n| n.as_str())
.unwrap_or("default")
.to_string();
let containers_data = item.get("containers").and_then(|c| c.as_array());
let mut containers = Vec::new();
let mut total_cpu_nano = 0u64;
let mut total_memory_kb = 0u64;
if let Some(containers_data) = containers_data {
for container in containers_data {
let container_name = container
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let cpu_usage = container
.get("usage")
.and_then(|u| u.get("cpu"))
.and_then(|c| c.as_str())
.unwrap_or("0")
.to_string();
let memory_usage = container
.get("usage")
.and_then(|u| u.get("memory"))
.and_then(|m| m.as_str())
.unwrap_or("0")
.to_string();
// Parse for totals
total_cpu_nano += parse_cpu_to_nanocores(&cpu_usage);
total_memory_kb += parse_memory_to_kb(&memory_usage);
containers.push(ContainerMetrics {
name: container_name,
cpu: cpu_usage,
memory: memory_usage,
});
}
}
metrics.push(PodMetrics {
name,
namespace,
containers,
cpu: format_cpu_from_nanocores(total_cpu_nano),
memory: format_memory_from_kb(total_memory_kb),
});
}
Ok(metrics)
}
/// Parse kubectl top nodes output (JSON format)
pub fn parse_node_metrics(json_output: &str) -> Result<Vec<NodeMetrics>> {
let value: serde_json::Value =
serde_json::from_str(json_output).context("Failed to parse kubectl top nodes JSON")?;
let items = value
.get("items")
.and_then(|v| v.as_array())
.context("Missing items array")?;
let mut metrics = Vec::new();
for item in items {
let name = item
.get("metadata")
.and_then(|m| m.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let cpu = item
.get("usage")
.and_then(|u| u.get("cpu"))
.and_then(|c| c.as_str())
.unwrap_or("0")
.to_string();
let memory = item
.get("usage")
.and_then(|u| u.get("memory"))
.and_then(|m| m.as_str())
.unwrap_or("0")
.to_string();
// Calculate percentages (simplified - would need capacity from kubectl get nodes).
//
// TODO(metrics): Populate these from node `status.capacity` once we add
// a second kubectl call to fetch node capacity. The metrics-server JSON
// returned by `kubectl top nodes` only reports raw `usage` (cpu in
// nanocores, memory in Ki), not the node's allocatable totals, so we
// cannot compute a real percentage from this response alone.
// Until that work is done these are reported as 0.0 and the frontend
// hides the percent column. Tracking issue: see Telemetry/Metrics
// backlog in the project tracker.
let cpu_percent = 0.0;
let memory_percent = 0.0;
metrics.push(NodeMetrics {
name,
cpu,
memory,
cpu_percent,
memory_percent,
});
}
Ok(metrics)
}
/// Parse CPU string to nanocores (e.g., "100m" -> 100000000, "2" -> 2000000000)
fn parse_cpu_to_nanocores(cpu: &str) -> u64 {
if cpu.ends_with('n') {
cpu.trim_end_matches('n').parse::<u64>().unwrap_or(0)
} else if cpu.ends_with('u') {
cpu.trim_end_matches('u').parse::<u64>().unwrap_or(0) * 1000
} else if cpu.ends_with('m') {
cpu.trim_end_matches('m').parse::<u64>().unwrap_or(0) * 1_000_000
} else {
cpu.parse::<u64>().unwrap_or(0) * 1_000_000_000
}
}
/// Parse memory string to kilobytes (e.g., "256Mi" -> 262144, "1Gi" -> 1048576)
fn parse_memory_to_kb(memory: &str) -> u64 {
if memory.ends_with("Ki") {
memory.trim_end_matches("Ki").parse::<u64>().unwrap_or(0)
} else if memory.ends_with("Mi") {
memory.trim_end_matches("Mi").parse::<u64>().unwrap_or(0) * 1024
} else if memory.ends_with("Gi") {
memory.trim_end_matches("Gi").parse::<u64>().unwrap_or(0) * 1024 * 1024
} else if memory.ends_with("Ti") {
memory.trim_end_matches("Ti").parse::<u64>().unwrap_or(0) * 1024 * 1024 * 1024
} else {
memory.parse::<u64>().unwrap_or(0) / 1024 // Assume bytes
}
}
/// Format nanocores back to human-readable (e.g., 100000000 -> "100m")
fn format_cpu_from_nanocores(nanocores: u64) -> String {
if nanocores >= 1_000_000_000 {
format!("{:.1}", nanocores as f64 / 1_000_000_000.0)
} else {
format!("{}m", nanocores / 1_000_000)
}
}
/// Format kilobytes back to human-readable (e.g., 262144 -> "256Mi")
fn format_memory_from_kb(kb: u64) -> String {
if kb >= 1024 * 1024 * 1024 {
format!("{:.1}Ti", kb as f64 / (1024.0 * 1024.0 * 1024.0))
} else if kb >= 1024 * 1024 {
format!("{:.0}Gi", kb as f64 / (1024.0 * 1024.0))
} else if kb >= 1024 {
format!("{:.0}Mi", kb as f64 / 1024.0)
} else {
format!("{}Ki", kb)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_cpu() {
assert_eq!(parse_cpu_to_nanocores("100m"), 100_000_000);
assert_eq!(parse_cpu_to_nanocores("2"), 2_000_000_000);
assert_eq!(parse_cpu_to_nanocores("500u"), 500_000);
}
#[test]
fn test_parse_memory() {
assert_eq!(parse_memory_to_kb("256Mi"), 262_144);
assert_eq!(parse_memory_to_kb("1Gi"), 1_048_576);
assert_eq!(parse_memory_to_kb("512Ki"), 512);
}
#[test]
fn test_format_cpu() {
assert_eq!(format_cpu_from_nanocores(100_000_000), "100m");
assert_eq!(format_cpu_from_nanocores(2_000_000_000), "2.0");
}
#[test]
fn test_format_memory() {
assert_eq!(format_memory_from_kb(262_144), "256Mi");
assert_eq!(format_memory_from_kb(1_048_576), "1Gi");
}
}