Files
bench_go/main.go
2026-01-11 23:40:18 +01:00

2862 lines
76 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"math"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
smartgo "github.com/anatol/smart.go"
"github.com/klauspost/cpuid"
lmsensors "github.com/mt-inside/go-lmsensors"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/shirou/gopsutil/v4/disk"
"github.com/shirou/gopsutil/v4/host"
"github.com/shirou/gopsutil/v4/mem"
gopsnet "github.com/shirou/gopsutil/v4/net"
smbios "github.com/u-root/u-root/pkg/smbios"
"gopkg.in/yaml.v3"
)
// ==========================================
// 1. TYPES & CONFIGURATION
// ==========================================
// ConfigStructure représente le fichier config.yaml distant
type Config struct {
ConfigVersion int `yaml:"config_version"`
Backend BackendConfig `yaml:"backend"`
Runtime RuntimeConfig `yaml:"runtime"`
Logging LoggingConfig `yaml:"logging"`
Payload PayloadConfig `yaml:"payload"`
Benchmarks BenchmarkConfig `yaml:"benchmarks"`
Collection CollectionConfig `yaml:"collection"`
RawInfo RawInfoConfig `yaml:"raw_info"`
}
type BackendConfig struct {
URL string `yaml:"url"`
Auth AuthConfig `yaml:"auth"`
HTTP HTTPConfig `yaml:"http"`
}
type AuthConfig struct {
Mode string `yaml:"mode"`
EnvVar string `yaml:"env_var"`
}
type HTTPConfig struct {
TimeoutS int `yaml:"timeout_s"`
Retries int `yaml:"retries"`
RetryBackoffS int `yaml:"retry_backoff_s"`
TLSVerify bool `yaml:"verify"`
}
type RuntimeConfig struct {
MaxTotalRuntime int `yaml:"max_total_runtime_s"`
CommandTimeout int `yaml:"command_timeout_s"`
TempDir string `yaml:"temp_dir"`
Locale string `yaml:"locale"`
PathPrepend []string `yaml:"path_prepend"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
Progress bool `yaml:"progress"`
ShowCommandLines bool `yaml:"show_command_lines"`
CaptureRawOutput bool `yaml:"capture_raw_output"`
RawOutputMaxKB int `yaml:"raw_output_max_kb"`
SavePayloadToTmp bool `yaml:"save_payload_to_tmp"`
PayloadTmpDir string `yaml:"payload_tmp_dir"`
PayloadFilenamePrefix string `yaml:"payload_filename_prefix"`
InteractiveDebugPause bool `yaml:"interactive_pause_on_debug"`
}
type PayloadConfig struct {
Dir string `yaml:"dir"`
Prefix string `yaml:"prefix"`
Always bool `yaml:"always_save"`
}
type BenchmarkConfig struct {
Enabled bool `yaml:"enabled"`
Weights map[string]float64 `yaml:"weights"`
CPU BenchmarkTool `yaml:"cpu_sysbench"`
Memory BenchmarkTool `yaml:"memory_sysbench"`
Disk BenchmarkTool `yaml:"disk_fio"`
Network BenchmarkTool `yaml:"network_iperf3"`
}
type BenchmarkTool struct {
Enabled bool `yaml:"enabled"`
Tool string `yaml:"tool"`
Server string `yaml:"server"`
Port int `yaml:"port"`
Params map[string]interface{} `yaml:"params"`
Safety map[string]interface{} `yaml:"safety"`
}
type CollectionConfig struct {
System CollectionGroup `yaml:"system"`
CPU CollectionGroup `yaml:"cpu"`
RAM CollectionGroup `yaml:"ram"`
Storage CollectionGroup `yaml:"storage"`
Network CollectionGroup `yaml:"network"`
GPU CollectionGroup `yaml:"gpu"`
Audio CollectionGroup `yaml:"audio"`
PCI CollectionGroup `yaml:"pci_devices"`
USB CollectionGroup `yaml:"usb_devices"`
}
type CollectionGroup struct {
Enabled bool `yaml:"enabled"`
}
type RawInfoConfig struct {
Enabled bool `yaml:"enabled"`
Include []string `yaml:"include"`
MaxSizeKB int `yaml:"max_size_kb"`
}
// FinalPayload est la structure JSON envoyée au backend
type FinalPayload struct {
DeviceIdentifier string `json:"device_identifier"`
BenchScriptVersion string `json:"bench_script_version"`
BenchClientVersion string `json:"bench_client_version"`
Hardware Hardware `json:"hardware"`
Results Results `json:"results"`
RawInfo map[string]string `json:"raw_info,omitempty"`
}
type Hardware struct {
CPU CPUDetails `json:"cpu"`
RAM RAMDetails `json:"ram"`
GPU GPUDetails `json:"gpu"`
Storage StorageDetails `json:"storage"`
PCIDevices []PCIInfo `json:"pci_devices"`
USBDevices []USBInfo `json:"usb_devices"`
NetworkShares []NetworkShare `json:"network_shares"`
Network NetworkDetails `json:"network"`
Motherboard MotherboardInfo `json:"motherboard"`
OS OSInfo `json:"os"`
Sensors SensorInfo `json:"sensors"`
Audio AudioInfo `json:"audio"`
RawInfo map[string]string `json:"raw_info"`
}
type CPUDetails struct {
Vendor string `json:"vendor"`
Model string `json:"model"`
Cores int `json:"cores"`
Threads int `json:"threads"`
BaseFreqGHz float64 `json:"base_freq_ghz"`
MaxFreqGHz float64 `json:"max_freq_ghz"`
CacheL1KB int `json:"cache_l1_kb"`
CacheL2KB int `json:"cache_l2_kb"`
CacheL3KB int `json:"cache_l3_kb"`
Flags []string `json:"flags"`
Microarchitecture string `json:"microarchitecture"`
TDPW *float64 `json:"tdp_w"`
}
type RAMDetails struct {
TotalMB int `json:"total_mb"`
UsedMB int `json:"used_mb"`
FreeMB int `json:"free_mb"`
SharedMB int `json:"shared_mb"`
SlotsTotal int `json:"slots_total"`
SlotsUsed int `json:"slots_used"`
MaxCapacityMB int `json:"max_capacity_mb"`
ECC bool `json:"ecc"`
Layout []MemoryModule `json:"layout"`
}
type MemoryModule struct {
Slot string `json:"slot"`
SizeMB int `json:"size_mb"`
Type string `json:"type"`
SpeedMHz int `json:"speed_mhz"`
ConfiguredMHz int `json:"configured_speed_mhz"`
FormFactor string `json:"form_factor"`
TypeDetail string `json:"type_detail"`
Rank string `json:"rank"`
Manufacturer string `json:"manufacturer"`
PartNumber string `json:"part_number"`
}
type GPUDetails struct {
Vendor string `json:"vendor"`
Model string `json:"model"`
DriverVersion string `json:"driver_version"`
MemoryDedicatedMB *int `json:"memory_dedicated_mb"`
MemorySharedMB *int `json:"memory_shared_mb"`
APISupport []string `json:"api_support"`
}
type StorageDetails struct {
Devices []StorageDevice `json:"devices"`
Partitions []PartitionInfo `json:"partitions"`
}
type StorageDevice struct {
Name string `json:"name"`
Type string `json:"type"`
Interface string `json:"interface"`
CapacityGB float64 `json:"capacity_gb"`
Vendor string `json:"vendor"`
Model string `json:"model"`
Serial string `json:"serial"`
SmartHealth string `json:"smart_health"`
Temperature int `json:"temperature_c"`
}
type PartitionInfo struct {
Name string `json:"name"`
MountPoint string `json:"mount_point"`
FSType string `json:"fs_type"`
TotalGB float64 `json:"total_gb"`
UsedGB *float64 `json:"used_gb"`
FreeGB *float64 `json:"free_gb"`
}
type PCIInfo struct {
Slot string `json:"slot"`
Class string `json:"class"`
Vendor string `json:"vendor"`
Device string `json:"device"`
}
type USBInfo struct {
Bus string `json:"bus"`
Device string `json:"device"`
VendorID string `json:"vendor_id"`
ProductID string `json:"product_id"`
Name string `json:"name"`
}
type NetworkShare struct {
Protocol string `json:"protocol"`
Source string `json:"source"`
MountPoint string `json:"mount_point"`
FSType string `json:"fs_type"`
Options string `json:"options"`
TotalGB *float64 `json:"total_gb"`
UsedGB *float64 `json:"used_gb"`
FreeGB *float64 `json:"free_gb"`
}
type NetworkDetails struct {
Interfaces []NetworkInterface `json:"interfaces"`
}
type NetworkInterface struct {
Name string `json:"name"`
Type string `json:"type"`
MAC string `json:"mac"`
IP string `json:"ip"`
SpeedMbps *int `json:"speed_mbps"`
Driver string `json:"driver"`
SSID string `json:"ssid"`
WakeOnLAN bool `json:"wake_on_lan"`
}
type MotherboardInfo struct {
Vendor string `json:"vendor"`
Model string `json:"model"`
BIOSVendor string `json:"bios_vendor"`
BIOSVersion string `json:"bios_version"`
BIOSDate string `json:"bios_date"`
}
type OSInfo struct {
Name string `json:"name"`
Version string `json:"version"`
KernelVersion string `json:"kernel_version"`
Architecture string `json:"architecture"`
SessionType string `json:"session_type"`
DisplayServer string `json:"display_server"`
ScreenResolution string `json:"screen_resolution"`
LastBootTime string `json:"last_boot_time"`
UptimeSeconds float64 `json:"uptime_seconds"`
BatteryPercentage *int `json:"battery_percentage"`
BatteryStatus string `json:"battery_status"`
BatteryHealth string `json:"battery_health"`
Hostname string `json:"hostname"`
VirtualizationType string `json:"virtualization_type"`
DesktopEnvironment string `json:"desktop_environment"`
}
type SensorInfo struct {
CPUTempC *float64 `json:"cpu_temp_c"`
DiskTempsC map[string]float64 `json:"disk_temps_c"`
Sensors []SensorReading `json:"sensors"`
}
type SensorReading struct {
Chip string `json:"chip"`
Name string `json:"name"`
Type string `json:"type"`
Value float64 `json:"value"`
Unit string `json:"unit"`
Alarm bool `json:"alarm"`
}
type AudioInfo struct {
Hardware AudioHardware `json:"hardware"`
Software AudioSoftware `json:"software"`
}
type AudioHardware struct {
PCIAudioDevices []string `json:"pci_audio_devices"`
AlsaPlayback string `json:"alsa_playback"`
AlsaCapture string `json:"alsa_capture"`
PCMDevices []string `json:"pcm_devices"`
}
type AudioSoftware struct {
Backend string `json:"backend"`
ServerName string `json:"server_name"`
ServerVersion string `json:"server_version"`
DefaultSink *string `json:"default_sink"`
DefaultSource *string `json:"default_source"`
Sinks []string `json:"sinks"`
Sources []string `json:"sources"`
}
type Results struct {
CPU CPUResult `json:"cpu"`
Memory MemResult `json:"memory"`
Disk DiskResult `json:"disk"`
Network NetResult `json:"network"`
GPU GPUResult `json:"gpu"`
GlobalScore float64 `json:"global_score"`
}
type CPUResult struct {
EventsPerSec float64 `json:"events_per_sec"`
EventsPerSecSingle float64 `json:"events_per_sec_single"`
EventsPerSecMulti float64 `json:"events_per_sec_multi"`
DurationSec float64 `json:"duration_s"`
Score float64 `json:"score"`
ScoreSingle float64 `json:"score_single"`
ScoreMulti float64 `json:"score_multi"`
}
type MemResult struct {
Throughput float64 `json:"throughput_mib_s"`
Score float64 `json:"score"`
}
type DiskResult struct {
ReadMBs float64 `json:"read_mb_s"`
WriteMBs float64 `json:"write_mb_s"`
IOPSRead float64 `json:"iops_read"`
IOPSWrite float64 `json:"iops_write"`
LatencyMs float64 `json:"latency_ms"`
Score float64 `json:"score"`
}
type DirectDiskMetrics struct {
WriteMBs float64
ReadMBs float64
}
type CacheSummary struct {
Sizes map[int]int
Counts map[int]int
}
type NetResult struct {
Upload float64 `json:"upload_mbps"`
Download float64 `json:"download_mbps"`
PingMs float64 `json:"ping_ms"`
JitterMs *float64 `json:"jitter_ms"`
PacketLossPct *float64 `json:"packet_loss_percent"`
Score float64 `json:"score"`
}
type GPUResult struct {
GLMark2Score *float64 `json:"glmark2_score"`
Score *float64 `json:"score"`
}
// ==========================================
// 2. GLOBALS & UTILS
// ==========================================
var (
cfg Config
debug = false
dryRun = false
)
const benchScriptVersion = "1.6.6"
const benchClientVersion = "1.0.2"
var (
sensorInitOnce sync.Once
sensorInitErr error
)
var (
sizeValueRegex = regexp.MustCompile(`(?i)^([\d\.]+)\s*([kmgtp]i?)?b?$`)
quotedStringRegexp = regexp.MustCompile(`"([^"]+)"`)
sysbenchEventsRegex = regexp.MustCompile(`events per second:\s+([\d\.]+)`)
sysbenchTimeRegex = regexp.MustCompile(`total time:\s+([\d\.]+)s`)
)
var (
debugLogFile *os.File
debugLogOnce sync.Once
debugLogErr error
debugLogMu sync.Mutex
)
const debugLogFilename = "debug.log"
func debugf(format string, args ...interface{}) {
if debug {
message := fmt.Sprintf("[DEBUG] "+format+"\n", args...)
fmt.Print(message)
if err := appendDebugLog(message); err != nil {
fmt.Printf("[WARN] impossible d'écrire dans %s: %v\n", debugLogFilename, err)
}
}
}
// safeRun exécute une commande avec timeout et gestion d'erreur
func safeRun(ctx context.Context, name string, args ...string) (string, error) {
if debug {
debugf("Exec: %s %v", name, args)
}
if dryRun {
return "DRY RUN OUTPUT", nil
}
cmd := exec.CommandContext(ctx, name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
return "", fmt.Errorf("%w: %s", err, stderr.String())
}
return stdout.String(), nil
}
func appendDebugLog(message string) error {
if err := ensureDebugLog(); err != nil {
return err
}
debugLogMu.Lock()
defer debugLogMu.Unlock()
_, err := debugLogFile.WriteString(message)
return err
}
func ensureDebugLog() error {
debugLogOnce.Do(func() {
wd, err := os.Getwd()
if err != nil {
debugLogErr = err
return
}
path := filepath.Join(wd, debugLogFilename)
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o777)
if err != nil {
debugLogErr = err
return
}
if err := file.Chmod(0o777); err != nil {
// ignore error
}
debugLogFile = file
})
return debugLogErr
}
func closeDebugLog() {
if debugLogFile != nil {
_ = debugLogFile.Close()
}
}
func collectOSInfo(ctx context.Context) OSInfo {
info := OSInfo{}
if h, err := host.InfoWithContext(ctx); err == nil {
info.Name = h.Platform
info.Version = h.PlatformVersion
info.KernelVersion = h.KernelVersion
info.Architecture = h.KernelArch
info.Hostname = h.Hostname
info.VirtualizationType = h.VirtualizationSystem
if h.VirtualizationSystem != "" && h.VirtualizationRole != "" {
info.VirtualizationType = fmt.Sprintf("%s-%s", h.VirtualizationSystem, h.VirtualizationRole)
}
info.UptimeSeconds = float64(h.Uptime)
}
if host, err := os.Hostname(); err == nil {
info.Hostname = host
}
if data, err := os.ReadFile("/etc/os-release"); err == nil {
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := parts[0]
value := strings.Trim(parts[1], "\"")
switch key {
case "NAME":
info.Name = value
case "VERSION":
info.Version = value
}
}
}
if out, err := safeRun(ctx, "uname", "-r"); err == nil {
info.KernelVersion = strings.TrimSpace(out)
}
if out, err := safeRun(ctx, "uname", "-m"); err == nil {
info.Architecture = strings.TrimSpace(out)
}
if vt, err := safeRun(ctx, "systemd-detect-virt"); err == nil {
vt = strings.TrimSpace(vt)
if vt == "none" {
info.VirtualizationType = "none"
} else if vt != "" {
info.VirtualizationType = vt
}
}
info.SessionType = os.Getenv("XDG_SESSION_TYPE")
if info.SessionType == "" {
if sessionID := os.Getenv("XDG_SESSION_ID"); sessionID != "" {
if out, err := safeRun(ctx, "loginctl", "show-session", sessionID, "-p", "Type"); err == nil {
info.SessionType = strings.TrimPrefix(strings.TrimSpace(out), "Type=")
}
}
}
info.DisplayServer = info.SessionType
info.DesktopEnvironment = os.Getenv("XDG_CURRENT_DESKTOP")
if out, err := safeRun(ctx, "xrandr"); err == nil {
if strings.Contains(out, "current") {
if parts := strings.Split(out, "\n")[0]; strings.Contains(parts, "current") {
fields := strings.Split(parts, ",")
for _, f := range fields {
f = strings.TrimSpace(f)
if strings.HasPrefix(f, "current") {
info.ScreenResolution = strings.TrimPrefix(f, "current ")
break
}
}
}
}
}
if out, err := safeRun(ctx, "xrandr", "--current"); err == nil && info.ScreenResolution == "" {
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "*") && strings.Contains(line, "+") {
info.ScreenResolution = strings.TrimSpace(strings.Fields(line)[0])
break
}
}
}
if out, err := safeRun(ctx, "who", "-b"); err == nil {
info.LastBootTime = strings.TrimSpace(strings.ReplaceAll(out, "system boot", ""))
}
if uptimeRaw, err := os.ReadFile("/proc/uptime"); err == nil {
if parts := strings.Fields(string(uptimeRaw)); len(parts) > 0 {
if val, err := strconv.ParseFloat(parts[0], 64); err == nil {
info.UptimeSeconds = val
}
}
}
info.BatteryPercentage = parseBatteryInt("/sys/class/power_supply/BAT0/capacity")
info.BatteryStatus = parseBatteryString("/sys/class/power_supply/BAT0/status")
info.BatteryHealth = parseBatteryString("/sys/class/power_supply/BAT0/health")
return info
}
func parseBatteryInt(path string) *int {
if data, err := os.ReadFile(path); err == nil {
if val, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
return &val
}
}
return nil
}
func parseBatteryString(path string) string {
if data, err := os.ReadFile(path); err == nil {
return strings.TrimSpace(string(data))
}
return ""
}
func collectCPUInfo(ctx context.Context) CPUDetails {
info := CPUDetails{}
cpuInfos, err := cpu.InfoWithContext(ctx)
if err != nil || len(cpuInfos) == 0 {
return info
}
first := cpuInfos[0]
info.Vendor = first.VendorID
info.Model = first.ModelName
if cores, err := cpu.CountsWithContext(ctx, false); err == nil && cores > 0 {
info.Cores = cores
} else if int(first.Cores) > 0 {
info.Cores = int(first.Cores)
}
if info.Cores == 0 {
info.Cores = len(cpuInfos)
}
if threads, err := cpu.CountsWithContext(ctx, true); err == nil && threads > 0 {
info.Threads = threads
} else {
info.Threads = len(cpuInfos)
}
info.Flags = first.Flags
if lscpuOut, err := safeRun(ctx, "lscpu"); err == nil {
l1, l2, l3 := parseLscpuCacheSizes(lscpuOut)
if l1 > 0 {
info.CacheL1KB = l1
}
if l2 > 0 {
info.CacheL2KB = l2
}
if l3 > 0 {
info.CacheL3KB = l3
}
}
cacheSummary := collectCacheSizes(ctx, info.Cores)
if info.CacheL1KB == 0 && cacheSummary.Sizes[1] > 0 {
info.CacheL1KB = cacheSummary.Sizes[1]
}
if info.CacheL2KB == 0 && cacheSummary.Sizes[2] > 0 {
info.CacheL2KB = cacheSummary.Sizes[2]
}
if info.CacheL3KB == 0 && cacheSummary.Sizes[3] > 0 {
info.CacheL3KB = cacheSummary.Sizes[3]
}
applyCpuidCacheInfo(&info, cacheSummary)
info.Microarchitecture = detectMicroarchitecture(first.ModelName, first.Family)
info.BaseFreqGHz, info.MaxFreqGHz = determineCPUFreqs(ctx, first)
return info
}
func collectCacheSizes(ctx context.Context, physicalCores int) CacheSummary {
cacheDir := "/sys/devices/system/cpu/cpu0/cache"
entries, err := os.ReadDir(cacheDir)
summary := CacheSummary{
Sizes: map[int]int{},
Counts: map[int]int{},
}
if err != nil {
return summary
}
for _, entry := range entries {
if !strings.HasPrefix(entry.Name(), "index") {
continue
}
levelPath := filepath.Join(cacheDir, entry.Name(), "level")
level := readIntFromFile(levelPath)
if level <= 0 {
continue
}
sizePath := filepath.Join(cacheDir, entry.Name(), "size")
sizeKB := parseCacheSize(readStringFromFile(sizePath))
if sizeKB > 0 {
summary.Sizes[level] += sizeKB
summary.Counts[level]++
}
}
return summary
}
func applyCpuidCacheInfo(info *CPUDetails, summary CacheSummary) {
cache := cpuid.CPU.Cache
if cache.L1I < 0 && cache.L1D < 0 && cache.L2 < 0 && cache.L3 < 0 {
return
}
cores := info.Cores
if cores <= 0 {
cores = cpuid.CPU.PhysicalCores
if cores <= 0 {
cores = 1
}
}
if summary.Counts == nil {
summary.Counts = map[int]int{}
}
l1Bytes := 0
if cache.L1I > 0 {
l1Bytes += cache.L1I
}
if cache.L1D > 0 {
l1Bytes += cache.L1D
}
if l1Bytes > 0 && info.CacheL1KB == 0 {
info.CacheL1KB = int(int64(l1Bytes) * int64(cores) / 1024)
}
if cache.L2 > 0 && info.CacheL2KB == 0 {
l2Slices := summary.Counts[2]
if l2Slices <= 0 {
l2Slices = cores
}
info.CacheL2KB = maxInt(info.CacheL2KB, int((int64(cache.L2)*int64(l2Slices))/1024))
}
if cache.L3 > 0 && info.CacheL3KB == 0 {
l3Slices := summary.Counts[3]
if l3Slices <= 0 {
l3Slices = 1
}
info.CacheL3KB = maxInt(info.CacheL3KB, int((int64(cache.L3)*int64(l3Slices))/1024))
}
}
func parseLscpuCacheSizes(output string) (l1, l2, l3 int) {
l1d := parseCacheValue(output, "L1d")
l1i := parseCacheValue(output, "L1i")
l2 = parseCacheValue(output, "L2")
l3 = parseCacheValue(output, "L3")
if l1d > 0 || l1i > 0 {
l1 = l1d + l1i
}
return
}
func parseCacheValue(output, label string) int {
re := regexp.MustCompile(fmt.Sprintf(`(?m)^Cache\s+%s\s*:\s*([\d\.]+)\s*([KMGT]iB?|B|K|M|G)`, regexp.QuoteMeta(label)))
matches := re.FindStringSubmatch(output)
if len(matches) != 3 && len(matches) != 4 {
return 0
}
value := matches[1]
unit := matches[len(matches)-1]
num, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
switch strings.ToUpper(unit) {
case "KIB", "KB", "K":
return int(num)
case "MIB", "MB", "M":
return int(num * 1024)
case "GIB", "GB", "G":
return int(num * 1024 * 1024)
default:
return int(num)
}
}
var (
dmidecodeCurrentSpeedRE = regexp.MustCompile(`(?m)^Current Speed:\s+([\d\.]+)\s+MHz`)
dmidecodeMaxSpeedRE = regexp.MustCompile(`(?m)^Max Speed:\s+([\d\.]+)\s+MHz`)
microarchitectureMap = map[string]string{
"25": "Zen",
}
)
func detectMicroarchitecture(modelName string, family string) string {
if strings.Contains(strings.ToLower(modelName), "ryzen") {
return "Zen"
}
if value, ok := microarchitectureMap[family]; ok {
return value
}
if family != "" {
return family
}
return ""
}
func determineCPUFreqs(ctx context.Context, info cpu.InfoStat) (float64, float64) {
base := info.Mhz / 1000
max := info.Mhz / 1000
if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq"); val > 0 {
max = float64(val) / 1_000_000
}
if val := readFrequencyFromFile("/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq"); val > 0 && base == 0 {
base = float64(val) / 1_000_000
}
if out, err := safeRun(ctx, "dmidecode", "-t", "4"); err == nil {
if curSpeed := parseDMIDecodeSpeed(out, dmidecodeCurrentSpeedRE); curSpeed > 0 {
base = curSpeed / 1000
}
if maxSpeed := parseDMIDecodeSpeed(out, dmidecodeMaxSpeedRE); maxSpeed > 0 {
max = maxSpeed / 1000
}
}
return round2(base), round2(max)
}
func parseDMIDecodeSpeed(output string, re *regexp.Regexp) float64 {
if match := re.FindStringSubmatch(output); len(match) == 2 {
if val, err := strconv.ParseFloat(match[1], 64); err == nil {
return val
}
}
return 0
}
func readFrequencyFromFile(path string) int64 {
data, err := os.ReadFile(path)
if err != nil {
return 0
}
val := strings.TrimSpace(string(data))
if parsed, err := strconv.ParseInt(val, 10, 64); err == nil {
return parsed
}
return 0
}
func parseCacheSize(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
units := []struct {
suffix string
multiplier int
}{
{"KIB", 1},
{"KB", 1},
{"K", 1},
{"MIB", 1024},
{"MB", 1024},
{"M", 1024},
{"GIB", 1024 * 1024},
{"GB", 1024 * 1024},
{"G", 1024 * 1024},
}
upper := strings.ToUpper(value)
multiplier := 1
for _, unit := range units {
if strings.HasSuffix(upper, unit.suffix) {
value = strings.TrimSpace(value[:len(value)-len(unit.suffix)])
multiplier = unit.multiplier
break
}
}
if value == "" {
return 0
}
num, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return int(num * float64(multiplier))
}
func readIntFromFile(path string) int {
value := readStringFromFile(path)
if value == "" {
return 0
}
if parsed, err := strconv.Atoi(value); err == nil {
return parsed
}
return 0
}
func readStringFromFile(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
func collectRAMInfo(ctx context.Context) RAMDetails {
res := RAMDetails{}
if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil {
res.TotalMB = int(vm.Total / (1024 * 1024))
res.UsedMB = int(vm.Used / (1024 * 1024))
res.FreeMB = int(vm.Available / (1024 * 1024))
res.SharedMB = int(vm.Shared / (1024 * 1024))
}
slotsTotal, slotsUsed, maxCapacity, ecc, layout := collectSMBIOSMemory()
if len(layout) > 0 {
correctSlots := len(layout)
if slotsTotal == 0 || slotsTotal < correctSlots || slotsTotal > correctSlots*4 {
debugf("Correction RAM slots_total=%d => %d (layout=%d)", slotsTotal, correctSlots, len(layout))
slotsTotal = correctSlots
}
if slotsUsed == 0 {
slotsUsed = len(layout)
}
}
if slotsTotal > 0 {
res.SlotsTotal = slotsTotal
}
if slotsUsed > 0 {
res.SlotsUsed = slotsUsed
}
if maxCapacity > 0 {
res.MaxCapacityMB = maxCapacity
}
res.ECC = ecc
res.Layout = layout
if res.MaxCapacityMB == 0 {
debugf("RAM fallback max capacity: SMBIOS=0 => utiliser total=%dMB", res.TotalMB)
res.MaxCapacityMB = res.TotalMB
}
if res.SlotsTotal == 0 && len(layout) > 0 {
debugf("RAM fallback slots total: SMBIOS=0 => %d modules détectés", len(layout))
res.SlotsTotal = len(layout)
}
if res.SlotsUsed == 0 && len(layout) > 0 {
debugf("RAM fallback slots used: SMBIOS=0 => %d modules détectés", len(layout))
res.SlotsUsed = len(layout)
}
debugf("RAM result: max=%dMB slotsTotal=%d slotsUsed=%d layout=%d modules", res.MaxCapacityMB, res.SlotsTotal, res.SlotsUsed, len(layout))
return res
}
const smbiosTableTypePhysicalMemoryArray smbios.TableType = 16
func collectSMBIOSMemory() (slotsTotal, slotsUsed, maxCapacityMB int, ecc bool, layout []MemoryModule) {
info, err := smbios.FromSysfs()
if err != nil {
debugf("Lecture SMBIOS impossible : %v", err)
return
}
debugf("SMBIOS détecté : %s", info)
slotsTotal, ecc, maxCapacityMB = parseSMBIOSPhysicalMemoryArray(info)
modules, err := info.GetMemoryDevices()
if err != nil {
debugf("Impossible de lire les modules mémoire SMBIOS : %v", err)
return
}
for _, md := range modules {
module := MemoryModule{
Slot: md.DeviceLocator,
Type: md.Type.String(),
TypeDetail: md.TypeDetail.String(),
FormFactor: md.FormFactor.String(),
Manufacturer: md.Manufacturer,
PartNumber: md.PartNumber,
SpeedMHz: int(md.Speed),
ConfiguredMHz: int(md.ConfiguredSpeed),
Rank: memoryRank(md.Attributes),
}
if size := md.GetSizeBytes(); size > 0 {
module.SizeMB = int(size / (1024 * 1024))
slotsUsed++
}
debugf("Module SMBIOS détecté : %s %+v", module.Slot, module)
layout = append(layout, module)
}
if slotsTotal == 0 {
slotsTotal = len(layout)
}
if slotsUsed == 0 {
slotsUsed = len(layout)
}
debugf("Mémoire SMBIOS : slots_total=%d slots_used=%d ecc=%t max=%dMB", slotsTotal, slotsUsed, ecc, maxCapacityMB)
return
}
func parseSMBIOSPhysicalMemoryArray(info *smbios.Info) (slotsTotal int, ecc bool, maxCapacityMB int) {
for _, table := range info.Tables {
if table.Type != smbiosTableTypePhysicalMemoryArray {
continue
}
if val, err := table.GetByteAt(13); err == nil && val > 0 {
debugf("[SMBIOS RAW] Handle=%s slots_byte=0x%X (%d)", table.Handle, val, val)
slotsTotal = int(val)
}
if val, err := table.GetByteAt(6); err == nil {
ecc = val != 0 && val != 3
}
if dw, err := table.GetDWordAt(7); err == nil {
if dw == 0x80000000 && table.Len() >= 0x10 {
if ext, err := table.GetDWordAt(12); err == nil && ext > 0 {
maxCapacityMB = int(ext / (1024 * 1024))
}
} else if dw > 0 {
maxCapacityMB = int(dw / 1024)
}
}
break
}
return
}
func memoryRank(attrs uint8) string {
rank := attrs & 0x0f
if rank == 0 {
return "Unknown"
}
return strconv.Itoa(int(rank))
}
func collectGPUInfo(ctx context.Context) GPUDetails {
info := GPUDetails{}
if out, err := safeRun(ctx, "lspci"); err == nil {
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "VGA compatible controller") || strings.Contains(line, "3D controller") {
parts := strings.SplitN(line, ":", 3)
if len(parts) >= 3 {
desc := strings.TrimSpace(parts[2])
info.Model = desc
info.Vendor = strings.Split(desc, " ")[0]
break
}
}
}
}
if out, err := safeRun(ctx, "nvidia-smi", "--query-gpu=name,driver_version,memory.total", "--format=csv,noheader,nounits"); err == nil {
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) > 0 {
fields := strings.Split(lines[0], ",")
if len(fields) >= 3 {
info.Model = strings.TrimSpace(fields[0])
info.DriverVersion = strings.TrimSpace(fields[1])
if v := atoi(strings.TrimSpace(fields[2])); v > 0 {
info.MemoryDedicatedMB = &v
}
}
}
}
return info
}
func collectStorageInfo(ctx context.Context) StorageDetails {
detail := StorageDetails{}
partitions, err := disk.PartitionsWithContext(ctx, true)
if err != nil {
return detail
}
deviceMap := map[string]*StorageDevice{}
for _, part := range partitions {
if shouldSkipPartition(part) {
continue
}
var usage *disk.UsageStat
if part.Mountpoint != "" {
if u, err := disk.UsageWithContext(ctx, part.Mountpoint); err == nil {
usage = u
}
}
record := PartitionInfo{
Name: part.Device,
MountPoint: part.Mountpoint,
FSType: part.Fstype,
TotalGB: bytesToGB(int64(usageTotal(usage))),
}
if usage != nil {
record.UsedGB = floatPtr(bytesToGB(int64(usage.Used)))
record.FreeGB = floatPtr(bytesToGB(int64(usage.Free)))
}
detail.Partitions = append(detail.Partitions, record)
devName := deriveDeviceRoot(part.Device)
if !isPhysicalDevice(devName) {
continue
}
if _, ok := deviceMap[devName]; !ok {
deviceMap[devName] = &StorageDevice{
Name: devName,
Type: guessDiskType(devName),
}
}
if usage != nil {
deviceMap[devName].CapacityGB = bytesToGB(int64(usage.Total))
}
}
populateDevicesFromLsblk(ctx, deviceMap)
for _, dev := range deviceMap {
annotateSmartDevice(dev)
}
if len(deviceMap) > 0 {
names := make([]string, 0, len(deviceMap))
for name := range deviceMap {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
detail.Devices = append(detail.Devices, *deviceMap[name])
}
}
return detail
}
func usageTotal(stats *disk.UsageStat) int64 {
if stats == nil {
return 0
}
return int64(stats.Total)
}
func guessDiskType(device string) string {
lower := strings.ToLower(device)
switch {
case strings.Contains(lower, "nvme"):
return "ssd"
case strings.Contains(lower, "sd"):
return "ssd"
case strings.Contains(lower, "hd"):
return "hdd"
default:
return "disk"
}
}
func populateDevicesFromLsblk(ctx context.Context, deviceMap map[string]*StorageDevice) {
if len(deviceMap) == 0 {
// still want to capture drives even if no partitions were added
}
raw, err := safeRun(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,TRAN,VENDOR,MODEL,SERIAL,SIZE")
if err != nil {
debugf("lsblk JSON échoue: %v", err)
return
}
var output lsblkOutput
if err := json.Unmarshal([]byte(raw), &output); err != nil {
debugf("lsblk JSON parse échoue: %v", err)
return
}
for _, entry := range output.BlockDevices {
addLsblkDevice(entry, deviceMap)
}
}
func addLsblkDevice(entry lsblkDevice, deviceMap map[string]*StorageDevice) {
if entry.Type == "" {
for _, child := range entry.Children {
addLsblkDevice(child, deviceMap)
}
return
}
if entry.Type == "disk" {
path := "/dev/" + entry.Name
if !isPhysicalDevice(path) || isIgnoredDevice(path) {
return
}
dev := deviceMap[path]
if dev == nil {
dev = &StorageDevice{Name: path}
deviceMap[path] = dev
}
if entry.Tran != "" {
dev.Interface = strings.ToLower(entry.Tran)
}
if entry.Vendor != "" {
dev.Vendor = entry.Vendor
}
if entry.Model != "" {
dev.Model = entry.Model
}
if entry.Serial != "" {
dev.Serial = entry.Serial
}
if sizeBytes, err := entry.Size.Int64(); err == nil && sizeBytes > 0 {
dev.CapacityGB = float64(sizeBytes) / (1024 * 1024 * 1024)
}
}
for _, child := range entry.Children {
addLsblkDevice(child, deviceMap)
}
}
type lsblkOutput struct {
BlockDevices []lsblkDevice `json:"blockdevices"`
}
type lsblkDevice struct {
Name string `json:"name"`
Type string `json:"type"`
Tran string `json:"tran"`
Vendor string `json:"vendor"`
Model string `json:"model"`
Serial string `json:"serial"`
Size json.Number `json:"size"`
Children []lsblkDevice `json:"children"`
}
func deriveDeviceRoot(device string) string {
if device == "" {
return device
}
root := device
if strings.HasPrefix(device, "/dev/") {
if idx := strings.LastIndex(device, "p"); idx > strings.LastIndex(device, "/") {
if _, err := strconv.Atoi(device[idx+1:]); err == nil {
candidate := device[:idx]
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
}
candidate := strings.TrimRightFunc(device, unicode.IsDigit)
if candidate != "" && candidate != device {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
}
return root
}
func shouldSkipPartition(part disk.PartitionStat) bool {
if part.Device == "" {
return true
}
lowerFs := strings.ToLower(part.Fstype)
if lowerFs != "" {
if _, ok := pseudoFSTypes[lowerFs]; ok {
return true
}
}
if strings.Contains(lowerFs, "nsfs") {
return true
}
if part.Mountpoint != "" {
switch {
case strings.HasPrefix(part.Mountpoint, "/run/docker"),
strings.HasPrefix(part.Mountpoint, "/sys"),
strings.HasPrefix(part.Mountpoint, "/proc"),
strings.HasPrefix(part.Mountpoint, "/dev"):
return true
}
}
if isIgnoredDevice(part.Device) {
return true
}
return false
}
func isPhysicalDevice(device string) bool {
device = strings.TrimSpace(device)
if device == "" || !strings.HasPrefix(device, "/dev/") {
return false
}
normalized := strings.TrimPrefix(device, "/dev/")
normalized = strings.TrimSuffix(normalized, "p")
lower := strings.ToLower(normalized)
physicalPrefixes := []string{"sd", "hd", "vd", "nvme", "mmcblk", "xvd", "mapper", "dm-"}
for _, prefix := range physicalPrefixes {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
func isIgnoredDevice(device string) bool {
lower := strings.ToLower(strings.TrimSpace(device))
ignored := []string{"/dev/loop", "/dev/ram", "/dev/fd", "/dev/sr", "/dev/mapper", "/dev/nbd", "none"}
for _, prefix := range ignored {
if strings.HasPrefix(lower, prefix) {
return true
}
}
return false
}
type mountEntry struct {
MountPoint string
FSName string
Source string
}
func isPseudoMountType(name string) bool {
_, ok := mountPseudoTypes[strings.ToLower(name)]
return ok
}
func chooseBenchDir() (string, mountEntry) {
mounts := parseProcMounts()
candidates := []string{cfg.Runtime.TempDir, "/var/tmp", os.TempDir()}
var fallback mountEntry
for _, base := range candidates {
if base == "" {
continue
}
absPath, err := filepath.Abs(base)
if err != nil {
continue
}
if err := os.MkdirAll(absPath, 0o755); err != nil {
continue
}
entry := findMountForPath(absPath, mounts)
if entry.MountPoint != "" {
if !isPseudoMountType(entry.FSName) && isPhysicalMount(entry) {
bench := filepath.Join(absPath, "bench-client")
if err := os.MkdirAll(bench, 0o755); err != nil {
continue
}
return bench, entry
}
if fallback.MountPoint == "" {
fallback = entry
}
}
}
for _, entry := range mounts {
if entry.MountPoint == "" || isPseudoMountType(entry.FSName) {
continue
}
if !isPhysicalMount(entry) {
if fallback.MountPoint == "" {
fallback = entry
}
continue
}
bench := filepath.Join(entry.MountPoint, "bench-client")
if err := os.MkdirAll(bench, 0o755); err != nil {
continue
}
return bench, entry
}
fallbackDir := filepath.Join(os.TempDir(), "bench-client")
_ = os.MkdirAll(fallbackDir, 0o755)
return fallbackDir, fallback
}
func parseProcMounts() []mountEntry {
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
entries := make([]mountEntry, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
entries = append(entries, mountEntry{
Source: decodeMountField(fields[0]),
MountPoint: decodeMountField(fields[1]),
FSName: fields[2],
})
}
sort.Slice(entries, func(i, j int) bool {
return len(entries[i].MountPoint) > len(entries[j].MountPoint)
})
return entries
}
func findMountForPath(path string, entries []mountEntry) mountEntry {
path = filepath.Clean(path)
var best mountEntry
bestLen := -1
for _, entry := range entries {
mp := entry.MountPoint
if mp == "" {
continue
}
if mp != "/" && !strings.HasPrefix(path, mp) {
continue
}
if len(mp) > bestLen {
best = entry
bestLen = len(mp)
}
}
return best
}
func isPhysicalMount(entry mountEntry) bool {
return entry.Source != "" && isPhysicalDevice(entry.Source)
}
func decodeMountField(field string) string {
return strings.ReplaceAll(field, "\\040", " ")
}
var pseudoFSTypes = map[string]struct{}{
"tmpfs": {},
"sysfs": {},
"proc": {},
"devtmpfs": {},
"cgroup": {},
"cgroup2": {},
"devpts": {},
"securityfs": {},
"squashfs": {},
"overlay": {},
}
var mountPseudoTypes = map[string]struct{}{
"tmpfs": {},
"overlay": {},
"squashfs": {},
"ramfs": {},
"devtmpfs": {},
"cgroup": {},
"cgroup2": {},
"devpts": {},
"securityfs": {},
"tracefs": {},
"fusectl": {},
"debugfs": {},
}
func annotateSmartDevice(dev *StorageDevice) {
if dev == nil || dev.Name == "" {
return
}
handle, err := smartgo.Open(dev.Name)
if err != nil {
debugf("SMART open %s impossible: %v", dev.Name, err)
dev.SmartHealth = "UNAVAILABLE"
return
}
defer handle.Close()
health := "UNKNOWN"
if attrs, err := handle.ReadGenericAttributes(); err == nil {
health = "PASSED"
if attrs.Temperature > 0 {
dev.Temperature = int(attrs.Temperature)
}
} else {
debugf("SMART lecture générique %s échouée: %v", dev.Name, err)
health = fmt.Sprintf("FAILED: %v", err)
}
var vendor, model, serial string
switch specific := handle.(type) {
case *smartgo.SataDevice:
if id, err := specific.Identify(); err == nil {
model = strings.TrimSpace(id.ModelNumber())
serial = strings.TrimSpace(id.SerialNumber())
if vendor == "" {
vendor = guessVendorFromModel(model)
}
} else {
debugf("SMART Identify SATA %s échoué: %v", dev.Name, err)
}
case *smartgo.NVMeDevice:
if ctrl, _, err := specific.Identify(); err == nil {
model = strings.TrimSpace(ctrl.ModelNumber())
serial = strings.TrimSpace(ctrl.SerialNumber())
if vendor == "" {
vendor = guessVendorFromModel(model)
}
} else {
debugf("SMART Identify NVMe %s échoué: %v", dev.Name, err)
}
case *smartgo.ScsiDevice:
if inq, err := specific.Inquiry(); err == nil {
vendor = strings.TrimSpace(string(bytes.TrimSpace(inq.VendorIdent[:])))
model = strings.TrimSpace(string(bytes.TrimSpace(inq.ProductIdent[:])))
} else {
debugf("SMART Inquiry SCSI %s échouée: %v", dev.Name, err)
}
if serialNum, err := specific.SerialNumber(); err == nil && serialNum != "" {
serial = strings.TrimSpace(serialNum)
} else if err != nil {
debugf("SMART Serial SCSI %s échouée: %v", dev.Name, err)
}
default:
debugf("SMART type inconnu pour %s", dev.Name)
}
if vendor != "" && dev.Vendor == "" {
dev.Vendor = vendor
}
if model != "" {
dev.Model = model
}
if serial != "" {
dev.Serial = serial
}
dev.SmartHealth = health
}
func guessVendorFromModel(model string) string {
if model == "" {
return ""
}
fields := strings.Fields(model)
if len(fields) == 0 {
return ""
}
return fields[0]
}
func bytesToGB(value int64) float64 {
return float64(value) / (1024 * 1024 * 1024)
}
func collectPCIDevices(ctx context.Context) []PCIInfo {
devices := []PCIInfo{}
out, err := safeRun(ctx, "lspci", "-mm")
if err != nil {
return devices
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
slot := ""
rest := line
if idx := strings.Index(line, " "); idx >= 0 {
slot = line[:idx]
rest = line[idx+1:]
}
slot = strings.TrimSpace(slot)
if slot == "" {
continue
}
matches := quotedStringRegexp.FindAllString(rest, -1)
if len(matches) < 3 {
continue
}
class := strings.Trim(matches[0], `"`)
vendor := strings.Trim(matches[1], `"`)
device := strings.Trim(matches[2], `"`)
devices = append(devices, PCIInfo{
Slot: slot,
Class: class,
Vendor: vendor,
Device: device,
})
}
return devices
}
func collectUSBDevices(ctx context.Context) []USBInfo {
var devices []USBInfo
out, err := safeRun(ctx, "lsusb")
if err != nil {
return devices
}
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 6 {
continue
}
bus := parts[1]
device := strings.TrimSuffix(parts[3], ":")
idParts := strings.Split(parts[5], ":")
vendorID := ""
productID := ""
if len(idParts) == 2 {
vendorID = idParts[0]
productID = idParts[1]
}
name := strings.Join(parts[6:], " ")
devices = append(devices, USBInfo{
Bus: bus,
Device: device,
VendorID: vendorID,
ProductID: productID,
Name: name,
})
}
return devices
}
func collectNetworkShares(ctx context.Context) []NetworkShare {
shares := []NetworkShare{}
data, err := os.ReadFile("/proc/mounts")
if err != nil {
return shares
}
for _, line := range strings.Split(string(data), "\n") {
if line = strings.TrimSpace(line); line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) < 6 {
continue
}
fs := strings.ToLower(parts[2])
if !isNetworkFSType(fs) {
continue
}
share := NetworkShare{
Protocol: fs,
Source: parts[0],
MountPoint: parts[1],
FSType: fs,
Options: parts[3],
}
if sz, used, free := dfStats(ctx, parts[1]); sz > 0 {
share.TotalGB = floatPtr(sz)
if used != 0 {
share.UsedGB = floatPtr(used)
}
if free != 0 {
share.FreeGB = floatPtr(free)
}
}
shares = append(shares, share)
}
return shares
}
func isNetworkFSType(fs string) bool {
networkFS := []string{"nfs", "nfs4", "cifs", "smbfs", "fuse.cifs", "fuse.smbfs", "afp", "afpfs", "fuse.afpfs", "sshfs", "fuse.sshfs", "ftpfs", "curlftpfs", "fuse.ftpfs", "davfs", "davfs2", "fuse.webdavfs"}
for _, candidate := range networkFS {
if fs == candidate {
return true
}
}
return false
}
func dfStats(ctx context.Context, mountPoint string) (total, used, free float64) {
out, err := safeRun(ctx, "df", "-k", mountPoint)
if err != nil {
return 0, 0, 0
}
lines := strings.Split(string(out), "\n")
if len(lines) < 2 {
return 0, 0, 0
}
fields := strings.Fields(lines[1])
if len(fields) < 5 {
return 0, 0, 0
}
total = kbToGB(atoi(fields[1]))
used = kbToGB(atoi(fields[2]))
free = kbToGB(atoi(fields[3]))
return total, used, free
}
func kbToGB(value int) float64 {
return float64(value) / (1024 * 1024)
}
func floatPtr(v float64) *float64 {
return &v
}
func collectNetworkInfo(ctx context.Context) NetworkDetails {
details := NetworkDetails{}
ifaces, err := gopsnet.InterfacesWithContext(ctx)
if err != nil {
return details
}
for _, iface := range ifaces {
ifaceName := iface.Name
if isLoopbackInterface(iface) || isVirtualInterface(ifaceName) {
continue
}
netType := guessNetworkType(ifaceName)
if netType != "ethernet" && netType != "wifi" {
continue
}
n := NetworkInterface{
Name: ifaceName,
MAC: iface.HardwareAddr,
Type: netType,
}
for _, addr := range iface.Addrs {
ip := addr.Addr
if idx := strings.Index(ip, "/"); idx >= 0 {
ip = ip[:idx]
}
if ip == "" {
continue
}
n.IP = ip
break
}
speed, driver, wake := getInterfaceStats(ctx, ifaceName)
if speed != nil {
n.SpeedMbps = speed
}
n.Driver = driver
n.WakeOnLAN = wake
if netType == "wifi" {
if ssid := getWirelessSSID(ctx, ifaceName); ssid != "" {
n.SSID = ssid
}
}
details.Interfaces = append(details.Interfaces, n)
}
return details
}
func guessNetworkType(name string) string {
switch {
case strings.HasPrefix(name, "en"), strings.HasPrefix(name, "eth"):
return "ethernet"
case strings.HasPrefix(name, "wl"):
return "wifi"
default:
return "unknown"
}
}
func isLoopbackInterface(iface gopsnet.InterfaceStat) bool {
for _, flag := range iface.Flags {
if strings.EqualFold(flag, "loopback") {
return true
}
}
return false
}
func isVirtualInterface(name string) bool {
name = strings.ToLower(name)
virtualPrefixes := []string{
"lo", "br-", "docker", "veth", "virbr", "vmnet",
"vmbr", "vboxnet", "tap", "tun", "zerotier", "wg",
}
for _, prefix := range virtualPrefixes {
if strings.HasPrefix(name, prefix) {
return true
}
}
return false
}
func getInterfaceStats(ctx context.Context, iface string) (*int, string, bool) {
var speed *int
driver := ""
wake := false
if out, err := safeRun(ctx, "ethtool", iface); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "Speed:") {
if val := parseSpeedValue(line); val != nil {
speed = val
}
}
if strings.HasPrefix(line, "Wake-on:") {
wake = parseWakeOnValue(line)
}
}
}
if out, err := safeRun(ctx, "ethtool", "-i", iface); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "driver:") {
driver = strings.TrimSpace(strings.TrimPrefix(line, "driver:"))
break
}
}
}
return speed, driver, wake
}
func parseSpeedValue(line string) *int {
if idx := strings.Index(line, ":"); idx >= 0 {
value := strings.TrimSpace(line[idx+1:])
value = strings.TrimSuffix(value, "Mb/s")
value = strings.TrimSpace(value)
if strings.EqualFold(value, "unknown!") || value == "" {
return nil
}
if parsed, err := strconv.Atoi(value); err == nil {
return &parsed
}
}
return nil
}
func parseWakeOnValue(line string) bool {
if idx := strings.Index(line, ":"); idx >= 0 {
value := strings.TrimSpace(line[idx+1:])
return strings.Contains(strings.ToLower(value), "g")
}
return false
}
func getWirelessSSID(ctx context.Context, iface string) string {
if out, err := safeRun(ctx, "iwgetid", iface, "-r"); err == nil {
if ssid := strings.TrimSpace(out); ssid != "" {
return ssid
}
}
if out, err := safeRun(ctx, "iw", iface, "link"); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "SSID:") {
return strings.TrimSpace(strings.TrimPrefix(line, "SSID:"))
}
}
}
return ""
}
func collectMotherboardInfo(ctx context.Context) MotherboardInfo {
info := MotherboardInfo{}
if base, err := safeRun(ctx, "dmidecode", "-t", "2"); err == nil {
for _, line := range strings.Split(base, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Manufacturer:"):
info.Vendor = strings.TrimSpace(strings.TrimPrefix(line, "Manufacturer:"))
case strings.HasPrefix(line, "Product Name:"):
info.Model = strings.TrimSpace(strings.TrimPrefix(line, "Product Name:"))
}
}
}
if bios, err := safeRun(ctx, "dmidecode", "-t", "0"); err == nil {
for _, line := range strings.Split(bios, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Vendor:"):
info.BIOSVendor = strings.TrimSpace(strings.TrimPrefix(line, "Vendor:"))
case strings.HasPrefix(line, "Version:"):
info.BIOSVersion = strings.TrimSpace(strings.TrimPrefix(line, "Version:"))
case strings.HasPrefix(line, "Release Date:"):
info.BIOSDate = strings.TrimSpace(strings.TrimPrefix(line, "Release Date:"))
}
}
}
return info
}
func collectSensors(ctx context.Context) SensorInfo {
info := SensorInfo{
DiskTempsC: make(map[string]float64),
Sensors: []SensorReading{},
}
if err := ensureSensorsInitialized(); err == nil {
if system, err := lmsensors.Get(); err == nil {
for chipID, chip := range system.Chips {
chipType := strings.ToLower(chip.Type)
for _, sensor := range chip.Sensors {
reading := SensorReading{
Chip: chipID,
Name: sensor.Name,
Type: strings.ToLower(sensor.SensorType.String()),
Value: sensor.Value,
Unit: sensor.Unit,
Alarm: sensor.Alarm,
}
info.Sensors = append(info.Sensors, reading)
if reading.Type == "temperature" && info.CPUTempC == nil && isCpuSensor(chipType, reading.Name) {
val := reading.Value
info.CPUTempC = &val
}
if reading.Type == "temperature" && isDiskSensor(chipType, reading.Name) {
info.DiskTempsC[fmt.Sprintf("%s:%s", chipID, reading.Name)] = reading.Value
}
}
}
} else {
debugf("lecture go-lmsensors échoue: %v", err)
}
} else {
debugf("initialisation go-lmsensors échoue: %v", err)
}
fillThermalFallback(&info)
return info
}
func ensureSensorsInitialized() error {
sensorInitOnce.Do(func() {
sensorInitErr = lmsensors.Init()
})
return sensorInitErr
}
func fillThermalFallback(info *SensorInfo) {
if info.DiskTempsC == nil {
info.DiskTempsC = make(map[string]float64)
}
if zones, err := os.ReadDir("/sys/class/thermal"); err == nil {
for _, zone := range zones {
zonePath := filepath.Join("/sys/class/thermal", zone.Name())
kind, err := os.ReadFile(filepath.Join(zonePath, "type"))
if err != nil {
continue
}
kindStr := strings.TrimSpace(string(kind))
tempRaw, err := os.ReadFile(filepath.Join(zonePath, "temp"))
if err != nil {
continue
}
tempVal := atoi(strings.TrimSpace(string(tempRaw)))
if tempVal == 0 {
continue
}
tempC := float64(tempVal) / 1000
lower := strings.ToLower(kindStr)
if info.CPUTempC == nil && (strings.Contains(lower, "package") || strings.Contains(lower, "cpu")) {
val := tempC
info.CPUTempC = &val
}
if strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") {
info.DiskTempsC[kindStr] = tempC
}
}
}
}
func isCpuSensor(chipType, name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "cpu") || strings.Contains(lower, "package") || strings.Contains(chipType, "core") || strings.Contains(chipType, "cpu")
}
func isDiskSensor(chipType, name string) bool {
lower := strings.ToLower(name)
return strings.Contains(lower, "nvme") || strings.Contains(lower, "disk") || strings.Contains(lower, "ssd") || strings.Contains(chipType, "nvme") || strings.Contains(chipType, "sata")
}
func collectAudioInfo(ctx context.Context) AudioInfo {
info := AudioInfo{
Hardware: AudioHardware{},
Software: AudioSoftware{},
}
if out, err := safeRun(ctx, "lspci"); err == nil {
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "Audio device") {
info.Hardware.PCIAudioDevices = append(info.Hardware.PCIAudioDevices, strings.TrimSpace(line))
}
}
}
if out, err := safeRun(ctx, "aplay", "-l"); err == nil {
info.Hardware.AlsaPlayback = out
}
if out, err := safeRun(ctx, "arecord", "-l"); err == nil {
info.Hardware.AlsaCapture = out
}
if out, err := safeRun(ctx, "aplay", "-L"); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line != "" {
info.Hardware.PCMDevices = append(info.Hardware.PCMDevices, line)
}
}
}
if out, err := safeRun(ctx, "pactl", "info"); err == nil {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "Server Name:"):
info.Software.ServerName = strings.TrimSpace(strings.TrimPrefix(line, "Server Name:"))
case strings.HasPrefix(line, "Server Version:"):
info.Software.ServerVersion = strings.TrimSpace(strings.TrimPrefix(line, "Server Version:"))
case strings.HasPrefix(line, "Default Sink:"):
value := strings.TrimSpace(strings.TrimPrefix(line, "Default Sink:"))
info.Software.DefaultSink = &value
case strings.HasPrefix(line, "Default Source:"):
value := strings.TrimSpace(strings.TrimPrefix(line, "Default Source:"))
info.Software.DefaultSource = &value
}
}
if strings.Contains(out, "PipeWire") {
info.Software.Backend = "pipewire"
} else if strings.Contains(out, "PulseAudio") {
info.Software.Backend = "pulseaudio"
} else {
info.Software.Backend = "alsa"
}
}
return info
}
func collectRawInfo(ctx context.Context, include []string, maxKB int) map[string]string {
raw := make(map[string]string)
if maxKB <= 0 {
maxKB = 5120
}
for _, item := range include {
item = strings.ToLower(item)
var cmd []string
switch item {
case "lscpu":
cmd = []string{"lscpu"}
case "lsblk":
cmd = []string{"lsblk", "-a"}
case "dmidecode":
cmd = []string{"dmidecode", "-t", "0,1,2,16,17"}
default:
continue
}
if len(cmd) == 0 {
continue
}
out, err := safeRun(ctx, cmd[0], cmd[1:]...)
if err != nil {
continue
}
raw[item] = truncateOutput(out, maxKB)
}
return raw
}
func truncateOutput(s string, maxKB int) string {
if len(s) <= maxKB*1024 {
return s
}
return s[:maxKB*1024]
}
func atoi(value string) int {
value = strings.TrimSpace(strings.Split(value, " ")[0])
if res, err := strconv.Atoi(value); err == nil {
return res
}
return 0
}
func parseMHz(value string) float64 {
if f, err := strconv.ParseFloat(strings.TrimSpace(strings.Split(value, " ")[0]), 64); err == nil {
return f / 1000
}
return 0
}
func parseSizeToKB(value string) int {
value = strings.TrimSpace(value)
if idx := strings.Index(value, "("); idx >= 0 {
value = strings.TrimSpace(value[:idx])
}
match := sizeValueRegex.FindStringSubmatch(value)
if len(match) < 2 {
return 0
}
num, err := strconv.ParseFloat(match[1], 64)
if err != nil {
return 0
}
unit := strings.ToLower(match[2])
multiplier := 1.0
switch unit {
case "k", "kb", "kib":
multiplier = 1
case "m", "mb", "mib":
multiplier = 1024
case "g", "gb", "gib":
multiplier = 1024 * 1024
case "t", "tb", "tib":
multiplier = 1024 * 1024 * 1024
default:
multiplier = 1
}
return int(num * multiplier)
}
func printProgress(step, total int, name, status string) {
// Simple affichage ANSI compatible
statusColor := "\033[32m" // Vert
if status == "WARN" || status == "SKIPPED" {
statusColor = "\033[33m" // Jaune
} else if strings.Contains(status, "ERROR") {
statusColor = "\033[31m" // Rouge
}
reset := "\033[0m"
fmt.Printf("[%d/%d] %-20s %s%s%s\n", step, total, name, statusColor, status, reset)
}
// ==========================================
// 3. CONFIG LOADER
// ==========================================
func loadConfig(url string) error {
var configData []byte
var err error
wd, wdErr := os.Getwd()
localPath := ""
if wdErr == nil {
localPath = filepath.Join(wd, "config.yaml")
if info, statErr := os.Stat(localPath); statErr == nil && !info.IsDir() {
configData, err = os.ReadFile(localPath)
if err == nil {
fmt.Printf("INFO: Configuration locale chargée depuis %s.\n", localPath)
debugf("Contenu de %s :\n%s", localPath, string(configData))
goto parse
}
fmt.Printf("WARN: Impossible de lire %s (%v), tentative fallback...\n", localPath, err)
} else {
debugf("Aucun config.yaml local (%s) : %v", localPath, statErr)
}
} else {
debugf("Impossible de déterminer le dossier d'exécution pour chercher config.yaml : %v", wdErr)
}
// Tentative HTTP
if url != "" {
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err == nil {
defer resp.Body.Close()
configData, err = io.ReadAll(resp.Body)
if err == nil {
fmt.Println("INFO: Configuration distante chargée.")
goto parse
}
}
fmt.Printf("WARN: Impossible de charger la config distante (%s), tentative fallback...\n", err)
}
// Fallback local (ou simulation pour cet exemple)
// Dans le réel, on lirait /var/cache/bench-client/config.yaml
fmt.Println("INFO: Utilisation de la configuration embarquée (fallback).")
configData = []byte(defaultConfigYAML) // Utilisation d'une constante pour la démo
parse:
err = yaml.Unmarshal(configData, &cfg)
if err == nil {
debugf("Config chargée : %+v", cfg)
}
return err
}
// ==========================================
// 4. COLLECTORS
// ==========================================
// ==========================================
// 5. BENCHMARKS
// ==========================================
func runCPUBench(ctx context.Context) CPUResult {
res := CPUResult{}
if !cfg.Benchmarks.CPU.Enabled {
return res
}
singleEvents, singleDuration := runSysbenchCPU(ctx, "1")
multiThreads := resolveThreadCount(ctx)
multiEvents, multiDuration := runSysbenchCPU(ctx, multiThreads)
res.EventsPerSecSingle = round2(singleEvents)
res.EventsPerSecMulti = round2(multiEvents)
res.EventsPerSec = round2(averagePositive(singleEvents, multiEvents))
res.DurationSec = round2(singleDuration + multiDuration)
res.ScoreSingle = res.EventsPerSecSingle
res.ScoreMulti = res.EventsPerSecMulti
res.Score = res.EventsPerSec
return res
}
func runSysbenchCPU(ctx context.Context, threads string) (float64, float64) {
args := []string{"cpu", fmt.Sprintf("--threads=%s", threads), "--time=10", "run"}
out, err := safeRun(ctx, "sysbench", args...)
if err != nil {
return 0, 0
}
return parseSysbenchCPU(out)
}
func parseSysbenchCPU(output string) (float64, float64) {
var events, duration float64
if matches := sysbenchEventsRegex.FindStringSubmatch(output); len(matches) > 1 {
events, _ = strconv.ParseFloat(matches[1], 64)
}
if matches := sysbenchTimeRegex.FindStringSubmatch(output); len(matches) > 1 {
duration, _ = strconv.ParseFloat(matches[1], 64)
}
return events, duration
}
func resolveThreadCount(ctx context.Context) string {
if v := os.Getenv("GOMAXPROCS"); v != "" {
return v
}
if out, err := safeRun(ctx, "nproc"); err == nil {
return strings.TrimSpace(out)
}
return "4"
}
func averagePositive(values ...float64) float64 {
var sum float64
var count int
for _, v := range values {
if v > 0 {
sum += v
count++
}
}
if count == 0 {
return 0
}
return sum / float64(count)
}
func maxFloat(a, b float64) float64 {
if b > a {
return b
}
return a
}
func runMemBench(ctx context.Context) MemResult {
res := MemResult{}
if !cfg.Benchmarks.Memory.Enabled {
return res
}
args := []string{"memory", "--memory-block-size=1K", "--memory-total-size=1G", "run"}
if out, err := safeRun(ctx, "sysbench", args...); err == nil {
if matches := regexp.MustCompile(`([\d\.]+)\s+MiB/sec`).FindStringSubmatch(out); len(matches) > 1 {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
res.Throughput = round2(val)
res.Score = res.Throughput
}
} else if matches := regexp.MustCompile(`transferred:\s+([\d\.]+)\s+MiB`).FindStringSubmatch(out); len(matches) > 1 {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
res.Throughput = round2(val)
res.Score = res.Throughput
}
}
}
return res
}
func runDiskBench(ctx context.Context) DiskResult {
res := DiskResult{}
if !cfg.Benchmarks.Disk.Enabled {
return res
}
benchDir, mountInfo := chooseBenchDir()
debugf("[3/6] Bench disque cible : %s (mount=%s fstype=%s source=%s)", benchDir, mountInfo.MountPoint, mountInfo.FSName, mountInfo.Source)
directMetrics := runDirectDiskTests(ctx, benchDir, mountInfo)
res.ReadMBs = directMetrics.ReadMBs
res.WriteMBs = directMetrics.WriteMBs
res.Score = averagePositive(res.ReadMBs, res.WriteMBs)
tmpFile := filepath.Join(benchDir, fmt.Sprintf("bench-%d.fio", time.Now().UnixNano()))
defer os.Remove(tmpFile)
args := []string{
"--name=bench", "--ioengine=libaio", "--rw=randrw", "--bs=4k",
"--direct=1", "--size=500M", "--numjobs=1", "--runtime=30",
"--time_based", "--output-format=json", "--filename=" + tmpFile,
}
out, err := safeRun(ctx, "fio", args...)
if err != nil {
return res
}
var fioJSON map[string]interface{}
if err := json.Unmarshal([]byte(out), &fioJSON); err != nil {
return res
}
if jobs, ok := fioJSON["jobs"].([]interface{}); ok && len(jobs) > 0 {
job := jobs[0].(map[string]interface{})
if read, ok := job["read"].(map[string]interface{}); ok {
res.ReadMBs = maxFloat(res.ReadMBs, convertBW(read["bw"]))
res.IOPSRead = convertFloat(read["iops"])
if hist, ok := read["clat_ns"].(map[string]interface{}); ok {
res.LatencyMs = convertFloat(hist["mean"]) / 1000000
}
}
if write, ok := job["write"].(map[string]interface{}); ok {
res.WriteMBs = maxFloat(res.WriteMBs, convertBW(write["bw"]))
res.IOPSWrite = convertFloat(write["iops"])
}
}
res.Score = round2(averagePositive(res.ReadMBs, res.WriteMBs))
res.ReadMBs = round2(res.ReadMBs)
res.WriteMBs = round2(res.WriteMBs)
res.LatencyMs = round2(res.LatencyMs)
res.IOPSRead = round2(res.IOPSRead)
res.IOPSWrite = round2(res.IOPSWrite)
debugf("[3/6] Bench disque final -> read=%.2f MB/s write=%.2f MB/s score=%.2f", res.ReadMBs, res.WriteMBs, res.Score)
return res
}
func convertBW(value interface{}) float64 {
if v, ok := value.(float64); ok {
return v / 1024
}
if num, ok := value.(json.Number); ok {
if parsed, err := num.Float64(); err == nil {
return parsed / 1024
}
}
return 0
}
func maxInt(a, b int) int {
if b > a {
return b
}
return a
}
func runDirectDiskTests(ctx context.Context, benchDir string, mountInfo mountEntry) DirectDiskMetrics {
if benchDir == "" {
return DirectDiskMetrics{}
}
debugf("[3/6] Bench disque direct sur %s (mnt=%s fs=%s)", mountInfo.Source, mountInfo.MountPoint, mountInfo.FSName)
tests := []struct {
suffix string
sizeMB int64
}{
{"500mb", 500},
{"1gb", 1024},
}
var cleanup []string
var totalWriteMB, totalReadMB float64
var totalWriteDur, totalReadDur time.Duration
for _, test := range tests {
path := filepath.Join(benchDir, fmt.Sprintf("direct-%s.bin", test.suffix))
cleanup = append(cleanup, path)
debugf("[3/6] Bench direct : écriture+lecture %s (%d MB)", path, test.sizeMB)
writeDur, readDur, err := benchmarkFile(ctx, path, test.sizeMB)
if err != nil {
debugf("[3/6] Bench direct %s échoué: %v", path, err)
continue
}
totalWriteMB += float64(test.sizeMB)
totalReadMB += float64(test.sizeMB)
totalWriteDur += writeDur
totalReadDur += readDur
}
const chunkCount = 50
const chunkSizeMB = 1
for idx := 1; idx <= chunkCount; idx++ {
path := filepath.Join(benchDir, fmt.Sprintf("direct-chunk-%02d.bin", idx))
cleanup = append(cleanup, path)
debugf("[3/6] Bench direct chunk %d/%d (%s)", idx, chunkCount, path)
writeDur, readDur, err := benchmarkFile(ctx, path, chunkSizeMB)
if err != nil {
debugf("[3/6] Bench direct chunk %s échoué: %v", path, err)
continue
}
totalWriteMB += float64(chunkSizeMB)
totalReadMB += float64(chunkSizeMB)
totalWriteDur += writeDur
totalReadDur += readDur
}
cleanupPaths(cleanup)
metrics := DirectDiskMetrics{
WriteMBs: round2(safeThroughput(totalWriteMB, totalWriteDur)),
ReadMBs: round2(safeThroughput(totalReadMB, totalReadDur)),
}
debugf("[3/6] Bench disque direct résumé -> write=%.2f MB/s read=%.2f MB/s", metrics.WriteMBs, metrics.ReadMBs)
return metrics
}
func cleanupPaths(paths []string) {
for _, path := range paths {
if path == "" {
continue
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
debugf("Impossible de supprimer %s: %v", path, err)
}
}
}
func benchmarkFile(ctx context.Context, path string, sizeMB int64) (time.Duration, time.Duration, error) {
if sizeMB <= 0 {
return 0, 0, fmt.Errorf("taille invalide %d", sizeMB)
}
totalBytes := int64(sizeMB) * 1024 * 1024
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
// ignore
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return 0, 0, err
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return 0, 0, err
}
writeBuf := make([]byte, 64*1024)
fillBuffer(writeBuf)
var written int64
start := time.Now()
for written < totalBytes {
if err := ctx.Err(); err != nil {
return 0, 0, err
}
toWrite := int64(len(writeBuf))
if totalBytes-written < toWrite {
toWrite = totalBytes - written
}
if _, err := file.Write(writeBuf[:toWrite]); err != nil {
return 0, 0, err
}
written += toWrite
}
if err := file.Sync(); err != nil {
return 0, 0, err
}
writeDur := time.Since(start)
if err := file.Close(); err != nil {
return writeDur, 0, err
}
readFile, err := os.Open(path)
if err != nil {
return writeDur, 0, err
}
defer readFile.Close()
readBuf := make([]byte, 64*1024)
start = time.Now()
for {
if err := ctx.Err(); err != nil {
return writeDur, 0, err
}
if _, err := readFile.Read(readBuf); err != nil {
if errors.Is(err, io.EOF) {
break
}
return writeDur, 0, err
}
}
readDur := time.Since(start)
return writeDur, readDur, nil
}
func safeThroughput(mb float64, duration time.Duration) float64 {
if duration <= 0 || mb <= 0 {
return 0
}
return mb / duration.Seconds()
}
func fillBuffer(buf []byte) {
for i := range buf {
buf[i] = byte(i % 256)
}
}
func round2(v float64) float64 {
if math.IsNaN(v) || math.IsInf(v, 0) {
return v
}
return math.Round(v*100) / 100
}
func convertFloat(value interface{}) float64 {
if v, ok := value.(float64); ok {
return v
}
return 0
}
func runNetBench(ctx context.Context) NetResult {
res := NetResult{}
if !cfg.Benchmarks.Network.Enabled {
return res
}
duration := 10
if params := cfg.Benchmarks.Network.Params; params != nil {
if raw, ok := params["duration_s"]; ok {
switch v := raw.(type) {
case int:
duration = v
case float64:
duration = int(v)
case string:
if parsed, err := strconv.Atoi(v); err == nil {
duration = parsed
}
}
}
}
args := []string{
"-c", cfg.Benchmarks.Network.Server,
"-p", strconv.Itoa(cfg.Benchmarks.Network.Port),
"-J", "-t", strconv.Itoa(duration),
}
out, err := safeRun(ctx, "iperf3", args...)
if err != nil {
return res
}
var iperfJSON map[string]interface{}
if err := json.Unmarshal([]byte(out), &iperfJSON); err != nil {
return res
}
if end, ok := iperfJSON["end"].(map[string]interface{}); ok {
if sumSent, ok := end["sum_sent"].(map[string]interface{}); ok {
if val := convertFloat(sumSent["bits_per_second"]); val > 0 {
res.Upload = round2(val / 1000 / 1000)
}
if val := convertFloat(sumSent["jitter_ms"]); val > 0 {
res.JitterMs = floatPtr(round2(val))
}
if val := convertFloat(sumSent["lost_percent"]); val > 0 {
res.PacketLossPct = floatPtr(round2(val))
}
}
if sumRecv, ok := end["sum_received"].(map[string]interface{}); ok {
if val := convertFloat(sumRecv["bits_per_second"]); val > 0 {
res.Download = round2(val / 1000 / 1000)
}
}
}
res.Score = round2(averagePositive(res.Upload, res.Download))
// Ping test
if cfg.Benchmarks.Network.Server != "" {
if ping := measurePing(ctx, cfg.Benchmarks.Network.Server); ping > 0 {
res.PingMs = round2(ping)
}
}
return res
}
func measurePing(ctx context.Context, target string) float64 {
out, err := safeRun(ctx, "ping", "-c", "1", "-W", "1", target)
if err != nil {
return 0
}
re := regexp.MustCompile(`time=([\d\.]+)\s*ms`)
if matches := re.FindStringSubmatch(out); len(matches) > 1 {
if val, err := strconv.ParseFloat(matches[1], 64); err == nil {
return val
}
}
return 0
}
// ==========================================
// 6. MAIN ORCHESTRATOR
// ==========================================
const defaultConfigYAML = `
config_version: 1
runtime:
max_total_runtime_s: 480
command_timeout_s: 30
temp_dir: "/tmp"
backend:
url: "http://10.0.0.50:8007/api/benchmark"
logging:
level: info
progress: true
show_command_lines: false
capture_raw_output: true
raw_output_max_kb: 5120
save_payload_to_tmp: true
payload_tmp_dir: "/tmp"
payload_filename_prefix: "bench_payload_"
interactive_pause_on_debug: true
payload:
dir: "."
prefix: "bench_payload_"
always_save: true
collection:
system: { enabled: true }
cpu: { enabled: true }
ram: { enabled: true }
storage: { enabled: true }
network: { enabled: true }
raw_info:
enabled: true
include: [lscpu, lsblk, dmidecode]
max_size_kb: 5120
benchmarks:
enabled: true
weights: { cpu: 0.4, memory: 0.2, disk: 0.2, network: 0.1, gpu: 0.1 }
cpu_sysbench:
enabled: true
params: { cpu_max_prime: 20000 }
memory_sysbench:
enabled: true
params: { total_size: "1G" }
disk_fio:
enabled: true
safety: { max_runtime_s: 60 }
network_iperf3:
enabled: true
server: "127.0.0.1" # Default pour test
port: 5201
params: { duration_s: 10 }
`
func calculateScore(h Hardware, r Results) float64 {
// Normalisation arbitraire pour l'exemple
// CPU: assume 2000 events/sec is 100 pts
cpuScore := ((r.CPU.ScoreSingle + r.CPU.ScoreMulti) / 2) / 20
memScore := r.Memory.Throughput / 100 // assume 1000 MB/s is 100 pts
diskScore := r.Disk.Score / 10 // assume 500 MB/s is 50 pts
netScore := r.Network.Score / 10 // assume 1000 Mbps is 100 pts
gpuScore := 0.0
if r.GPU.Score != nil {
gpuScore = *r.GPU.Score
}
w := cfg.Benchmarks.Weights
debugf("Poids benchmarks : %+v", w)
debugf("Scores normalisés : cpu=%.2f mem=%.2f disk=%.2f net=%.2f", cpuScore, memScore, diskScore, netScore)
return (cpuScore*w["cpu"] + memScore*w["memory"] + diskScore*w["disk"] + netScore*w["network"] + gpuScore*w["gpu"]) * 100
}
func persistPayload(data []byte) string {
cacheDir := cfg.Payload.Dir
if cacheDir == "" {
if wd, err := os.Getwd(); err == nil {
cacheDir = wd
} else {
fmt.Printf("WARN: impossible de déterminer le dossier d'exécution : %v\n", err)
cacheDir = os.TempDir()
}
}
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
fmt.Printf("WARN: impossible de créer %s : %v\n", cacheDir, err)
return ""
}
prefix := cfg.Payload.Prefix
if prefix == "" {
prefix = cfg.Logging.PayloadFilenamePrefix
}
if prefix == "" {
prefix = "bench_payload_"
}
filename := prefix + "last.json"
path := filepath.Join(cacheDir, filename)
if err := os.WriteFile(path, data, 0o777); err != nil {
fmt.Printf("WARN: impossible d'écrire %s : %v\n", path, err)
return ""
}
debugf("Payload sauvegardé au format JSON dans %s (%d octets)", path, len(data))
return path
}
func main() {
// Flags
configURL := flag.String("config-url", "", "URL de la config distante")
flag.BoolVar(&debug, "debug", false, "Mode debug")
flag.BoolVar(&dryRun, "dry-run", false, "Simulation d'exécution")
flag.Parse()
defer closeDebugLog()
// 1. Chargement Config
fmt.Println("[1/6] Configuration...")
if err := loadConfig(*configURL); err != nil {
fmt.Printf("FATAL: Erreur chargement config: %v\n", err)
os.Exit(1)
}
printProgress(1, 6, "Configuration", "OK")
debugf("Configuration complète : %+v", cfg)
// 2. Contexte global
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Runtime.MaxTotalRuntime)*time.Second)
defer cancel()
// 3. Collecte Info
fmt.Println("[2/6] Collecte informations système...")
debugf("[2/6] Début collecte CPU")
cpuDetails := collectCPUInfo(ctx)
debugf("[2/6] Détails CPU: %+v", cpuDetails)
debugf("[2/6] Début collecte RAM")
ramDetails := collectRAMInfo(ctx)
debugf("[2/6] Détails RAM: %+v", ramDetails)
debugf("[2/6] Début collecte GPU")
gpuDetails := collectGPUInfo(ctx)
debugf("[2/6] Détails GPU: %+v", gpuDetails)
debugf("[2/6] Début collecte stockage")
storageDetails := collectStorageInfo(ctx)
debugf("[2/6] Détails stockage: %+v", storageDetails)
debugf("[2/6] Début collecte PCI devices")
pciDetails := collectPCIDevices(ctx)
debugf("[2/6] Détails PCI: %+v", pciDetails)
debugf("[2/6] Début collecte USB devices")
usbDetails := collectUSBDevices(ctx)
debugf("[2/6] Détails USB: %+v", usbDetails)
debugf("[2/6] Début collecte partages réseau")
networkShares := collectNetworkShares(ctx)
debugf("[2/6] Détails partages réseau: %+v", networkShares)
debugf("[2/6] Début collecte interfaces réseau")
networkDetails := collectNetworkInfo(ctx)
debugf("[2/6] Détails réseau: %+v", networkDetails)
debugf("[2/6] Début collecte carte mère")
motherboardInfo := collectMotherboardInfo(ctx)
debugf("[2/6] Détails carte mère: %+v", motherboardInfo)
debugf("[2/6] Début collecte OS")
osInfo := collectOSInfo(ctx)
debugf("[2/6] Détails OS: %+v", osInfo)
debugf("[2/6] Début collecte capteurs")
sensorInfo := collectSensors(ctx)
debugf("[2/6] Détails capteurs: %+v", sensorInfo)
debugf("[2/6] Début collecte audio")
audioInfo := collectAudioInfo(ctx)
debugf("[2/6] Détails audio: %+v", audioInfo)
hw := Hardware{
CPU: cpuDetails,
RAM: ramDetails,
GPU: gpuDetails,
Storage: storageDetails,
PCIDevices: pciDetails,
USBDevices: usbDetails,
NetworkShares: networkShares,
Network: networkDetails,
Motherboard: motherboardInfo,
OS: osInfo,
Sensors: sensorInfo,
Audio: audioInfo,
}
if cfg.Logging.CaptureRawOutput {
hw.RawInfo = collectRawInfo(ctx, cfg.RawInfo.Include, cfg.RawInfo.MaxSizeKB)
}
printProgress(2, 6, "Collecte", "OK")
debugf("Matériel collecté : %+v", hw)
// 4. Benchmarks
fmt.Println("[3/6] Exécution Benchmarks...")
debugf("[3/6] Benchmark CPU démarré")
cpuResult := runCPUBench(ctx)
debugf("[3/6] Résultat CPU: %+v", cpuResult)
debugf("[3/6] Benchmark mémoire démarré")
memResult := runMemBench(ctx)
debugf("[3/6] Résultat mémoire: %+v", memResult)
debugf("[3/6] Benchmark disque démarré")
diskResult := runDiskBench(ctx)
debugf("[3/6] Résultat disque: %+v", diskResult)
debugf("[3/6] Benchmark réseau démarré")
netResult := runNetBench(ctx)
debugf("[3/6] Résultat réseau: %+v", netResult)
results := Results{
CPU: cpuResult,
Memory: memResult,
Disk: diskResult,
Network: netResult,
}
printProgress(3, 6, "Benchmarks", "OK")
debugf("Résultats des benchmarks : %+v", results)
// 5. Score
fmt.Println("[4/6] Calcul Score...")
results.GlobalScore = calculateScore(hw, results)
printProgress(4, 6, "Score", fmt.Sprintf("%.2f", results.GlobalScore))
debugf("Score global calculé : %.2f", results.GlobalScore)
// 6. Payload & Envoi
rawData := hw.RawInfo
if rawData == nil {
rawData = make(map[string]string)
}
if debug {
rawData["debug"] = "active"
}
payload := FinalPayload{
DeviceIdentifier: hw.OS.Hostname,
BenchScriptVersion: benchScriptVersion,
BenchClientVersion: benchClientVersion,
Hardware: hw,
Results: results,
RawInfo: rawData,
}
jsonData, err := json.MarshalIndent(payload, "", " ")
if err != nil {
fmt.Printf("FATAL: JSON error: %v\n", err)
os.Exit(1)
}
printProgress(5, 6, "JSON", "OK")
debugf("Payload JSON (%d octets) : %s", len(jsonData), string(jsonData))
fmt.Println("[6/6] Envoi Backend...")
if !dryRun {
token, tokenSet := os.LookupEnv("API_TOKEN")
if !tokenSet {
token = "test_hardware_perf"
fmt.Println("INFO: API_TOKEN absent, envoi avec token de test 'test_hardware_perf'.")
} else {
debugf("API_TOKEN fourni via l'environnement.")
}
req, _ := http.NewRequest("POST", cfg.Backend.URL, bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Do(req)
if err != nil {
printProgress(6, 6, "Envoi HTTP", "ERROR")
fmt.Printf("Erreur: %v\n", err)
} else {
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
printProgress(6, 6, "Envoi HTTP", "OK")
} else {
printProgress(6, 6, "Envoi HTTP", fmt.Sprintf("FAIL %d", resp.StatusCode))
}
}
} else {
fmt.Println("DRY RUN: Payload non envoyé.")
fmt.Println(string(jsonData))
}
if path := persistPayload(jsonData); path != "" {
fmt.Printf("INFO: Payload enregistré dans %s\n", path)
}
}