diff --git a/cli/src/host_commands.rs b/cli/src/host_commands.rs index dac8835..050356d 100644 --- a/cli/src/host_commands.rs +++ b/cli/src/host_commands.rs @@ -1,5 +1,6 @@ // SPDX-FileCopyrightText: 2023 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 +mod kernel_stats; use anyhow::{Context, Result}; use clap::{Args, Subcommand}; @@ -11,6 +12,8 @@ use feos_proto::host_service::{ use tokio_stream::StreamExt; use tonic::transport::Channel; +use crate::host_commands::kernel_stats::get_kernel_stats; + #[derive(Args, Debug)] pub struct HostArgs { #[arg( @@ -34,6 +37,8 @@ pub enum HostCommand { Memory, /// Display CPU information from /proc/cpuinfo CpuInfo, + /// Display CPU usage statistics from /proc/stat + KernelStats, /// Display network interface statistics NetworkInfo, /// Upgrade the FeOS binary from a remote URL @@ -68,6 +73,7 @@ pub async fn handle_host_command(args: HostArgs) -> Result<()> { HostCommand::Hostname => get_hostname(&mut client).await?, HostCommand::Memory => get_memory(&mut client).await?, HostCommand::CpuInfo => get_cpu_info(&mut client).await?, + HostCommand::KernelStats => get_kernel_stats(&mut client).await?, HostCommand::NetworkInfo => get_network_info(&mut client).await?, HostCommand::Upgrade { url, sha256_sum } => { upgrade_feos(&mut client, url, sha256_sum).await? diff --git a/cli/src/host_commands/kernel_stats.rs b/cli/src/host_commands/kernel_stats.rs new file mode 100644 index 0000000..38986f1 --- /dev/null +++ b/cli/src/host_commands/kernel_stats.rs @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +use anyhow::{Context, Result}; +use feos_proto::host_service::host_service_client::HostServiceClient; +use feos_proto::host_service::GetKernelStatsRequest; +use tonic::transport::Channel; + +pub async fn get_kernel_stats(client: &mut HostServiceClient) -> Result<()> { + use std::time::Duration; + use tokio::time::sleep; + + println!("Taking CPU measurements..."); + + // First sample + let request = GetKernelStatsRequest {}; + let response1 = client.get_kernel_stats(request).await?.into_inner(); + let stats1 = response1.stats.context("No CPU stats in response")?; + + // Wait 1 second + sleep(Duration::from_secs(1)).await; + + // Second sample + let stats2 = client + .get_kernel_stats(GetKernelStatsRequest {}) + .await? + .into_inner() + .stats + .context("No CPU stats in response")?; + + // Calculate and display usage + display_cpu_usage(&stats1, &stats2); + + // Display additional stats + println!("\nSystem Statistics:"); + println!(" Context Switches: {}", stats2.context_switches); + println!(" Boot Time: {}", format_boot_time(stats2.boot_time)); + println!( + " Processes: {} created, {} running, {} blocked", + stats2.processes_created, stats2.processes_running, stats2.processes_blocked + ); + + Ok(()) +} + +fn display_cpu_usage( + stats1: &feos_proto::host_service::KernelStats, + stats2: &feos_proto::host_service::KernelStats, +) { + println!("\nCPU Usage (1 second average):"); + + // Calculate total CPU usage + if let (Some(total1), Some(total2)) = (&stats1.total, &stats2.total) { + let usage = calculate_cpu_usage_percent(total1, total2); + println!( + " Overall: {:>5.1}% (user: {:.1}%, system: {:.1}%, iowait: {:.1}%)", + usage.total, usage.user, usage.system, usage.iowait + ); + } + + // Calculate per-CPU usage + println!("\n Per-CPU:"); + for (cpu1, cpu2) in stats1.per_cpu.iter().zip(stats2.per_cpu.iter()) { + let usage = calculate_cpu_usage_percent(cpu1, cpu2); + println!(" {:<6} {:>5.1}%", cpu2.name, usage.total); + } +} + +struct CpuUsagePercent { + total: f64, + user: f64, + system: f64, + iowait: f64, +} + +fn calculate_cpu_usage_percent( + before: &feos_proto::host_service::CpuTime, + after: &feos_proto::host_service::CpuTime, +) -> CpuUsagePercent { + let total_before = before.user + + before.nice + + before.system + + before.idle + + before.iowait + + before.irq + + before.softirq + + before.steal; + let total_after = after.user + + after.nice + + after.system + + after.idle + + after.iowait + + after.irq + + after.softirq + + after.steal; + + let total_delta = total_after.saturating_sub(total_before) as f64; + + if total_delta == 0.0 { + return CpuUsagePercent { + total: 0.0, + user: 0.0, + system: 0.0, + iowait: 0.0, + }; + } + + let idle_delta = (after.idle + after.iowait).saturating_sub(before.idle + before.iowait) as f64; + let user_delta = after.user.saturating_sub(before.user) as f64; + let system_delta = after.system.saturating_sub(before.system) as f64; + let iowait_delta = after.iowait.saturating_sub(before.iowait) as f64; + + CpuUsagePercent { + total: ((total_delta - idle_delta) / total_delta) * 100.0, + user: (user_delta / total_delta) * 100.0, + system: (system_delta / total_delta) * 100.0, + iowait: (iowait_delta / total_delta) * 100.0, + } +} + +fn format_boot_time(boot_time: u64) -> String { + use chrono::{DateTime, TimeZone, Utc}; + let dt: DateTime = Utc + .timestamp_opt(boot_time as i64, 0) + .single() + .unwrap_or_default(); + dt.format("%Y-%m-%d %H:%M:%S UTC").to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use feos_proto::host_service::CpuTime; + + fn create_cpu_time( + name: &str, + user: u64, + nice: u64, + system: u64, + idle: u64, + iowait: u64, + ) -> CpuTime { + CpuTime { + name: name.to_string(), + user, + nice, + system, + idle, + iowait, + irq: 0, + softirq: 0, + steal: 0, + guest: 0, + guest_nice: 0, + } + } + + #[test] + fn test_calculate_cpu_usage_percent_basic() { + // Sample 1: total = 1000 ticks, idle = 800, iowait = 50 + let before = create_cpu_time("cpu0", 100, 0, 50, 800, 50); + // Sample 2: total = 1500 ticks, idle = 900, iowait = 150 (delta: 500 total, 200 idle+iowait) + let after = create_cpu_time("cpu0", 200, 150, 100, 900, 150); + + let usage = calculate_cpu_usage_percent(&before, &after); + + // Total delta = 500, idle+iowait delta = (900+150) - (800+50) = 200 + // Busy = 500 - 200 = 300 + // Usage = 300/500 * 100 = 60% + assert_eq!(usage.total, 60.0); + + // User delta = 100, system delta = 50 + assert_eq!(usage.user, 20.0); // 100/500 * 100 + assert_eq!(usage.system, 10.0); // 50/500 * 100 + } + + #[test] + fn test_calculate_cpu_usage_percent_zero_delta() { + let cpu = create_cpu_time("cpu0", 100, 0, 50, 800, 50); + + // Same values - no time passed + let usage = calculate_cpu_usage_percent(&cpu, &cpu); + + assert_eq!(usage.total, 0.0); + assert_eq!(usage.user, 0.0); + assert_eq!(usage.system, 0.0); + assert_eq!(usage.iowait, 0.0); + } + + #[test] + fn test_calculate_cpu_usage_percent_100_percent() { + // All busy, no idle time + let before = create_cpu_time("cpu0", 100, 0, 50, 50, 0); + let after = create_cpu_time("cpu0", 200, 0, 150, 50, 0); + + let usage = calculate_cpu_usage_percent(&before, &after); + + // Total delta = 200, idle delta = 0 + // Usage should be 100% + assert_eq!(usage.total, 100.0); + } + + #[test] + fn test_calculate_cpu_usage_percent_all_idle() { + // All idle time + let before = create_cpu_time("cpu0", 100, 0, 50, 800, 50); + let after = create_cpu_time("cpu0", 100, 0, 50, 1300, 50); + + let usage = calculate_cpu_usage_percent(&before, &after); + + // Only idle increased by 500 + // Usage should be 0% + assert_eq!(usage.total, 0.0); + } + + #[test] + fn test_calculate_cpu_usage_percent_high_iowait() { + let before = create_cpu_time("cpu0", 100, 0, 50, 800, 50); + let after = create_cpu_time("cpu0", 150, 0, 75, 900, 375); + + let usage = calculate_cpu_usage_percent(&before, &after); + + // IOWait delta = 325 out of 500 total + assert_eq!(usage.iowait, 65.0); + } + + #[test] + fn test_format_boot_time() { + // Test with known timestamp: 2024-01-15 12:00:00 UTC + let timestamp = 1705320000u64; + let formatted = format_boot_time(timestamp); + + assert_eq!(formatted, "2024-01-15 12:00:00 UTC"); + } + + #[test] + fn test_format_boot_time_epoch() { + let formatted = format_boot_time(0); + assert_eq!(formatted, "1970-01-01 00:00:00 UTC"); + } +} diff --git a/feos/services/host-service/src/api.rs b/feos/services/host-service/src/api.rs index ef73f72..935a7e2 100644 --- a/feos/services/host-service/src/api.rs +++ b/feos/services/host-service/src/api.rs @@ -4,10 +4,11 @@ use crate::Command; use feos_proto::host_service::{ host_service_server::HostService, FeosLogEntry, GetCpuInfoRequest, GetCpuInfoResponse, - GetNetworkInfoRequest, GetNetworkInfoResponse, GetVersionInfoRequest, GetVersionInfoResponse, - HostnameRequest, HostnameResponse, KernelLogEntry, MemoryRequest, MemoryResponse, - RebootRequest, RebootResponse, ShutdownRequest, ShutdownResponse, StreamFeosLogsRequest, - StreamKernelLogsRequest, UpgradeFeosBinaryRequest, UpgradeFeosBinaryResponse, + GetKernelStatsRequest, GetKernelStatsResponse, GetNetworkInfoRequest, GetNetworkInfoResponse, + GetVersionInfoRequest, GetVersionInfoResponse, HostnameRequest, HostnameResponse, + KernelLogEntry, MemoryRequest, MemoryResponse, RebootRequest, RebootResponse, ShutdownRequest, + ShutdownResponse, StreamFeosLogsRequest, StreamKernelLogsRequest, UpgradeFeosBinaryRequest, + UpgradeFeosBinaryResponse, }; use log::info; use std::pin::Pin; @@ -79,6 +80,14 @@ impl HostService for HostApiHandler { dispatch_and_wait(&self.dispatcher_tx, Command::GetCPUInfo).await } + async fn get_kernel_stats( + &self, + _request: Request, + ) -> Result, Status> { + info!("HostApi: Received GetKernelStats request."); + dispatch_and_wait(&self.dispatcher_tx, Command::GetKernelStats).await + } + async fn get_network_info( &self, _request: Request, diff --git a/feos/services/host-service/src/dispatcher.rs b/feos/services/host-service/src/dispatcher.rs index a7093c3..9599a9b 100644 --- a/feos/services/host-service/src/dispatcher.rs +++ b/feos/services/host-service/src/dispatcher.rs @@ -38,6 +38,9 @@ impl HostServiceDispatcher { Command::GetCPUInfo(responder) => { tokio::spawn(worker::handle_get_cpu_info(responder)); } + Command::GetKernelStats(responder) => { + tokio::spawn(worker::handle_get_kernel_stats(responder)); + } Command::GetNetworkInfo(responder) => { tokio::spawn(worker::handle_get_network_info(responder)); } diff --git a/feos/services/host-service/src/lib.rs b/feos/services/host-service/src/lib.rs index ea4c8d6..067df6e 100644 --- a/feos/services/host-service/src/lib.rs +++ b/feos/services/host-service/src/lib.rs @@ -3,9 +3,10 @@ use crate::error::HostError; use feos_proto::host_service::{ - FeosLogEntry, GetCpuInfoResponse, GetNetworkInfoResponse, GetVersionInfoResponse, - HostnameResponse, KernelLogEntry, MemoryResponse, RebootRequest, RebootResponse, - ShutdownRequest, ShutdownResponse, UpgradeFeosBinaryRequest, UpgradeFeosBinaryResponse, + FeosLogEntry, GetCpuInfoResponse, GetKernelStatsResponse, GetNetworkInfoResponse, + GetVersionInfoResponse, HostnameResponse, KernelLogEntry, MemoryResponse, RebootRequest, + RebootResponse, ShutdownRequest, ShutdownResponse, UpgradeFeosBinaryRequest, + UpgradeFeosBinaryResponse, }; use std::path::PathBuf; use tokio::sync::{mpsc, oneshot}; @@ -21,6 +22,7 @@ pub enum Command { GetHostname(oneshot::Sender>), GetMemory(oneshot::Sender>), GetCPUInfo(oneshot::Sender>), + GetKernelStats(oneshot::Sender>), GetNetworkInfo(oneshot::Sender>), GetVersionInfo(oneshot::Sender>), UpgradeFeosBinary( diff --git a/feos/services/host-service/src/worker/kernel_stats.rs b/feos/services/host-service/src/worker/kernel_stats.rs new file mode 100644 index 0000000..4aec314 --- /dev/null +++ b/feos/services/host-service/src/worker/kernel_stats.rs @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +use crate::error::HostError; +use feos_proto::host_service::{CpuTime, GetKernelStatsResponse, KernelStats}; +use log::{error, info, warn}; +use tokio::fs::File; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::oneshot; + +/// Parse a single CPU line from /proc/stat +/// Format: cpu user nice system idle iowait irq softirq steal guest guest_nice +fn parse_cpu_line(line: &str) -> Option { + let parts: Vec<&str> = line.split_whitespace().collect(); + + if parts.is_empty() || !parts[0].starts_with("cpu") { + return None; + } + + let name = parts[0].to_string(); + + // Parse numeric values, defaulting to 0 if missing + // Some older kernels may not have all fields + let get_value = |index: usize| -> u64 { + parts + .get(index) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0) + }; + + Some(CpuTime { + name, + user: get_value(1), + nice: get_value(2), + system: get_value(3), + idle: get_value(4), + iowait: get_value(5), + irq: get_value(6), + softirq: get_value(7), + steal: get_value(8), + guest: get_value(9), + guest_nice: get_value(10), + }) +} + +async fn read_and_parse_proc_stat() -> Result { + let path = "/proc/stat"; + let file = File::open(path) + .await + .map_err(|e| HostError::SystemInfoRead { + source: e, + path: path.to_string(), + })?; + + let reader = BufReader::new(file); + let mut lines = reader.lines(); + + let mut total: Option = None; + let mut per_cpu = Vec::new(); + let mut context_switches = 0u64; + let mut boot_time = 0u64; + let mut processes_created = 0u64; + let mut processes_running = 0u32; + let mut processes_blocked = 0u32; + + while let Some(line) = lines + .next_line() + .await + .map_err(|e| HostError::SystemInfoRead { + source: e, + path: path.to_string(), + })? + { + let prefix = line.split_whitespace().next().unwrap_or(""); + match prefix { + "cpu" if line.starts_with("cpu ") => { + // Total CPU stats (note the space after "cpu") + total = parse_cpu_line(&line); + } + p if p.starts_with("cpu") => { + // Per-CPU stats (cpu0, cpu1, etc.) + if let Some(cpu) = parse_cpu_line(&line) { + per_cpu.push(cpu); + } + } + "ctxt" => { + // Context switches + if let Some(value) = line.split_whitespace().nth(1) { + context_switches = value.parse().unwrap_or(0); + } + } + "btime" => { + // Boot time (seconds since epoch) + if let Some(value) = line.split_whitespace().nth(1) { + boot_time = value.parse().unwrap_or(0); + } + } + "processes" => { + // Number of processes created since boot + if let Some(value) = line.split_whitespace().nth(1) { + processes_created = value.parse().unwrap_or(0); + } + } + "procs_running" => { + // Number of processes currently running + if let Some(value) = line.split_whitespace().nth(1) { + processes_running = value.parse().unwrap_or(0); + } + } + "procs_blocked" => { + // Number of processes blocked waiting for I/O + if let Some(value) = line.split_whitespace().nth(1) { + processes_blocked = value.parse().unwrap_or(0); + } + } + _ => {} + } + } + + if total.is_none() { + warn!("Failed to parse total CPU stats from /proc/stat"); + } + + if per_cpu.is_empty() { + warn!("No per-CPU stats found in /proc/stat"); + } + + Ok(KernelStats { + total, + per_cpu, + context_switches, + boot_time, + processes_created, + processes_running, + processes_blocked, + }) +} + +pub async fn handle_get_kernel_stats( + responder: oneshot::Sender>, +) { + info!("HostWorker: Processing GetKernelStats request."); + let result = read_and_parse_proc_stat() + .await + .map(|stats| GetKernelStatsResponse { stats: Some(stats) }); + + if responder.send(result).is_err() { + error!( + "HostWorker: Failed to send response for GetKernelStats. API handler may have timed out." + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cpu_line() { + let line = "cpu 123 456 789 1011 1213 1415 1617 1819 2021 2223"; + let cpu = parse_cpu_line(line).unwrap(); + + assert_eq!(cpu.name, "cpu"); + assert_eq!(cpu.user, 123); + assert_eq!(cpu.nice, 456); + assert_eq!(cpu.system, 789); + assert_eq!(cpu.idle, 1011); + assert_eq!(cpu.iowait, 1213); + assert_eq!(cpu.irq, 1415); + assert_eq!(cpu.softirq, 1617); + assert_eq!(cpu.steal, 1819); + assert_eq!(cpu.guest, 2021); + assert_eq!(cpu.guest_nice, 2223); + } + + #[test] + fn test_parse_cpu_line_minimal() { + // Older kernels might have fewer fields + let line = "cpu0 123 456 789 1011"; + let cpu = parse_cpu_line(line).unwrap(); + + assert_eq!(cpu.name, "cpu0"); + assert_eq!(cpu.user, 123); + assert_eq!(cpu.nice, 456); + assert_eq!(cpu.system, 789); + assert_eq!(cpu.idle, 1011); + assert_eq!(cpu.iowait, 0); + } +} diff --git a/feos/services/host-service/src/worker/mod.rs b/feos/services/host-service/src/worker/mod.rs index fb44224..9467456 100644 --- a/feos/services/host-service/src/worker/mod.rs +++ b/feos/services/host-service/src/worker/mod.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 pub mod info; +pub mod kernel_stats; pub mod ops; pub mod power; pub mod time; @@ -10,6 +11,7 @@ pub use info::{ handle_get_cpu_info, handle_get_memory, handle_get_network_info, handle_get_version_info, handle_hostname, }; +pub use kernel_stats::*; pub use ops::{handle_stream_feos_logs, handle_stream_kernel_logs, handle_upgrade}; pub use power::{handle_reboot, handle_shutdown}; pub use time::TimeSyncWorker; diff --git a/feos/tests/integration/host_tests.rs b/feos/tests/integration/host_tests.rs index 9ba7b87..83780fe 100644 --- a/feos/tests/integration/host_tests.rs +++ b/feos/tests/integration/host_tests.rs @@ -180,3 +180,42 @@ async fn test_get_network_info() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_get_kernel_stats() -> Result<()> { + ensure_server().await; + let (_, mut host_client, _) = get_public_clients().await?; + + info!("Sending GetKernelStats request"); + let response = host_client + .get_kernel_stats(feos_proto::host_service::GetKernelStatsRequest {}) + .await? + .into_inner(); + + let stats = response + .stats + .context("KernelStats was not present in the response")?; + + info!( + "Received kernel stats: processes_running={}, processes_blocked={}, context_switches={}", + stats.processes_running, stats.processes_blocked, stats.context_switches + ); + + assert!( + stats.processes_running > 0, + "There should be at least one running process" + ); + assert!( + stats.context_switches > 0, + "Context switches should be greater than zero" + ); + + let cpu_totals = stats.total.context("CPU totals not present")?; + + info!( + "Received CPU totals: user={}, system={}, idle={}", + cpu_totals.user, cpu_totals.system, cpu_totals.idle + ); + + Ok(()) +} diff --git a/proto/v1/host.proto b/proto/v1/host.proto index 3a629ce..d94d819 100644 --- a/proto/v1/host.proto +++ b/proto/v1/host.proto @@ -12,6 +12,7 @@ service HostService { rpc Hostname(HostnameRequest) returns (HostnameResponse); rpc GetMemory(MemoryRequest) returns (MemoryResponse); rpc GetCPUInfo(GetCPUInfoRequest) returns (GetCPUInfoResponse); + rpc GetKernelStats(GetKernelStatsRequest) returns (GetKernelStatsResponse); // Retrieves statistics for all network interfaces. rpc GetNetworkInfo(GetNetworkInfoRequest) returns (GetNetworkInfoResponse); rpc Shutdown(ShutdownRequest) returns (ShutdownResponse); @@ -152,6 +153,43 @@ message CPUInfo { string power_management = 26; } +message GetKernelStatsRequest {} + +message GetKernelStatsResponse { + KernelStats stats = 1; +} + +message KernelStats { + // Total CPU (the "cpu" line from /proc/stat) + CpuTime total = 1; + // Per-CPU stats (cpu0, cpu1, etc.) + repeated CpuTime per_cpu = 2; + // Additional kernel statistics from /proc/stat + uint64 context_switches = 3; + uint64 boot_time = 4; + uint64 processes_created = 5; + uint32 processes_running = 6; + uint32 processes_blocked = 7; +} + +message CpuTime { + // CPU identifier: "cpu" for total, "cpu0", "cpu1", etc. for individual cores + string name = 1; + + // All values are in USER_HZ units (typically 1/100th of a second) + // These are monotonic counters that only increase + uint64 user = 2; // Time in user mode + uint64 nice = 3; // Time in user mode with low priority (nice) + uint64 system = 4; // Time in system mode + uint64 idle = 5; // Time in idle task + uint64 iowait = 6; // Time waiting for I/O to complete + uint64 irq = 7; // Time servicing interrupts + uint64 softirq = 8; // Time servicing softirqs + uint64 steal = 9; // Stolen time (time spent in other OS in virtualized environment) + uint64 guest = 10; // Time spent running a virtual CPU for guest OS + uint64 guest_nice = 11; // Time spent running a niced guest +} + message GetNetworkInfoRequest {} message GetNetworkInfoResponse { @@ -195,4 +233,4 @@ message GetVersionInfoResponse { string kernel_version = 1; // The version of the running FeOS binary. string feos_version = 2; -} \ No newline at end of file +}