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:
2026-07-04 03:53:30 -04:00
parent 8949e67783
commit 1479e161ab
15 changed files with 4013 additions and 0 deletions
+339
View File
@@ -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 ""
+28
View File
@@ -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.
+583
View File
@@ -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)
+995
View File
@@ -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()
+250
View File
@@ -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 "$@"
+94
View File
@@ -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
+39
View File
@@ -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"
+63
View File
@@ -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"
+80
View File
@@ -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"]
}
}