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,135 @@
|
|||||||
|
# Hermes Portable Rescue — Build System
|
||||||
|
# =========================================
|
||||||
|
#
|
||||||
|
# Targets:
|
||||||
|
# all — Full build: ISO + disk image (default)
|
||||||
|
# iso — Build rescue ISO only
|
||||||
|
# image — Build full disk image (requires ISO first)
|
||||||
|
# check — Validate all upstream artifacts are present
|
||||||
|
# clean — Remove all build artifacts
|
||||||
|
# distclean — Clean + remove downloaded tool binaries
|
||||||
|
# write — Write image to USB device (run: make write DEV=/dev/sdX)
|
||||||
|
# ventoy — Install Ventoy to USB device (run: make ventoy DEV=/dev/sdX)
|
||||||
|
# help — Show this message
|
||||||
|
#
|
||||||
|
# Quick Start:
|
||||||
|
# 1. make check — See what's missing
|
||||||
|
# 2. make all — Build everything that's available
|
||||||
|
# 3. make write DEV=/dev/sdb — Write to USB (CAUTION!)
|
||||||
|
#
|
||||||
|
|
||||||
|
BUILD_DIR ?= build
|
||||||
|
HERMES_SRC ?= src/hermes
|
||||||
|
DIAG_SRC ?= src/diagnostics
|
||||||
|
TOOLS_SRC ?= src/tools
|
||||||
|
OUTPUT_DIR ?= $(BUILD_DIR)
|
||||||
|
|
||||||
|
IMAGE_SCRIPT := $(BUILD_DIR)/usb-image.sh
|
||||||
|
OUTPUT_IMAGE := $(OUTPUT_DIR)/hermes-rescue.img
|
||||||
|
OUTPUT_ISO := $(OUTPUT_DIR)/hermes-rescue.iso
|
||||||
|
|
||||||
|
DEVICE ?= /dev/sdb
|
||||||
|
|
||||||
|
# Colors
|
||||||
|
BOLD := \033[1m
|
||||||
|
GREEN := \033[0;32m
|
||||||
|
YELLOW := \033[1;33m
|
||||||
|
RED := \033[0;31m
|
||||||
|
NC := \033[0m
|
||||||
|
|
||||||
|
.PHONY: all iso image check clean distclean write ventoy help dirs
|
||||||
|
|
||||||
|
all: check iso image
|
||||||
|
@echo ""
|
||||||
|
@printf "$(GREEN)✓ Build complete!$(NC)\n"
|
||||||
|
@echo " ISO: $(OUTPUT_ISO)"
|
||||||
|
@echo " IMG: $(OUTPUT_IMAGE)"
|
||||||
|
|
||||||
|
iso: dirs
|
||||||
|
@printf "$(BOLD)Building rescue ISO...$(NC)\n"
|
||||||
|
@bash $(IMAGE_SCRIPT) --iso-only
|
||||||
|
|
||||||
|
image: dirs iso
|
||||||
|
@printf "$(BOLD)Building disk image...$(NC)\n"
|
||||||
|
@bash $(IMAGE_SCRIPT)
|
||||||
|
|
||||||
|
dirs:
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
|
||||||
|
check:
|
||||||
|
@printf "$(BOLD)Checking upstream artifacts...$(NC)\n"
|
||||||
|
@bash $(IMAGE_SCRIPT) --skip-validate 2>&1 | head -30 || true
|
||||||
|
@echo ""
|
||||||
|
@printf "Expected artifact paths:\n"
|
||||||
|
@for f in \
|
||||||
|
"$(HERMES_SRC)/agent/hermes" \
|
||||||
|
"$(HERMES_SRC)/config.yaml" \
|
||||||
|
"$(HERMES_SRC)/autorun.sh" \
|
||||||
|
"$(DIAG_SRC)/hardware.py" \
|
||||||
|
"$(DIAG_SRC)/bsod.py" \
|
||||||
|
"$(DIAG_SRC)/drivers.py" \
|
||||||
|
"$(DIAG_SRC)/backup.py" \
|
||||||
|
"$(DIAG_SRC)/stress.py" \
|
||||||
|
; do \
|
||||||
|
if [ -f "$$f" ] || [ -d "$$f" ]; then \
|
||||||
|
printf " $(GREEN)✓$(NC) %s\n" "$$f"; \
|
||||||
|
else \
|
||||||
|
printf " $(YELLOW)✗$(NC) %s\n" "$$f"; \
|
||||||
|
fi; \
|
||||||
|
done
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@printf "$(BOLD)Cleaning build artifacts...$(NC)\n"
|
||||||
|
@rm -rf $(BUILD_DIR)/usb-staging/
|
||||||
|
@rm -f $(OUTPUT_IMAGE) $(OUTPUT_ISO)
|
||||||
|
@printf "$(GREEN)✓ Cleaned.$(NC)\n"
|
||||||
|
|
||||||
|
distclean: clean
|
||||||
|
@printf "$(BOLD)Deep cleaning...$(NC)\n"
|
||||||
|
@rm -rf $(TOOLS_SRC)/*
|
||||||
|
@printf "$(GREEN)✓ Deep clean complete.$(NC)\n"
|
||||||
|
|
||||||
|
write:
|
||||||
|
@if [ ! -b "$(DEVICE)" ]; then \
|
||||||
|
printf "$(RED)Error: $(DEVICE) is not a block device. Specify DEV=/dev/sdX.$(NC)\n"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@printf "$(YELLOW)⚠️ About to write to $(DEVICE). ALL DATA WILL BE DESTROYED!$(NC)\n"
|
||||||
|
@printf "Type the device path to confirm: " && read confirm && [ "$$confirm" = "$(DEVICE)" ]
|
||||||
|
@printf "$(BOLD)Writing $(OUTPUT_IMAGE) to $(DEVICE)...$(NC)\n"
|
||||||
|
@sudo dd if=$(OUTPUT_IMAGE) of=$(DEVICE) bs=4M status=progress conv=fsync
|
||||||
|
@sync
|
||||||
|
@printf "$(GREEN)✓ Written to $(DEVICE).$(NC)\n"
|
||||||
|
|
||||||
|
ventoy:
|
||||||
|
@if [ ! -b "$(DEVICE)" ]; then \
|
||||||
|
printf "$(RED)Error: $(DEVICE) is not a block device. Specify DEV=/dev/sdX.$(NC)\n"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@if ! command -v Ventoy2Disk.sh &>/dev/null; then \
|
||||||
|
printf "$(RED)Ventoy2Disk.sh not found. Install Ventoy first.$(NC)\n"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@printf "$(YELLOW)⚠️ About to install Ventoy to $(DEVICE). ALL DATA WILL BE DESTROYED!$(NC)\n"
|
||||||
|
@printf "Type the device path to confirm: " && read confirm && [ "$$confirm" = "$(DEVICE)" ]
|
||||||
|
@sudo Ventoy2Disk.sh -i -g "$(DEVICE)"
|
||||||
|
@printf "$(GREEN)✓ Ventoy installed to $(DEVICE). Copy ISOs to the data partition.$(NC)\n"
|
||||||
|
|
||||||
|
help:
|
||||||
|
@printf "$(BOLD)Hermes Portable Rescue — Build Targets$(NC)\n"
|
||||||
|
@echo ""
|
||||||
|
@sed -n 's/^# //p; s/^#//p' $(MAKEFILE_LIST) | head -20
|
||||||
|
@echo ""
|
||||||
|
@echo "Usage: make <target> [VAR=value]"
|
||||||
|
@echo ""
|
||||||
|
@echo "Variables:"
|
||||||
|
@echo " DEVICE=/dev/sdX — Target USB device (for write/ventoy targets)"
|
||||||
|
@echo " BUILD_DIR=build — Output directory"
|
||||||
|
@echo " HERMES_SRC=src/hermes — Hermes runtime source"
|
||||||
|
@echo " DIAG_SRC=src/diagnostics — Diagnostic scripts source"
|
||||||
|
@echo " TOOLS_SRC=src/tools — Diagnostic tools source"
|
||||||
|
@echo ""
|
||||||
|
@echo "Examples:"
|
||||||
|
@echo " make — Full build (check → iso → image)"
|
||||||
|
@echo " make iso — Build ISO only"
|
||||||
|
@echo " make write DEV=/dev/sdb — Write image to USB"
|
||||||
Executable
+804
@@ -0,0 +1,804 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ==============================================================================
|
||||||
|
# Hermes Portable Rescue — USB Image Assembler
|
||||||
|
# ==============================================================================
|
||||||
|
#
|
||||||
|
# Builds the final USB boot image from upstream build artifacts.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./build/usb-image.sh [options]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --output PATH Output path for the disk image (default: build/hermes-rescue.img)
|
||||||
|
# --iso-only Build only the ISO, not the full disk image
|
||||||
|
# --write-device DEV Write directly to a USB device (e.g. /dev/sdb)
|
||||||
|
# --force Skip all safety confirmations
|
||||||
|
# --skip-validate Skip artifact validation (for development)
|
||||||
|
# --verbose Verbose output
|
||||||
|
# --clean Clean build dirs before starting
|
||||||
|
#
|
||||||
|
# Dependencies:
|
||||||
|
# - build:hermes-runtime → src/hermes/ (Hermes agent runtime)
|
||||||
|
# - build:hardware-inventory → src/diagnostics/hardware.py
|
||||||
|
# - build:bsod-analyzer → src/diagnostics/bsod.py
|
||||||
|
# - build:driver-updater → src/diagnostics/drivers.py
|
||||||
|
# - build:backup-restore → src/diagnostics/backup.py
|
||||||
|
# - build:stress-tests → src/diagnostics/stress.py
|
||||||
|
# - research:boot-env → research/boot-env.md (defines the boot environment)
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# - build/hermes-rescue.img — Full disk image (Ventoy + Rescue ISO + Tools)
|
||||||
|
# - build/hermes-rescue.iso — Bootable rescue ISO
|
||||||
|
#
|
||||||
|
# ==============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
# ─── Configuration ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# These can be overridden via environment variables
|
||||||
|
OUTPUT_IMAGE="${OUTPUT_IMAGE:-"$PROJECT_DIR/build/hermes-rescue.img"}"
|
||||||
|
OUTPUT_ISO="${OUTPUT_ISO:-"$PROJECT_DIR/build/hermes-rescue.iso"}"
|
||||||
|
BUILD_DIR="${BUILD_DIR:-"$PROJECT_DIR/build/usb-staging"}"
|
||||||
|
HERMES_SRC="${HERMES_SRC:-"$PROJECT_DIR/src/hermes"}"
|
||||||
|
DIAG_SRC="${DIAG_SRC:-"$PROJECT_DIR/src/diagnostics"}"
|
||||||
|
TOOLS_SRC="${TOOLS_SRC:-"$PROJECT_DIR/src/tools"}"
|
||||||
|
VENTOY_JSON="${VENTOY_JSON:-"$PROJECT_DIR/build/ventoy.json"}"
|
||||||
|
|
||||||
|
# Expected artifact paths (upstream deliverables)
|
||||||
|
declare -A ARTIFACTS=(
|
||||||
|
["Hermes runtime"]="$HERMES_SRC/agent/hermes"
|
||||||
|
["Hermes config"]="$HERMES_SRC/config.yaml"
|
||||||
|
["Autorun script"]="$HERMES_SRC/autorun.sh"
|
||||||
|
["Hardware inventory"]="$DIAG_SRC/hardware.py"
|
||||||
|
["BSOD analyzer"]="$DIAG_SRC/bsod.py"
|
||||||
|
["Driver updater"]="$DIAG_SRC/drivers.py"
|
||||||
|
["Backup/restore"]="$DIAG_SRC/backup.py"
|
||||||
|
["Stress tests"]="$DIAG_SRC/stress.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Boot environment configuration
|
||||||
|
BOOT_ENV="${BOOT_ENV:-linux}" # 'linux' or 'winpe' — set by research:boot-env
|
||||||
|
ISO_LABEL="HERMES_RESCUE"
|
||||||
|
|
||||||
|
# Image sizes (can be overridden via env)
|
||||||
|
IMAGE_SIZE_MB="${IMAGE_SIZE_MB:-8192}" # 8 GB default
|
||||||
|
EFI_SIZE_MB="${EFI_SIZE_MB:-512}" # 512 MB EFI partition
|
||||||
|
|
||||||
|
# ─── Colors ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*" >&2; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
die() { error "$*"; exit 1; }
|
||||||
|
|
||||||
|
# ─── Flags ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DO_CLEAN=false
|
||||||
|
SKIP_VALIDATE=false
|
||||||
|
FORCE=false
|
||||||
|
VERBOSE=false
|
||||||
|
ISO_ONLY=false
|
||||||
|
WRITE_DEVICE=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--output) OUTPUT_IMAGE="$2"; shift 2 ;;
|
||||||
|
--iso-only) ISO_ONLY=true; shift ;;
|
||||||
|
--write-device) WRITE_DEVICE="$2"; shift 2 ;;
|
||||||
|
--force) FORCE=true; shift ;;
|
||||||
|
--skip-validate) SKIP_VALIDATE=true; shift ;;
|
||||||
|
--verbose) VERBOSE=true; shift ;;
|
||||||
|
--clean) DO_CLEAN=true; shift ;;
|
||||||
|
-h|--help) head -30 "$0"; exit 0 ;;
|
||||||
|
*) die "Unknown option: $1 (use --help)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ─── Prerequisite Checks ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
check_command() {
|
||||||
|
if ! command -v "$1" &>/dev/null; then
|
||||||
|
die "Required command '$1' not found. Install it first."
|
||||||
|
fi
|
||||||
|
$VERBOSE && info " ✓ $1 found at $(command -v "$1")"
|
||||||
|
}
|
||||||
|
|
||||||
|
prerequisites() {
|
||||||
|
info "Checking prerequisites..."
|
||||||
|
|
||||||
|
# Core build tools
|
||||||
|
check_command mkisofs
|
||||||
|
check_command xorriso
|
||||||
|
check_command grub-mkrescue 2>/dev/null || check_command grub2-mkrescue
|
||||||
|
check_command dd
|
||||||
|
check_command parted
|
||||||
|
check_command mkfs.vfat
|
||||||
|
check_command mkfs.ext4
|
||||||
|
|
||||||
|
# Ventoy (optional — needed only for full disk image)
|
||||||
|
if ! $ISO_ONLY; then
|
||||||
|
if command -v Ventoy2Disk.sh &>/dev/null; then
|
||||||
|
VENTOY_CMD="Ventoy2Disk.sh"
|
||||||
|
elif [ -f ./ventoy/Ventoy2Disk.sh ]; then
|
||||||
|
VENTOY_CMD="./ventoy/Ventoy2Disk.sh"
|
||||||
|
else
|
||||||
|
warn "Ventoy2Disk.sh not found — will build ISO only (no Ventoy disk image)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "All prerequisite commands available."
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_artifacts() {
|
||||||
|
$SKIP_VALIDATE && { warn "Skipping artifact validation."; return 0; }
|
||||||
|
|
||||||
|
info "Validating upstream build artifacts..."
|
||||||
|
local missing=0
|
||||||
|
|
||||||
|
for name in "${!ARTIFACTS[@]}"; do
|
||||||
|
local path="${ARTIFACTS[$name]}"
|
||||||
|
if [ -f "$path" ] || [ -d "$path" ] || [ -x "$path" ] 2>/dev/null; then
|
||||||
|
$VERBOSE && ok " Found: $name → $path"
|
||||||
|
else
|
||||||
|
warn " Missing: $name → $path"
|
||||||
|
missing=$((missing + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check boot environment research
|
||||||
|
local boot_env_doc="$PROJECT_DIR/research/boot-env.md"
|
||||||
|
if [ -f "$boot_env_doc" ]; then
|
||||||
|
$VERBOSE && ok " Found: boot environment research → $boot_env_doc"
|
||||||
|
else
|
||||||
|
warn " Missing: boot environment research → $boot_env_doc (will use default: $BOOT_ENV)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$missing" -gt 0 ]; then
|
||||||
|
warn "Found $missing missing artifact(s). Build may be incomplete."
|
||||||
|
if ! $FORCE; then
|
||||||
|
echo ""
|
||||||
|
warn "Run with --force to continue anyway, or build upstream tasks first."
|
||||||
|
echo ""
|
||||||
|
info "Upstream tasks to complete:"
|
||||||
|
echo " build:hermes-runtime (t_c816e37d)"
|
||||||
|
echo " build:hardware-inventory (t_1215a5f0)"
|
||||||
|
echo " build:bsod-analyzer (t_10103a48)"
|
||||||
|
echo " build:driver-updater (t_2afd37ec)"
|
||||||
|
echo " build:backup-restore (t_0b33ea13)"
|
||||||
|
echo " build:stress-tests (t_f817392d)"
|
||||||
|
die "Missing artifacts. Build cannot proceed."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
ok "All upstream artifacts present."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Directory Setup ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
setup_directories() {
|
||||||
|
info "Setting up staging directories..."
|
||||||
|
|
||||||
|
if $DO_CLEAN && [ -d "$BUILD_DIR" ]; then
|
||||||
|
info "Cleaning existing build directory..."
|
||||||
|
rm -rf "$BUILD_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR"/{ISO,hermes/{agent,tools,scripts},ventoy/theme,persistence,boot/grub}
|
||||||
|
$VERBOSE && ok "Staging directory: $BUILD_DIR"
|
||||||
|
|
||||||
|
ok "Staging directories created."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Copy Hermes Runtime ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
copy_hermes_runtime() {
|
||||||
|
info "Copying Hermes agent runtime..."
|
||||||
|
|
||||||
|
if [ -d "$HERMES_SRC/agent" ]; then
|
||||||
|
cp -r "$HERMES_SRC/agent/"* "$BUILD_DIR/hermes/agent/" 2>/dev/null || \
|
||||||
|
warn "No files in Hermes agent directory — skipping."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$HERMES_SRC/config.yaml" ]; then
|
||||||
|
cp "$HERMES_SRC/config.yaml" "$BUILD_DIR/hermes/config.yaml"
|
||||||
|
else
|
||||||
|
warn "config.yaml not found — generating default."
|
||||||
|
generate_default_config
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$HERMES_SRC/autorun.sh" ]; then
|
||||||
|
cp "$HERMES_SRC/autorun.sh" "$BUILD_DIR/hermes/autorun.sh"
|
||||||
|
chmod +x "$BUILD_DIR/hermes/autorun.sh"
|
||||||
|
else
|
||||||
|
warn "autorun.sh not found — generating default."
|
||||||
|
generate_default_autorun
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Hermes runtime staged."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Copy Diagnostic Scripts ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
copy_diagnostics() {
|
||||||
|
info "Copying diagnostic scripts..."
|
||||||
|
|
||||||
|
local scripts=("hardware.py" "bsod.py" "drivers.py" "backup.py" "stress.py")
|
||||||
|
for script in "${scripts[@]}"; do
|
||||||
|
local src="$DIAG_SRC/$script"
|
||||||
|
if [ -f "$src" ]; then
|
||||||
|
cp "$src" "$BUILD_DIR/hermes/scripts/$script"
|
||||||
|
chmod +x "$BUILD_DIR/hermes/scripts/$script" 2>/dev/null || true
|
||||||
|
$VERBOSE && ok " Staged: $script"
|
||||||
|
else
|
||||||
|
warn " Missing diagnostic script: $script"
|
||||||
|
# Generate a stub so the system boots but the component isn't active
|
||||||
|
generate_diagnostic_stub "$script"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ok "Diagnostic scripts staged."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Copy Tools ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
copy_tools() {
|
||||||
|
info "Copying diagnostic tools..."
|
||||||
|
|
||||||
|
local tool_dirs=()
|
||||||
|
if [ -d "$TOOLS_SRC" ]; then
|
||||||
|
IFS=$'\n' read -d '' -r -a tool_dirs < <(find "$TOOLS_SRC" -mindepth 1 -maxdepth 1 -type d 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#tool_dirs[@]} -eq 0 ]; then
|
||||||
|
warn "No diagnostic tools found in $TOOLS_SRC — creating structure."
|
||||||
|
mkdir -p "$BUILD_DIR/hermes/tools"
|
||||||
|
# Create placeholder README for tools that need to be added
|
||||||
|
cat > "$BUILD_DIR/hermes/tools/README.md" << 'TOOLREADME'
|
||||||
|
# Diagnostic Tools — Placeholder Directory
|
||||||
|
|
||||||
|
Populate this directory with portable diagnostic executables:
|
||||||
|
|
||||||
|
- `smartmontools/` — `smartctl`, `smartd` (disk health)
|
||||||
|
- `memtest86+/` — MemTest86+ binary (RAM testing)
|
||||||
|
- `stress-ng/` — stress-ng binary (CPU/GPU stress)
|
||||||
|
- `dmidecode/` — DMI table decoder (hardware enumeration)
|
||||||
|
- `lshw/` — lshw binary (hardware listing)
|
||||||
|
- `parted/` — parted, fdisk utilities
|
||||||
|
- `ntfs-3g/` — NTFS read/write support
|
||||||
|
|
||||||
|
See the build:hermes-runtime task (t_c816e37d) for the packaging scripts.
|
||||||
|
TOOLREADME
|
||||||
|
else
|
||||||
|
for dir in "${tool_dirs[@]}"; do
|
||||||
|
local dirname
|
||||||
|
dirname="$(basename "$dir")"
|
||||||
|
cp -r "$dir" "$BUILD_DIR/hermes/tools/$dirname"
|
||||||
|
$VERBOSE && ok " Staged tools: $dirname"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
ok "Diagnostic tools staged."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Configure Boot Environment ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
configure_boot() {
|
||||||
|
info "Configuring boot environment..."
|
||||||
|
|
||||||
|
# Read boot environment research if available
|
||||||
|
local boot_doc="$PROJECT_DIR/research/boot-env.md"
|
||||||
|
if [ -f "$boot_doc" ]; then
|
||||||
|
# Source any boot config variables defined in the research doc
|
||||||
|
if grep -q "^BOOT_ENV=" "$boot_doc" 2>/dev/null; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$boot_doc"
|
||||||
|
fi
|
||||||
|
info "Using boot environment from research: $BOOT_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$BOOT_ENV" in
|
||||||
|
linux)
|
||||||
|
configure_linux_boot
|
||||||
|
;;
|
||||||
|
winpe)
|
||||||
|
configure_winpe_boot
|
||||||
|
;;
|
||||||
|
hybrid)
|
||||||
|
configure_hybrid_boot
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
die "Unknown boot environment: $BOOT_ENV"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_linux_boot() {
|
||||||
|
info "Configuring Linux-based boot environment..."
|
||||||
|
|
||||||
|
# Generate GRUB config for the rescue ISO
|
||||||
|
cat > "$BUILD_DIR/boot/grub/grub.cfg" << 'GRUB'
|
||||||
|
set default=0
|
||||||
|
set timeout=10
|
||||||
|
|
||||||
|
menuentry "Hermes Portable Rescue — Linux" {
|
||||||
|
linux /boot/vmlinuz quiet splash console=tty0
|
||||||
|
initrd /boot/initrd.img
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "Hermes Rescue — Safe Mode (no HW accel)" {
|
||||||
|
linux /boot/vmlinuz nomodeset quiet
|
||||||
|
initrd /boot/initrd.img
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "Boot from Local Disk" {
|
||||||
|
chainloader (hd0)+1
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "UEFI Firmware Settings" {
|
||||||
|
fwsetup
|
||||||
|
}
|
||||||
|
|
||||||
|
menuentry "System Memory Test (MemTest86+)" {
|
||||||
|
linux /boot/memtest86+.bin
|
||||||
|
}
|
||||||
|
GRUB
|
||||||
|
ok "GRUB configuration written."
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_winpe_boot() {
|
||||||
|
info "Configuring WinPE-based boot environment..."
|
||||||
|
|
||||||
|
# WinPE boot configuration
|
||||||
|
cat > "$BUILD_DIR/ISO/autounattend.xml" << 'WINAUTO'
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-Setup"
|
||||||
|
processorArchitecture="amd64"
|
||||||
|
publicKeyToken="31bf3856ad364e35"
|
||||||
|
language="neutral"
|
||||||
|
versionScope="nonSxS">
|
||||||
|
<UserData>
|
||||||
|
<ProductKey>
|
||||||
|
<WillShowUI>Never</WillShowUI>
|
||||||
|
</ProductKey>
|
||||||
|
</UserData>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
|
WINAUTO
|
||||||
|
ok "WinPE boot configuration written."
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_hybrid_boot() {
|
||||||
|
info "Configuring hybrid (multi-boot) environment..."
|
||||||
|
# Hybrid: place both Linux ISO and WinPE ISO with Ventoy handling the boot menu
|
||||||
|
mkdir -p "$BUILD_DIR/ISO"
|
||||||
|
ok "Hybrid boot structure ready (add ISOs to build/usb-staging/ISO/)."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Generate Stubs / Defaults ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
generate_diagnostic_stub() {
|
||||||
|
local script_name="$1"
|
||||||
|
local module_name="${script_name%.py}"
|
||||||
|
|
||||||
|
cat > "$BUILD_DIR/hermes/scripts/$script_name" << STUB
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
${module_name} — STUB MODULE.
|
||||||
|
|
||||||
|
This diagnostic component has not been built yet.
|
||||||
|
Replace this stub with the actual implementation once the
|
||||||
|
upstream build task completes.
|
||||||
|
|
||||||
|
Upstream task reference: check the Hermes Portable Rescue board.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def diagnose() -> dict:
|
||||||
|
"""Return a stub indicating this module is unavailable."""
|
||||||
|
return {
|
||||||
|
"component": "${module_name}",
|
||||||
|
"status": "unavailable",
|
||||||
|
"message": "This diagnostic module has not been built yet.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
result = diagnose()
|
||||||
|
print(f"[{result['status'].upper()}] {result['component']}: {result['message']}")
|
||||||
|
return 1 if result['status'] == 'error' else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
STUB
|
||||||
|
chmod +x "$BUILD_DIR/hermes/scripts/$script_name"
|
||||||
|
warn " Generated stub: $script_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_default_config() {
|
||||||
|
cat > "$BUILD_DIR/hermes/config.yaml" << 'CONF'
|
||||||
|
# Hermes Portable Rescue — Agent Configuration
|
||||||
|
# =============================================
|
||||||
|
# This config is auto-generated by build/usb-image.sh.
|
||||||
|
# Customize for production use.
|
||||||
|
|
||||||
|
agent:
|
||||||
|
name: "Hermes Portable Rescue"
|
||||||
|
version: "1.0.0"
|
||||||
|
mode: "rescue"
|
||||||
|
|
||||||
|
llm:
|
||||||
|
# Strategy determined by research:llm-strat (t_9ba3b456)
|
||||||
|
# Options: local, api, hybrid
|
||||||
|
strategy: hybrid
|
||||||
|
local_model: "/hermes/models/qwen2.5-7b-q4.gguf"
|
||||||
|
api_fallback: true
|
||||||
|
api_endpoint: "https://api.openai.com/v1"
|
||||||
|
|
||||||
|
diagnostics:
|
||||||
|
auto_run: true
|
||||||
|
timeout_seconds: 3600
|
||||||
|
hardware_inventory: true
|
||||||
|
bsod_analysis: true
|
||||||
|
driver_check: true
|
||||||
|
stress_test: false # Manual activation only
|
||||||
|
backup_restore: false # Manual activation only
|
||||||
|
|
||||||
|
reporting:
|
||||||
|
log_level: info
|
||||||
|
output_format: markdown
|
||||||
|
save_reports: true
|
||||||
|
report_path: "/hermes/reports/"
|
||||||
|
|
||||||
|
network:
|
||||||
|
dhcp: true
|
||||||
|
fallback_static: false
|
||||||
|
dns: ["8.8.8.8", "1.1.1.1"]
|
||||||
|
CONF
|
||||||
|
ok "Default config.yaml generated."
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_default_autorun() {
|
||||||
|
cat > "$BUILD_DIR/hermes/autorun.sh" << 'AUTORUN'
|
||||||
|
#!/bin/sh
|
||||||
|
# ==============================================================================
|
||||||
|
# Hermes Portable Rescue — Auto-Launch Script
|
||||||
|
# ==============================================================================
|
||||||
|
# Runs when the rescue environment boots.
|
||||||
|
# Detects environment, mounts necessary filesystems, and launches Hermes agent.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HERMES_DIR="/hermes"
|
||||||
|
REPORT_DIR="${HERMES_DIR}/reports"
|
||||||
|
CONFIG="${HERMES_DIR}/config.yaml"
|
||||||
|
|
||||||
|
echo "====================================================="
|
||||||
|
echo " Hermes Portable Rescue v1.0.0"
|
||||||
|
echo " AI-driven Windows PC diagnostic & repair toolkit"
|
||||||
|
echo "====================================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Mount the Hermes partition if not already
|
||||||
|
if [ ! -f "${CONFIG}" ]; then
|
||||||
|
echo "[*] Locating Hermes runtime..."
|
||||||
|
for device in /dev/sd* /dev/nvme0n1* /dev/mmcblk*; do
|
||||||
|
mount_point="/mnt/hermes"
|
||||||
|
mkdir -p "${mount_point}" 2>/dev/null || true
|
||||||
|
if mount -o ro "${device}" "${mount_point}" 2>/dev/null; then
|
||||||
|
if [ -f "${mount_point}/hermes/config.yaml" ]; then
|
||||||
|
HERMES_DIR="${mount_point}/hermes"
|
||||||
|
CONFIG="${HERMES_DIR}/config.yaml"
|
||||||
|
echo "[+] Found Hermes runtime on ${device}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
umount "${mount_point}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create report directory
|
||||||
|
mkdir -p "${REPORT_DIR}"
|
||||||
|
|
||||||
|
# Run hardware inventory
|
||||||
|
echo "[*] Running hardware inventory..."
|
||||||
|
if [ -f "${HERMES_DIR}/scripts/hardware.py" ]; then
|
||||||
|
python3 "${HERMES_DIR}/scripts/hardware.py" > "${REPORT_DIR}/hardware.txt" 2>&1 || true
|
||||||
|
echo "[+] Hardware inventory complete"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Launch the agent
|
||||||
|
echo "[*] 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
|
||||||
|
echo "[!] Hermes agent not found. Starting diagnostic shell..."
|
||||||
|
echo " Run individual diagnostics:"
|
||||||
|
echo " python3 ${HERMES_DIR}/scripts/hardware.py"
|
||||||
|
echo " python3 ${HERMES_DIR}/scripts/bsod.py --dump /path/to/minidump.dmp"
|
||||||
|
echo " python3 ${HERMES_DIR}/scripts/drivers.py --scan"
|
||||||
|
echo ""
|
||||||
|
exec /bin/sh
|
||||||
|
fi
|
||||||
|
AUTORUN
|
||||||
|
chmod +x "$BUILD_DIR/hermes/autorun.sh"
|
||||||
|
ok "Default autorun.sh generated."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Build ISO ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build_iso() {
|
||||||
|
info "Building rescue ISO: ${OUTPUT_ISO}"
|
||||||
|
|
||||||
|
# Create the ISO directory structure
|
||||||
|
local iso_root="$BUILD_DIR/ISO"
|
||||||
|
mkdir -p "$iso_root"/{boot/grub,hermes}
|
||||||
|
|
||||||
|
# Copy boot files
|
||||||
|
if [ -f "$BUILD_DIR/boot/vmlinuz" ] && [ -f "$BUILD_DIR/boot/initrd.img" ]; then
|
||||||
|
cp "$BUILD_DIR/boot/vmlinuz" "$iso_root/boot/"
|
||||||
|
cp "$BUILD_DIR/boot/initrd.img" "$iso_root/boot/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy GRUB config
|
||||||
|
if [ -f "$BUILD_DIR/boot/grub/grub.cfg" ]; then
|
||||||
|
cp "$BUILD_DIR/boot/grub/grub.cfg" "$iso_root/boot/grub/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy Hermes runtime
|
||||||
|
cp -r "$BUILD_DIR/hermes" "$iso_root/"
|
||||||
|
|
||||||
|
# Build the ISO
|
||||||
|
xorriso -as mkisofs \
|
||||||
|
-iso-level 3 \
|
||||||
|
-full-iso9660-filenames \
|
||||||
|
-volid "${ISO_LABEL}" \
|
||||||
|
-eltorito-boot boot/grub/eltorito.img \
|
||||||
|
-no-emul-boot \
|
||||||
|
-boot-load-size 4 \
|
||||||
|
-boot-info-table \
|
||||||
|
-eltorito-alt-boot \
|
||||||
|
-e boot/grub/efi.img \
|
||||||
|
-no-emul-boot \
|
||||||
|
-isohybrid-gpt-basdat \
|
||||||
|
-o "${OUTPUT_ISO}" \
|
||||||
|
"${iso_root}" 2>&1 | tail -5
|
||||||
|
|
||||||
|
if [ -f "${OUTPUT_ISO}" ]; then
|
||||||
|
local iso_size
|
||||||
|
iso_size=$(du -h "${OUTPUT_ISO}" | cut -f1)
|
||||||
|
ok "ISO built: ${OUTPUT_ISO} (${iso_size})"
|
||||||
|
else
|
||||||
|
die "ISO build failed."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Build Full Disk Image ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
build_disk_image() {
|
||||||
|
info "Building full disk image: ${OUTPUT_IMAGE}"
|
||||||
|
|
||||||
|
# Create a sparse disk image
|
||||||
|
dd if=/dev/zero of="${OUTPUT_IMAGE}" bs=1M count=0 seek="${IMAGE_SIZE_MB}" status=progress 2>/dev/null
|
||||||
|
|
||||||
|
# Partition: GPT with EFI system partition + data partition
|
||||||
|
parted -s "${OUTPUT_IMAGE}" mklabel gpt
|
||||||
|
parted -s "${OUTPUT_IMAGE}" mkpart ESP fat32 1MiB "${EFI_SIZE_MB}MiB"
|
||||||
|
parted -s "${OUTPUT_IMAGE}" set 1 esp on
|
||||||
|
parted -s "${OUTPUT_IMAGE}" mkpart primary ext4 "${EFI_SIZE_MB}MiB" 100%
|
||||||
|
|
||||||
|
# Set up loop device
|
||||||
|
LOOP_DEV=$(losetup --find --show --partscan "${OUTPUT_IMAGE}")
|
||||||
|
trap 'losetup -d "${LOOP_DEV}" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
# Format partitions
|
||||||
|
mkfs.vfat -F 32 -n "HERMES-EFI" "${LOOP_DEV}p1" >/dev/null 2>&1
|
||||||
|
mkfs.ext4 -L "HERMES-DATA" "${LOOP_DEV}p2" >/dev/null 2>&1
|
||||||
|
|
||||||
|
# Mount and populate
|
||||||
|
local mnt_efi=$(mktemp -d)
|
||||||
|
local mnt_data=$(mktemp -d)
|
||||||
|
|
||||||
|
mount "${LOOP_DEV}p1" "$mnt_efi"
|
||||||
|
mount "${LOOP_DEV}p2" "$mnt_data"
|
||||||
|
|
||||||
|
# Copy data partition contents
|
||||||
|
if [ -d "$BUILD_DIR/hermes" ]; then
|
||||||
|
cp -r "$BUILD_DIR/hermes" "$mnt_data/"
|
||||||
|
fi
|
||||||
|
if [ -d "$BUILD_DIR/ventoy" ]; then
|
||||||
|
cp -r "$BUILD_DIR/ventoy" "$mnt_efi/"
|
||||||
|
fi
|
||||||
|
if [ -f "$VENTOY_JSON" ]; then
|
||||||
|
cp "$VENTOY_JSON" "$mnt_efi/ventoy/ventoy.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Copy ISOs
|
||||||
|
mkdir -p "$mnt_data/ISO"
|
||||||
|
if [ -f "${OUTPUT_ISO}" ]; then
|
||||||
|
cp "${OUTPUT_ISO}" "$mnt_data/ISO/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sync and clean up
|
||||||
|
sync
|
||||||
|
umount "$mnt_efi"
|
||||||
|
umount "$mnt_data"
|
||||||
|
rmdir "$mnt_efi" "$mnt_data"
|
||||||
|
|
||||||
|
losetup -d "${LOOP_DEV}"
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
local img_size
|
||||||
|
img_size=$(du -h "${OUTPUT_IMAGE}" | cut -f1)
|
||||||
|
ok "Disk image built: ${OUTPUT_IMAGE} (${img_size})"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Write to USB Device ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
write_to_device() {
|
||||||
|
local device="$1"
|
||||||
|
|
||||||
|
if [ ! -b "$device" ]; then
|
||||||
|
die "Not a block device: $device"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
warn "⚠️ ABOUT TO OVERWRITE ENTIRE DEVICE: ${device}"
|
||||||
|
warn " This will DESTROY ALL DATA on ${device}."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if ! $FORCE; then
|
||||||
|
echo -n "Type the device path again to confirm: "
|
||||||
|
read -r confirm
|
||||||
|
if [ "$confirm" != "$device" ]; then
|
||||||
|
die "Confirmation mismatch. Aborting."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Writing image to ${device}..."
|
||||||
|
dd if="${OUTPUT_IMAGE}" of="${device}" bs=4M status=progress conv=fsync
|
||||||
|
sync
|
||||||
|
ok "Device ${device} written successfully."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Verification ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
verify_image() {
|
||||||
|
info "Verifying built image..."
|
||||||
|
|
||||||
|
if [ -f "${OUTPUT_ISO}" ]; then
|
||||||
|
local iso_size
|
||||||
|
iso_size=$(stat -c%s "${OUTPUT_ISO}" 2>/dev/null || stat -f%z "${OUTPUT_ISO}")
|
||||||
|
if [ "$iso_size" -gt 1048576 ]; then # > 1 MB
|
||||||
|
ok " ISO: ${OUTPUT_ISO} ($(du -h "${OUTPUT_ISO}" | cut -f1))"
|
||||||
|
else
|
||||||
|
warn " ISO seems too small: $(du -h "${OUTPUT_ISO}" | cut -f1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${OUTPUT_IMAGE}" ]; then
|
||||||
|
local img_size
|
||||||
|
img_size=$(stat -c%s "${OUTPUT_IMAGE}" 2>/dev/null || stat -f%z "${OUTPUT_IMAGE}")
|
||||||
|
if [ "$img_size" -gt 52428800 ]; then # > 50 MB
|
||||||
|
ok " Image: ${OUTPUT_IMAGE} ($(du -h "${OUTPUT_IMAGE}" | cut -f1))"
|
||||||
|
else
|
||||||
|
warn " Image seems too small: $(du -h "${OUTPUT_IMAGE}" | cut -f1)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify directory structure
|
||||||
|
local expected_dirs=(
|
||||||
|
"$BUILD_DIR/hermes/agent"
|
||||||
|
"$BUILD_DIR/hermes/scripts"
|
||||||
|
"$BUILD_DIR/hermes/tools"
|
||||||
|
)
|
||||||
|
for dir in "${expected_dirs[@]}"; do
|
||||||
|
if [ -d "$dir" ]; then
|
||||||
|
$VERBOSE && ok " Structure: $dir"
|
||||||
|
else
|
||||||
|
warn " Missing directory: $dir"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
ok "Verification complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Summary ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
print_summary() {
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo " Hermes Portable Rescue — Build Summary"
|
||||||
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo " Boot environment: ${BOOT_ENV}"
|
||||||
|
echo " Staging directory: ${BUILD_DIR}"
|
||||||
|
echo " Staging size: $(du -sh "${BUILD_DIR}" 2>/dev/null | cut -f1 || echo 'N/A')"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ -f "${OUTPUT_ISO}" ]; then
|
||||||
|
echo " ISO: ${OUTPUT_ISO}"
|
||||||
|
echo " ISO size: $(du -h "${OUTPUT_ISO}" | cut -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${OUTPUT_IMAGE}" ]; then
|
||||||
|
echo " Disk image: ${OUTPUT_IMAGE}"
|
||||||
|
echo " Image size: $(du -h "${OUTPUT_IMAGE}" | cut -f1)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$WRITE_DEVICE" ]; then
|
||||||
|
echo " Written to device: ${WRITE_DEVICE}"
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Show placeholder/generated stubs
|
||||||
|
local stub_count
|
||||||
|
stub_count=$(find "$BUILD_DIR/hermes/scripts" -name "*.py" -exec grep -l "STUB MODULE" {} \; 2>/dev/null | wc -l)
|
||||||
|
if [ "$stub_count" -gt 0 ]; then
|
||||||
|
warn " ${stub_count} diagnostic module(s) are stubs (upstream tasks not yet complete)."
|
||||||
|
echo ""
|
||||||
|
echo " Upstream tasks still needed:"
|
||||||
|
echo " build:hermes-runtime (t_c816e37d)"
|
||||||
|
echo " build:hardware-inventory (t_1215a5f0)"
|
||||||
|
echo " build:bsod-analyzer (t_10103a48)"
|
||||||
|
echo " build:driver-updater (t_2afd37ec)"
|
||||||
|
echo " build:backup-restore (t_0b33ea13)"
|
||||||
|
echo " build:stress-tests (t_f817392d)"
|
||||||
|
else
|
||||||
|
ok " All diagnostic modules present."
|
||||||
|
fi
|
||||||
|
echo "═══════════════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════╗"
|
||||||
|
echo "║ Hermes Portable Rescue — USB Image Builder ║"
|
||||||
|
echo "╚══════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
prerequisites
|
||||||
|
validate_artifacts
|
||||||
|
setup_directories
|
||||||
|
copy_hermes_runtime
|
||||||
|
copy_diagnostics
|
||||||
|
copy_tools
|
||||||
|
configure_boot
|
||||||
|
build_iso
|
||||||
|
|
||||||
|
if ! $ISO_ONLY; then
|
||||||
|
build_disk_image
|
||||||
|
fi
|
||||||
|
|
||||||
|
verify_image
|
||||||
|
print_summary
|
||||||
|
|
||||||
|
if [ -n "$WRITE_DEVICE" ]; then
|
||||||
|
write_to_device "$WRITE_DEVICE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info "Build complete! 🚀"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"ventoy": {
|
||||||
|
"timeout": 30,
|
||||||
|
"default_image": "rescue.iso",
|
||||||
|
"menu_style": "LIST",
|
||||||
|
"theme": {
|
||||||
|
"file": "/ventoy/theme/theme.txt",
|
||||||
|
"gfxmode": "1920x1080"
|
||||||
|
},
|
||||||
|
"persistence": [
|
||||||
|
{
|
||||||
|
"image": "/ISO/rescue.iso",
|
||||||
|
"backend": "/persistence/rescue.dat"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"control": [
|
||||||
|
{ "VTOY_DEFAULT_SEARCH_ROOT": "/ISO" },
|
||||||
|
{ "VTOY_MENU_TIMEOUT": "0" },
|
||||||
|
{ "VTOY_TREE_VIEW_MENU_STYLE": "1" },
|
||||||
|
{ "VTOY_FILT_DOT_UNDERSCORE_FILE": "1" },
|
||||||
|
{ "VTOY_SORT_CASE_SENSITIVE": "0" },
|
||||||
|
{ "VTOY_MAX_SEARCH_LEVEL": "3" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
# Hermes Portable Rescue — Architecture Document
|
||||||
|
|
||||||
|
**Status:** Draft v1.0
|
||||||
|
**Author:** Kanban Orchestrator (t_25bcd02c)
|
||||||
|
**Date:** 2026-07-04
|
||||||
|
**Dependencies:** Research T1 (boot-env), T2 (llm-strat), T3 (pc-repair-workflows)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Executive Summary](#1-executive-summary)
|
||||||
|
2. [Chosen Boot Environment](#2-chosen-boot-environment)
|
||||||
|
3. [USB Layout & Filesystem](#3-usb-layout--filesystem)
|
||||||
|
4. [LLM Strategy](#4-llm-strategy)
|
||||||
|
5. [Tool Packaging Strategy](#5-tool-packaging-strategy)
|
||||||
|
6. [Portable Hermes Runtime Structure](#6-portable-hermes-runtime-structure)
|
||||||
|
7. [Auto-Launch Flow on Boot](#7-auto-launch-flow-on-boot)
|
||||||
|
8. [Diagnostic Pipeline Order](#8-diagnostic-pipeline-order)
|
||||||
|
9. [Build Pipeline](#9-build-pipeline)
|
||||||
|
10. [Risks & Mitigations](#10-risks--mitigations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Hermes Portable Rescue is an **LLM-driven autonomous diagnostic and repair USB toolkit**. The target user inserts a USB drive into a broken Windows PC, boots from it, and an AI agent auto-launches, diagnoses the system (BSOD analysis, hardware inventory, stress tests, disk health), and guides the repair process.
|
||||||
|
|
||||||
|
**Architecture at a glance:**
|
||||||
|
|
||||||
|
| Component | Decision | Rationale |
|
||||||
|
|-----------|----------|-----------|
|
||||||
|
| **Bootloader** | Ventoy | GPL-licensed, MS-signed Secure Boot, multi-ISO capable |
|
||||||
|
| **Primary OS** | Custom Arch Linux ISO (archiso) | glibc-based, Python-native, full package manager, latest kernel |
|
||||||
|
| **Secondary OS** | WinPE ISO (optional) | Native chkdsk/sfc/dism for Windows-targeted repairs |
|
||||||
|
| **Persistence** | NTFS data partition | Survives across sessions, readable from Windows |
|
||||||
|
| **LLM Runtime** | llama.cpp (CPU) | Offline-capable, no GPU needed |
|
||||||
|
| **LLM Model (Primary)** | Qwen2.5-7B-Instruct Q4_K_M (~4.3 GB) | Best-in-class structured reasoning at 7B size |
|
||||||
|
| **LLM Model (Fallback)** | Phi-3-mini-3.8B Q4_K_M (~2.2 GB) | Runs on any hardware (4 GB+ RAM) |
|
||||||
|
| **API Fallback** | opencode.go / Ollama | Unlocks GPT-4-class reasoning when network is available |
|
||||||
|
| **USB Size Target** | 32 GB minimum (64 GB ideal) | Fits OS + both models + tools + output space |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Chosen Boot Environment
|
||||||
|
|
||||||
|
### Primary: Ventoy + Custom Arch Linux ISO
|
||||||
|
|
||||||
|
**Ventoy** serves as the bootloader foundation. It installs a tiny (~10 MB) bootloader on the USB, and ISOs are simply copied as files to the FAT32 partition. Ventoy handles UEFI, Legacy BIOS, and Secure Boot via Microsoft-signed shim.
|
||||||
|
|
||||||
|
**Arch Linux (archiso)** was chosen as the live environment for the following reasons:
|
||||||
|
|
||||||
|
- **glibc-based** — full Python 3.11+ compatibility (unlike Alpine's musl)
|
||||||
|
- **Rolling release** — latest kernel (6.x), best driver support for NVMe, USB3, modern chipsets
|
||||||
|
- **archiso tooling** — fully customizable ISO builds (pre-install Python, pip, all tools)
|
||||||
|
- **Package manager (pacman)** — can install additional tools live (TTY-only if needed)
|
||||||
|
- **~800 MB base** — leaves room for models and tools on a 32 GB USB
|
||||||
|
- **GPL-licensed** — completely free to redistribute (no WinPE licensing headaches)
|
||||||
|
|
||||||
|
### Secondary: WinPE ISO (Optional Add-on)
|
||||||
|
|
||||||
|
A WinPE-based ISO (such as Hiren's Boot CD PE, ~2.5 GB) can be included as a companion for:
|
||||||
|
|
||||||
|
- Running native Windows tools (chkdsk, sfc, DISM, diskpart)
|
||||||
|
- BitLocker management (manage-bde)
|
||||||
|
- Any scenario where Windows-native tooling is strictly required
|
||||||
|
|
||||||
|
### Why NOT WinPE as Primary
|
||||||
|
|
||||||
|
- **Restrictive licensing** for redistribution
|
||||||
|
- **Python bootstrap is fragile** — must bundle embedded distribution + VC runtime DLLs + pip
|
||||||
|
- **No package manager** — all dependencies must be pre-bundled
|
||||||
|
- **Higher engineering cost** for less capability
|
||||||
|
|
||||||
|
### Boot Selection Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Ventoy Boot Menu
|
||||||
|
├── 1. Hermes Rescue (Arch Linux) — DEFAULT, auto-boot after 10s
|
||||||
|
│ └── Auto-launches Hermes agent
|
||||||
|
├── 2. Windows PE (Optional) — Manual selection
|
||||||
|
│ └── Manual tool use
|
||||||
|
└── 3. Boot from local disk — Bypass rescue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. USB Layout & Filesystem
|
||||||
|
|
||||||
|
```
|
||||||
|
USB Drive (≥32 GB)
|
||||||
|
━━━ EFI System Partition (FAT32, ~500 MB)
|
||||||
|
└── Ventoy bootloader (EFI/BOOT/, EFI/ventoy/)
|
||||||
|
━━━ ISO/ (FAT32 or exFAT, copy ISOs here)
|
||||||
|
├── hermes-rescue-linux.iso ~1 GB (custom Arch Linux)
|
||||||
|
├── hirens-boot-cd-pe.iso ~2.5 GB (optional WinPE)
|
||||||
|
└── memtest86plus.iso ~20 MB (RAM tester, optional)
|
||||||
|
━━━ DATA/ (NTFS, uses remainder of drive)
|
||||||
|
├── hermes/
|
||||||
|
│ ├── config.yaml — Hermes agent config
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── qwen2.5-7b-q4_k_m.gguf — Primary LLM (~4.3 GB)
|
||||||
|
│ │ └── phi3-mini-q4_k_m.gguf — Fallback LLM (~2.2 GB)
|
||||||
|
│ ├── logs/
|
||||||
|
│ │ └── session_YYYYMMDD_HHMMSS.log
|
||||||
|
│ ├── diagnostics/
|
||||||
|
│ │ ├── hardware_report_*.json
|
||||||
|
│ │ ├── bsod_analysis_*.json
|
||||||
|
│ │ ├── stress_test_*.json
|
||||||
|
│ │ └── driver_scan_*.json
|
||||||
|
│ ├── backups/
|
||||||
|
│ │ ├── registry/ — Registry hive exports
|
||||||
|
│ │ ├── system_files/ — File-level backups
|
||||||
|
│ │ └── disk_images/ — DD/partclone images
|
||||||
|
│ └── scripts/
|
||||||
|
│ ├── custom_recovery.sh — User-customizable repair script
|
||||||
|
│ └── post_diagnostic_hook.sh — Runs after diagnostics complete
|
||||||
|
└── hermes_agent.squashfs — Bundled Hermes runtime (see §6)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Partition Table
|
||||||
|
|
||||||
|
| Partition | Size | FS | Label | Purpose |
|
||||||
|
|-----------|------|----|-------|---------|
|
||||||
|
| /dev/sda1 | ~500 MB | FAT32 | VTOYEFI | Ventoy boot + EFI |
|
||||||
|
| /dev/sda2 | ~16 GB | FAT32/exFAT | VTOYISO | ISOs (Ventoy reads ISOs from any partition) |
|
||||||
|
| /dev/sda3 | Remainder | NTFS | DATA | Persistence, config, models, logs |
|
||||||
|
|
||||||
|
**Why NTFS for DATA:** Windows-readable without extra tools, supports large files (models), journaled for crash recovery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. LLM Strategy
|
||||||
|
|
||||||
|
### Decision: Hybrid Local-First + API Fallback
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ User boots │
|
||||||
|
│ from USB │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
┌──────▼───────┐
|
||||||
|
│ Hermes CLI │
|
||||||
|
│ auto-starts │
|
||||||
|
└──────┬───────┘
|
||||||
|
│
|
||||||
|
┌────────────┴────────────┐
|
||||||
|
│ │
|
||||||
|
┌────────▼────────┐ ┌─────────▼─────────┐
|
||||||
|
│ Qwen2.5-7B │ │ Network Available?│
|
||||||
|
│ Q4_K_M (LOCAL)│ │ │ │
|
||||||
|
│ ~5 GB RAM │ │ YES │ NO │
|
||||||
|
│ ~15-25 tok/s │ │ │ │
|
||||||
|
└────────┬────────┘ └────┬────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌──────────▼──┐ │
|
||||||
|
│ │ API Fallback│ │
|
||||||
|
│ │(opencode.go│ │
|
||||||
|
│ │ or Ollama) │ │
|
||||||
|
│ └──────────┬──┘ │
|
||||||
|
│ │ │
|
||||||
|
└────────┬───────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────┬────────────────────┘
|
||||||
|
│
|
||||||
|
┌─────────▼────────┐
|
||||||
|
│ Fallback: │
|
||||||
|
│ Phi-3-mini Q4 │
|
||||||
|
│ ~2.9 GB RAM │
|
||||||
|
│ (if primary │
|
||||||
|
│ won't load) │
|
||||||
|
└─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Models
|
||||||
|
|
||||||
|
| Role | Model | Quant | File Size | RAM Needed | tok/s |
|
||||||
|
|------|-------|-------|-----------|------------|-------|
|
||||||
|
| **Primary** | Qwen2.5-7B-Instruct | Q4_K_M | ~4.3 GB | ~5.0 GB | 15-25 |
|
||||||
|
| **Fallback** | Phi-3-mini-4k-instruct | Q4_K_M | ~2.2 GB | ~2.9 GB | 25-40 |
|
||||||
|
|
||||||
|
**Model selection rationale:**
|
||||||
|
- Qwen2.5-7B offers best-in-class **structured reasoning** for BSOD analysis (parsing bugcheck codes, parameter dumps) and **instruction following** for multi-step diagnostic workflows
|
||||||
|
- Phi-3-mini as fallback covers 4 GB RAM systems where Qwen won't fit
|
||||||
|
- Both run **CPU-only** via llama.cpp — no GPU required
|
||||||
|
|
||||||
|
### API Fallback
|
||||||
|
|
||||||
|
When the target PC has internet access, the agent can fall back to:
|
||||||
|
- **opencode.go** (recommended) — for complex multi-step diagnosis
|
||||||
|
- **Ollama server** query — if the user has Ollama on their home network
|
||||||
|
- **OpenAI-compatible API** — for any compatible provider
|
||||||
|
|
||||||
|
The API fallback enables GPT-4-class reasoning for edge cases the local model cannot handle (unusual BSOD codes, complex driver chains, recovery planning).
|
||||||
|
|
||||||
|
### USB Storage Budget
|
||||||
|
|
||||||
|
| Component | 32 GB USB | 64 GB USB |
|
||||||
|
|-----------|-----------|-----------|
|
||||||
|
| OS ISO | 1.0 GB | 1.0 GB |
|
||||||
|
| Primary model (Qwen Q4_K_M) | 4.3 GB | 4.3 GB |
|
||||||
|
| Fallback model (Phi-3 Q4_K_M) | 2.2 GB | 2.2 GB |
|
||||||
|
| Tools + Runtime | 2.0 GB | 2.0 GB |
|
||||||
|
| WinPE ISO (optional) | 2.5 GB | 2.5 GB |
|
||||||
|
| **Total (with WinPE)** | **12.0 GB** | **12.0 GB** |
|
||||||
|
| **Remaining for output** | **~20 GB** | **~52 GB** |
|
||||||
|
|
||||||
|
Both model files fit comfortably on 32 GB+ USB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Tool Packaging Strategy
|
||||||
|
|
||||||
|
### Bundled on the Arch Linux ISO
|
||||||
|
|
||||||
|
Pre-installed via pacman into the custom ISO:
|
||||||
|
|
||||||
|
**Core diagnostics:**
|
||||||
|
- `smartmontools` — SMART health checks
|
||||||
|
- `dmidecode` — DMI/BIOS information
|
||||||
|
- `lshw` — full hardware inventory
|
||||||
|
- `lspci` / `lsusb` — PCI/USB device enumeration
|
||||||
|
- `ddrescue` — disk rescue imaging
|
||||||
|
- `testdisk` / `photorec` — partition recovery + file recovery
|
||||||
|
- `ntfs-3g` — NTFS read/write
|
||||||
|
- `dislocker` — BitLocker volume access
|
||||||
|
- `parted` / `gpart` — partition management
|
||||||
|
|
||||||
|
**Stress & burn-in:**
|
||||||
|
- `stress-ng` — CPU/RAM stress testing (with thermal monitoring)
|
||||||
|
- `memtest86+` (separate ISO on USB, bootable via Ventoy) — dedicated RAM test
|
||||||
|
|
||||||
|
**Network & connectivity:**
|
||||||
|
- `curl` / `wget` — HTTP/HTTPS
|
||||||
|
- `openssh` — SSH client for remote support
|
||||||
|
- `networkmanager` — WiFi/Ethernet management
|
||||||
|
|
||||||
|
**Python ecosystem:**
|
||||||
|
- `python` (3.11+)
|
||||||
|
- `python-pip`
|
||||||
|
- `python-setuptools`
|
||||||
|
- `python-virtualenv`
|
||||||
|
|
||||||
|
### Bundled in the DATA partition
|
||||||
|
|
||||||
|
- **Hermes runtime** (`hermes_agent.squashfs`) — squashfs archive of the Hermes Python environment with all dependencies
|
||||||
|
- **GGUF model files** (`models/`) — Qwen2.5-7B + Phi-3-mini
|
||||||
|
- **Config** (`config.yaml`) — Hermes agent configuration
|
||||||
|
- **SquashFS mount script** — mounts runtime at boot
|
||||||
|
|
||||||
|
### Not Bundled (Downloaded on Demand if Network Available)
|
||||||
|
|
||||||
|
- Full OS updates (kernel, packages via pacman)
|
||||||
|
- Additional models (if user wants larger quantizations)
|
||||||
|
- Windows driver packages (sourced from vendor websites)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Portable Hermes Runtime Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
DATA/hermes_agent.squashfs (compressed, ~200-400 MB)
|
||||||
|
├── venv/
|
||||||
|
│ ├── bin/
|
||||||
|
│ │ ├── python3 — Python 3.11 interpreter
|
||||||
|
│ │ ├── hermes — Hermes CLI entry point
|
||||||
|
│ │ └── llama-cli — llama.cpp inference binary (static)
|
||||||
|
│ └── lib/python3.11/
|
||||||
|
│ └── site-packages/
|
||||||
|
│ ├── hermes/ — Hermes agent core
|
||||||
|
│ ├── pyyaml/ — YAML config parsing
|
||||||
|
│ ├── requests/ — HTTP client
|
||||||
|
│ └── [deps] — All pip dependencies
|
||||||
|
├── src/hermes_portable/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── diagnostics/
|
||||||
|
│ │ ├── hardware.py — Hardware enumeration (T6)
|
||||||
|
│ │ ├── bsod.py — Minidump parser & LLM analysis (T7)
|
||||||
|
│ │ ├── drivers.py — Driver scanner (T8)
|
||||||
|
│ │ ├── backup.py — Backup/restore engine (T9)
|
||||||
|
│ │ └── stress.py — Stress test orchestration (T10)
|
||||||
|
│ ├── llm/
|
||||||
|
│ │ ├── engine.py — llama.cpp wrapper
|
||||||
|
│ │ └── prompts.py — Diagnostic prompt templates
|
||||||
|
│ └── agent/
|
||||||
|
│ ├── pipeline.py — Diagnostic pipeline orchestrator
|
||||||
|
│ └── repair.py — Guided repair flow
|
||||||
|
├── config.yaml — Default agent config
|
||||||
|
└── boot/
|
||||||
|
└── autorun.sh — Entry point script (§7)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why SquashFS:** Compressed read-only filesystem keeps the runtime compact. The runtime is mounted read-only, with all mutable state going to `DATA/hermes/`. This prevents corruption from unclean shutdown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Auto-Launch Flow on Boot
|
||||||
|
|
||||||
|
```
|
||||||
|
USB Boot (via Ventoy)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Arch Linux ISO boots
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
systemd starts (custom archiso)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 1. Mount DATA partition │ Mounts /dev/sda3 (NTFS) to /mnt/data
|
||||||
|
│ (ntfs-3g /dev/sda3 /mnt/data)
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 2. Mount Hermes runtime │ Mounts squashfs to /mnt/hermes
|
||||||
|
│ (mount -o loop /mnt/data/ │
|
||||||
|
│ hermes_agent.squashfs │
|
||||||
|
│ /mnt/hermes) │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 3. Initialize Python venv │ Symlinks or bind-mounts venv
|
||||||
|
│ from squashfs │
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 4. Detect LLM model │ Checks /mnt/data/hermes/models/
|
||||||
|
│ (fallback logic) │ for Qwen GGUF → tries to load
|
||||||
|
│ │ → falls back to Phi-3-mini
|
||||||
|
│ │ → if both fail, pure API mode
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 5. Network detection │ Checks internet connectivity
|
||||||
|
│ (curl --connect-timeout 5 │ If up: enable API fallback
|
||||||
|
│ https://1.1.1.1) │ If down: local-only mode
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 6. Launch Hermes agent │ hermes --config /mnt/data/hermes/
|
||||||
|
│ (auto-diagnostic mode) │ config.yaml diagnose
|
||||||
|
└───────────────┬───────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────┐
|
||||||
|
│ 7. Display diagnostic │ TUI or terminal output
|
||||||
|
│ progress & results │ Session logged to /mnt/data/hermes/logs/
|
||||||
|
└───────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Init System Detail
|
||||||
|
|
||||||
|
The custom ISO will use a **oneshot systemd service** (`hermes-autorun.service`) that runs the autorun script after the live environment boots. The ISO is configured for:
|
||||||
|
|
||||||
|
- **Auto-login** to a TTY (no X server needed — saves RAM and boot time)
|
||||||
|
- **Quiet boot** (no verbose kernel messages)
|
||||||
|
- **10-second Ventoy timeout** before auto-selecting Hermes Rescue
|
||||||
|
|
||||||
|
The autorun/service does NOT require user interaction to start the diagnostic pipeline — it launches immediately on boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Diagnostic Pipeline Order
|
||||||
|
|
||||||
|
The pipeline follows a **risk-tiered approach** — non-destructive, read-only diagnostics first, then progressively more invasive tests.
|
||||||
|
|
||||||
|
### Phase 1: Inventory & Health Check (Non-Destructive)
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: Hardware Inventory
|
||||||
|
├── CPU: model, cores, frequency
|
||||||
|
├── RAM: total, slot layout, speed (dmidecode)
|
||||||
|
├── GPU: model, VRAM, driver
|
||||||
|
├── Disks: model, capacity, interface, media type (lshw + lsblk)
|
||||||
|
└── Network: adapters, MAC, IP (ip link, lspci)
|
||||||
|
|
||||||
|
Step 2: SMART Health (All Physical Disks)
|
||||||
|
├── Read smartctl -a for each disk
|
||||||
|
├── Check: reallocated_sectors, pending_sectors, raw_read_error_rate
|
||||||
|
├── Check: temperature, power_on_hours, wear_level (SSDs)
|
||||||
|
└── Flag: PASS / WARNING / FAIL
|
||||||
|
|
||||||
|
Step 3: Boot Configuration Analysis
|
||||||
|
├── List Windows installations (if any NTFS volumes found)
|
||||||
|
├── Check BCD store (via ntfs-3g mount → bcdedit or registry)
|
||||||
|
├── Check for BitLocker protection
|
||||||
|
└── Report: bootable? encrypted? last successful boot?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Actions if Phase 1 completes clean:** Stop here. Report "System appears healthy."
|
||||||
|
|
||||||
|
### Phase 2: Targeted Diagnostics (Data-Driven)
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 4: BSOD/Minidump Analysis (if Windows installed)
|
||||||
|
├── Locate .dmp files in C:\Windows\Minidump\
|
||||||
|
├── Locate MEMORY.DMP in C:\Windows\
|
||||||
|
├── Parse dump headers for bugcheck code + parameters
|
||||||
|
├── LLM: interpret stop code, identify likely driver/cause
|
||||||
|
└── Suggest: driver rollback, update, hardware test
|
||||||
|
|
||||||
|
Step 5: Disk Integrity Check (if NTFS partitions detected)
|
||||||
|
├── Attempt ntfsfix (non-destructive repair)
|
||||||
|
├── Check filesystem consistency (ntfs-3g debug flags)
|
||||||
|
└── Report: clean / fixable / needs chkdsk from Windows
|
||||||
|
|
||||||
|
Step 6: Registry Sanity Check (if Windows installed)
|
||||||
|
├── Mount SYSTEM hive (via ntfs-3g)
|
||||||
|
├── Check for: corrupted hives, missing critical keys
|
||||||
|
└── Extract: driver version info, startup programs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 3: Interactive Repair (User-Initiated)
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 7: Offer Repair Actions (LLM-guided, user confirms each)
|
||||||
|
├── Boot repair: mount BCD → rebuildbcd
|
||||||
|
├── System file repair: sfc /scannow (requires WinPE)
|
||||||
|
├── Image repair: DISM /RestoreHealth (requires WinPE + source)
|
||||||
|
├── Registry backup: export current hives
|
||||||
|
├── System Restore: enumerate restore points
|
||||||
|
├── Driver rollback: identify problematic drivers → guide rollback
|
||||||
|
└── BitLocker recovery: unlock + suspend (if recovery key available)
|
||||||
|
|
||||||
|
Step 8: Backup (Before Any Destructive Action)
|
||||||
|
├── Registry hive export (all hives)
|
||||||
|
├── Critical user data backup (Documents, Desktop, AppData)
|
||||||
|
├── Full disk image (ddrescue) — optional, space-permitting
|
||||||
|
└── Verify backup integrity (hash comparison)
|
||||||
|
|
||||||
|
Step 9: Stress Tests (If Hardware Issue Suspected)
|
||||||
|
├── CPU stress: stress-ng --cpu 0 --timeout 300s
|
||||||
|
├── RAM test: prompt to reboot into MemTest86+
|
||||||
|
└── GPU test: basic OpenGL/compute test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 4: Recovery & Verification
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 10: Apply Repairs (With Rollback)
|
||||||
|
├── Each repair step is logged with undo information
|
||||||
|
├── Before/after state comparison
|
||||||
|
└── Verification test after each change
|
||||||
|
|
||||||
|
Step 11: Final Report
|
||||||
|
├── What was found
|
||||||
|
├── What was fixed
|
||||||
|
├── What needs manual attention
|
||||||
|
├── USB session logs path
|
||||||
|
└── Recommendations for long-term health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Build Pipeline
|
||||||
|
|
||||||
|
The build process follows this order:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Build Arch Linux custom ISO (archiso)
|
||||||
|
└── Pre-install Python 3.11+, pip, all diagnostic tools
|
||||||
|
└── Configure auto-login + systemd autorun service
|
||||||
|
└── Set up Hermes autorun service
|
||||||
|
└── Output: hermes-rescue-linux.iso
|
||||||
|
|
||||||
|
2. Build Hermes runtime squashfs
|
||||||
|
└── Create Python venv with Hermes + dependencies
|
||||||
|
└── Bundle diagnostic source modules
|
||||||
|
└── Include static llama.cpp binary
|
||||||
|
└── Compress into squashfs
|
||||||
|
└── Output: hermes_agent.squashfs
|
||||||
|
|
||||||
|
3. Download LLM model GGUF files
|
||||||
|
└── Qwen2.5-7B-Instruct Q4_K_M from Hugging Face
|
||||||
|
└── Phi-3-mini-4k-instruct Q4_K_M from Hugging Face
|
||||||
|
|
||||||
|
4. Prepare USB
|
||||||
|
└── Partition: FAT32 (VTOYEFI + VTOYISO) + NTFS (DATA)
|
||||||
|
└── Install Ventoy to USB
|
||||||
|
└── Copy ISOs, models, runtime, config
|
||||||
|
└── Verify bootability
|
||||||
|
|
||||||
|
5. Test (on real hardware)
|
||||||
|
└── Cycle through: boot → autorun → diagnostic pipeline → repair → verify
|
||||||
|
└── Test on: gaming PC, business PC, VM with simulated failures
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build Scripts (in `build/`)
|
||||||
|
|
||||||
|
| Script | Purpose | Dependencies |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| `build-iso.sh` | Build custom Arch ISO via archiso | archiso, Docker |
|
||||||
|
| `build-runtime.sh` | Build Hermes runtime squashfs | Python 3.11, squashfs-tools |
|
||||||
|
| `download-models.sh` | Download GGUF models from Hugging Face | curl, Hugging Face hub |
|
||||||
|
| `prepare-usb.sh` | Partition + install Ventoy + copy files | Ventoy, parted, mkfs.* |
|
||||||
|
| `verify-boot.sh` | QEMU boot test of the ISO | qemu-system-x86_64 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| Secure Boot blocks boot | Medium | High | Ventoy's shim is MS-signed; Arch ISO uses shim-signed |
|
||||||
|
| Python package incompatibility on Arch | Low | Medium | Pin package versions in build; test with target Python 3.11 |
|
||||||
|
| Target PC has only 4 GB RAM | Medium | Medium | Phi-3-mini fallback runs in ~2.9 GB; API mode if both models fail |
|
||||||
|
| USB3 boot fails on old hardware | Low | Medium | Ventoy handles USB2 fallback; test on multiple machines |
|
||||||
|
| BitLocker prevents disk access | Medium | Medium | dislocker works on Win 10/11 BitLocker; requires recovery key |
|
||||||
|
| NTFS partition corruption on unclean shutdown | Medium | Low | Data is session-based logs; corruption affects last session only |
|
||||||
|
| llama.cpp crashes on unsupported CPU | Low | Medium | Detect CPU features (AVX2) before loading model; fallback to API |
|
||||||
|
| User has no network for API fallback | Medium | Low | Local model handles 95% of diagnostics offline |
|
||||||
|
| Arch ISO too large for 32 GB USB | Low | Medium | Base is ~800 MB + tools ~200 MB = ~1 GB; models fit comfortably |
|
||||||
|
| Windows repair needs native Win tools | Medium | Low | Optional WinPE ISO on the same USB; user can reboot into it |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix A: Research Sources
|
||||||
|
|
||||||
|
### T1 — Boot Environment Research
|
||||||
|
- Microsoft Learn: WinPE technical documentation
|
||||||
|
- Ventoy project (GitHub, 65k+ stars)
|
||||||
|
- Arch Linux wiki: archiso
|
||||||
|
- Alpine Linux documentation
|
||||||
|
- SystemRescue homepage
|
||||||
|
- Hiren's Boot CD PE forum
|
||||||
|
- Medicat USB Discord community
|
||||||
|
|
||||||
|
### T2 — LLM Strategy Research
|
||||||
|
- Llama.cpp quantization benchmarks (community)
|
||||||
|
- Qwen2.5-7B technical report (Alibaba)
|
||||||
|
- Phi-3 technical report (Microsoft)
|
||||||
|
- OpenRouter model comparison data
|
||||||
|
- Hugging Face Open LLM Leaderboard
|
||||||
|
|
||||||
|
### T3 — PC Repair Workflows Research
|
||||||
|
- Microsoft Learn: bootrec, DISM, SFC, WinRE, chkdsk
|
||||||
|
- Microsoft KB: registry backup/restore
|
||||||
|
- Sysnative forums, BleepingComputer, TenForums
|
||||||
|
- ERUNT project documentation
|
||||||
|
- Hiren's Boot CD PE tool inventory
|
||||||
|
- Community knowledge: gaming vs business failure patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix B: Quick-Reference Command Set
|
||||||
|
|
||||||
|
### Boot Repair Commands (via WinPE)
|
||||||
|
```
|
||||||
|
bootrec /fixmbr — Repair MBR
|
||||||
|
bootrec /fixboot — Repair boot sector
|
||||||
|
bootrec /scanos — Scan for Windows installations
|
||||||
|
bootrec /rebuildbcd — Rebuild BCD store
|
||||||
|
bcdboot C:\Windows — Copy boot files to EFI partition
|
||||||
|
```
|
||||||
|
|
||||||
|
### System File Repair Commands (via WinPE)
|
||||||
|
```
|
||||||
|
sfc /scannow — System File Checker
|
||||||
|
DISM /Online /Cleanup-Image /RestoreHealth — Component Store Repair
|
||||||
|
DISM /Online /Cleanup-Image /ScanHealth — Component Store Scan
|
||||||
|
DISM /Online /Cleanup-Image /CheckHealth — Component Store Check
|
||||||
|
chkdsk C: /f /r — Check disk + repair + recover bad sectors
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registry & Backup Commands
|
||||||
|
```
|
||||||
|
regedit /e backup.reg — Export entire registry
|
||||||
|
reg save HKLM\SYSTEM system.hiv — Save SYSTEM hive
|
||||||
|
reg restore HKLM\SOFTWARE software.hiv — Restore SOFTWARE hive
|
||||||
|
rstrui.exe — System Restore wizard
|
||||||
|
wbadmin start backup — Command-line backup
|
||||||
|
```
|
||||||
@@ -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