build:usb-image — Final USB image assembly script, Ventoy config, and Hermes agent runtime structure
- build/usb-image.sh: Main USB image assembler with artifact validation, ISO building, disk image creation, and USB write support - build/ventoy.json: Ventoy configuration for multi-boot USB - src/hermes/config.yaml: Portable Hermes agent configuration - src/hermes/autorun.sh: Auto-launch script with full diagnostic pipeline - Makefile: Build system with iso/image/check/clean/write targets Generates fallback stubs for any diagnostic modules whose upstream build tasks haven't completed yet, ensuring the image boots even during active development.
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Hermes Portable Rescue — Build Hermes Runtime
|
||||
# =============================================================================
|
||||
# Builds the portable Hermes Agent runtime for USB deployment.
|
||||
# Run this script from the project root to produce src/hermes/ content.
|
||||
#
|
||||
# Usage:
|
||||
# ./src/build-hermes-runtime.sh [--output DIR]
|
||||
#
|
||||
# The resulting directory can be copied to a USB drive, SD card, or ISO.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
# --- Config ------------------------------------------------------------------
|
||||
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/hermes}"
|
||||
PYTHON_VERSION="${PYTHON_VERSION:-3.11}"
|
||||
VENV_DIR="$OUTPUT_DIR/venv"
|
||||
TOOLS_DIR="$OUTPUT_DIR/tools"
|
||||
SCRIPTS_DIR="$OUTPUT_DIR/scripts"
|
||||
LIB_DIR="$OUTPUT_DIR/lib"
|
||||
MODELS_DIR="$OUTPUT_DIR/models"
|
||||
CONFIG_DIR="$OUTPUT_DIR"
|
||||
|
||||
# Source for static tool binaries (host system or prebuilt cache)
|
||||
TOOL_CACHE="${TOOL_CACHE:-$PROJECT_ROOT/tools/prebuilt}"
|
||||
|
||||
# Colors
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||
|
||||
# --- Preflight ---------------------------------------------------------------
|
||||
info "Building Hermes Portable Rescue Runtime"
|
||||
info " Output: $OUTPUT_DIR"
|
||||
info " Python: $PYTHON_VERSION"
|
||||
|
||||
command -v python3 >/dev/null 2>&1 || error "python3 not found"
|
||||
command -v pip3 >/dev/null 2>&1 || error "pip3 not found"
|
||||
command -v curl >/dev/null 2>&1 || warn "curl not found — LLM download will fail"
|
||||
|
||||
PYVER="$(python3 --version 2>&1)"
|
||||
info "Host Python: $PYVER"
|
||||
|
||||
if [[ ! "$PYVER" =~ Python\ 3\.(1[1-9]|2[0-9]) ]]; then
|
||||
warn "Python $PYVER detected — recommended >=3.11"
|
||||
fi
|
||||
|
||||
# --- Step 1: Create directory structure --------------------------------------
|
||||
info "Creating directory structure…"
|
||||
mkdir -p "$VENV_DIR" "$TOOLS_DIR" "$SCRIPTS_DIR" "$LIB_DIR" "$MODELS_DIR"
|
||||
|
||||
# --- Step 2: Static config files (copy from source) --------------------------
|
||||
info "Copying configuration…"
|
||||
CONFIG_SRC="$SCRIPT_DIR/hermes"
|
||||
if [[ -d "$CONFIG_SRC" ]]; then
|
||||
cp -v "$CONFIG_SRC/config.yaml" "$CONFIG_DIR/config.yaml" 2>/dev/null || warn "config.yaml not found"
|
||||
cp -v "$CONFIG_SRC/tool-manifest.json" "$CONFIG_DIR/tool-manifest.json" 2>/dev/null || warn "tool-manifest.json not found"
|
||||
cp -v "$CONFIG_SRC/launch.sh" "$CONFIG_DIR/launch.sh" 2>/dev/null || warn "launch.sh not found"
|
||||
cp -v "$CONFIG_SRC/launch.ps1" "$CONFIG_DIR/launch.ps1" 2>/dev/null || warn "launch.ps1 not found"
|
||||
# Copy scripts
|
||||
if [[ -d "$CONFIG_SRC/scripts" ]]; then
|
||||
cp -r "$CONFIG_SRC/scripts/"* "$SCRIPTS_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
else
|
||||
warn "Config source $CONFIG_SRC not found — skipping"
|
||||
fi
|
||||
|
||||
# --- Step 3: Bootstrap Python venv -------------------------------------------
|
||||
if [[ ! -f "$VENV_DIR/bin/python3" ]]; then
|
||||
info "Creating Python $PYTHON_VERSION virtual environment…"
|
||||
python3 -m venv "$VENV_DIR" --clear
|
||||
else
|
||||
info "Virtual environment already exists — skipping create"
|
||||
fi
|
||||
|
||||
info "Installing Hermes Agent and dependencies…"
|
||||
"$VENV_DIR/bin/pip3" install --upgrade pip setuptools wheel 2>&1 | tail -1
|
||||
"$VENV_DIR/bin/pip3" install --no-cache-dir \
|
||||
hermes-agent \
|
||||
pyyaml \
|
||||
psutil \
|
||||
requests \
|
||||
2>&1 | tail -3
|
||||
|
||||
# Record installed version for provenance
|
||||
"$VENV_DIR/bin/pip3" freeze > "$OUTPUT_DIR/pip-freeze.txt"
|
||||
|
||||
info "Pruning venv to reduce size…"
|
||||
# Remove cache, docs, test dirs, __pycache__
|
||||
find "$VENV_DIR" -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type d -name 'tests' -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$VENV_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
rm -rf "$VENV_DIR/share" 2>/dev/null || true
|
||||
|
||||
# --- Step 4: Collect portable diagnostic tools -------------------------------
|
||||
if [[ -d "$TOOL_CACHE" ]]; then
|
||||
info "Copying prebuilt diagnostic tools from $TOOL_CACHE…"
|
||||
cp -r "$TOOL_CACHE/"* "$TOOLS_DIR/" 2>/dev/null || warn "No tools in cache"
|
||||
else
|
||||
info "No tool cache at $TOOL_CACHE — tools must be fetched at runtime"
|
||||
# Create a stub script that fetches tools on first run
|
||||
cat > "$TOOLS_DIR/fetch-tools.sh" << 'TOOLEOF'
|
||||
#!/usr/bin/env bash
|
||||
# Fetch portable diagnostic tools — run once on target
|
||||
set -euo pipefail
|
||||
TOOLS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
echo "[hermes] Fetching diagnostic tools…"
|
||||
# smartmontools (static build)
|
||||
if [[ ! -f "$TOOLS_DIR/smartctl" ]]; then
|
||||
echo " Downloading smartmontools…"
|
||||
curl -sL "https://static.smartmontools.org/smartctl-x86_64" -o "$TOOLS_DIR/smartctl"
|
||||
chmod +x "$TOOLS_DIR/smartctl"
|
||||
fi
|
||||
# stress-ng
|
||||
if [[ ! -f "$TOOLS_DIR/stress-ng" ]]; then
|
||||
echo " Downloading stress-ng…"
|
||||
curl -sL "https://github.com/ColinIanKing/stress-ng/raw/master/stress-ng" -o "$TOOLS_DIR/stress-ng" 2>/dev/null && chmod +x "$TOOLS_DIR/stress-ng" || echo " (stress-ng not available as single binary — will use system package)"
|
||||
fi
|
||||
echo "[hermes] Tools ready."
|
||||
TOOLEOF
|
||||
chmod +x "$TOOLS_DIR/fetch-tools.sh"
|
||||
fi
|
||||
|
||||
# --- Step 5: Create launch script (if not already copied) --------------------
|
||||
if [[ ! -f "$OUTPUT_DIR/launch.sh" ]]; then
|
||||
cat > "$OUTPUT_DIR/launch.sh" << 'LAUNCHEOF'
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Hermes Portable Rescue — Launch Script (Linux boot environment)
|
||||
# =============================================================================
|
||||
# Source this or run directly to start the Hermes agent.
|
||||
# Assumes: Python 3.11+ venv at HERMES_ROOT/venv
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
export HERMES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export HERMES_CONFIG="$HERMES_ROOT/config.yaml"
|
||||
export PATH="$HERMES_ROOT/venv/bin:$HERMES_ROOT/tools:$PATH"
|
||||
|
||||
echo "╔══════════════════════════════════════════════╗"
|
||||
echo "║ Hermes Portable Rescue — Runtime v1.0.0 ║"
|
||||
echo "╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Root: $HERMES_ROOT"
|
||||
echo "Config: $HERMES_CONFIG"
|
||||
|
||||
# --- Probe network -----------------------------------------------------------
|
||||
echo ""
|
||||
echo "[*] Checking environment…"
|
||||
|
||||
if command -v smartctl &>/dev/null; then
|
||||
echo " ✓ smartctl (S.M.A.R.T.)"
|
||||
else
|
||||
echo " ✗ smartctl not found — run tools/fetch-tools.sh"
|
||||
fi
|
||||
|
||||
if command -v stress-ng &>/dev/null; then
|
||||
echo " ✓ stress-ng"
|
||||
fi
|
||||
|
||||
if command -v dmidecode &>/dev/null; then
|
||||
echo " ✓ dmidecode"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[*] Starting Hermes Agent…"
|
||||
|
||||
# Launch the agent
|
||||
hermes --config "$HERMES_CONFIG"
|
||||
LAUNCHEOF
|
||||
chmod +x "$OUTPUT_DIR/launch.sh"
|
||||
fi
|
||||
|
||||
# --- Step 6: Create Windows launch script (if not already copied) ------------
|
||||
if [[ ! -f "$OUTPUT_DIR/launch.ps1" ]]; then
|
||||
cat > "$OUTPUT_DIR/launch.ps1" << 'PS1EOF'
|
||||
# Hermes Portable Rescue — Launch Script (Windows / WinPE)
|
||||
# Run: powershell -ExecutionPolicy Bypass -File .\launch.ps1
|
||||
param(
|
||||
[string]$HermesRoot = $PSScriptRoot
|
||||
)
|
||||
|
||||
$env:HERMES_ROOT = $HermesRoot
|
||||
$env:HERMES_CONFIG = Join-Path $HermesRoot "config.yaml"
|
||||
$env:Path = "$HermesRoot\venv\Scripts;$HermesRoot\tools;$env:Path"
|
||||
|
||||
Write-Host "╔══════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||
Write-Host "║ Hermes Portable Rescue — Runtime v1.0.0 ║" -ForegroundColor Cyan
|
||||
Write-Host "╚══════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Root: $HermesRoot"
|
||||
|
||||
# Check Python availability
|
||||
$python = Get-Command "python.exe" -ErrorAction SilentlyContinue
|
||||
if (-not $python) {
|
||||
Write-Host "[ERROR] Python not found in $HermesRoot\venv" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[*] Python: $($python.Path)"
|
||||
|
||||
# Launch agent
|
||||
Write-Host "[*] Starting Hermes Agent..."
|
||||
& "$HermesRoot\venv\Scripts\hermes.exe" --config "$env:HERMES_CONFIG"
|
||||
PS1EOF
|
||||
fi
|
||||
|
||||
# --- Step 7: Generate tool manifest ------------------------------------------
|
||||
if [[ ! -f "$OUTPUT_DIR/tool-manifest.json" ]]; then
|
||||
cat > "$OUTPUT_DIR/tool-manifest.json" << 'MANIFEST'
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_by": "build-hermes-runtime.sh",
|
||||
"tools": {
|
||||
"smartctl": {
|
||||
"description": "S.M.A.R.T. hard drive health check",
|
||||
"expected_path": "tools/smartctl",
|
||||
"type": "binary",
|
||||
"test_command": "smartctl --version",
|
||||
"source": "https://static.smartmontools.org/"
|
||||
},
|
||||
"stress-ng": {
|
||||
"description": "CPU and memory stress testing",
|
||||
"expected_path": "tools/stress-ng",
|
||||
"type": "binary",
|
||||
"test_command": "stress-ng --version",
|
||||
"source": "https://github.com/ColinIanKing/stress-ng"
|
||||
},
|
||||
"dmidecode": {
|
||||
"description": "Hardware inventory (DMI/SMBIOS)",
|
||||
"expected_path": "/usr/sbin/dmidecode",
|
||||
"type": "system_package",
|
||||
"test_command": "dmidecode --version",
|
||||
"notes": "Usually pre-installed on Linux live environments"
|
||||
},
|
||||
"lshw": {
|
||||
"description": "Full hardware listing",
|
||||
"expected_path": "/usr/bin/lshw",
|
||||
"type": "system_package",
|
||||
"test_command": "lshw -version",
|
||||
"notes": "May need 'lshw' package in live env"
|
||||
},
|
||||
"memtester": {
|
||||
"description": "Userspace memory tester",
|
||||
"expected_path": "tools/memtester",
|
||||
"type": "binary",
|
||||
"test_command": "memtester --version",
|
||||
"source": "https://pyropus.ca/software/memtester/"
|
||||
}
|
||||
}
|
||||
}
|
||||
MANIFEST
|
||||
fi
|
||||
|
||||
# --- Step 8: Create USB autorun.inf ------------------------------------------
|
||||
if [[ ! -f "$PROJECT_ROOT/src/autorun.inf" ]]; then
|
||||
cat > "$PROJECT_ROOT/src/autorun.inf" << 'AUTORUN'
|
||||
[Autorun]
|
||||
Action=Start Hermes Portable Rescue
|
||||
Label=Hermes Rescue USB
|
||||
Icon=hermes\hermes.ico
|
||||
Open=hermes\launch.ps1
|
||||
UseAutoPlay=1
|
||||
AUTORUN
|
||||
fi
|
||||
|
||||
# --- Step 9: Create README for the runtime -----------------------------------
|
||||
if [[ ! -f "$OUTPUT_DIR/README.md" ]]; then
|
||||
cat > "$OUTPUT_DIR/README.md" << 'README'
|
||||
# Hermes Portable Rescue — Runtime Package
|
||||
|
||||
This directory contains the portable Hermes Agent runtime.
|
||||
It is designed to be copied onto a USB drive and booted on any Windows PC.
|
||||
|
||||
## Contents
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `venv/` | Portable Python 3.11 virtual environment with Hermes Agent |
|
||||
| `config.yaml` | Hermes Agent configuration for rescue mode |
|
||||
| `launch.sh` | Linux / Linux-live boot entry point |
|
||||
| `launch.ps1` | Windows / WinPE entry point |
|
||||
| `tools/` | Portable diagnostic binaries (smartctl, stress-ng, etc.) |
|
||||
| `scripts/` | Diagnostic helper scripts |
|
||||
| `models/` | (optional) Local LLM model files |
|
||||
| `tool-manifest.json` | Tool registry for Hermes Agent capabilities |
|
||||
| `pip-freeze.txt` | Installed Python packages manifest |
|
||||
|
||||
## Quick Start
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
cd /path/to/hermes
|
||||
./launch.sh
|
||||
```
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\launch.ps1
|
||||
```
|
||||
|
||||
## Size Budget
|
||||
|
||||
| Component | Approx Size |
|
||||
|-----------|-------------|
|
||||
| Python venv (pruned) | 60-80 MB |
|
||||
| Hermes Agent + deps | 20-30 MB |
|
||||
| Tools (smartctl, stress-ng) | 10-15 MB |
|
||||
| Models (optional) | 2-5 GB |
|
||||
| **Total (minimal)** | **~100 MB** |
|
||||
|
||||
## Notes
|
||||
|
||||
- The venv is created for the **host architecture** (x86_64).
|
||||
- For ARM64 targets, build on an ARM machine or use QEMU user-mode.
|
||||
- LLM model files go in `models/` if running local inference.
|
||||
README
|
||||
fi
|
||||
|
||||
# --- Summary ----------------------------------------------------------------
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " Hermes Portable Rescue Runtime — Built!"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo "Output: $OUTPUT_DIR"
|
||||
echo "Size: $(du -sh "$OUTPUT_DIR" | cut -f1)"
|
||||
echo "Python: $("$VENV_DIR/bin/python3" --version 2>/dev/null || echo 'not built')"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Copy $OUTPUT_DIR to your USB drive"
|
||||
echo " 2. Add diagnostic tools via tools/fetch-tools.sh"
|
||||
echo " 3. (Optional) Add LLM model to models/"
|
||||
echo " 4. Boot target machine and run: ./launch.sh"
|
||||
echo ""
|
||||
@@ -0,0 +1,28 @@
|
||||
"""
|
||||
Hermes Portable Rescue — Diagnostic modules.
|
||||
|
||||
Modules
|
||||
-------
|
||||
hardware : HardwareInventory
|
||||
CPU, RAM, GPU, disk enumeration and SMART health checks.
|
||||
stress : RAMStressTester, CPUStressTester, GPUStressTester
|
||||
Stress-test wrappers (available when stress.py is present).
|
||||
|
||||
Each submodule exposes a ``run() -> dict`` for JSON and a
|
||||
``text_report() -> str`` for human consumption.
|
||||
"""
|
||||
from src.diagnostics.hardware import HardwareInventory
|
||||
|
||||
try:
|
||||
from src.diagnostics.stress import (
|
||||
CPUStressTester,
|
||||
GPUStressTester,
|
||||
RAMStressTester,
|
||||
run_all_stress_tests,
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__all__ = [
|
||||
"HardwareInventory",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,583 @@
|
||||
"""
|
||||
Hermes Portable Rescue — Hardware Inventory & Health Checks.
|
||||
|
||||
Enumerates and assesses CPU, RAM, GPU, and disk subsystems from a
|
||||
Linux-based rescue environment. Wraps CLI tools (dmidecode, lshw,
|
||||
smartctl, lscpu) and falls back to /sys and /proc where possible.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from diagnostics.hardware import HardwareInventory
|
||||
|
||||
hw = HardwareInventory(log_warnings=True)
|
||||
report = hw.run() # dict, JSON-serialisable
|
||||
print(hw.text_report()) # human-readable summary
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field, fields, is_dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _asdict(obj) -> dict[str, Any]:
|
||||
"""Recursive dataclass -> dict, filtering None and empty containers."""
|
||||
if not is_dataclass(obj):
|
||||
return obj
|
||||
out = {}
|
||||
for f in fields(obj):
|
||||
val = getattr(obj, f.name)
|
||||
if isinstance(val, list):
|
||||
val = [_asdict(v) if is_dataclass(v) else v for v in val]
|
||||
if not val:
|
||||
continue
|
||||
elif is_dataclass(val):
|
||||
val = _asdict(val)
|
||||
if not any(val.values()):
|
||||
continue
|
||||
elif val is None:
|
||||
continue
|
||||
out[f.name] = val
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUInfo:
|
||||
"""Central processing unit details."""
|
||||
|
||||
model: str = ""
|
||||
architecture: str = ""
|
||||
cores: int = 0
|
||||
threads: int = 0
|
||||
max_speed_mhz: Optional[float] = None
|
||||
min_speed_mhz: Optional[float] = None
|
||||
cache_l2_kb: Optional[int] = None
|
||||
cache_l3_kb: Optional[int] = None
|
||||
flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RAMModule:
|
||||
"""A single physical DIMM / soldered memory module."""
|
||||
|
||||
size_gb: float = 0
|
||||
type: str = ""
|
||||
speed_mhz: Optional[int] = None
|
||||
slot: str = ""
|
||||
manufacturer: str = ""
|
||||
part_number: str = ""
|
||||
serial: str = ""
|
||||
voltage_v: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RAMInfo:
|
||||
"""Overall RAM configuration."""
|
||||
|
||||
total_gb: float = 0
|
||||
slots_used: int = 0
|
||||
slots_total: int = 0
|
||||
modules: list[RAMModule] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GPUInfo:
|
||||
"""Graphics processing unit details."""
|
||||
|
||||
model: str = ""
|
||||
vendor: str = ""
|
||||
vram_mb: Optional[int] = None
|
||||
driver: str = ""
|
||||
pci_slot: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskSMART:
|
||||
"""SMART health summary for one disk."""
|
||||
|
||||
status: str = "UNKNOWN"
|
||||
model: str = ""
|
||||
serial: str = ""
|
||||
size_gb: float = 0
|
||||
interface: str = ""
|
||||
reallocated_sectors: Optional[int] = None
|
||||
pending_sectors: Optional[int] = None
|
||||
temperature_c: Optional[float] = None
|
||||
power_on_hours: Optional[int] = None
|
||||
power_cycles: Optional[int] = None
|
||||
raw_read_error_rate: Optional[int] = None
|
||||
wear_level: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskInfo:
|
||||
"""One block device with its SMART data."""
|
||||
|
||||
device: str = ""
|
||||
smart: DiskSMART = field(default_factory=DiskSMART)
|
||||
|
||||
|
||||
@dataclass
|
||||
class HardwareReport:
|
||||
"""Complete snapshot of all subsystems."""
|
||||
|
||||
cpu: CPUInfo = field(default_factory=CPUInfo)
|
||||
ram: RAMInfo = field(default_factory=RAMInfo)
|
||||
gpus: list[GPUInfo] = field(default_factory=list)
|
||||
disks: list[DiskInfo] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shell helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run(cmd: list[str], timeout: float = 15.0) -> str:
|
||||
"""Run a command, return stdout, or empty string on failure."""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return res.stdout
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.CalledProcessError):
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_int(value: str | None) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_float(value: str | None) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _find_sys_block() -> list[Path]:
|
||||
"""List physical block devices (exclude ram, loop, dm, zram)."""
|
||||
skip_prefixes = ("ram", "loop", "dm-", "zram", "sr", "md")
|
||||
devices = []
|
||||
for entry in Path("/sys/block").iterdir():
|
||||
name = entry.name
|
||||
if any(name.startswith(p) for p in skip_prefixes):
|
||||
continue
|
||||
devices.append(entry)
|
||||
return sorted(devices)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collectors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def get_cpu_info(errors: list[str]) -> CPUInfo:
|
||||
"""Collect CPU details from lscpu and /proc/cpuinfo."""
|
||||
info = CPUInfo()
|
||||
|
||||
out = _run(["lscpu"])
|
||||
if out:
|
||||
for line in out.splitlines():
|
||||
m = re.match(r"Model\s*name[:\s]+(.+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.model = m.group(1).strip()
|
||||
m = re.match(r"Architecture[:\s]+(.+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.architecture = m.group(1).strip()
|
||||
m = re.match(r"^CPU\(s\)[:\s]+(\d+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.threads = int(m.group(1))
|
||||
m = re.match(r"Core\(s\)\s*per\s*socket[:\s]+(\d+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.cores = int(m.group(1))
|
||||
m = re.match(r"CPU\s*max\s*MHz[:\s]+([\d.]+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.max_speed_mhz = round(float(m.group(1)))
|
||||
m = re.match(r"CPU\s*min\s*MHz[:\s]+([\d.]+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.min_speed_mhz = round(float(m.group(1)))
|
||||
m = re.match(r"L2\s*cache[:\s]+(\d+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.cache_l2_kb = int(m.group(1))
|
||||
m = re.match(r"L3\s*cache[:\s]+(\d+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.cache_l3_kb = int(m.group(1))
|
||||
m = re.match(r"Flags[:\s]+(.+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
info.flags = m.group(1).strip().split()
|
||||
|
||||
if not info.model:
|
||||
try:
|
||||
cpuinfo = Path("/proc/cpuinfo").read_text()
|
||||
for line in cpuinfo.splitlines():
|
||||
m = re.match(r"model\s*name\s*:\s*(.+)", line, re.IGNORECASE)
|
||||
if m and not info.model:
|
||||
info.model = m.group(1).strip()
|
||||
cores = set()
|
||||
threads = 0
|
||||
for line in cpuinfo.splitlines():
|
||||
m = re.match(r"core\s*id\s*:\s*(\d+)", line)
|
||||
if m:
|
||||
cores.add(m.group(1))
|
||||
m2 = re.match(r"processor\s*:\s*(\d+)", line)
|
||||
if m2:
|
||||
threads = max(threads, int(m2.group(1)) + 1)
|
||||
if cores and not info.cores:
|
||||
info.cores = len(cores)
|
||||
if threads and not info.threads:
|
||||
info.threads = threads
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
|
||||
def get_ram_info(errors: list[str]) -> RAMInfo:
|
||||
"""Collect RAM configuration from dmidecode."""
|
||||
info = RAMInfo()
|
||||
|
||||
out = _run(["dmidecode", "--type", "17"], timeout=10.0)
|
||||
if not out:
|
||||
errors.append("dmidecode not available or returned no memory info")
|
||||
return info
|
||||
|
||||
blocks = re.split(r"\n\s*\n", out)
|
||||
current: dict[str, str] = {}
|
||||
|
||||
for block in blocks:
|
||||
if "Memory Device" not in block:
|
||||
continue
|
||||
current.clear()
|
||||
for line in block.splitlines():
|
||||
m = re.match(r"\s*(\w[\w\s]*?):\s+(.*)", line)
|
||||
if m:
|
||||
key = m.group(1).strip()
|
||||
val = m.group(2).strip()
|
||||
current[key] = val
|
||||
|
||||
size_str = current.get("Size", "No Module Installed")
|
||||
if not size_str or "No Module Installed" in size_str or "Not Installed" in size_str:
|
||||
continue
|
||||
|
||||
sm = re.match(r"(\d+)\s*(MB|GB)", size_str)
|
||||
if not sm:
|
||||
continue
|
||||
num = int(sm.group(1))
|
||||
unit = sm.group(2)
|
||||
size_mb = float(num) * 1024 if unit == "GB" else float(num)
|
||||
|
||||
module = RAMModule(
|
||||
size_gb=round(size_mb / 1024, 1),
|
||||
type=current.get("Type", ""),
|
||||
speed_mhz=_parse_int(current.get("Speed")),
|
||||
slot=current.get("Locator", ""),
|
||||
manufacturer=current.get("Manufacturer", ""),
|
||||
part_number=current.get("Part Number", ""),
|
||||
serial=current.get("Serial Number", ""),
|
||||
voltage_v=_parse_float(current.get("Configured Voltage", current.get("Voltage", ""))),
|
||||
)
|
||||
info.modules.append(module)
|
||||
info.total_gb += module.size_gb
|
||||
|
||||
info.slots_used = len(info.modules)
|
||||
|
||||
slots_out = _run(["dmidecode", "--type", "16"], timeout=5.0)
|
||||
if slots_out:
|
||||
for line in slots_out.splitlines():
|
||||
m = re.match(r"\s*Number\s+Of\s+Devices\s*:\s*(\d+)", line)
|
||||
if m:
|
||||
info.slots_total = int(m.group(1))
|
||||
else:
|
||||
info.slots_total = info.slots_used
|
||||
|
||||
info.total_gb = round(info.total_gb, 1)
|
||||
return info
|
||||
|
||||
|
||||
def get_gpu_info(errors: list[str]) -> list[GPUInfo]:
|
||||
"""Collect GPU information from lspci and lshw."""
|
||||
gpus: list[GPUInfo] = []
|
||||
|
||||
out = _run(["lspci", "-mm"])
|
||||
if not out:
|
||||
errors.append("lspci not available — cannot enumerate PCI devices")
|
||||
return gpus
|
||||
|
||||
gpu_lines = [line for line in out.splitlines() if "VGA" in line or "3D" in line or "Display" in line]
|
||||
|
||||
for line in gpu_lines:
|
||||
parts = line.strip().split('"')
|
||||
if len(parts) < 3:
|
||||
continue
|
||||
raw_desc = parts[1] if len(parts) > 1 else ""
|
||||
vendor = parts[3] if len(parts) > 3 else ""
|
||||
model_raw = parts[5] if len(parts) > 5 else ""
|
||||
pci_slot = parts[0].strip().split("pci@")[-1].strip() if "pci@" in parts[0] else parts[0].strip()
|
||||
|
||||
gpu = GPUInfo(model=model_raw.strip(), vendor=vendor.strip(), pci_slot=pci_slot)
|
||||
gpus.append(gpu)
|
||||
|
||||
lshw_out = _run(["lshw", "-c", "display"], timeout=10.0)
|
||||
for gpu in gpus:
|
||||
if lshw_out:
|
||||
blocks = re.split(r"\*\-\w+", lshw_out)
|
||||
for block in blocks:
|
||||
if gpu.pci_slot and gpu.pci_slot in block:
|
||||
m = re.search(r"size:\s*(\d+)([KMGT]i?B?)", block, re.IGNORECASE)
|
||||
if m:
|
||||
size_val = int(m.group(1))
|
||||
unit = m.group(2).upper()
|
||||
unit_map = {"KB": 1, "KIB": 1, "MB": 1024, "MIB": 1024, "GB": 1024**2, "GIB": 1024**2, "TB": 1024**3, "TIB": 1024**3}
|
||||
vram_kb = size_val * unit_map.get(unit, 1)
|
||||
if vram_kb >= 1024:
|
||||
gpu.vram_mb = vram_kb // 1024
|
||||
break
|
||||
|
||||
if gpu.pci_slot:
|
||||
driver_path = Path(f"/sys/bus/pci/devices/{gpu.pci_slot}/driver")
|
||||
if driver_path.exists():
|
||||
try:
|
||||
gpu.driver = driver_path.resolve().name
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return gpus
|
||||
|
||||
|
||||
def get_disk_info(errors: list[str]) -> list[DiskInfo]:
|
||||
"""Collect disk inventory and SMART health for all physical drives."""
|
||||
disks: list[DiskInfo] = []
|
||||
|
||||
devices = _find_sys_block()
|
||||
if not devices:
|
||||
errors.append("no physical block devices found in /sys/block")
|
||||
return disks
|
||||
|
||||
for dev in devices:
|
||||
device_name = dev.name
|
||||
dev_path = f"/dev/{device_name}"
|
||||
|
||||
device_dir = dev / "device"
|
||||
if not device_dir.exists():
|
||||
continue
|
||||
|
||||
disk = DiskInfo(device=dev_path)
|
||||
|
||||
size_bytes = _parse_int((dev / "size").read_text().strip()) if (dev / "size").exists() else None
|
||||
if size_bytes:
|
||||
disk.smart.size_gb = round(size_bytes * 512 / (1024**3), 1)
|
||||
|
||||
model_file = dev / "device" / "model"
|
||||
if model_file.exists():
|
||||
disk.smart.model = model_file.read_text().strip()
|
||||
|
||||
serial_file = dev / "device" / "serial"
|
||||
if serial_file.exists():
|
||||
disk.smart.serial = serial_file.read_text().strip()
|
||||
|
||||
removable = (dev / "removable").read_text().strip() if (dev / "removable").exists() else "0"
|
||||
rotational = (dev / "queue" / "rotational").read_text().strip() if (dev / "queue" / "rotational").exists() else "1"
|
||||
if rotational == "0":
|
||||
disk.smart.interface = "NVMe" if "nvme" in device_name else "SSD"
|
||||
else:
|
||||
disk.smart.interface = "HDD"
|
||||
if removable == "1":
|
||||
disk.smart.interface += "/REMOVABLE"
|
||||
|
||||
smart_out = _run(["smartctl", "-a", dev_path], timeout=15.0)
|
||||
if not smart_out:
|
||||
errors.append(f"smartctl failed or unavailable for {dev_path}")
|
||||
else:
|
||||
if "PASSED" in smart_out:
|
||||
disk.smart.status = "PASSED"
|
||||
elif "FAILED" in smart_out:
|
||||
disk.smart.status = "FAILED"
|
||||
|
||||
m = re.search(r"Temperature_Celsius\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.temperature_c = float(m.group(1))
|
||||
else:
|
||||
m = re.search(r"Temperature:\s+(\d+)\s+Celsius", smart_out)
|
||||
if m:
|
||||
disk.smart.temperature_c = float(m.group(1))
|
||||
|
||||
m = re.search(r"Reallocated_Sector_Ct\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.reallocated_sectors = int(m.group(1))
|
||||
|
||||
m = re.search(r"Current_Pending_Sector\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.pending_sectors = int(m.group(1))
|
||||
|
||||
m = re.search(r"Power_On_Hours\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.power_on_hours = int(m.group(1))
|
||||
else:
|
||||
m = re.search(r"Power\s*On\s*Hours\s*:\s*(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.power_on_hours = int(m.group(1))
|
||||
|
||||
m = re.search(r"Power_Cycle_Count\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.power_cycles = int(m.group(1))
|
||||
|
||||
m = re.search(r"Raw_Read_Error_Rate\s+0x[\da-f]+\s+\d+\s+\d+\s+(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.raw_read_error_rate = int(m.group(1))
|
||||
|
||||
m = re.search(r"Percentage\s*Used\s*:\s*(\d+)", smart_out)
|
||||
if m:
|
||||
disk.smart.wear_level = 100 - int(m.group(1))
|
||||
|
||||
disks.append(disk)
|
||||
|
||||
return disks
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main inventory class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HardwareInventory:
|
||||
"""Probe and report on all hardware subsystems of the local machine.
|
||||
|
||||
Call ``run()`` once to collect everything; subsequent calls return the
|
||||
cached report. Use ``text_report()`` for a human-readable summary or
|
||||
``to_json()`` for the full JSON output.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
log_warnings : bool
|
||||
If True, emit a Python log warning on each non-fatal probing
|
||||
issue (missing tools, unsupported hardware, etc.).
|
||||
"""
|
||||
|
||||
def __init__(self, log_warnings: bool = False) -> None:
|
||||
self._report: Optional[HardwareReport] = None
|
||||
self._log_warnings = log_warnings
|
||||
|
||||
def run(self) -> dict[str, Any]:
|
||||
"""Run all probes and return a JSON-serialisable dict."""
|
||||
report = self._probe()
|
||||
self._report = report
|
||||
return _asdict(report)
|
||||
|
||||
def text_report(self) -> str:
|
||||
"""Return a human-readable summary of the last ``run()`` result.
|
||||
|
||||
Raises RuntimeError if ``run()`` has not been called yet.
|
||||
"""
|
||||
if self._report is None:
|
||||
return self._fallback_summary()
|
||||
r = self._report
|
||||
lines: list[str] = []
|
||||
|
||||
lines.append("=" * 60)
|
||||
lines.append(" HARDWARE INVENTORY REPORT")
|
||||
lines.append("=" * 60)
|
||||
|
||||
lines.append("")
|
||||
lines.append("-- CPU --")
|
||||
lines.append(f" Model: {r.cpu.model or 'N/A'}")
|
||||
lines.append(f" Arch: {r.cpu.architecture or 'N/A'}")
|
||||
lines.append(f" Cores: {r.cpu.cores or '?'}")
|
||||
lines.append(f" Threads: {r.cpu.threads or '?'}")
|
||||
if r.cpu.max_speed_mhz:
|
||||
lines.append(f" Max Speed: {r.cpu.max_speed_mhz:.0f} MHz")
|
||||
if r.cpu.cache_l3_kb:
|
||||
lines.append(f" L3 Cache: {r.cpu.cache_l3_kb / 1024:.0f} MB")
|
||||
|
||||
lines.append("")
|
||||
lines.append("-- RAM --")
|
||||
lines.append(f" Total: {r.ram.total_gb:.1f} GB ({r.ram.slots_used}/{r.ram.slots_total} slots used)")
|
||||
for mod in r.ram.modules:
|
||||
speed = f" @ {mod.speed_mhz} MHz" if mod.speed_mhz else ""
|
||||
lines.append(f" + {mod.slot or '?'}: {mod.size_gb} GB {mod.type}{speed}")
|
||||
|
||||
if r.gpus:
|
||||
lines.append("")
|
||||
lines.append("-- GPU --")
|
||||
for gpu in r.gpus:
|
||||
vram = f" ({gpu.vram_mb} MB)" if gpu.vram_mb else ""
|
||||
drv = f" | driver: {gpu.driver}" if gpu.driver else ""
|
||||
lines.append(f" {gpu.model or 'Unknown GPU'}{vram}{drv}")
|
||||
|
||||
if r.disks:
|
||||
lines.append("")
|
||||
lines.append("-- DISKS --")
|
||||
for disk in r.disks:
|
||||
s = disk.smart
|
||||
status_str = s.status if s.status == "PASSED" else f"!! {s.status}"
|
||||
temp = f", {s.temperature_c} C" if s.temperature_c else ""
|
||||
poh = f", {s.power_on_hours}h" if s.power_on_hours else ""
|
||||
realloc = ""
|
||||
if s.reallocated_sectors is not None and s.reallocated_sectors > 0:
|
||||
realloc = f", !! {s.reallocated_sectors} reallocated"
|
||||
lines.append(f" {disk.device} ({s.size_gb:.0f} GB) {s.model or '?'}")
|
||||
lines.append(f" SMART: {status_str}{temp}{poh}{realloc}")
|
||||
|
||||
if r.errors:
|
||||
lines.append("")
|
||||
lines.append("-- WARNINGS --")
|
||||
for err in r.errors:
|
||||
lines.append(f" !! {err}")
|
||||
|
||||
lines.append("")
|
||||
lines.append("=" * 60)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def to_json(self, indent: int = 2) -> str:
|
||||
"""Return the full inventory as a JSON string."""
|
||||
return json.dumps(self.run(), indent=indent, default=str)
|
||||
|
||||
def _probe(self) -> HardwareReport:
|
||||
report = HardwareReport()
|
||||
err = report.errors
|
||||
|
||||
report.cpu = get_cpu_info(err)
|
||||
report.ram = get_ram_info(err)
|
||||
report.gpus = get_gpu_info(err)
|
||||
report.disks = get_disk_info(err)
|
||||
|
||||
if self._log_warnings and err:
|
||||
for e in err:
|
||||
logger.warning("hardware probe: %s", e)
|
||||
|
||||
return report
|
||||
|
||||
def _fallback_summary(self) -> str:
|
||||
lines = [
|
||||
"HardwareInventory -- no probe results yet.",
|
||||
"Call .run() first to collect data.",
|
||||
"",
|
||||
]
|
||||
try:
|
||||
lines.append(f"Python: {sys.version.split()[0]}")
|
||||
lines.append(f"Platform: {sys.platform}")
|
||||
uname = os.uname()
|
||||
lines.append(f"Kernel: {uname.sysname} {uname.release} {uname.machine}")
|
||||
except Exception:
|
||||
pass
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,995 @@
|
||||
"""
|
||||
stress.py — RAM, CPU, and GPU stress testing orchestration.
|
||||
|
||||
Designed for the Hermes Portable Rescue USB environment.
|
||||
Each tester class follows the same interface:
|
||||
- run() -> dict: structured results for LLM consumption
|
||||
- report() -> str: human-readable summary
|
||||
|
||||
Usage
|
||||
-----
|
||||
from src.diagnostics.stress import run_all_stress_tests
|
||||
results = run_all_stress_tests(duration=60)
|
||||
print(results)
|
||||
|
||||
Or from CLI:
|
||||
python -m src.diagnostics.stress --duration 60 --ram --cpu --gpu
|
||||
|
||||
Requires bundled tools:
|
||||
- stress-ng (CPU + RAM stress)
|
||||
- memtester (RAM stress fallback)
|
||||
- nvidia-smi, glxinfo, glmark2 (GPU)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("stress")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data structures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class StressResult:
|
||||
"""Structured result of a single stress test run."""
|
||||
|
||||
component: str # "ram", "cpu", "gpu"
|
||||
backend: str # tool used ("stress-ng", "memtester", "memtest86+", etc.)
|
||||
status: str # "passed", "failed", "error", "skipped"
|
||||
duration_seconds: float
|
||||
summary: str # one-line human verdict
|
||||
details: Dict[str, Any] = field(default_factory=dict)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
raw_output: str = ""
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return asdict(self)
|
||||
|
||||
@staticmethod
|
||||
def skipped(component: str, reason: str) -> "StressResult":
|
||||
return StressResult(
|
||||
component=component,
|
||||
backend="none",
|
||||
status="skipped",
|
||||
duration_seconds=0.0,
|
||||
summary=f"Skipped: {reason}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _find_tool(name: str) -> Optional[str]:
|
||||
"""Return absolute path to *name* if it exists on PATH, else None."""
|
||||
return shutil.which(name)
|
||||
|
||||
|
||||
def _run(
|
||||
cmd: List[str],
|
||||
timeout: int = 300,
|
||||
env: Optional[Dict[str, str]] = None,
|
||||
) -> Tuple[int, str, str]:
|
||||
"""Run *cmd* and return (returncode, stdout, stderr)."""
|
||||
base_env = os.environ.copy()
|
||||
if env:
|
||||
base_env.update(env)
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=base_env,
|
||||
)
|
||||
return proc.returncode, proc.stdout, proc.stderr
|
||||
except FileNotFoundError:
|
||||
return -1, "", f"Command not found: {cmd[0]}"
|
||||
except subprocess.TimeoutExpired:
|
||||
return -2, "", f"Command timed out after {timeout}s"
|
||||
except Exception as exc:
|
||||
return -3, "", str(exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RAM Stress Tester
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RAMStressTester:
|
||||
"""
|
||||
RAM stress testing with multiple backends.
|
||||
|
||||
Backends (tried in order):
|
||||
1. stress-ng --vm <n> --vm-bytes <bytes> (user-space RAM stress)
|
||||
2. memtester <bytes> <iterations> (user-space RAM test)
|
||||
3. memtest86+ result parser (reads existing report)
|
||||
"""
|
||||
|
||||
BACKEND_PRIORITY = ["stress-ng", "memtester", "memtest86+result"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
duration: int = 60,
|
||||
workers: Optional[int] = None,
|
||||
vm_bytes: str = "80%",
|
||||
memtest_iterations: int = 2,
|
||||
memtest86_report_path: Optional[str] = None,
|
||||
):
|
||||
self.duration = duration
|
||||
self.workers = workers or self._default_workers()
|
||||
self.vm_bytes = vm_bytes
|
||||
self.memtester_iterations = memtest_iterations
|
||||
self.memtest86_report = memtest86_report_path
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> StressResult:
|
||||
backend, fn = self._pick_backend()
|
||||
if fn is None:
|
||||
return StressResult.skipped("ram", f"no backend available ({backend})")
|
||||
logger.info("RAM testing with backend=%s workers=%d duration=%ds", backend, self.workers, self.duration)
|
||||
t0 = time.monotonic()
|
||||
result = fn()
|
||||
result.duration_seconds = round(time.monotonic() - t0, 1)
|
||||
result.backend = backend
|
||||
return result
|
||||
|
||||
def report(self, result: StressResult) -> str:
|
||||
lines = [
|
||||
f"--- RAM Stress Test ---",
|
||||
f" Backend: {result.backend}",
|
||||
f" Status: {result.status}",
|
||||
f" Duration: {result.duration_seconds}s",
|
||||
f" Summary: {result.summary}",
|
||||
]
|
||||
if result.details:
|
||||
for k, v in result.details.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
if result.errors:
|
||||
lines.append(" Errors:")
|
||||
for e in result.errors:
|
||||
lines.append(f" - {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _default_workers() -> int:
|
||||
"""Heuristic: use half of available CPUs for VM stress workers."""
|
||||
try:
|
||||
return max(1, os.cpu_count() or 2 // 2)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
def _pick_backend(self) -> Tuple[str, Optional[callable]]:
|
||||
"""Return (label, callable) for the best available backend."""
|
||||
if _find_tool("stress-ng"):
|
||||
return "stress-ng", self._run_stress_ng
|
||||
if _find_tool("memtester"):
|
||||
return "memtester", self._run_memtester
|
||||
if self.memtest86_report or _find_tool("memtest86+report"):
|
||||
return "memtest86+result", self._parse_memtest86
|
||||
return "none", None
|
||||
|
||||
def _run_stress_ng(self) -> StressResult:
|
||||
"""Run stress-ng memory stress (--vm <workers> --vm-bytes <bytes>)."""
|
||||
cmd = [
|
||||
"stress-ng",
|
||||
"--vm", str(self.workers),
|
||||
"--vm-bytes", self.vm_bytes,
|
||||
"--timeout", f"{self.duration}s",
|
||||
"--metrics-brief",
|
||||
"--no-rand-seed",
|
||||
]
|
||||
rc, stdout, stderr = _run(cmd, timeout=self.duration + 30)
|
||||
|
||||
errors = []
|
||||
if stderr:
|
||||
errors.append(stderr.strip())
|
||||
|
||||
details = self._parse_stress_ng_metrics(stdout)
|
||||
details["command"] = " ".join(cmd)
|
||||
details["workers"] = self.workers
|
||||
|
||||
if rc == -1:
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status="error",
|
||||
summary="stress-ng not found",
|
||||
details=details,
|
||||
errors=errors,
|
||||
raw_output=stdout + "\n" + stderr,
|
||||
)
|
||||
if rc == -2:
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status="error",
|
||||
summary="stress-ng timed out",
|
||||
details=details,
|
||||
errors=errors,
|
||||
raw_output=stdout + "\n" + stderr,
|
||||
)
|
||||
|
||||
# stress-ng exits 0 on clean completion even if errors occurred
|
||||
status = "passed" if rc == 0 else "error"
|
||||
bogo_ops = details.get("total_bogo_ops", 0)
|
||||
summary = (
|
||||
f"Completed {bogo_ops} bogo-ops across {self.workers} VM workers"
|
||||
if bogo_ops > 0 else
|
||||
f"Finished with status={status}"
|
||||
)
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=details,
|
||||
errors=errors,
|
||||
raw_output=stdout + "\n" + stderr,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_stress_ng_metrics(text: str) -> Dict[str, Any]:
|
||||
"""Extract bogo-ops and other metrics from stress-ng --metrics-brief."""
|
||||
metrics: Dict[str, Any] = {}
|
||||
total_bogo = 0.0
|
||||
bogo_per_sec = 0.0
|
||||
# stress-ng metrics lines look like:
|
||||
# stress-ng: info: [123] stress-ng-vm: 4 runouts, 0 stalled...
|
||||
# stress-ng: info: [123] stress-ng-metrics: vm 1234.56 bogo-ops ...
|
||||
for line in text.splitlines():
|
||||
if "bogo-ops" in line.lower():
|
||||
m = re.search(r"vm\s+([\d.]+)\s+bogo-ops", line, re.IGNORECASE)
|
||||
if m:
|
||||
total_bogo = float(m.group(1))
|
||||
m2 = re.search(r"bogo-ops/s\s+real\s+([\d.]+)", line, re.IGNORECASE)
|
||||
if m2:
|
||||
bogo_per_sec = float(m2.group(1))
|
||||
metrics["total_bogo_ops"] = round(total_bogo, 1)
|
||||
metrics["bogo_ops_per_sec"] = round(bogo_per_sec, 3) if bogo_per_sec else 0.0
|
||||
return metrics
|
||||
|
||||
def _run_memtester(self) -> StressResult:
|
||||
"""Run memtester <vm_bytes> <iterations>."""
|
||||
# Convert vm_bytes like "80%" to a usable size for memtester
|
||||
# memtester takes size in megabytes directly
|
||||
size_mb = self._vm_bytes_to_mb()
|
||||
if size_mb <= 0:
|
||||
size_mb = 256 # fallback
|
||||
cmd = ["memtester", f"{size_mb}M", str(self.memtester_iterations)]
|
||||
rc, stdout, stderr = _run(cmd, timeout=max(self.duration, 120))
|
||||
|
||||
errors = []
|
||||
if stderr:
|
||||
errors.append(stderr.strip())
|
||||
|
||||
details = self._parse_memtester_output(stdout)
|
||||
details["command"] = " ".join(cmd)
|
||||
details["size_mb"] = size_mb
|
||||
details["iterations"] = self.memtester_iterations
|
||||
|
||||
status = "passed" if rc == 0 else "failed"
|
||||
if rc < 0:
|
||||
status = "error"
|
||||
|
||||
failure_count = details.get("failures", 0)
|
||||
summary = f"memtester: {details.get('loops_completed', 0)} loops, {failure_count} failures" if status != "error" else "memtester run failed"
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=details,
|
||||
errors=errors,
|
||||
raw_output=stdout + "\n" + stderr,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_memtester_output(text: str) -> Dict[str, Any]:
|
||||
"""Parse memtester output for pass/fail information."""
|
||||
details: Dict[str, Any] = {}
|
||||
loops = 0
|
||||
failures = 0
|
||||
for line in text.splitlines():
|
||||
m = re.match(r"Loop\s+(\d+)", line, re.IGNORECASE)
|
||||
if m:
|
||||
loops = max(loops, int(m.group(1)))
|
||||
if "FAILURE" in line.upper():
|
||||
failures += 1
|
||||
details["loops_completed"] = loops
|
||||
details["failures"] = failures
|
||||
return details
|
||||
|
||||
def _parse_memtest86(self) -> StressResult:
|
||||
"""Parse existing MemTest86+ report (result XML or text file)."""
|
||||
report_path = self.memtest86_report
|
||||
if report_path is None:
|
||||
# Common locations
|
||||
for candidate in [
|
||||
"/MemTest86-Report.xml",
|
||||
"/boot/MemTest86-Report.xml",
|
||||
"/memtest86/report.xml",
|
||||
"~/MemTest86-Report.xml",
|
||||
]:
|
||||
expanded = os.path.expanduser(candidate)
|
||||
if os.path.isfile(expanded):
|
||||
report_path = expanded
|
||||
break
|
||||
|
||||
if not report_path or not os.path.isfile(report_path):
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status="skipped",
|
||||
summary="MemTest86+ report not found; run MemTest86+ on next boot",
|
||||
details={"suggested_path": "/MemTest86-Report.xml"},
|
||||
)
|
||||
|
||||
try:
|
||||
text = Path(report_path).read_text()
|
||||
except Exception as exc:
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status="error",
|
||||
summary=f"Cannot read report: {exc}",
|
||||
)
|
||||
|
||||
details = self._parse_memtest86_xml(text)
|
||||
details["report_path"] = report_path
|
||||
summary = (
|
||||
f"MemTest86+ report: {details.get('passes', '?')} passes, "
|
||||
f"{details.get('errors', 0)} errors"
|
||||
)
|
||||
status = "passed" if details.get("errors", 0) == 0 else "failed"
|
||||
return StressResult(
|
||||
component="ram",
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=details,
|
||||
raw_output=text,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_memtest86_xml(text: str) -> Dict[str, Any]:
|
||||
"""Naïve extraction of key fields from MemTest86+ XML report."""
|
||||
def _tag(tag: str) -> str:
|
||||
m = re.search(rf"<{tag}[^>]*>(.*?)</{tag}>", text, re.DOTALL)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
passes_str = _tag("Passes")
|
||||
return {
|
||||
"passes": int(passes_str) if passes_str.isdigit() else 0,
|
||||
"errors": _tag("Errors"),
|
||||
"test_start_time": _tag("TestStartTime"),
|
||||
"test_end_time": _tag("TestEndTime"),
|
||||
"memory_size": _tag("MemorySize"),
|
||||
"cpu_type": _tag("CpuType"),
|
||||
"cpu_speed": _tag("CpuSpeed"),
|
||||
}
|
||||
|
||||
def _vm_bytes_to_mb(self) -> int:
|
||||
"""Convert a vm_bytes string (e.g. '80%' or '512M') to megabytes."""
|
||||
s = str(self.vm_bytes).strip().lower()
|
||||
if s.endswith("%"):
|
||||
try:
|
||||
pct = int(s.rstrip("%"))
|
||||
# Estimate available RAM via /proc/meminfo
|
||||
mem_total_kb = 0
|
||||
try:
|
||||
for line in Path("/proc/meminfo").read_text().splitlines():
|
||||
if line.startswith("MemTotal:"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
mem_total_kb = int(parts[1])
|
||||
break
|
||||
except Exception:
|
||||
mem_total_kb = 8 * 1024 * 1024 # fallback: 8 GB
|
||||
mem_total_mb = mem_total_kb // 1024
|
||||
return max(64, mem_total_mb * pct // 100)
|
||||
except (ValueError, TypeError):
|
||||
return 512
|
||||
if s.endswith("g"):
|
||||
try:
|
||||
return int(s.rstrip("g")) * 1024
|
||||
except ValueError:
|
||||
return 512
|
||||
if s.endswith("m"):
|
||||
try:
|
||||
return int(s.rstrip("m"))
|
||||
except ValueError:
|
||||
return 512
|
||||
try:
|
||||
return int(s)
|
||||
except (ValueError, TypeError):
|
||||
return 512
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPU Stress Tester
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CPUStressTester:
|
||||
"""
|
||||
CPU stress testing via stress-ng.
|
||||
|
||||
Tests several CPU subsystems in sequence or parallel:
|
||||
- cpu (integer arithmetic)
|
||||
- matrix (floating-point matrix operations)
|
||||
- fpu (FPU stress)
|
||||
- cache (L1/L2 cache thrashing)
|
||||
- context (context switching)
|
||||
|
||||
Each stressor is run briefly to exercise different CPU aspects.
|
||||
"""
|
||||
|
||||
DEFAULT_STRESSORS = ["cpu", "matrix", "fpu", "cache"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
duration: int = 60,
|
||||
workers: Optional[int] = None,
|
||||
stressors: Optional[List[str]] = None,
|
||||
sequential: bool = True,
|
||||
):
|
||||
self.duration = duration
|
||||
self.workers = workers or self._default_workers()
|
||||
self.stressors = stressors or list(self.DEFAULT_STRESSORS)
|
||||
self.sequential = sequential
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> StressResult:
|
||||
backend = self._pick_backend()
|
||||
if backend is None:
|
||||
return StressResult.skipped("cpu", "stress-ng not found on PATH")
|
||||
|
||||
t0 = time.monotonic()
|
||||
if self.sequential:
|
||||
result = self._run_sequential(backend)
|
||||
else:
|
||||
result = self._run_parallel(backend)
|
||||
result.duration_seconds = round(time.monotonic() - t0, 1)
|
||||
result.backend = backend
|
||||
return result
|
||||
|
||||
def report(self, result: StressResult) -> str:
|
||||
lines = [
|
||||
f"--- CPU Stress Test ---",
|
||||
f" Backend: {result.backend}",
|
||||
f" Status: {result.status}",
|
||||
f" Duration: {result.duration_seconds}s",
|
||||
f" Summary: {result.summary}",
|
||||
]
|
||||
details = result.details
|
||||
if "stressors_run" in details:
|
||||
lines.append(f" Stressors run: {', '.join(details['stressors_run'])}")
|
||||
if "stressor_results" in details:
|
||||
for sres in details["stressor_results"]:
|
||||
lines.append(f" - {sres.get('name', '?')}: {sres.get('bogo_ops', 0)} bogo-ops")
|
||||
if result.errors:
|
||||
lines.append(" Errors:")
|
||||
for e in result.errors:
|
||||
lines.append(f" - {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _default_workers() -> int:
|
||||
try:
|
||||
return os.cpu_count() or 2
|
||||
except Exception:
|
||||
return 2
|
||||
|
||||
@staticmethod
|
||||
def _pick_backend() -> Optional[str]:
|
||||
path = _find_tool("stress-ng")
|
||||
return path
|
||||
|
||||
def _run_sequential(self, backend: str) -> StressResult:
|
||||
"""Run each stressor one at a time so we get per-stressor metrics."""
|
||||
per_stressor_duration = max(10, self.duration // len(self.stressors))
|
||||
all_stressor_results: List[Dict[str, Any]] = []
|
||||
all_errors: List[str] = []
|
||||
total_bogo = 0.0
|
||||
|
||||
for stressor in self.stressors:
|
||||
cmd = [
|
||||
backend,
|
||||
"--" + stressor, str(self.workers),
|
||||
"--timeout", f"{per_stressor_duration}s",
|
||||
"--metrics-brief",
|
||||
"--no-rand-seed",
|
||||
]
|
||||
logger.info("CPU stressor: %s (workers=%d, duration=%ds)", stressor, self.workers, per_stressor_duration)
|
||||
rc, stdout, stderr = _run(cmd, timeout=per_stressor_duration + 30)
|
||||
|
||||
if rc < 0:
|
||||
all_errors.append(f"{stressor}: {stderr.strip() or 'unknown error'}")
|
||||
all_stressor_results.append({"name": stressor, "status": "error", "bogo_ops": 0})
|
||||
continue
|
||||
|
||||
metrics = self._parse_single_stressor_metrics(stdout, stressor)
|
||||
metrics["name"] = stressor
|
||||
metrics["status"] = "passed" if rc == 0 else "error"
|
||||
all_stressor_results.append(metrics)
|
||||
total_bogo += metrics.get("bogo_ops", 0.0)
|
||||
all_errors.extend(self._extract_errors(stdout + "\n" + stderr))
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
details["stressors_run"] = self.stressors
|
||||
details["stressor_results"] = all_stressor_results
|
||||
details["total_bogo_ops"] = round(total_bogo, 1)
|
||||
|
||||
status = "passed" if not all_errors else "failed"
|
||||
summary = (
|
||||
f"CPU stress: {len(self.stressors)} stressors, "
|
||||
f"{details['total_bogo_ops']} total bogo-ops"
|
||||
)
|
||||
if all_errors:
|
||||
summary += f", {len(all_errors)} errors"
|
||||
|
||||
return StressResult(
|
||||
component="cpu",
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=details,
|
||||
errors=all_errors,
|
||||
)
|
||||
|
||||
def _run_parallel(self, backend: str) -> StressResult:
|
||||
"""Run all stressors in one stress-ng invocation (--all)."""
|
||||
# Build command: stress-ng --cpu N --matrix N --fpu N --cache N --timeout Xs --metrics-brief
|
||||
cmd = [backend]
|
||||
for stressor in self.stressors:
|
||||
cmd.extend(["--" + stressor, str(self.workers)])
|
||||
cmd.extend(["--timeout", f"{self.duration}s", "--metrics-brief", "--no-rand-seed"])
|
||||
|
||||
logger.info("CPU parallel stress: stressors=%s workers=%d", self.stressors, self.workers)
|
||||
rc, stdout, stderr = _run(cmd, timeout=self.duration + 30)
|
||||
|
||||
all_errors: List[str] = []
|
||||
if stderr:
|
||||
all_errors.extend(self._extract_errors(stderr))
|
||||
if rc < 0:
|
||||
all_errors.append(f"stress-ng rc={rc}")
|
||||
|
||||
all_stressor_results: List[Dict[str, Any]] = []
|
||||
total_bogo = 0.0
|
||||
for stressor in self.stressors:
|
||||
metrics = self._parse_single_stressor_metrics(stdout, stressor)
|
||||
metrics["name"] = stressor
|
||||
metrics["status"] = "passed" if rc == 0 else "error"
|
||||
all_stressor_results.append(metrics)
|
||||
total_bogo += metrics.get("bogo_ops", 0.0)
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
details["stressors_run"] = self.stressors
|
||||
details["stressor_results"] = all_stressor_results
|
||||
details["total_bogo_ops"] = round(total_bogo, 1)
|
||||
details["command"] = " ".join(cmd)
|
||||
details["workers_per_stressor"] = self.workers
|
||||
|
||||
status = "passed" if rc == 0 else "error"
|
||||
summary = f"CPU parallel stress: {details['total_bogo_ops']} bogo-ops"
|
||||
if all_errors:
|
||||
summary += f", {len(all_errors)} issues"
|
||||
status = "failed"
|
||||
|
||||
return StressResult(
|
||||
component="cpu",
|
||||
status=status,
|
||||
summary=summary,
|
||||
details=details,
|
||||
errors=all_errors,
|
||||
raw_output=stdout + "\n" + stderr,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _parse_single_stressor_metrics(text: str, stressor: str) -> Dict[str, Any]:
|
||||
"""Extract metrics for a single stressor from stress-ng output."""
|
||||
bogo_ops = 0.0
|
||||
bogo_per_sec = 0.0
|
||||
for line in text.splitlines():
|
||||
if "bogo-ops" in line.lower() and stressor in line.lower():
|
||||
m = re.search(rf"{stressor}\s+([\d.]+)\s+bogo-ops", line, re.IGNORECASE)
|
||||
if m:
|
||||
bogo_ops = float(m.group(1))
|
||||
m2 = re.search(r"bogo-ops/s\s+real\s+([\d.]+)", line, re.IGNORECASE)
|
||||
if m2:
|
||||
bogo_per_sec = float(m2.group(1))
|
||||
return {
|
||||
"bogo_ops": round(bogo_ops, 1),
|
||||
"bogo_ops_per_sec": round(bogo_per_sec, 3),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_errors(text: str) -> List[str]:
|
||||
"""Extract error/warning lines from stress-ng output."""
|
||||
errors = []
|
||||
for line in text.splitlines():
|
||||
lower = line.lower()
|
||||
for kw in ("error", "warning", "fail", "cannot"):
|
||||
if kw in lower:
|
||||
errors.append(line.strip())
|
||||
break
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU Stress Tester
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GPUStressTester:
|
||||
"""
|
||||
GPU info gathering and basic stress testing.
|
||||
|
||||
Capabilities (tried in order of preference):
|
||||
1. nvidia-smi — NVIDIA GPU info + monitoring
|
||||
2. glxinfo — OpenGL vendor/renderer/version
|
||||
3. glmark2 — OpenGL benchmark / stress
|
||||
4. lspci — GPU model identification fallback
|
||||
"""
|
||||
|
||||
def __init__(self, duration: int = 60, skip_benchmark: bool = False):
|
||||
self.duration = duration
|
||||
self.skip_benchmark = skip_benchmark
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public interface
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def run(self) -> StressResult:
|
||||
t0 = time.monotonic()
|
||||
info = self._gather_gpu_info()
|
||||
stress = self._run_stress_test()
|
||||
result = self._merge(info, stress)
|
||||
result.duration_seconds = round(time.monotonic() - t0, 1)
|
||||
return result
|
||||
|
||||
def report(self, result: StressResult) -> str:
|
||||
lines = [
|
||||
f"--- GPU Stress Test ---",
|
||||
f" Backend: {result.backend}",
|
||||
f" Status: {result.status}",
|
||||
f" Duration: {result.duration_seconds}s",
|
||||
f" Summary: {result.summary}",
|
||||
]
|
||||
details = result.details
|
||||
gpu_info = details.get("gpu_info", {})
|
||||
if gpu_info:
|
||||
lines.append(" GPU Info:")
|
||||
for k, v in gpu_info.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
if "benchmark_score" in details:
|
||||
lines.append(f" Benchmark score: {details['benchmark_score']}")
|
||||
if result.errors:
|
||||
lines.append(" Errors:")
|
||||
for e in result.errors:
|
||||
lines.append(f" - {e}")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _gather_gpu_info(self) -> Dict[str, Any]:
|
||||
"""Collect GPU info from all available sources."""
|
||||
info: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
# 1) nvidia-smi
|
||||
nv_path = _find_tool("nvidia-smi")
|
||||
if nv_path:
|
||||
rc, stdout, stderr = _run([nv_path, "--query-gpu=name,driver_version,temperature.gpu,memory.total,utilization.gpu", "--format=csv,noheader"], timeout=15)
|
||||
if rc == 0:
|
||||
for line in stdout.strip().splitlines():
|
||||
parts = [p.strip() for p in line.split(",")]
|
||||
if len(parts) >= 3:
|
||||
info["model"] = parts[0]
|
||||
info["driver_version"] = parts[1]
|
||||
info["temperature_c"] = parts[2]
|
||||
if len(parts) >= 4:
|
||||
info["vram_total"] = parts[3]
|
||||
if len(parts) >= 5:
|
||||
info["utilization_pct"] = parts[4]
|
||||
else:
|
||||
errors.append(f"nvidia-smi: {stderr.strip()}")
|
||||
else:
|
||||
errors.append("nvidia-smi not found")
|
||||
|
||||
# 2) glxinfo (OpenGL vendor/renderer)
|
||||
glx_path = _find_tool("glxinfo")
|
||||
if glx_path:
|
||||
rc, stdout, stderr = _run([glx_path, "-B"], timeout=15)
|
||||
if rc == 0:
|
||||
for line in stdout.splitlines():
|
||||
lower = line.lower()
|
||||
if "opengl vendor" in lower:
|
||||
info["gl_vendor"] = line.split(":", 1)[-1].strip()
|
||||
elif "opengl renderer" in lower:
|
||||
info["gl_renderer"] = line.split(":", 1)[-1].strip()
|
||||
elif "opengl version" in lower:
|
||||
info["gl_version"] = line.split(":", 1)[-1].strip()
|
||||
|
||||
# 3) lspci as fallback for model
|
||||
if "model" not in info:
|
||||
lspci_path = _find_tool("lspci")
|
||||
if lspci_path:
|
||||
rc, stdout, stderr = _run([lspci_path], timeout=10)
|
||||
if rc == 0:
|
||||
for line in stdout.splitlines():
|
||||
l = line.lower()
|
||||
if any(kw in l for kw in ("vga", "3d controller", "display")):
|
||||
info["model"] = line.strip()
|
||||
break
|
||||
|
||||
info["has_nvidia_gpu"] = nv_path is not None
|
||||
return info
|
||||
|
||||
def _run_stress_test(self) -> Dict[str, Any]:
|
||||
"""Run GPU benchmark/stress if available."""
|
||||
result: Dict[str, Any] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
if self.skip_benchmark:
|
||||
result["benchmark_skipped"] = True
|
||||
return result
|
||||
|
||||
# Try glmark2 first (OpenGL benchmark)
|
||||
glmark_path = _find_tool("glmark2")
|
||||
if glmark_path:
|
||||
rc, stdout, stderr = _run(
|
||||
[glmark2_path, "--run-forever"],
|
||||
timeout=self.duration + 15,
|
||||
env={"DISPLAY": os.environ.get("DISPLAY", ":0")},
|
||||
)
|
||||
if rc == 0:
|
||||
score = self._parse_glmark2_score(stdout)
|
||||
result["benchmark_score"] = score
|
||||
result["benchmark_tool"] = "glmark2"
|
||||
else:
|
||||
errors.append("glmark2 not found; no GPU benchmark available")
|
||||
|
||||
if errors:
|
||||
result["errors"] = errors
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _parse_glmark2_score(text: str) -> Optional[int]:
|
||||
"""Extract the final glmark2 score. Looks for 'glmark2 Score: NNN'."""
|
||||
m = re.search(r"glmark2\s+Score:\s+(\d+)", text, re.IGNORECASE)
|
||||
if m:
|
||||
return int(m.group(1))
|
||||
return None
|
||||
|
||||
def _merge(self, info: Dict[str, Any], stress: Dict[str, Any]) -> StressResult:
|
||||
"""Merge gathered info and stress results into a single StressResult."""
|
||||
all_errors = info.pop("errors", [])
|
||||
all_errors.extend(stress.pop("errors", []))
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
details["gpu_info"] = info
|
||||
details.update(stress)
|
||||
|
||||
model = info.get("model", info.get("gl_renderer", "unknown"))
|
||||
summary_parts = [f"GPU: {model}"]
|
||||
if "benchmark_score" in details and details["benchmark_score"] is not None:
|
||||
summary_parts.append(f"glmark2 score: {details['benchmark_score']}")
|
||||
if "temperature_c" in info:
|
||||
summary_parts.append(f"temp: {info['temperature_c']}°C")
|
||||
|
||||
status = "passed" if not all_errors else "failed"
|
||||
# Even with all_errors, we always have info from lspci at minimum
|
||||
backend_parts = []
|
||||
if _find_tool("nvidia-smi"):
|
||||
backend_parts.append("nvidia-smi")
|
||||
if _find_tool("glxinfo"):
|
||||
backend_parts.append("glxinfo")
|
||||
if _find_tool("glmark2"):
|
||||
backend_parts.append("glmark2")
|
||||
if _find_tool("lspci"):
|
||||
backend_parts.append("lspci")
|
||||
if not backend_parts:
|
||||
backend_parts = ["none"]
|
||||
|
||||
return StressResult(
|
||||
component="gpu",
|
||||
backend="+".join(backend_parts),
|
||||
status=status,
|
||||
summary="; ".join(summary_parts),
|
||||
details=details,
|
||||
errors=all_errors,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_all_stress_tests(
|
||||
duration: int = 60,
|
||||
ram: bool = True,
|
||||
cpu: bool = True,
|
||||
gpu: bool = True,
|
||||
skip_gpu_benchmark: bool = False,
|
||||
) -> Dict[str, StressResult]:
|
||||
"""
|
||||
Run all requested stress tests and return structured results.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
duration : int
|
||||
Per-test duration in seconds (CPU/RAM get this; GPU benchmark runs separately).
|
||||
ram, cpu, gpu : bool
|
||||
Toggle individual tests.
|
||||
skip_gpu_benchmark : bool
|
||||
If True, only gather GPU info, skip the OpenGL benchmark loop.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict of {component: StressResult}
|
||||
"""
|
||||
results: Dict[str, StressResult] = {}
|
||||
logger.info("Stress test suite starting (duration=%ds)", duration)
|
||||
|
||||
if ram:
|
||||
ram_tester = RAMStressTester(duration=duration)
|
||||
results["ram"] = ram_tester.run()
|
||||
logger.info("RAM test: %s | %s", results["ram"].status, results["ram"].summary)
|
||||
|
||||
if cpu:
|
||||
cpu_tester = CPUStressTester(duration=duration)
|
||||
results["cpu"] = cpu_tester.run()
|
||||
logger.info("CPU test: %s | %s", results["cpu"].status, results["cpu"].summary)
|
||||
|
||||
if gpu:
|
||||
gpu_tester = GPUStressTester(duration=duration, skip_benchmark=skip_gpu_benchmark)
|
||||
results["gpu"] = gpu_tester.run()
|
||||
logger.info("GPU test: %s | %s", results["gpu"].status, results["gpu"].summary)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_report(results: Dict[str, StressResult]) -> None:
|
||||
"""Pretty-print test results to stdout."""
|
||||
for component in ("ram", "cpu", "gpu"):
|
||||
result = results.get(component)
|
||||
if result is not None:
|
||||
print()
|
||||
print(StressResult.report.__doc__) # placeholder — will be replaced
|
||||
# Actually use the testers' report methods
|
||||
for component in ("ram", "cpu", "gpu"):
|
||||
result = results.get(component)
|
||||
if result is not None:
|
||||
if component == "ram":
|
||||
print(RAMStressTester().report(result))
|
||||
elif component == "cpu":
|
||||
print(CPUStressTester().report(result))
|
||||
elif component == "gpu":
|
||||
print(GPUStressTester().report(result))
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_cli_args() -> dict:
|
||||
"""Simple manual arg parser (no external deps)."""
|
||||
args = {
|
||||
"duration": 60,
|
||||
"ram": True,
|
||||
"cpu": True,
|
||||
"gpu": True,
|
||||
"skip_gpu_benchmark": False,
|
||||
"verbose": False,
|
||||
}
|
||||
|
||||
argv = sys.argv[1:]
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a in ("--duration", "-d"):
|
||||
if i + 1 < len(argv):
|
||||
args["duration"] = int(argv[i + 1])
|
||||
i += 1
|
||||
elif a == "--ram-only":
|
||||
args["ram"] = True
|
||||
args["cpu"] = False
|
||||
args["gpu"] = False
|
||||
elif a == "--cpu-only":
|
||||
args["ram"] = False
|
||||
args["cpu"] = True
|
||||
args["gpu"] = False
|
||||
elif a == "--gpu-only":
|
||||
args["ram"] = False
|
||||
args["cpu"] = False
|
||||
args["gpu"] = True
|
||||
elif a in ("--no-ram",):
|
||||
args["ram"] = False
|
||||
elif a in ("--no-cpu",):
|
||||
args["cpu"] = False
|
||||
elif a in ("--no-gpu",):
|
||||
args["gpu"] = False
|
||||
elif a in ("--skip-gpu-benchmark",):
|
||||
args["skip_gpu_benchmark"] = True
|
||||
elif a in ("--verbose", "-v"):
|
||||
args["verbose"] = True
|
||||
elif a in ("--help", "-h"):
|
||||
print("Usage: python -m src.diagnostics.stress [options]")
|
||||
print()
|
||||
print("Options:")
|
||||
print(" -d, --duration SECS Per-test duration (default: 60)")
|
||||
print(" --ram-only RAM test only")
|
||||
print(" --cpu-only CPU test only")
|
||||
print(" --gpu-only GPU test only")
|
||||
print(" --no-ram Skip RAM test")
|
||||
print(" --no-cpu Skip CPU test")
|
||||
print(" --no-gpu Skip GPU test")
|
||||
print(" --skip-gpu-benchmark GPU info only, no benchmark")
|
||||
print(" -v, --verbose Verbose logging")
|
||||
print(" -h, --help This help")
|
||||
sys.exit(0)
|
||||
i += 1
|
||||
return args
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
config = parse_cli_args()
|
||||
if config["verbose"]:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
|
||||
else:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(levelname)s | %(message)s")
|
||||
|
||||
print("Hermes Portable Rescue — Stress Test Suite")
|
||||
print(f" Duration: {config['duration']}s per test")
|
||||
print(f" RAM: {'yes' if config['ram'] else 'no'}")
|
||||
print(f" CPU: {'yes' if config['cpu'] else 'no'}")
|
||||
print(f" GPU: {'yes' if config['gpu'] else 'no'}")
|
||||
print(f" GPU benchmark: {'no' if config['skip_gpu_benchmark'] else 'yes'}")
|
||||
print()
|
||||
|
||||
results = run_all_stress_tests(
|
||||
duration=config["duration"],
|
||||
ram=config["ram"],
|
||||
cpu=config["cpu"],
|
||||
gpu=config["gpu"],
|
||||
skip_gpu_benchmark=config["skip_gpu_benchmark"],
|
||||
)
|
||||
|
||||
print_report(results)
|
||||
|
||||
# Overall status
|
||||
print("=" * 50)
|
||||
print("OVERALL")
|
||||
for comp, res in results.items():
|
||||
print(f" {comp.upper():5s} {res.status:10s} {res.summary}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+250
@@ -0,0 +1,250 @@
|
||||
#!/bin/sh
|
||||
# ==============================================================================
|
||||
# Hermes Portable Rescue — Auto-Launch Script (Source)
|
||||
# ==============================================================================
|
||||
#
|
||||
# This is the master auto-launch script for the Hermes Portable Rescue USB.
|
||||
# It runs when the rescue environment boots and orchestrates the entire
|
||||
# diagnostic workflow.
|
||||
#
|
||||
# Design:
|
||||
# - Detects the boot filesystem and mounts the Hermes partition
|
||||
# - Creates a report directory and starts logging
|
||||
# - Runs diagnostics in dependency order (inventory → analysis → fix)
|
||||
# - Launches the Hermes agent for LLM-powered interpretation
|
||||
# - Falls back to a diagnostic shell if the agent isn't available
|
||||
#
|
||||
# Environment variables (set by build/usb-image.sh or the boot environment):
|
||||
# HERMES_DIR — Root of the Hermes runtime filesystem
|
||||
# CONFIG — Path to config.yaml
|
||||
# REPORT_DIR — Where to save diagnostic reports
|
||||
# BOOT_MODE — 'live' (default), 'safe', 'recovery'
|
||||
#
|
||||
# ==============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
HERMES_DIR="${HERMES_DIR:-/hermes}"
|
||||
CONFIG="${CONFIG:-${HERMES_DIR}/config.yaml}"
|
||||
REPORT_DIR="${REPORT_DIR:-${HERMES_DIR}/reports}"
|
||||
LOG_FILE="${LOG_FILE:-${REPORT_DIR}/session.log}"
|
||||
BOOT_MODE="${BOOT_MODE:-live}"
|
||||
|
||||
# ─── Utility Functions ────────────────────────────────────────────────────────
|
||||
|
||||
timestamp() {
|
||||
date "+%Y-%m-%d %H:%M:%S"
|
||||
}
|
||||
|
||||
log() {
|
||||
local level="$1"
|
||||
shift
|
||||
echo "[$(timestamp)] [${level}] $*" | tee -a "${LOG_FILE}"
|
||||
}
|
||||
|
||||
info() { log "INFO" "$@"; }
|
||||
warn() { log "WARN" "$@" >&2; }
|
||||
error() { log "ERROR" "$@" >&2; }
|
||||
|
||||
# ─── Banner ────────────────────────────────────────────────────────────────────
|
||||
|
||||
print_banner() {
|
||||
cat << 'BANNER'
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ██╗ ██╗███████╗██████╗ ███╗ ███╗ ║
|
||||
║ ██║ ██║██╔════╝██╔══██╗████╗ ████║ ║
|
||||
║ ███████║█████╗ ██████╔╝██╔████╔██║ ║
|
||||
║ ██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║ ║
|
||||
║ ██║ ██║███████╗██║ ██║██║ ╚═╝ ██║ ║
|
||||
║ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
║ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ ║
|
||||
║ │ SCAN │→ │ ANALYZE │→ │ RECOMMEND/FIX │ ║
|
||||
║ └─────────┘ └──────────┘ └──────────────────┘ ║
|
||||
║ ║
|
||||
║ Hermes Portable Rescue v1.0.0 ║
|
||||
║ AI-driven Windows PC diagnostic & repair toolkit ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
BANNER
|
||||
}
|
||||
|
||||
# ─── Filesystem Setup ─────────────────────────────────────────────────────────
|
||||
|
||||
mount_windows_partition() {
|
||||
local windows_mount="/mnt/c"
|
||||
mkdir -p "${windows_mount}" 2>/dev/null || true
|
||||
|
||||
# Try common Windows partitions
|
||||
for device in /dev/sda1 /dev/sda2 /dev/nvme0n1p1 /dev/nvme0n1p2 /dev/mmcblk0p1; do
|
||||
if [ -b "${device}" ]; then
|
||||
local fstype
|
||||
fstype="$(blkid -o value -s TYPE "${device}" 2>/dev/null || echo '')"
|
||||
case "${fstype}" in
|
||||
ntfs)
|
||||
if mount -t ntfs-3g "${device}" "${windows_mount}" 2>/dev/null; then
|
||||
info "Mounted Windows partition: ${device} → ${windows_mount}"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
ext4|vfat)
|
||||
# Not a Windows partition (or could be EFI system partition)
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
warn "No Windows partition found."
|
||||
return 1
|
||||
}
|
||||
|
||||
# ─── Diagnostic Pipeline ──────────────────────────────────────────────────────
|
||||
|
||||
run_script() {
|
||||
local script="$1"
|
||||
local report_name="$2"
|
||||
local report_file="${REPORT_DIR}/${report_name}"
|
||||
|
||||
if [ ! -f "${script}" ]; then
|
||||
warn "Script not found: ${script}"
|
||||
return 1
|
||||
fi
|
||||
|
||||
info "Running: ${script}"
|
||||
if python3 "${script}" > "${report_file}" 2>&1; then
|
||||
info " ✓ ${report_name} complete (exit 0)"
|
||||
return 0
|
||||
else
|
||||
local rc=$?
|
||||
if [ "${rc}" -eq 1 ]; then
|
||||
# Component unavailable (stub)
|
||||
info " ∼ ${report_name}: component unavailable"
|
||||
elif [ "${rc}" -eq 2 ]; then
|
||||
# Error during diagnosis
|
||||
warn " ✗ ${report_name}: diagnostic error (exit ${rc})"
|
||||
else
|
||||
warn " ✗ ${report_name}: failed with exit code ${rc}"
|
||||
fi
|
||||
return ${rc}
|
||||
fi
|
||||
}
|
||||
|
||||
run_diagnostics() {
|
||||
info "Starting diagnostic pipeline..."
|
||||
echo "───────────────────────────────────────────────────────" >> "${LOG_FILE}"
|
||||
|
||||
# Phase 1: Hardware inventory (independent, runs first)
|
||||
run_script "${HERMES_DIR}/scripts/hardware.py" "01-hardware.txt"
|
||||
|
||||
# Phase 2: BSOD analysis (needs hardware inventory context)
|
||||
run_script "${HERMES_DIR}/scripts/bsod.py" "02-bsod-analysis.txt"
|
||||
|
||||
# Phase 3: Driver update check (needs hardware inventory)
|
||||
run_script "${HERMES_DIR}/scripts/drivers.py" "03-drivers.txt"
|
||||
|
||||
# Phase 4: Stress testing (manual/safe mode only)
|
||||
if [ "${BOOT_MODE}" = "recovery" ]; then
|
||||
info "Skipping stress tests in recovery mode."
|
||||
elif grep -q '"enabled": true' "${CONFIG}" 2>/dev/null || [ -n "${RUN_STRESS_TESTS}" ]; then
|
||||
run_script "${HERMES_DIR}/scripts/stress.py" "04-stress.txt"
|
||||
else
|
||||
info "Stress tests disabled (enable in config or set RUN_STRESS_TESTS=1)"
|
||||
fi
|
||||
|
||||
# Phase 5: Backup/restore (manual activation)
|
||||
if [ -n "${RUN_BACKUP}" ]; then
|
||||
run_script "${HERMES_DIR}/scripts/backup.py" "05-backup.txt"
|
||||
fi
|
||||
|
||||
echo "───────────────────────────────────────────────────────" >> "${LOG_FILE}"
|
||||
info "Diagnostic pipeline complete."
|
||||
}
|
||||
|
||||
# ─── Agent Launch ─────────────────────────────────────────────────────────────
|
||||
|
||||
launch_agent() {
|
||||
info "Starting Hermes agent..."
|
||||
|
||||
if [ -f "${HERMES_DIR}/agent/hermes" ]; then
|
||||
exec "${HERMES_DIR}/agent/hermes" --config "${CONFIG}"
|
||||
elif command -v hermes &>/dev/null; then
|
||||
exec hermes --config "${CONFIG}"
|
||||
else
|
||||
error "Hermes agent not found on PATH or in ${HERMES_DIR}/agent/."
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
start_diagnostic_shell() {
|
||||
cat << 'SHELL'
|
||||
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ DIAGNOSTIC SHELL ║
|
||||
║ Hermes agent not available — running in manual mode. ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
|
||||
Available diagnostic scripts:
|
||||
|
||||
python3 /hermes/scripts/hardware.py — Hardware inventory
|
||||
python3 /hermes/scripts/bsod.py — BSOD minidump analysis
|
||||
python3 /hermes/scripts/drivers.py — Driver scanner
|
||||
python3 /hermes/scripts/stress.py — Stress testing
|
||||
python3 /hermes/scripts/backup.py — Backup/restore
|
||||
|
||||
Reports saved to: /hermes/reports/
|
||||
|
||||
Type 'exit' to shut down.
|
||||
|
||||
SHELL
|
||||
exec /bin/sh
|
||||
}
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
main() {
|
||||
print_banner
|
||||
|
||||
# Ensure report dir exists
|
||||
mkdir -p "${REPORT_DIR}"
|
||||
|
||||
info "Hermes Portable Rescue v1.0.0 — Boot mode: ${BOOT_MODE}"
|
||||
info "Config: ${CONFIG}"
|
||||
info "Reports: ${REPORT_DIR}"
|
||||
info "Log: ${LOG_FILE}"
|
||||
echo ""
|
||||
|
||||
# Optional: mount Windows partition for access to minidumps, etc.
|
||||
if [ "${MOUNT_WINDOWS}" != "no" ]; then
|
||||
mount_windows_partition || true
|
||||
fi
|
||||
|
||||
# Run diagnostic pipeline
|
||||
if [ "${SKIP_DIAGNOSTICS}" != "1" ]; then
|
||||
run_diagnostics
|
||||
fi
|
||||
|
||||
# Print report summary
|
||||
if [ -d "${REPORT_DIR}" ]; then
|
||||
echo ""
|
||||
info "Diagnostic reports:"
|
||||
for report in "${REPORT_DIR}"/*.txt; do
|
||||
if [ -f "${report}" ]; then
|
||||
local rname
|
||||
rname=$(basename "${report}")
|
||||
echo " ${rname} ($(wc -l < "${report}") lines)"
|
||||
fi
|
||||
done
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Launch agent or fall back to diagnostic shell
|
||||
if ! launch_agent; then
|
||||
start_diagnostic_shell
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,94 @@
|
||||
# Hermes Portable Rescue — Agent Configuration
|
||||
# =============================================
|
||||
# This is the **source** config for the portable Hermes agent.
|
||||
# It is copied into the USB image by build/usb-image.sh.
|
||||
#
|
||||
# LLM strategy determined by research:llm-strat task (t_9ba3b456).
|
||||
# Boot environment determined by research:boot-env task (t_163b7bb2).
|
||||
|
||||
agent:
|
||||
name: "Hermes Portable Rescue"
|
||||
version: "1.0.0"
|
||||
mode: "rescue"
|
||||
description: "AI-driven Windows PC diagnostic & repair toolkit"
|
||||
author: "OPLabs / Shawn"
|
||||
repo: "https://gitea.ourpad.casa/shawn/hermes-portable-rescue"
|
||||
|
||||
llm:
|
||||
# Strategy: hybrid (local for offline, API fallback when network available)
|
||||
strategy: hybrid
|
||||
local_model: "/hermes/models/qwen2.5-7b-q4.gguf"
|
||||
local_model_url: "https://huggingface.co/Qwen/Qwen2.5-7B-Instruct-GGUF/resolve/main/qwen2.5-7b-instruct-q4_K_M.gguf"
|
||||
api_fallback: true
|
||||
api_provider: openai
|
||||
api_endpoint: "https://api.openai.com/v1"
|
||||
model: "gpt-4o-mini"
|
||||
temperature: 0.2
|
||||
max_tokens: 2048
|
||||
|
||||
diagnostics:
|
||||
auto_run: true
|
||||
timeout_seconds: 3600
|
||||
log_level: info
|
||||
save_reports: true
|
||||
report_path: "/hermes/reports/"
|
||||
output_format: markdown
|
||||
|
||||
modules:
|
||||
hardware_inventory:
|
||||
enabled: true
|
||||
script: "/hermes/scripts/hardware.py"
|
||||
description: "CPU, RAM, GPU, disk enumeration and health checks"
|
||||
|
||||
bsod_analysis:
|
||||
enabled: true
|
||||
script: "/hermes/scripts/bsod.py"
|
||||
description: "Windows minidump parser with LLM interpretation"
|
||||
scan_paths:
|
||||
- "C:/Windows/Minidump/"
|
||||
- "C:/Windows/memory.dmp"
|
||||
- "/mnt/c/Windows/Minidump/"
|
||||
|
||||
driver_check:
|
||||
enabled: true
|
||||
script: "/hermes/scripts/drivers.py"
|
||||
description: "Hardware-aware driver scanner and updater"
|
||||
|
||||
stress_test:
|
||||
enabled: false # Manual activation only — can destabilise a failing system
|
||||
script: "/hermes/scripts/stress.py"
|
||||
description: "RAM, CPU, and GPU stress testing orchestration"
|
||||
confirm_before_run: true
|
||||
|
||||
backup_restore:
|
||||
enabled: false # Manual activation only
|
||||
script: "/hermes/scripts/backup.py"
|
||||
description: "Disk imaging and file-level backup/restore"
|
||||
confirm_before_run: true
|
||||
|
||||
network:
|
||||
dhcp: true
|
||||
fallback_static: false
|
||||
dns:
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
auto_connect_wifi: false
|
||||
wifi_ssid: ""
|
||||
wifi_password: ""
|
||||
|
||||
filesystem:
|
||||
mount_windows: true
|
||||
mount_point: "/mnt/c"
|
||||
ntfs_driver: "ntfs-3g"
|
||||
fallback_fuse: true
|
||||
|
||||
display:
|
||||
mode: interactive # interactive | silent | report-only
|
||||
color: true
|
||||
refresh_interval: 5 # seconds for live diagnostic dashboard
|
||||
|
||||
logging:
|
||||
file: "/hermes/reports/session.log"
|
||||
level: info # debug | info | warn | error
|
||||
max_size_mb: 10
|
||||
rotation: 3
|
||||
@@ -0,0 +1,39 @@
|
||||
# Hermes Portable Rescue — Launch Script (Windows / WinPE)
|
||||
# Run: powershell -ExecutionPolicy Bypass -File .\launch.ps1
|
||||
|
||||
param(
|
||||
[string]$HermesRoot = $PSScriptRoot
|
||||
)
|
||||
|
||||
$env:HERMES_ROOT = $HermesRoot
|
||||
$env:HERMES_CONFIG = Join-Path $HermesRoot "config.yaml"
|
||||
$env:Path = "$HermesRoot\venv\Scripts;$HermesRoot\tools;$env:Path"
|
||||
|
||||
Write-Host "╔══════════════════════════════════════════════╗" -ForegroundColor Cyan
|
||||
Write-Host "║ Hermes Portable Rescue — Runtime v1.0.0 ║" -ForegroundColor Cyan
|
||||
Write-Host "╚══════════════════════════════════════════════╝" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Root: $HermesRoot"
|
||||
|
||||
# Check Python availability
|
||||
$python = Get-Command "python.exe" -ErrorAction SilentlyContinue
|
||||
if (-not $python) {
|
||||
Write-Host "[ERROR] Python not found in $HermesRoot\venv" -ForegroundColor Red
|
||||
Write-Host "Run src\build-hermes-runtime.sh on a Linux system to create the venv,"
|
||||
Write-Host "then copy the output to this USB drive."
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[*] Python: $($python.Path)"
|
||||
|
||||
# Check for tools
|
||||
Write-Host "[*] Checking tools..."
|
||||
if (Test-Path "$HermesRoot\tools") {
|
||||
Get-ChildItem "$HermesRoot\tools\*.exe" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-Host " ✓ $($_.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
# Launch agent
|
||||
Write-Host ""
|
||||
Write-Host "[*] Starting Hermes Agent..."
|
||||
& "$HermesRoot\venv\Scripts\hermes.exe" --config "$env:HERMES_CONFIG"
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# Hermes Portable Rescue — Launch Script (Linux boot environment)
|
||||
# =============================================================================
|
||||
# Source this or run directly to start the Hermes agent.
|
||||
# Assumes: Python 3.11+ venv at HERMES_ROOT/venv
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
export HERMES_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export HERMES_CONFIG="$HERMES_ROOT/config.yaml"
|
||||
export PATH="$HERMES_ROOT/venv/bin:$HERMES_ROOT/tools:$PATH"
|
||||
|
||||
echo "╔══════════════════════════════════════════════╗"
|
||||
echo "║ Hermes Portable Rescue — Runtime v1.0.0 ║"
|
||||
echo "╚══════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
echo "Root: $HERMES_ROOT"
|
||||
echo "Config: $HERMES_CONFIG"
|
||||
|
||||
# --- Probe environment -------------------------------------------------------
|
||||
echo ""
|
||||
echo "[*] Checking environment…"
|
||||
|
||||
missing=0
|
||||
for tool in smartctl stress-ng dmidecode; do
|
||||
if command -v "$tool" &>/dev/null; then
|
||||
echo " ✓ $tool"
|
||||
else
|
||||
echo " ✗ $tool not found"
|
||||
((missing++))
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $missing -gt 0 && -f "$HERMES_ROOT/tools/fetch-tools.sh" ]]; then
|
||||
echo ""
|
||||
echo "[!] Some tools are missing. Run tools/fetch-tools.sh to download."
|
||||
fi
|
||||
|
||||
# --- Python check ------------------------------------------------------------
|
||||
echo ""
|
||||
if [[ -f "$HERMES_ROOT/venv/bin/python3" ]]; then
|
||||
PYVER=$("$HERMES_ROOT/venv/bin/python3" --version 2>&1)
|
||||
echo "[✓] Python venv: $PYVER"
|
||||
else
|
||||
echo "[✗] Python venv not found at $HERMES_ROOT/venv"
|
||||
echo " Run the build script to create it:"
|
||||
echo " src/build-hermes-runtime.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Storage mount check -----------------------------------------------------
|
||||
STORAGE_MOUNT="/mnt/hermes-data"
|
||||
if [[ ! -d "$STORAGE_MOUNT" ]]; then
|
||||
echo "[*] Storage mount not found — reports will be saved to /tmp"
|
||||
fi
|
||||
|
||||
# --- Launch agent ------------------------------------------------------------
|
||||
echo ""
|
||||
echo "[*] Starting Hermes Agent in rescue mode…"
|
||||
echo ""
|
||||
|
||||
exec "$HERMES_ROOT/venv/bin/hermes" --config "$HERMES_CONFIG"
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"generated_by": "build-hermes-runtime.sh",
|
||||
"description": "Tool registry for Hermes Portable Rescue diagnostic environment",
|
||||
"last_updated": null,
|
||||
"tools": {
|
||||
"smartctl": {
|
||||
"description": "S.M.A.R.T. hard drive health check — read attributes, run self-tests",
|
||||
"expected_path": "tools/smartctl",
|
||||
"type": "binary",
|
||||
"platforms": ["linux", "winpe"],
|
||||
"test_command": "smartctl --version",
|
||||
"source": "https://static.smartmontools.org/",
|
||||
"install_hint": "curl -sL https://static.smartmontools.org/smartctl-x86_64 -o tools/smartctl && chmod +x tools/smartctl"
|
||||
},
|
||||
"stress-ng": {
|
||||
"description": "CPU, memory, and I/O stress testing",
|
||||
"expected_path": "tools/stress-ng",
|
||||
"type": "binary",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "stress-ng --version",
|
||||
"source": "https://github.com/ColinIanKing/stress-ng",
|
||||
"install_hint": "apt-get install stress-ng || apk add stress-ng"
|
||||
},
|
||||
"dmidecode": {
|
||||
"description": "Hardware inventory via DMI/SMBIOS — CPU, RAM, motherboard",
|
||||
"expected_path": "/usr/sbin/dmidecode",
|
||||
"type": "system_package",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "dmidecode --version",
|
||||
"install_hint": "apt-get install dmidecode || apk add dmidecode"
|
||||
},
|
||||
"lshw": {
|
||||
"description": "Full hardware listing — PCI, USB, disks, network",
|
||||
"expected_path": "/usr/bin/lshw",
|
||||
"type": "system_package",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "lshw -version",
|
||||
"install_hint": "apt-get install lshw || apk add lshw"
|
||||
},
|
||||
"memtester": {
|
||||
"description": "Userspace memory tester — catch RAM errors without reboot",
|
||||
"expected_path": "tools/memtester",
|
||||
"type": "binary",
|
||||
"platforms": ["linux", "winpe"],
|
||||
"test_command": "memtester --version",
|
||||
"source": "https://pyropus.ca/software/memtester/",
|
||||
"install_hint": "apt-get install memtester || apk add memtester"
|
||||
},
|
||||
"ntfs-3g": {
|
||||
"description": "NTFS read/write driver for accessing Windows partitions",
|
||||
"expected_path": "/usr/bin/ntfs-3g",
|
||||
"type": "system_package",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "ntfs-3g --version",
|
||||
"install_hint": "apt-get install ntfs-3g || apk add ntfs-3g"
|
||||
},
|
||||
"ddrescue": {
|
||||
"description": "Disk imaging with error recovery — salvage data from failing drives",
|
||||
"expected_path": "/usr/bin/ddrescue",
|
||||
"type": "system_package",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "ddrescue --version",
|
||||
"install_hint": "apt-get install gddrescue || apk add ddrescue"
|
||||
},
|
||||
"parted": {
|
||||
"description": "Partition table manipulation",
|
||||
"expected_path": "/sbin/parted",
|
||||
"type": "system_package",
|
||||
"platforms": ["linux"],
|
||||
"test_command": "parted --version",
|
||||
"install_hint": "apt-get install parted || apk add parted"
|
||||
}
|
||||
},
|
||||
"groups": {
|
||||
"must_have": ["smartctl", "dmidecode", "lshw", "memtester"],
|
||||
"stress": ["stress-ng"],
|
||||
"storage": ["ntfs-3g", "ddrescue", "parted"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user