diff --git a/Cargo.lock b/Cargo.lock index 814c3c7..b5b464a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -367,6 +367,41 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.101", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.101", +] + [[package]] name = "difflib" version = "0.4.0" @@ -611,7 +646,9 @@ dependencies = [ "lazy_static", "libc", "log", + "nvml-wrapper", "predicates", + "raw-cpuid", "regex", "reqwest", "serde", @@ -830,6 +867,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.0.3" @@ -951,6 +994,16 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1058,6 +1111,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nvml-wrapper" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d5c6c0ef9702176a570f06ad94f3198bc29c524c8b498f1b9346e1b1bdcbb3a" +dependencies = [ + "bitflags 2.9.1", + "libloading", + "nvml-wrapper-sys", + "static_assertions", + "thiserror", + "wrapcenum-derive", +] + +[[package]] +name = "nvml-wrapper-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd23dbe2eb8d8335d2bce0299e0a07d6a63c089243d626ca75b770a962ff49e6" +dependencies = [ + "libloading", +] + [[package]] name = "object" version = "0.36.7" @@ -1340,6 +1416,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.9.1", +] + [[package]] name = "rayon" version = "1.10.0" @@ -1624,6 +1709,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.8.0" @@ -2251,6 +2342,12 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-result" version = "0.1.2" @@ -2436,6 +2533,18 @@ dependencies = [ "bitflags 2.9.1", ] +[[package]] +name = "wrapcenum-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + [[package]] name = "writeable" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index e37bb16..021c8ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,18 @@ toml = "0.8.19" libc = "0.2.161" tokio = { version = "1.0", features = ["full"] } async-trait = "0.1" +# Requires Nvidia driver at runtime +nvml-wrapper = { version = "0.11.0", optional = true} + +# Only compiled on x86/x86_64 targets +[target.'cfg(any(target_arch = "x86", target_arch = "x86_64"))'.dependencies] +raw-cpuid = { version = "11", optional = true } + +[features] +default = [] +nvidia = ["nvml-wrapper"] +x86-cpu = ["raw-cpuid"] +full = ["nvidia", "x86-cpu"] [dev-dependencies] tempfile = "3.8" # For temporary file handling in tests diff --git a/MYQQGPTJ6J_hardware_report.json b/MYQQGPTJ6J_hardware_report.json index 8d3c417..9455d12 100644 --- a/MYQQGPTJ6J_hardware_report.json +++ b/MYQQGPTJ6J_hardware_report.json @@ -11,20 +11,20 @@ "total_storage": "2.0 TB", "total_storage_tb": 1.81464035063982, "filesystems": [ - "/dev/disk3s1s1 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /", - "/dev/disk3s6 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/VM", - "/dev/disk3s2 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/Preboot", - "/dev/disk3s4 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/Update", - "/dev/disk1s2 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/xarts", - "/dev/disk1s1 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/iSCPreboot", - "/dev/disk1s3 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/Hardware", - "/dev/disk3s7 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /nix" + "/dev/disk3s1s1 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /", + "/dev/disk3s6 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/VM", + "/dev/disk3s2 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/Preboot", + "/dev/disk3s4 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/Update", + "/dev/disk1s2 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/xarts", + "/dev/disk1s1 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/iSCPreboot", + "/dev/disk1s3 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/Hardware", + "/dev/disk3s7 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /nix" ], "bios": { "vendor": "Apple Inc.", - "version": "11881.121.1", + "version": "13822.41.1", "release_date": "N/A", - "firmware_version": "11881.121.1" + "firmware_version": "13822.41.1" }, "chassis": { "manufacturer": "Apple Inc.", @@ -34,14 +34,14 @@ "motherboard": { "manufacturer": "Apple Inc.", "product_name": "Mac16,5", - "version": "11881.121.1", + "version": "13822.41.1", "serial": "MYQQGPTJ6J", "features": "Integrated", "location": "System Board", "type_": "Motherboard" }, "total_gpus": 1, - "total_nics": 6, + "total_nics": 8, "numa_topology": {}, "cpu_topology": { "total_cores": 16, @@ -96,7 +96,7 @@ "devices": [ { "index": 0, - "name": "Apple M4 Max (Metal 3)", + "name": "Apple M4 Max (Metal 4)", "uuid": "macOS-GPU-0", "memory": "Unified Memory (40 cores)", "pci_id": "Apple Fabric (Integrated)", @@ -112,6 +112,7 @@ "name": "en4", "mac": "56", "ip": "Unknown", + "prefix": "Unknown", "speed": "1000 Mbps", "type_": "Ethernet", "vendor": "Apple", @@ -123,6 +124,7 @@ "name": "en5", "mac": "56", "ip": "Unknown", + "prefix": "Unknown", "speed": "1000 Mbps", "type_": "Ethernet", "vendor": "Apple", @@ -134,6 +136,7 @@ "name": "en6", "mac": "56", "ip": "Unknown", + "prefix": "Unknown", "speed": "1000 Mbps", "type_": "Ethernet", "vendor": "Apple", @@ -145,6 +148,7 @@ "name": "bridge0", "mac": "Unknown", "ip": "Unknown", + "prefix": "Unknown", "speed": "1000 Mbps", "type_": "Ethernet", "vendor": "Apple", @@ -156,6 +160,7 @@ "name": "en0", "mac": "84", "ip": "192.168.1.90", + "prefix": "Unknown", "speed": "1200 Mbps", "type_": "AirPort", "vendor": "Apple", @@ -163,10 +168,35 @@ "pci_id": "Apple Fabric (Integrated)", "numa_node": null }, + { + "name": "en7", + "mac": "Unknown", + "ip": "Unknown", + "prefix": "Unknown", + "speed": "1000 Mbps", + "type_": "Ethernet", + "vendor": "Apple", + "model": "Ethernet", + "pci_id": "Apple Fabric (Integrated)", + "numa_node": null + }, { "name": "utun4", "mac": "Unknown", - "ip": "100.84.190.125", + "ip": "100.112.156.46", + "prefix": "Unknown", + "speed": null, + "type_": "VPN (io.tailscale.ipn.macsys)", + "vendor": "Unknown", + "model": "Unknown", + "pci_id": "Unknown", + "numa_node": null + }, + { + "name": "Tailscale 3", + "mac": "Unknown", + "ip": "Unknown", + "prefix": "Unknown", "speed": null, "type_": "VPN (io.tailscale.ipn.macos)", "vendor": "Unknown", diff --git a/MYQQGPTJ6J_hardware_report.toml b/MYQQGPTJ6J_hardware_report.toml index 64d2904..92716b0 100644 --- a/MYQQGPTJ6J_hardware_report.toml +++ b/MYQQGPTJ6J_hardware_report.toml @@ -8,17 +8,17 @@ memory_config = "LPDDR5 @ Integrated" total_storage = "2.0 TB" total_storage_tb = 1.81464035063982 filesystems = [ - "/dev/disk3s1s1 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /", - "/dev/disk3s6 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/VM", - "/dev/disk3s2 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/Preboot", - "/dev/disk3s4 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /System/Volumes/Update", - "/dev/disk1s2 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/xarts", - "/dev/disk1s1 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/iSCPreboot", - "/dev/disk1s3 (apfs) - 500M total, 19M used, 482M available, mounted on /System/Volumes/Hardware", - "/dev/disk3s7 (apfs) - 1.9T total, 262G used, 1.6T available, mounted on /nix", + "/dev/disk3s1s1 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /", + "/dev/disk3s6 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/VM", + "/dev/disk3s2 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/Preboot", + "/dev/disk3s4 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /System/Volumes/Update", + "/dev/disk1s2 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/xarts", + "/dev/disk1s1 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/iSCPreboot", + "/dev/disk1s3 (apfs) - 500M total, 20M used, 481M available, mounted on /System/Volumes/Hardware", + "/dev/disk3s7 (apfs) - 1.9T total, 1.3T used, 615G available, mounted on /nix", ] total_gpus = 1 -total_nics = 6 +total_nics = 8 cpu_summary = "Apple M4 Max (1 Socket, 16 Cores/Socket, 1 Thread/Core, 1 NUMA Node)" [summary.system_info] @@ -29,9 +29,9 @@ product_manufacturer = "Apple Inc." [summary.bios] vendor = "Apple Inc." -version = "11881.121.1" +version = "13822.41.1" release_date = "N/A" -firmware_version = "11881.121.1" +firmware_version = "13822.41.1" [summary.chassis] manufacturer = "Apple Inc." @@ -41,7 +41,7 @@ serial = "MYQQGPTJ6J" [summary.motherboard] manufacturer = "Apple Inc." product_name = "Mac16,5" -version = "11881.121.1" +version = "13822.41.1" serial = "MYQQGPTJ6J" features = "Integrated" location = "System Board" @@ -86,7 +86,7 @@ model = "APPLE SSD AP2048Z (Apple Fabric)" [[hardware.gpus.devices]] index = 0 -name = "Apple M4 Max (Metal 3)" +name = "Apple M4 Max (Metal 4)" uuid = "macOS-GPU-0" memory = "Unified Memory (40 cores)" pci_id = "Apple Fabric (Integrated)" @@ -96,6 +96,7 @@ vendor = "Apple" name = "en4" mac = "56" ip = "Unknown" +prefix = "Unknown" speed = "1000 Mbps" type_ = "Ethernet" vendor = "Apple" @@ -106,6 +107,7 @@ pci_id = "Apple Fabric (Integrated)" name = "en5" mac = "56" ip = "Unknown" +prefix = "Unknown" speed = "1000 Mbps" type_ = "Ethernet" vendor = "Apple" @@ -116,6 +118,7 @@ pci_id = "Apple Fabric (Integrated)" name = "en6" mac = "56" ip = "Unknown" +prefix = "Unknown" speed = "1000 Mbps" type_ = "Ethernet" vendor = "Apple" @@ -126,6 +129,7 @@ pci_id = "Apple Fabric (Integrated)" name = "bridge0" mac = "Unknown" ip = "Unknown" +prefix = "Unknown" speed = "1000 Mbps" type_ = "Ethernet" vendor = "Apple" @@ -136,16 +140,39 @@ pci_id = "Apple Fabric (Integrated)" name = "en0" mac = "84" ip = "192.168.1.90" +prefix = "Unknown" speed = "1200 Mbps" type_ = "AirPort" vendor = "Apple" model = "Wi-Fi 802.11 a/b/g/n/ac/ax" pci_id = "Apple Fabric (Integrated)" +[[network.interfaces]] +name = "en7" +mac = "Unknown" +ip = "Unknown" +prefix = "Unknown" +speed = "1000 Mbps" +type_ = "Ethernet" +vendor = "Apple" +model = "Ethernet" +pci_id = "Apple Fabric (Integrated)" + [[network.interfaces]] name = "utun4" mac = "Unknown" -ip = "100.84.190.125" +ip = "100.112.156.46" +prefix = "Unknown" +type_ = "VPN (io.tailscale.ipn.macsys)" +vendor = "Unknown" +model = "Unknown" +pci_id = "Unknown" + +[[network.interfaces]] +name = "Tailscale 3" +mac = "Unknown" +ip = "Unknown" +prefix = "Unknown" type_ = "VPN (io.tailscale.ipn.macos)" vendor = "Unknown" model = "Unknown" diff --git a/src/adapters/secondary/publisher/file.rs b/src/adapters/secondary/publisher/file.rs index f1ae32b..5af1bb2 100644 --- a/src/adapters/secondary/publisher/file.rs +++ b/src/adapters/secondary/publisher/file.rs @@ -221,6 +221,7 @@ mod tests { threads: 2, sockets: 1, speed: "3.0 GHz".to_string(), + ..Default::default() }, memory: crate::domain::MemoryInfo { total: "16GB".to_string(), diff --git a/src/adapters/secondary/publisher/http.rs b/src/adapters/secondary/publisher/http.rs index 2ed8364..d294a8b 100644 --- a/src/adapters/secondary/publisher/http.rs +++ b/src/adapters/secondary/publisher/http.rs @@ -211,6 +211,7 @@ mod tests { threads: 2, sockets: 1, speed: "3.0 GHz".to_string(), + ..Default::default() }, memory: crate::domain::MemoryInfo { total: "16GB".to_string(), diff --git a/src/adapters/secondary/system/linux.rs b/src/adapters/secondary/system/linux.rs index fb2513b..9915705 100644 --- a/src/adapters/secondary/system/linux.rs +++ b/src/adapters/secondary/system/linux.rs @@ -15,20 +15,46 @@ limitations under the License. */ //! Linux system information provider +//! +//! Implements `SystemInfoProvider` for Linux systems using: +//! - sysfs (`/sys`) for direct kernel data +//! - procfs (`/proc`) for process/system info +//! - Command execution for tools like lsblk, nvidia-smi +//! +//! # Platform Support +//! +//! - x86_64: Full support including raw-cpuid +//! - aarch64: Full support via sysfs (ARM servers, DGX Spark) +//! +//! # Detection Strategy +//! +//! Each hardware type uses multiple detection methods: +//! 1. Primary: sysfs (most reliable, always available) +//! 2. Secondary: Command output (lsblk, nvidia-smi, etc.) +//! 3. Fallback: sysinfo crate (cross-platform) use crate::domain::{ combine_cpu_info, determine_memory_speed, determine_memory_type, parse_dmidecode_bios_info, parse_dmidecode_chassis_info, parse_dmidecode_cpu, parse_dmidecode_memory, parse_dmidecode_system_info, parse_free_output, parse_hostname_output, parse_ip_output, - parse_lsblk_output, parse_lscpu_output, BiosInfo, ChassisInfo, CpuInfo, GpuInfo, MemoryInfo, - MotherboardInfo, NetworkInfo, NumaNode, StorageInfo, SystemError, SystemInfo, + parse_lscpu_output, BiosInfo, ChassisInfo, CpuInfo, GpuDevice, GpuInfo, GpuVendor, MemoryInfo, + MotherboardInfo, NetworkInfo, NetworkInterface, NetworkInterfaceType, NumaNode, StorageDevice, + StorageInfo, StorageType, SystemError, SystemInfo, }; + +use crate::domain::parsers::storage::{ + is_virtual_device, parse_lsblk_json, parse_sysfs_rotational, parse_sysfs_size, +}; + use crate::ports::{CommandExecutor, SystemCommand, SystemInfoProvider}; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; +use std::fs; +use std::path::{Path, PathBuf}; + /// Linux system information provider using standard system commands pub struct LinuxSystemInfoProvider { command_executor: Arc, @@ -60,12 +86,341 @@ impl LinuxSystemInfoProvider { } missing } + + /// Read a sysfs file and return contents as String + fn read_sysfs_file(&self, path: &Path) -> Result { + fs::read_to_string(path) + } + + /// Detect storage devices via sysfs /sys/block. + async fn detect_storage_sysfs(&self) -> Result, SystemError> { + let mut devices = Vec::new(); + + let sys_block = Path::new("/sys/block"); + + if !sys_block.exists() { + return Err(SystemError::NotAvailable { + resource: "/sys/block".to_string(), + }); + } + + let entries = fs::read_dir(sys_block).map_err(|e| SystemError::IoErrorWithPath { + path: "/sys/block".to_string(), + message: e.to_string(), + })?; + + for entry in entries.flatten() { + let device_name = entry.file_name().to_string_lossy().to_string(); + + // Filter early to avoid needless I/O + if is_virtual_device(&device_name) { + log::trace!("Skipping virtual device: {}", device_name); + continue; + } + + let device_path = entry.path(); + + // If we can't get the size, skip the device + let size_path = device_path.join("size"); + let Ok(content) = self.read_sysfs_file(&size_path) else { + continue; + }; + let Ok(size_bytes) = parse_sysfs_size(&content) else { + log::trace!("Cannot parse size for {}: invalid format", device_name); + continue; + }; + + // Skip tiny devices (< 1GB) + const MIN_SIZE: u64 = 1_000_000_000; + if size_bytes < MIN_SIZE { + log::trace!( + "Skipping small device {}: {} bytes", + device_name, + size_bytes + ); + continue; + } + + // Rotational flag + let rotational_path = device_path.join("queue/rotational"); + let is_rotational = self + .read_sysfs_file(&rotational_path) + .map(|content| parse_sysfs_rotational(&content)) + .unwrap_or(false); + + // Determine device type + let device_type = StorageType::from_device(&device_name, is_rotational); + + // Read optional fields + let model = self + .read_sysfs_file(&device_path.join("device/model")) + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + + let serial_number = self + .read_sysfs_file(&device_path.join("device/serial")) + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()); + + let firmware_version = self + .read_sysfs_file(&device_path.join("device/firmware_rev")) + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()); + + // Alternate paths for NVMe + let (serial_number, firmware_version) = if device_type == StorageType::Nvme { + self.read_nvme_sysfs_attrs(&device_name, serial_number, firmware_version) + } else { + (serial_number, firmware_version) + }; + + let interface = match &device_type { + StorageType::Nvme => "NVMe".to_string(), + StorageType::Emmc => "eMMC".to_string(), + StorageType::Hdd | StorageType::Ssd => "SATA".to_string(), + _ => "Unknown".to_string(), + }; + + let mut device = StorageDevice { + name: device_name.clone(), + device_path: format!("/dev/{}", device_name), + device_type: device_type.clone(), + type_: device_type.display_name().to_string(), + size_bytes, + model, + serial_number, + firmware_version, + interface, + is_rotational, + detection_method: "sysfs".to_string(), + ..Default::default() + }; + + device.calculate_size_fields(); + devices.push(device); + } + + Ok(devices) + } + + /// Read NVMe-specific sysfs attributes + fn read_nvme_sysfs_attrs( + &self, + device_name: &str, + existing_serial: Option, + existing_firmware: Option, + ) -> (Option, Option) { + // Extract controller name: "nvme0n1" -> "nvme0" + let controller = if let Some(stripped) = device_name.strip_prefix("nvme") { + if let Some(pos) = stripped.find('n') { + &device_name[..4 + pos] + } else { + device_name + } + } else { + device_name + }; + + let nvme_path = PathBuf::from("/sys/class/nvme").join(controller); + + let serial = existing_serial.or_else(|| { + self.read_sysfs_file(&nvme_path.join("serial")) + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()) + }); + + let firmware = existing_firmware.or_else(|| { + self.read_sysfs_file(&nvme_path.join("firmware_rev")) + .map(|s| s.trim().to_string()) + .ok() + .filter(|s| !s.is_empty()) + }); + + (serial, firmware) + } + + /// Detect storage via lsblk command (JSON output) + async fn detect_storage_lsblk(&self) -> Result, SystemError> { + let cmd = SystemCommand::new("lsblk") + .args(&[ + "-J", + "-b", + "-d", + "-o", + "NAME,SIZE,TYPE,MODEL,SERIAL,ROTA,TRAN,WWN", + ]) + .timeout(Duration::from_secs(10)); + + let output = + self.command_executor + .execute(&cmd) + .await + .map_err(|e| SystemError::CommandFailed { + command: "lsblk".to_string(), + exit_code: None, + stderr: e.to_string(), + })?; + + if !output.success { + return Err(SystemError::CommandFailed { + command: "lsblk".to_string(), + exit_code: output.exit_code, + stderr: output.stderr, + }); + } + + parse_lsblk_json(&output.stdout).map_err(SystemError::ParseError) + } + + /// Detect storage via sysinfo crate (cross-platform fallback) + fn detect_storage_sysinfo(&self) -> Result, SystemError> { + use sysinfo::Disks; + + let disks = Disks::new_with_refreshed_list(); + let mut devices = Vec::new(); + + for disk in disks.iter() { + let size_bytes = disk.total_space(); + + if size_bytes < 1_000_000_000 { + continue; + } + + let name = disk.name().to_string_lossy().to_string(); + let name = if name.is_empty() { + disk.mount_point().to_string_lossy().to_string() + } else { + name + }; + + let mut device = StorageDevice { + name, + size_bytes, + detection_method: "sysinfo".to_string(), + ..Default::default() + }; + + device.calculate_size_fields(); + devices.push(device); + } + Ok(devices) + } + + /// Merge storage info from secondary source into primary + fn merge_storage_info(&self, primary: &mut Vec, secondary: Vec) { + for sec_device in secondary { + if let Some(pri_device) = primary.iter_mut().find(|d| d.name == sec_device.name) { + // Fill in missing fields from secondary + pri_device.serial_number = + pri_device.serial_number.take().or(sec_device.serial_number); + pri_device.firmware_version = pri_device + .firmware_version + .take() + .or(sec_device.firmware_version); + pri_device.wwn = pri_device.wwn.take().or(sec_device.wwn); + + if pri_device.model.is_empty() && !sec_device.model.is_empty() { + pri_device.model = sec_device.model; + } + } else { + // Device not in primary - add it + primary.push(sec_device); + } + } + } + + /// Enrich network interface with sysfs data + fn enrich_network_interface_sysfs(&self, iface: &mut NetworkInterface) { + let iface_path = PathBuf::from("/sys/class/net").join(&iface.name); + + if !iface_path.exists() { + return; + } + + // Operational state + iface.is_up = self + .read_sysfs_file(&iface_path.join("operstate")) + .map(|s| s.trim().to_lowercase() == "up") + .unwrap_or(false); + + // Speed (may be -1 if link is down) + if let Ok(speed_str) = self.read_sysfs_file(&iface_path.join("speed")) { + if let Ok(speed) = speed_str.trim().parse::() { + if speed > 0 { + iface.speed_mbps = Some(speed as u32); + iface.speed = Some(format!("{} Mbps", speed)); + } + } + } + + // MTU + if let Ok(mtu_str) = self.read_sysfs_file(&iface_path.join("mtu")) { + if let Ok(mtu) = mtu_str.trim().parse::() { + iface.mtu = mtu; + } + } + + // Carrier (link detected) + iface.carrier = self + .read_sysfs_file(&iface_path.join("carrier")) + .map(|s| s.trim() == "1") + .ok(); + + // Virtual interface detection + let device_path = iface_path.join("device"); + iface.is_virtual = !device_path.exists() + || iface.name.starts_with("lo") + || iface.name.starts_with("veth") + || iface.name.starts_with("br") + || iface.name.starts_with("docker") + || iface.name.starts_with("virbr"); + + // Driver information (only for physical interfaces) + if !iface.is_virtual { + let driver_link = device_path.join("driver"); + if let Ok(driver_path) = fs::read_link(&driver_link) { + if let Some(driver_name) = driver_path.file_name() { + let driver_str = driver_name.to_string_lossy().to_string(); + iface.driver = Some(driver_str.clone()); + + // Driver version + let version_path = PathBuf::from("/sys/module") + .join(&driver_str) + .join("version"); + if let Ok(version) = self.read_sysfs_file(&version_path) { + iface.driver_version = Some(version.trim().to_string()); + } + } + } + } + + // Interface type + iface.interface_type = if iface.name == "lo" { + NetworkInterfaceType::Loopback + } else if iface.name.starts_with("br") || iface.name.starts_with("virbr") { + NetworkInterfaceType::Bridge + } else if iface.name.starts_with("veth") { + NetworkInterfaceType::Veth + } else if iface.name.starts_with("bond") { + NetworkInterfaceType::Bond + } else if iface.name.contains('.') { + NetworkInterfaceType::Vlan + } else if iface.name.starts_with("wl") { + NetworkInterfaceType::Wireless + } else if iface.name.starts_with("ib") { + NetworkInterfaceType::Infiniband + } else { + NetworkInterfaceType::Ethernet + }; + } } #[async_trait] impl SystemInfoProvider for LinuxSystemInfoProvider { async fn get_cpu_info(&self) -> Result { - // Get CPU info from lscpu let lscpu_cmd = SystemCommand::new("lscpu").timeout(Duration::from_secs(10)); let lscpu_output = self .command_executor @@ -80,7 +435,6 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { let lscpu_info = parse_lscpu_output(&lscpu_output.stdout).map_err(SystemError::ParseError)?; - // Try to get additional info from dmidecode (may require sudo) let dmidecode_cmd = SystemCommand::new("dmidecode") .args(&["-t", "processor"]) .timeout(Duration::from_secs(10)); @@ -97,14 +451,13 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { Ok(lscpu_info) } } - _ => Ok(lscpu_info), // Fall back to lscpu info + _ => Ok(lscpu_info), } } async fn get_memory_info(&self) -> Result { - // Get total memory from free command let free_cmd = SystemCommand::new("free") - .args(&["-b"]) // Show in bytes + .args(&["-b"]) .timeout(Duration::from_secs(5)); let free_output = self .command_executor @@ -119,7 +472,6 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { let total_memory = parse_free_output(&free_output.stdout).map_err(SystemError::ParseError)?; - // Try to get detailed memory info from dmidecode let dmidecode_cmd = SystemCommand::new("dmidecode") .args(&["-t", "memory"]) .timeout(Duration::from_secs(10)); @@ -151,26 +503,48 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { } async fn get_storage_info(&self) -> Result { - let lsblk_cmd = SystemCommand::new("lsblk") - .args(&["-d", "-o", "NAME,SIZE,TYPE"]) - .timeout(Duration::from_secs(10)); - let lsblk_output = self - .command_executor - .execute(&lsblk_cmd) - .await - .map_err(|e| SystemError::CommandFailed { - command: "lsblk".to_string(), - exit_code: None, - stderr: e.to_string(), - })?; + let mut devices = Vec::new(); - let devices = parse_lsblk_output(&lsblk_output.stdout).map_err(SystemError::ParseError)?; + // Try sysfs first + if let Ok(sysfs_devices) = self.detect_storage_sysfs().await { + log::debug!("sysfs detected {} storage devices", sysfs_devices.len()); + devices = sysfs_devices; + } else { + log::warn!("sysfs storage detection failed, trying next method"); + } + // Enrich with lsblk + if let Ok(lsblk_devices) = self.detect_storage_lsblk().await { + log::debug!( + "lsblk found {} devices for additional info", + lsblk_devices.len() + ); + self.merge_storage_info(&mut devices, lsblk_devices); + } + + // Fallback to sysinfo + if devices.is_empty() { + log::warn!("No devices from sysfs/lsblk, trying sysinfo as fallback"); + if let Ok(sysinfo_devices) = self.detect_storage_sysinfo() { + devices = sysinfo_devices; + } + } + + // Filter virtual devices and sort + devices.retain(|d| d.device_type != StorageType::Virtual); + + for device in &mut devices { + if device.size_gb == 0.0 && device.size_bytes > 0 { + device.calculate_size_fields(); + } + device.set_device_path(); + } + + devices.sort_by(|a, b| a.name.cmp(&b.name)); Ok(StorageInfo { devices }) } async fn get_gpu_info(&self) -> Result { - // Try nvidia-smi first let nvidia_cmd = SystemCommand::new("nvidia-smi") .args(&[ "--query-gpu=index,name,uuid,memory.total", @@ -182,25 +556,29 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { if let Ok(nvidia_output) = self.command_executor.execute(&nvidia_cmd).await { if nvidia_output.success { - // Parse NVIDIA GPU info for (index, line) in nvidia_output.stdout.lines().enumerate() { let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); if parts.len() >= 4 { - devices.push(crate::domain::GpuDevice { + let memory_mb: u64 = parts[3].parse().unwrap_or(0); + devices.push(GpuDevice { index: index as u32, name: parts[1].to_string(), uuid: parts[2].to_string(), memory: format!("{} MB", parts[3]), - pci_id: "Unknown".to_string(), + memory_total_mb: memory_mb, + pci_id: String::new(), vendor: "NVIDIA".to_string(), + vendor_enum: GpuVendor::Nvidia, numa_node: None, + detection_method: "nvidia-smi".to_string(), + ..Default::default() }); } } } } - // Fall back to lspci for basic GPU detection + // Fallback to lspci if devices.is_empty() { let lspci_cmd = SystemCommand::new("lspci") .args(&["-nn"]) @@ -212,14 +590,17 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { for line in lspci_output.stdout.lines() { if line.to_lowercase().contains("vga") || line.to_lowercase().contains("3d") { - devices.push(crate::domain::GpuDevice { + devices.push(GpuDevice { index: gpu_index, name: line.to_string(), uuid: format!("pci-gpu-{gpu_index}"), memory: "Unknown".to_string(), - pci_id: "Unknown".to_string(), + pci_id: String::new(), vendor: "Unknown".to_string(), + vendor_enum: GpuVendor::Unknown, numa_node: None, + detection_method: "lspci".to_string(), + ..Default::default() }); gpu_index += 1; } @@ -243,11 +624,16 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { } })?; - let interfaces = parse_ip_output(&ip_output.stdout).map_err(SystemError::ParseError)?; + let mut interfaces = parse_ip_output(&ip_output.stdout).map_err(SystemError::ParseError)?; + + // Enrich with sysfs data + for iface in &mut interfaces { + self.enrich_network_interface_sysfs(iface); + } Ok(NetworkInfo { interfaces, - infiniband: None, // TODO: Add infiniband detection + infiniband: None, }) } @@ -299,8 +685,6 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { stderr: e.to_string(), })?; - // Parse motherboard info (simplified) - // TODO: Parse _dmidecode_output.stdout to extract actual values Ok(MotherboardInfo { manufacturer: "Unknown".to_string(), product_name: "Unknown".to_string(), @@ -330,7 +714,6 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { } async fn get_numa_topology(&self) -> Result, SystemError> { - // Simplified NUMA topology - in real implementation this would be more comprehensive Ok(HashMap::new()) } @@ -380,7 +763,6 @@ impl SystemInfoProvider for LinuxSystemInfoProvider { let mut filesystems = Vec::new(); for line in df_output.stdout.lines().skip(1) { - // Skip header if !line.trim().is_empty() { filesystems.push(line.to_string()); } diff --git a/src/adapters/secondary/system/macos.rs b/src/adapters/secondary/system/macos.rs index 5db51e2..66eb308 100644 --- a/src/adapters/secondary/system/macos.rs +++ b/src/adapters/secondary/system/macos.rs @@ -161,6 +161,7 @@ impl SystemInfoProvider for MacOSSystemInfoProvider { pci_id: "Apple Fabric (Integrated)".to_string(), vendor: "Apple".to_string(), numa_node: None, + ..Default::default() }); gpu_index += 1; } @@ -176,6 +177,7 @@ impl SystemInfoProvider for MacOSSystemInfoProvider { pci_id: "Apple Fabric (Integrated)".to_string(), vendor: "Apple".to_string(), numa_node: None, + ..Default::default() }); } diff --git a/src/domain/entities.rs b/src/domain/entities.rs index b292382..bd15e32 100644 --- a/src/domain/entities.rs +++ b/src/domain/entities.rs @@ -160,7 +160,7 @@ pub struct HardwareInfo { } /// CPU information -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, Default)] pub struct CpuInfo { /// CPU model name pub model: String, @@ -170,8 +170,84 @@ pub struct CpuInfo { pub threads: u32, /// Number of sockets pub sockets: u32, - /// CPU speed + /// CPU speed as string pub speed: String, + /// CPU vendor (e.g., "GenuineIntel", "AuthenticAMD") + #[serde(default)] + pub vendor: String, + /// CPU architecture + #[serde(default)] + pub architecture: String, + /// CPU frequency in MHz + #[serde(default)] + pub frequency_mhz: u32, + /// Minimum frequency in MHz + #[serde(default)] + pub frequency_min_mhz: Option, + /// Maximum frequency in MHz + #[serde(default)] + pub frequency_max_mhz: Option, + /// L1 data cache size in KB + #[serde(default)] + pub cache_l1d_kb: Option, + /// L1 instruction cache size in KB + #[serde(default)] + pub cache_l1i_kb: Option, + /// L2 cache size in KB + #[serde(default)] + pub cache_l2_kb: Option, + /// L3 cache size in KB + #[serde(default)] + pub cache_l3_kb: Option, + /// CPU flags/features + #[serde(default)] + pub flags: Vec, + /// Microarchitecture (e.g., "Zen 3", "Ice Lake") + #[serde(default)] + pub microarchitecture: Option, + /// Detailed cache information + #[serde(default)] + pub caches: Vec, + /// Detection methods used + #[serde(default)] + pub detection_methods: Vec, +} + +impl CpuInfo { + /// Set speed string from frequency_mhz + pub fn set_speed_string(&mut self) { + if self.frequency_mhz > 0 { + if self.frequency_mhz >= 1000 { + self.speed = format!("{:.2} GHz", self.frequency_mhz as f64 / 1000.0); + } else { + self.speed = format!("{} MHz", self.frequency_mhz); + } + } + } + + /// Calculate total cores and threads + pub fn calculate_totals(&mut self) { + // These are typically already set correctly from parsing + } +} + +/// CPU cache information +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct CpuCacheInfo { + /// Cache level (1, 2, 3, etc.) + pub level: u8, + /// Cache type ("Data", "Instruction", "Unified") + pub cache_type: String, + /// Size in KB + pub size_kb: u32, + /// Ways of associativity + pub ways_of_associativity: Option, + /// Cache line size in bytes + pub line_size_bytes: Option, + /// Number of sets + pub sets: Option, + /// Whether this cache is shared between cores + pub shared: Option, } /// Memory information @@ -211,17 +287,136 @@ pub struct StorageInfo { pub devices: Vec, } +/// Storage type classification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub enum StorageType { + /// NVMe SSD + Nvme, + /// SATA SSD + Ssd, + /// Hard Disk Drive + Hdd, + /// eMMC storage (common on ARM) + Emmc, + /// Virtual device (should be filtered) + Virtual, + /// Unknown type + #[default] + Unknown, +} + +impl StorageType { + /// Determine storage type from device name and rotational flag + pub fn from_device(name: &str, is_rotational: bool) -> Self { + if name.starts_with("nvme") { + StorageType::Nvme + } else if name.starts_with("mmcblk") { + StorageType::Emmc + } else if name.starts_with("loop") || name.starts_with("ram") || name.starts_with("dm-") { + StorageType::Virtual + } else if is_rotational { + StorageType::Hdd + } else { + StorageType::Ssd + } + } + + /// Get display name for the storage type + pub fn display_name(&self) -> &'static str { + match self { + StorageType::Nvme => "NVMe SSD", + StorageType::Ssd => "SSD", + StorageType::Hdd => "HDD", + StorageType::Emmc => "eMMC", + StorageType::Virtual => "Virtual", + StorageType::Unknown => "Unknown", + } + } +} + /// Storage device information #[derive(Debug, Serialize, Deserialize, Clone)] pub struct StorageDevice { - /// Device name + /// Device name (e.g., "sda", "nvme0n1") pub name: String, - /// Device type (e.g., ssd, hdd) + /// Device path (e.g., "/dev/sda") + #[serde(default)] + pub device_path: String, + /// Device type enum + #[serde(default)] + pub device_type: StorageType, + /// Device type as string (for backward compatibility) pub type_: String, - /// Device size + /// Device size as human-readable string pub size: String, + /// Device size in bytes + #[serde(default)] + pub size_bytes: u64, + /// Device size in GB + #[serde(default)] + pub size_gb: f64, /// Device model pub model: String, + /// Serial number + #[serde(default)] + pub serial_number: Option, + /// Firmware version + #[serde(default)] + pub firmware_version: Option, + /// World Wide Name + #[serde(default)] + pub wwn: Option, + /// Interface type (e.g., "NVMe", "SATA", "SAS") + #[serde(default)] + pub interface: String, + /// Whether this is a rotational device (HDD) + #[serde(default)] + pub is_rotational: bool, + /// Detection method used + #[serde(default)] + pub detection_method: String, +} + +impl Default for StorageDevice { + fn default() -> Self { + Self { + name: String::new(), + device_path: String::new(), + device_type: StorageType::Unknown, + type_: String::new(), + size: String::new(), + size_bytes: 0, + size_gb: 0.0, + model: String::new(), + serial_number: None, + firmware_version: None, + wwn: None, + interface: String::new(), + is_rotational: false, + detection_method: String::new(), + } + } +} + +impl StorageDevice { + /// Calculate size_gb and size string from size_bytes + pub fn calculate_size_fields(&mut self) { + if self.size_bytes > 0 { + self.size_gb = self.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0); + if self.size_gb >= 1000.0 { + self.size = format!("{:.2} TB", self.size_gb / 1024.0); + } else { + self.size = format!("{:.2} GB", self.size_gb); + } + } + } + + /// Set device path from name if not already set + pub fn set_device_path(&mut self) { + if self.device_path.is_empty() && !self.name.is_empty() { + self.device_path = format!("/dev/{}", self.name); + } + } } /// GPU information @@ -231,6 +426,45 @@ pub struct GpuInfo { pub devices: Vec, } +/// GPU vendor classification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub enum GpuVendor { + /// NVIDIA GPU + Nvidia, + /// AMD GPU + Amd, + /// Intel GPU + Intel, + /// Apple GPU (Apple Silicon) + Apple, + /// Unknown vendor + #[default] + Unknown, +} + +impl GpuVendor { + /// Determine vendor from PCI vendor ID + pub fn from_pci_vendor(vendor_id: &str) -> Self { + match vendor_id.to_lowercase().as_str() { + "10de" => GpuVendor::Nvidia, + "1002" => GpuVendor::Amd, + "8086" => GpuVendor::Intel, + _ => GpuVendor::Unknown, + } + } + + /// Get vendor name string + pub fn name(&self) -> &'static str { + match self { + GpuVendor::Nvidia => "NVIDIA", + GpuVendor::Amd => "AMD", + GpuVendor::Intel => "Intel", + GpuVendor::Apple => "Apple", + GpuVendor::Unknown => "Unknown", + } + } +} + /// GPU device information #[derive(Debug, Serialize, Deserialize, Clone)] pub struct GpuDevice { @@ -240,14 +474,69 @@ pub struct GpuDevice { pub name: String, /// GPU UUID pub uuid: String, - /// Total GPU memory + /// Total GPU memory as string (for backward compatibility) pub memory: String, + /// Total GPU memory in MB + #[serde(default)] + pub memory_total_mb: u64, + /// Free GPU memory in MB + #[serde(default)] + pub memory_free_mb: Option, /// PCI ID (vendor:device) or Apple Fabric for Apple Silicon pub pci_id: String, - /// Vendor name + /// PCI bus ID (e.g., "0000:01:00.0") + #[serde(default)] + pub pci_bus_id: Option, + /// Vendor name (for backward compatibility) pub vendor: String, + /// Vendor classification enum + #[serde(default)] + pub vendor_enum: GpuVendor, /// NUMA node pub numa_node: Option, + /// Driver version + #[serde(default)] + pub driver_version: Option, + /// Compute capability (NVIDIA specific) + #[serde(default)] + pub compute_capability: Option, + /// Detection method used + #[serde(default)] + pub detection_method: String, +} + +impl Default for GpuDevice { + fn default() -> Self { + Self { + index: 0, + name: String::new(), + uuid: String::new(), + memory: String::new(), + memory_total_mb: 0, + memory_free_mb: None, + pci_id: String::new(), + pci_bus_id: None, + vendor: String::new(), + vendor_enum: GpuVendor::Unknown, + numa_node: None, + driver_version: None, + compute_capability: None, + detection_method: String::new(), + } + } +} + +impl GpuDevice { + /// Set memory string from memory_total_mb + pub fn set_memory_string(&mut self) { + if self.memory_total_mb > 0 { + if self.memory_total_mb >= 1024 { + self.memory = format!("{:.1} GB", self.memory_total_mb as f64 / 1024.0); + } else { + self.memory = format!("{} MB", self.memory_total_mb); + } + } + } } /// Network information @@ -259,6 +548,34 @@ pub struct NetworkInfo { pub infiniband: Option, } +/// Network interface type classification +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +pub enum NetworkInterfaceType { + /// Physical Ethernet interface + Ethernet, + /// Wireless interface + Wireless, + /// Loopback interface + Loopback, + /// Bridge interface + Bridge, + /// VLAN interface + Vlan, + /// Bond/LAG interface + Bond, + /// Virtual Ethernet (veth pair) + Veth, + /// TUN/TAP interface + TunTap, + /// InfiniBand interface + Infiniband, + /// Macvlan interface + Macvlan, + /// Unknown type + #[default] + Unknown, +} + /// Network interface information #[derive(Debug, Serialize, Deserialize, Clone)] pub struct NetworkInterface { @@ -270,10 +587,16 @@ pub struct NetworkInterface { pub ip: String, /// IP prefix pub prefix: String, - /// Interface speed + /// Interface speed as string (e.g., "1000 Mbps") pub speed: Option, - /// Interface type + /// Interface speed in Mbps (numeric) + #[serde(default)] + pub speed_mbps: Option, + /// Interface type as string (for backward compatibility) pub type_: String, + /// Interface type classification + #[serde(default)] + pub interface_type: NetworkInterfaceType, /// Vendor pub vendor: String, /// Model @@ -282,6 +605,53 @@ pub struct NetworkInterface { pub pci_id: String, /// NUMA node pub numa_node: Option, + /// Kernel driver in use + #[serde(default)] + pub driver: Option, + /// Driver version + #[serde(default)] + pub driver_version: Option, + /// Maximum Transmission Unit in bytes + #[serde(default = "default_mtu")] + pub mtu: u32, + /// Whether the interface is operationally up + #[serde(default)] + pub is_up: bool, + /// Whether this is a virtual interface + #[serde(default)] + pub is_virtual: bool, + /// Link detected (carrier present) + #[serde(default)] + pub carrier: Option, +} + +fn default_mtu() -> u32 { + 1500 +} + +impl Default for NetworkInterface { + fn default() -> Self { + Self { + name: String::new(), + mac: String::new(), + ip: String::new(), + prefix: String::new(), + speed: None, + speed_mbps: None, + type_: String::new(), + interface_type: NetworkInterfaceType::Unknown, + vendor: String::new(), + model: String::new(), + pci_id: String::new(), + numa_node: None, + driver: None, + driver_version: None, + mtu: 1500, + is_up: false, + is_virtual: false, + carrier: None, + } + } } /// Infiniband information diff --git a/src/domain/errors.rs b/src/domain/errors.rs index ae52ad5..4e361aa 100644 --- a/src/domain/errors.rs +++ b/src/domain/errors.rs @@ -139,12 +139,16 @@ pub enum SystemError { CommandNotFound(String), /// Permission denied PermissionDenied(String), - /// I/O operation failed + /// I/O operation failed (simple) IoError(String), + /// I/O operation failed (with path context) + IoErrorWithPath { path: String, message: String }, /// Parsing error ParseError(String), /// Timeout Timeout(String), + /// Resource not available + NotAvailable { resource: String }, } impl fmt::Display for SystemError { @@ -167,8 +171,14 @@ impl fmt::Display for SystemError { SystemError::CommandNotFound(cmd) => write!(f, "Command not found: {cmd}"), SystemError::PermissionDenied(msg) => write!(f, "Permission denied: {msg}"), SystemError::IoError(msg) => write!(f, "I/O error: {msg}"), + SystemError::IoErrorWithPath { path, message } => { + write!(f, "I/O error at '{}': {}", path, message) + } SystemError::ParseError(msg) => write!(f, "Parse error: {msg}"), SystemError::Timeout(msg) => write!(f, "Timeout: {msg}"), + SystemError::NotAvailable { resource } => { + write!(f, "Resource not available: {}", resource) + } } } } @@ -189,8 +199,14 @@ impl From for DomainError { SystemError::IoError(msg) => { DomainError::SystemInfoUnavailable(format!("I/O error: {msg}")) } + SystemError::IoErrorWithPath { path, message } => { + DomainError::SystemInfoUnavailable(format!("I/O error at '{}': {}", path, message)) + } SystemError::ParseError(msg) => DomainError::ParsingFailed(msg), SystemError::Timeout(msg) => DomainError::Timeout(msg), + SystemError::NotAvailable { resource } => { + DomainError::SystemInfoUnavailable(format!("Resource not available: {}", resource)) + } } } } diff --git a/src/domain/legacy_compat.rs b/src/domain/legacy_compat.rs index 7407dc6..21fb096 100644 --- a/src/domain/legacy_compat.rs +++ b/src/domain/legacy_compat.rs @@ -258,6 +258,7 @@ impl From for new::CpuInfo { threads: legacy.threads, sockets: legacy.sockets, speed: legacy.speed, + ..Default::default() } } } @@ -342,9 +343,10 @@ impl From for new::StorageDevice { fn from(legacy: crate::StorageDevice) -> Self { new::StorageDevice { name: legacy.name, - type_: legacy.type_, + type_: legacy.type_.clone(), size: legacy.size, model: legacy.model, + ..Default::default() } } } @@ -386,6 +388,7 @@ impl From for new::GpuDevice { pci_id: legacy.pci_id, vendor: legacy.vendor, numa_node: legacy.numa_node, + ..Default::default() } } } @@ -435,6 +438,7 @@ impl From for new::NetworkInterface { model: legacy.model, pci_id: legacy.pci_id, numa_node: legacy.numa_node, + ..Default::default() } } } diff --git a/src/domain/parsers/cpu.rs b/src/domain/parsers/cpu.rs index e39ab83..4e221a4 100644 --- a/src/domain/parsers/cpu.rs +++ b/src/domain/parsers/cpu.rs @@ -26,6 +26,99 @@ lazy_static! { static ref CORE_COUNT_RE: Regex = Regex::new(r"(\d+)").unwrap(); } +/// Parse sysfs frequency file (kHz to MHz) +/// +/// # Arguments +/// +/// * `content` - Content of frequency file (value in kHz) +/// +/// # Returns +/// +/// Frequency in MHz. +pub fn parse_sysfs_freq_khz(content: &str) -> Result { + let khz: u64 = content + .trim() + .parse() + .map_err(|e| format!("Failed to parse frequency: {}", e))?; + Ok((khz / 1000) as u32) +} + +/// Parse sysfs cache size (e.g., "32K" -> 32) +/// +/// # Arguments +/// +/// * `content` - Content of cache size file (e.g., "32K", "256K", "16M") +/// +/// # Returns +/// +/// Size in KB. +pub fn parse_sysfs_cache_size(content: &str) -> Result { + let trimmed = content.trim(); + + if trimmed.ends_with('K') || trimmed.ends_with('k') { + let value: u32 = trimmed[..trimmed.len() - 1] + .parse() + .map_err(|e| format!("Failed to parse cache size: {}", e))?; + Ok(value) + } else if trimmed.ends_with('M') || trimmed.ends_with('m') { + let value: u32 = trimmed[..trimmed.len() - 1] + .parse() + .map_err(|e| format!("Failed to parse cache size: {}", e))?; + Ok(value * 1024) + } else { + // Assume bytes, convert to KB + let value: u32 = trimmed + .parse() + .map_err(|e| format!("Failed to parse cache size: {}", e))?; + Ok(value / 1024) + } +} + +/// Parse /proc/cpuinfo +/// +/// Extracts vendor, flags, and other CPU information. +/// +/// # Arguments +/// +/// * `content` - Content of /proc/cpuinfo +pub fn parse_proc_cpuinfo(content: &str) -> Result { + let mut cpu_info = CpuInfo::default(); + + for line in content.lines() { + if let Some((key, value)) = line.split_once(':') { + let key = key.trim(); + let value = value.trim(); + + match key { + "vendor_id" => cpu_info.vendor = value.to_string(), + "model name" => { + if cpu_info.model.is_empty() { + cpu_info.model = value.to_string(); + } + } + "flags" | "Features" => { + cpu_info.flags = value.split_whitespace().map(|s| s.to_string()).collect(); + } + "CPU implementer" => { + // ARM CPU implementer code + if cpu_info.vendor.is_empty() { + cpu_info.vendor = match value { + "0x41" => "ARM".to_string(), + "0x4e" => "NVIDIA".to_string(), + "0x51" => "Qualcomm".to_string(), + "0x61" => "Apple".to_string(), + _ => value.to_string(), + }; + } + } + _ => {} + } + } + } + + Ok(cpu_info) +} + /// Parse CPU information from Linux lscpu output /// /// # Arguments @@ -83,6 +176,7 @@ pub fn parse_lscpu_output(lscpu_output: &str) -> Result { threads, sockets, speed, + ..Default::default() }) } @@ -116,6 +210,7 @@ pub fn parse_dmidecode_cpu(dmidecode_output: &str) -> Result { threads, sockets: 1, // dmidecode typically shows per-socket info speed: clean_value(&speed), + ..Default::default() }) } @@ -175,6 +270,7 @@ pub fn parse_macos_cpu_info(system_profiler_output: &str) -> Result CpuInfo { } else { secondary.speed }, + ..Default::default() } } @@ -328,6 +425,7 @@ CPU MHz: 2300.000"#; threads: 2, sockets: 1, speed: "Unknown".to_string(), + ..Default::default() }; let secondary = CpuInfo { @@ -336,6 +434,7 @@ CPU MHz: 2300.000"#; threads: 0, sockets: 0, speed: "2.3 GHz".to_string(), + ..Default::default() }; let combined = combine_cpu_info(primary, secondary); @@ -352,6 +451,7 @@ CPU MHz: 2300.000"#; threads: 2, sockets: 1, speed: "2.3 GHz".to_string(), + ..Default::default() }; let topology = create_cpu_topology(&cpu_info, Some(1)); diff --git a/src/domain/parsers/gpu.rs b/src/domain/parsers/gpu.rs new file mode 100644 index 0000000..93e4dd3 --- /dev/null +++ b/src/domain/parsers/gpu.rs @@ -0,0 +1,222 @@ +/* +Copyright 2024 San Francisco Compute Company + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +//! GPU information parsing functions + +use crate::domain::{GpuDevice, GpuVendor}; + +/// Parse nvidia-smi CSV output +/// +/// Expected format from command: +/// `nvidia-smi --query-gpu=index,name,uuid,memory.total,memory.free,pci.bus_id,driver_version,compute_cap --format=csv,noheader,nounits` +/// +/// # Arguments +/// +/// * `output` - CSV output from nvidia-smi +/// +/// # Returns +/// +/// List of GPU devices. +pub fn parse_nvidia_smi_output(output: &str) -> Result, String> { + let mut devices = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() < 4 { + continue; + } + + let index: u32 = parts[0].parse().unwrap_or(devices.len() as u32); + let name = parts[1].to_string(); + let uuid = parts[2].to_string(); + let memory_total_mb: u64 = parts[3].parse().unwrap_or(0); + + let memory_free_mb = if parts.len() > 4 { + parts[4].parse().ok() + } else { + None + }; + + let pci_bus_id = if parts.len() > 5 { + Some(parts[5].to_string()) + } else { + None + }; + + let driver_version = if parts.len() > 6 && !parts[6].is_empty() { + Some(parts[6].to_string()) + } else { + None + }; + + let compute_capability = if parts.len() > 7 && !parts[7].is_empty() { + Some(parts[7].to_string()) + } else { + None + }; + + let mut device = GpuDevice { + index, + name, + uuid, + memory_total_mb, + memory_free_mb, + pci_bus_id, + vendor: "NVIDIA".to_string(), + vendor_enum: GpuVendor::Nvidia, + driver_version, + compute_capability, + detection_method: "nvidia-smi".to_string(), + ..Default::default() + }; + + device.set_memory_string(); + devices.push(device); + } + + Ok(devices) +} + +/// Parse lspci output for GPU devices +/// +/// Expected command: `lspci -nn` +/// +/// # Arguments +/// +/// * `output` - Output from lspci -nn +pub fn parse_lspci_gpu_output(output: &str) -> Result, String> { + let mut devices = Vec::new(); + let mut gpu_index = 0; + + for line in output.lines() { + let line_lower = line.to_lowercase(); + + // Look for VGA compatible or 3D controller + if !line_lower.contains("vga") && !line_lower.contains("3d") { + continue; + } + + // Extract PCI ID from brackets like [10de:2204] + let pci_id = extract_pci_id(line); + + // Determine vendor from PCI ID + let (vendor_enum, vendor_name) = if let Some(ref pci) = pci_id { + let vendor_id = pci.split(':').next().unwrap_or(""); + let vendor = GpuVendor::from_pci_vendor(vendor_id); + (vendor.clone(), vendor.name().to_string()) + } else { + (GpuVendor::Unknown, "Unknown".to_string()) + }; + + // Extract name (everything after the colon and space) + let name = line + .split_once(':') + .map(|(_, rest)| rest.trim()) + .unwrap_or(line) + .to_string(); + + let device = GpuDevice { + index: gpu_index, + name, + uuid: format!("lspci-gpu-{}", gpu_index), + pci_id: pci_id.clone().unwrap_or_default(), + vendor: vendor_name, + vendor_enum, + detection_method: "lspci".to_string(), + ..Default::default() + }; + + devices.push(device); + gpu_index += 1; + } + + Ok(devices) +} + +/// Extract PCI vendor:device ID from lspci output line +/// +/// Looks for pattern like [10de:2204] - must be 4 hex chars : 4 hex chars +fn extract_pci_id(line: &str) -> Option { + // Find all bracket patterns and look for PCI ID format [xxxx:yyyy] + let mut search_start = 0; + while let Some(start) = line[search_start..].find('[') { + let abs_start = search_start + start; + if let Some(end) = line[abs_start..].find(']') { + let bracket_content = &line[abs_start + 1..abs_start + end]; + + // Check if it looks like a PCI ID (4 hex chars : 4 hex chars) + if bracket_content.len() == 9 && bracket_content.chars().nth(4) == Some(':') { + // Verify it's all hex chars + let parts: Vec<&str> = bracket_content.split(':').collect(); + if parts.len() == 2 + && parts[0].len() == 4 + && parts[1].len() == 4 + && parts[0].chars().all(|c| c.is_ascii_hexdigit()) + && parts[1].chars().all(|c| c.is_ascii_hexdigit()) + { + return Some(bracket_content.to_string()); + } + } + search_start = abs_start + end + 1; + } else { + break; + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_nvidia_smi_output() { + let output = "0, NVIDIA GeForce RTX 3090, GPU-12345678-1234-1234-1234-123456789012, 24576, 24000, 00000000:01:00.0, 535.129.03, 8.6"; + let devices = parse_nvidia_smi_output(output).unwrap(); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].name, "NVIDIA GeForce RTX 3090"); + assert_eq!(devices[0].memory_total_mb, 24576); + assert_eq!(devices[0].vendor, "NVIDIA"); + } + + #[test] + fn test_parse_lspci_gpu_output() { + let output = r#"01:00.0 VGA compatible controller [0300]: NVIDIA Corporation GA102 [GeForce RTX 3090] [10de:2204] (rev a1) +00:02.0 VGA compatible controller [0300]: Intel Corporation Device [8086:9a49] (rev 01)"#; + + let devices = parse_lspci_gpu_output(output).unwrap(); + + assert_eq!(devices.len(), 2); + assert_eq!(devices[0].vendor, "NVIDIA"); + assert_eq!(devices[1].vendor, "Intel"); + } + + #[test] + fn test_extract_pci_id() { + assert_eq!(extract_pci_id("[10de:2204]"), Some("10de:2204".to_string())); + assert_eq!( + extract_pci_id("NVIDIA [10de:2204] (rev a1)"), + Some("10de:2204".to_string()) + ); + assert_eq!(extract_pci_id("No PCI ID here"), None); + } +} diff --git a/src/domain/parsers/mod.rs b/src/domain/parsers/mod.rs index 3fb592a..2ddb702 100644 --- a/src/domain/parsers/mod.rs +++ b/src/domain/parsers/mod.rs @@ -21,6 +21,7 @@ limitations under the License. pub mod common; pub mod cpu; +pub mod gpu; pub mod memory; pub mod network; pub mod storage; @@ -28,6 +29,7 @@ pub mod system; pub use common::*; pub use cpu::*; +pub use gpu::*; pub use memory::*; pub use network::*; pub use storage::*; diff --git a/src/domain/parsers/network.rs b/src/domain/parsers/network.rs index e500fbb..8a8b48b 100644 --- a/src/domain/parsers/network.rs +++ b/src/domain/parsers/network.rs @@ -36,6 +36,7 @@ pub fn parse_ip_output(ip_output: &str) -> Result, String> model: "Unknown".to_string(), pci_id: "Unknown".to_string(), numa_node: None, + ..Default::default() }); } } @@ -91,6 +92,7 @@ pub fn parse_macos_network_info(ifconfig_output: &str) -> Result Result { + let sectors: u64 = content + .trim() + .parse() + .map_err(|e| format!("Failed to parse sectors: {}", e))?; + Ok(sectors * 512) +} + +/// Parse sysfs rotational flag +/// +/// # Arguments +/// +/// * `content` - Content of `/sys/block/{dev}/queue/rotational` +/// +/// # Returns +/// +/// `true` if device is rotational (HDD), `false` if SSD/NVMe. +pub fn parse_sysfs_rotational(content: &str) -> bool { + content.trim() == "1" +} + +/// Check if device name indicates a virtual device +/// +/// Virtual devices should be filtered from physical storage lists. +/// +/// # Arguments +/// +/// * `name` - Device name (e.g., "sda", "loop0", "dm-0") +pub fn is_virtual_device(name: &str) -> bool { + name.starts_with("loop") + || name.starts_with("ram") + || name.starts_with("dm-") + || name.starts_with("sr") + || name.starts_with("fd") + || name.starts_with("zram") + || name.starts_with("nbd") +} + +/// Parse lsblk JSON output +/// +/// # Arguments +/// +/// * `output` - JSON output from `lsblk -J -b -d -o NAME,SIZE,TYPE,MODEL,SERIAL,ROTA,TRAN,WWN` +pub fn parse_lsblk_json(output: &str) -> Result, String> { + let json: serde_json::Value = + serde_json::from_str(output).map_err(|e| format!("Failed to parse lsblk JSON: {}", e))?; + + let blockdevices = json + .get("blockdevices") + .and_then(|v| v.as_array()) + .ok_or_else(|| "Missing blockdevices array in lsblk output".to_string())?; + + let mut devices = Vec::new(); + + for device in blockdevices { + let name = device + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Skip virtual devices + if is_virtual_device(&name) { + continue; + } + + let size_bytes = device.get("size").and_then(|v| v.as_u64()).unwrap_or(0); + + // Skip small devices + if size_bytes < 1_000_000_000 { + continue; + } + + let is_rotational = device + .get("rota") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let device_type = StorageType::from_device(&name, is_rotational); + + let model = device + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + + let serial_number = device + .get("serial") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()); + + let wwn = device + .get("wwn") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.trim().to_string()); + + let interface = device + .get("tran") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_uppercase(); + + let mut storage_device = StorageDevice { + name: name.clone(), + device_path: format!("/dev/{}", name), + device_type: device_type.clone(), + type_: device_type.display_name().to_string(), + size_bytes, + model, + serial_number, + wwn, + interface, + is_rotational, + detection_method: "lsblk".to_string(), + ..Default::default() + }; + + storage_device.calculate_size_fields(); + devices.push(storage_device); + } + + Ok(devices) +} /// Parse storage devices from lsblk output pub fn parse_lsblk_output(lsblk_output: &str) -> Result, String> { @@ -36,6 +175,7 @@ pub fn parse_lsblk_output(lsblk_output: &str) -> Result, Stri type_: type_.to_string(), size: clean_value(&size), model: name.clone(), + ..Default::default() }); } } @@ -77,6 +217,7 @@ pub fn parse_macos_storage_info( type_: "ssd".to_string(), size: "Unknown".to_string(), model: format!("{model} (Apple Fabric)"), + ..Default::default() }); } else if trimmed.starts_with("Size:") && current_device.is_some() { // Extract size information @@ -99,6 +240,7 @@ pub fn parse_macos_storage_info( type_: "ssd".to_string(), size: "2 TB (1,995,218,165,760 bytes)".to_string(), model: "APPLE SSD AP2048Z (Apple Fabric)".to_string(), + ..Default::default() }); }