save_bios now distinguishes nvflash driver-loaded refusal from a missing sysfs rom node (ROM BAR not exposed) vs enable denial, and appends host lockdown/Secure Boot state to the note. New opt-in last resort: unload NVIDIA modules, retry nvflash --save, always reload (y/N prompt default n, or --bios-unload). Docs (flow, flags, safety, AGENTS.md invariant) updated to match.
933 lines
35 KiB
Python
Executable File
933 lines
35 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""CMP 170HX checker - shared library (preflight, detection, PCIe checks, BIOS, verify, unlock install, Vulkan)."""
|
|
import datetime
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
HARNESS_DIR = SCRIPT_DIR / "harness"
|
|
CARDS_DIR = SCRIPT_DIR / "cards"
|
|
UNIDENTIFIED_DIR = SCRIPT_DIR / "unidentified"
|
|
|
|
REPOS = {
|
|
"170th-Street": "https://github.com/amoghmunikote/170th-Street.git",
|
|
"cmpunlocker": "https://github.com/amoghmunikote/cmpunlocker.git",
|
|
}
|
|
VERIFY_SH = HARNESS_DIR / "cmpunlocker" / "verify.sh"
|
|
INSTALL_SH = HARNESS_DIR / "cmpunlocker" / "install.sh"
|
|
GPU_IDS = ("20c2", "2082")
|
|
NVIDIA_MODULES = ("nvidia_uvm", "nvidia_drm", "nvidia_modeset", "nvidia")
|
|
VULKAN_TEST_SECONDS = 300
|
|
|
|
SPEED_TO_GEN = {"2.5GT/s": 1, "5GT/s": 2, "8GT/s": 3, "16GT/s": 4, "32GT/s": 5, "64GT/s": 6}
|
|
|
|
USE_COLOR = sys.stdout.isatty()
|
|
|
|
|
|
class C:
|
|
RESET = "\033[0m" if USE_COLOR else ""
|
|
BOLD = "\033[1m" if USE_COLOR else ""
|
|
DIM = "\033[2m" if USE_COLOR else ""
|
|
GREEN = "\033[32m" if USE_COLOR else ""
|
|
YELLOW = "\033[33m" if USE_COLOR else ""
|
|
RED = "\033[31m" if USE_COLOR else ""
|
|
CYAN = "\033[36m" if USE_COLOR else ""
|
|
|
|
|
|
def info(msg):
|
|
print(f"{C.CYAN}[*]{C.RESET} {msg}", flush=True)
|
|
|
|
|
|
def ok(msg):
|
|
print(f"{C.GREEN}[+]{C.RESET} {msg}", flush=True)
|
|
|
|
|
|
def warn(msg):
|
|
print(f"{C.YELLOW}[!]{C.RESET} {msg}", flush=True)
|
|
|
|
|
|
def err(msg):
|
|
print(f"{C.RED}[-]{C.RESET} {msg}", flush=True)
|
|
|
|
|
|
def step(msg):
|
|
print(f"\n{C.BOLD}== {msg} =={C.RESET}", flush=True)
|
|
|
|
|
|
def ask(prompt, default=""):
|
|
suffix = f" [{default}]" if default else ""
|
|
try:
|
|
val = input(f"{C.BOLD}?{C.RESET} {prompt}{suffix}: ").strip()
|
|
except EOFError:
|
|
return default
|
|
return val if val else default
|
|
|
|
|
|
def now_iso():
|
|
return datetime.datetime.now().isoformat(timespec="seconds")
|
|
|
|
|
|
def sanitize(name):
|
|
return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_") or "UNKNOWN"
|
|
|
|
|
|
# ---------------------------------------------------------------- subprocess
|
|
|
|
def run(cmd, timeout=60, env=None, cwd=None):
|
|
try:
|
|
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env, cwd=cwd)
|
|
return {"rc": p.returncode, "out": p.stdout, "err": p.stderr, "cmd": cmd}
|
|
except FileNotFoundError:
|
|
return {"rc": 127, "out": "", "err": f"not found: {cmd[0]}", "cmd": cmd}
|
|
except subprocess.TimeoutExpired as e:
|
|
out = e.stdout.decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
|
|
return {"rc": 124, "out": out, "err": "timeout", "cmd": cmd}
|
|
|
|
|
|
def run_live(cmd, cwd=None, timeout=600, log_path=None):
|
|
"""Run cmd while echoing every output line to the terminal as it arrives.
|
|
|
|
The complete output is also written to log_path (operator keeps the full log,
|
|
the terminal keeps the progress). stderr is merged into stdout, so out holds
|
|
everything. Returns the same {rc, out, err, cmd} shape as run(): rc 127 =
|
|
not found, rc 124 = timeout.
|
|
"""
|
|
try:
|
|
p = subprocess.Popen(cmd, cwd=cwd, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, text=True, bufsize=1)
|
|
except FileNotFoundError:
|
|
return {"rc": 127, "out": "", "err": f"not found: {cmd[0]}", "cmd": cmd}
|
|
except OSError as e:
|
|
return {"rc": 126, "out": "", "err": str(e), "cmd": cmd}
|
|
|
|
lines, done = [], threading.Event()
|
|
|
|
def _pump():
|
|
try:
|
|
for line in p.stdout or "":
|
|
lines.append(line)
|
|
print(line, end="", flush=True)
|
|
except (ValueError, OSError):
|
|
pass
|
|
done.set()
|
|
|
|
pump = threading.Thread(target=_pump, daemon=True)
|
|
pump.start()
|
|
|
|
timed_out = interrupted = False
|
|
try:
|
|
finished = done.wait(timeout)
|
|
except KeyboardInterrupt:
|
|
finished, interrupted = True, True
|
|
if not finished:
|
|
timed_out = True
|
|
if timed_out or interrupted:
|
|
try:
|
|
p.kill()
|
|
except OSError:
|
|
pass
|
|
pump.join(timeout=15)
|
|
rc = p.wait()
|
|
out = "".join(lines)
|
|
err = ""
|
|
if timed_out:
|
|
rc, err = 124, "timeout"
|
|
elif interrupted:
|
|
err = "interrupted"
|
|
|
|
if log_path:
|
|
path = Path(log_path)
|
|
try:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(f"$ {' '.join(str(c) for c in cmd)}\n"
|
|
f"cwd: {cwd or os.getcwd()}\n"
|
|
f"{out}\nrc={rc}{f' ({err})' if err else ''}\n")
|
|
except OSError as e:
|
|
warn(f"could not write log {path}: {e}")
|
|
if interrupted:
|
|
raise KeyboardInterrupt
|
|
return {"rc": rc, "out": out, "err": err, "cmd": cmd}
|
|
|
|
|
|
def as_root(cmd, timeout=60, cwd=None):
|
|
if os.geteuid() == 0:
|
|
return run(cmd, timeout=timeout, cwd=cwd)
|
|
if shutil.which("sudo"):
|
|
r = run(["sudo", "-n"] + cmd, timeout=timeout, cwd=cwd)
|
|
if r["rc"] != 0 and "password is required" in (r["err"] + r["out"]).lower():
|
|
r["err"] = "sudo requires a password (sudo -n failed)"
|
|
return r
|
|
return {"rc": 1, "out": "", "err": "no root: neither uid 0 nor sudo available", "cmd": cmd}
|
|
|
|
|
|
def which(name):
|
|
return shutil.which(name)
|
|
|
|
|
|
def find_nvflash():
|
|
"""nvflash binary: repo-bundled copy first (no system install needed), then PATH."""
|
|
import platform
|
|
arch_map = {"x86_64": "x64", "amd64": "x64", "aarch64": "aarch64",
|
|
"ppc64le": "ppc64", "ppc64": "ppc64", "i686": "x86", "i386": "x86"}
|
|
arch = arch_map.get(platform.machine(), "x64")
|
|
candidates = [
|
|
*sorted(SCRIPT_DIR.glob(f"nvflash_*_linux/{arch}/nvflash")),
|
|
*sorted(SCRIPT_DIR.glob("nvflash_*_linux/x64/nvflash")),
|
|
SCRIPT_DIR / "nvflash",
|
|
]
|
|
for p in candidates:
|
|
if p.is_file() and os.access(p, os.X_OK):
|
|
return str(p)
|
|
return which("nvflash")
|
|
|
|
|
|
def have_root():
|
|
return os.geteuid() == 0 or bool(shutil.which("sudo"))
|
|
|
|
|
|
# ---------------------------------------------------------------- preflight
|
|
|
|
PM_CANDIDATES = [
|
|
("apt-get", ["apt-get", "install", "-y"]),
|
|
("dnf", ["dnf", "install", "-y"]),
|
|
("yum", ["yum", "install", "-y"]),
|
|
("pacman", ["pacman", "-S", "--noconfirm"]),
|
|
("zypper", ["zypper", "--non-interactive", "install"]),
|
|
("xbps-install", ["xbps-install", "-y"]),
|
|
]
|
|
|
|
|
|
def detect_pm():
|
|
for name, cmd in PM_CANDIDATES:
|
|
if which(name):
|
|
return cmd
|
|
return None
|
|
|
|
|
|
def install_packages(packages):
|
|
pm = detect_pm()
|
|
if not pm:
|
|
return False, "no supported package manager found (apt/dnf/pacman/zypper/xbps)"
|
|
installed, log = [], []
|
|
for pkg in packages:
|
|
r = as_root(pm + [pkg], timeout=300)
|
|
log.append(f"$ {' '.join(pm + [pkg])}\nrc={r['rc']}\n{r['out'][-1500:]}{r['err'][-800:]}")
|
|
if r["rc"] == 0:
|
|
installed.append(pkg)
|
|
else:
|
|
warn(f"could not install '{pkg}' via {pm[0]} (rc={r['rc']})")
|
|
return bool(installed), "\n".join(log)
|
|
|
|
|
|
def ensure_repos():
|
|
for name, url in REPOS.items():
|
|
dest = HARNESS_DIR / name
|
|
if (dest / ".git").exists():
|
|
continue
|
|
info(f"cloning {name} ...")
|
|
r = run(["git", "clone", "--depth", "1", url, str(dest)], timeout=300)
|
|
if r["rc"] != 0:
|
|
err(f"git clone failed for {name}: {r['err'].strip()}")
|
|
return False
|
|
ok(f"cloned {name}")
|
|
return True
|
|
|
|
|
|
TOOL_CHECKS = {
|
|
"git": ("critical", "clone harness repos"),
|
|
"lspci": ("critical", "PCIe checks (package: pciutils)"),
|
|
"setpci": ("critical", "link generation probe (package: pciutils)"),
|
|
"nvidia-smi": ("critical-if-card-present", "GPU state / cmpunlocker verify"),
|
|
"nvflash": ("critical", "VBIOS backup + serial (bundled in repo, or on PATH)"),
|
|
"dmesg": ("optional", "kernel log scrape"),
|
|
"journalctl": ("optional", "kernel log scrape fallback"),
|
|
"vulkaninfo": ("optional", "vulkan presence (package: vulkan-tools)"),
|
|
"vkmark": ("optional", "5-min VRAM stress (package: vkmark)"),
|
|
"vkcube": ("optional", "VRAM stress fallback (package: vulkan-tools)"),
|
|
}
|
|
PKG_FOR = {"lspci": "pciutils", "setpci": "pciutils", "vulkaninfo": "vulkan-tools",
|
|
"vkcube": "vulkan-tools", "vkmark": "vkmark", "git": "git"}
|
|
|
|
|
|
def os_release():
|
|
data = read_file("/etc/os-release") or ""
|
|
out = {}
|
|
for line in data.splitlines():
|
|
if "=" in line:
|
|
k, v = line.split("=", 1)
|
|
out[k] = v.strip().strip('"')
|
|
return out
|
|
|
|
|
|
def sudo_precondition(interactive=True):
|
|
"""Precondition for package installation (--preflight): root or working sudo.
|
|
|
|
Tries passwordless sudo first; falls back to an interactive `sudo -v` so the
|
|
credentials get cached, after which plain sudo -n calls succeed.
|
|
"""
|
|
if os.geteuid() == 0:
|
|
return True, "running as root"
|
|
if not shutil.which("sudo"):
|
|
return False, "sudo is not installed"
|
|
r = run(["sudo", "-n", "true"], timeout=15)
|
|
if r["rc"] == 0:
|
|
return True, "sudo available (credentials cached)"
|
|
if interactive and sys.stdin.isatty():
|
|
info("sudo authentication required for package installation - enter your password")
|
|
try:
|
|
p = subprocess.run(["sudo", "-v"])
|
|
except KeyboardInterrupt:
|
|
return False, "interrupted"
|
|
if p.returncode == 0:
|
|
return True, "sudo authenticated just now"
|
|
return False, "sudo authentication failed"
|
|
return False, "sudo requires a password (non-interactive session)"
|
|
|
|
|
|
def install_nvidia_driver():
|
|
"""Ubuntu: prefer 'ubuntu-drivers install', else the nvidia-driver metapackage."""
|
|
if which("ubuntu-drivers"):
|
|
info("installing NVIDIA driver via 'ubuntu-drivers install'")
|
|
r = as_root(["ubuntu-drivers", "install"], timeout=900)
|
|
else:
|
|
pm = detect_pm()
|
|
if not pm:
|
|
return False, "no package manager available for driver install"
|
|
info("installing NVIDIA driver metapackage 'nvidia-driver'")
|
|
r = as_root(pm + ["nvidia-driver"], timeout=900)
|
|
return r["rc"] == 0, (r["out"][-2000:] + r["err"][-1000:])
|
|
|
|
|
|
def preflight(auto_install=True, do_clone=True):
|
|
step("Preflight")
|
|
report = {"tools": {}, "root": have_root()}
|
|
critical_missing, optional_missing = [], []
|
|
|
|
rel = os_release()
|
|
report["os"] = {"id": rel.get("ID", ""), "version_id": rel.get("VERSION_ID", ""),
|
|
"pretty": rel.get("PRETTY_NAME", "unknown")}
|
|
info(f"OS: {report['os']['pretty']}")
|
|
|
|
for tool, (level, why) in TOOL_CHECKS.items():
|
|
path = find_nvflash() if tool == "nvflash" else which(tool)
|
|
report["tools"][tool] = {"found": bool(path), "path": path, "level": level, "why": why}
|
|
if not path:
|
|
(critical_missing if level.startswith("critical") else optional_missing).append(tool)
|
|
|
|
want_nvidia = False
|
|
if auto_install:
|
|
pkgs = sorted({PKG_FOR[t] for t in critical_missing + optional_missing if t in PKG_FOR})
|
|
if "nvidia-smi" in critical_missing and sys.stdin.isatty():
|
|
if ask("nvidia-smi missing - install the NVIDIA driver now? (y/N)", "n").lower().startswith("y"):
|
|
want_nvidia = True
|
|
if pkgs or want_nvidia:
|
|
ok_sudo, why = sudo_precondition()
|
|
report["sudo"] = {"ok": ok_sudo, "why": why}
|
|
if not ok_sudo:
|
|
err(f"sudo PRECONDITION FAILED: {why}")
|
|
err("--preflight package installation requires sudo privileges. Fix one of:")
|
|
err(" - run 'sudo -v' first, then re-run this command")
|
|
err(" - run the script as root")
|
|
err(" - re-run with --no-install to skip package installation")
|
|
report["install_skipped"] = why
|
|
else:
|
|
info(f"sudo precondition OK: {why}")
|
|
pm = detect_pm()
|
|
if pkgs:
|
|
if pm and pm[0] == "apt-get":
|
|
info("apt-get update ...")
|
|
as_root(["apt-get", "update"], timeout=300)
|
|
if pm:
|
|
info(f"installing: {' '.join(pkgs)}")
|
|
_, log = install_packages(pkgs)
|
|
report["install_log"] = log
|
|
else:
|
|
report["install_log"] = "no package manager - manual install required"
|
|
warn(f"no package manager found - install manually: {' '.join(pkgs)}")
|
|
if want_nvidia:
|
|
if pm or which("ubuntu-drivers"):
|
|
ok_d, log_d = install_nvidia_driver()
|
|
report["nvidia_driver"] = {"ok": ok_d, "log": log_d}
|
|
if ok_d:
|
|
ok("NVIDIA driver installed - reboot, then re-run the check")
|
|
else:
|
|
warn("NVIDIA driver install failed (see report['nvidia_driver']['log'])")
|
|
else:
|
|
warn("no package manager - cannot install NVIDIA driver automatically")
|
|
for tool in list(critical_missing) + list(optional_missing):
|
|
if which(tool):
|
|
if tool in critical_missing:
|
|
critical_missing.remove(tool)
|
|
if tool in optional_missing:
|
|
optional_missing.remove(tool)
|
|
report["tools"][tool].update(found=True, path=which(tool))
|
|
else:
|
|
report["sudo"] = {"ok": True, "why": "nothing to install"}
|
|
|
|
if do_clone:
|
|
ensure_repos()
|
|
report["repos_ok"] = all((HARNESS_DIR / n / ".git").exists() for n in REPOS)
|
|
|
|
report["verify_sh_exists"] = VERIFY_SH.exists()
|
|
|
|
for t, st in report["tools"].items():
|
|
if st["found"]:
|
|
mark = f"{C.GREEN}OK{C.RESET}"
|
|
elif st["level"].startswith("critical"):
|
|
mark = f"{C.RED}MISSING{C.RESET}"
|
|
else:
|
|
mark = f"{C.YELLOW}missing (optional){C.RESET}"
|
|
print(f" {t:<14} {mark} {C.DIM}{st['why']}{C.RESET}")
|
|
|
|
if not find_nvflash():
|
|
warn("nvflash not found - VBIOS backup will fall back to sysfs ROM, serial to manual entry")
|
|
print(" Bundle it in the repo: nvflash_<version>_linux/x64/nvflash (chmod +x),")
|
|
print(" or pass --nvflash /path/to/nvflash, or place it on PATH.")
|
|
else:
|
|
info(f"nvflash: {find_nvflash()}")
|
|
if not which("nvidia-smi"):
|
|
warn("nvidia-smi missing - cmpunlocker verify will be skipped (install nvidia-open driver)")
|
|
if not report["root"]:
|
|
warn("no sudo and not root: privileged steps (rescan, retrain, rom, dmesg) will be skipped")
|
|
|
|
blocking = [t for t in ("git", "lspci", "setpci") if not which(t)]
|
|
report["blocking_missing"] = blocking
|
|
report["preflight_ok"] = not blocking
|
|
if blocking:
|
|
err("blocking tools still missing: " + ", ".join(blocking))
|
|
else:
|
|
ok("preflight complete")
|
|
return report
|
|
|
|
|
|
# ---------------------------------------------------------------- GPU detect
|
|
|
|
def detect_gpus():
|
|
bdfs = []
|
|
for dev_id in GPU_IDS:
|
|
r = run(["lspci", "-D", "-d", f"10de:{dev_id}"])
|
|
for line in r["out"].splitlines():
|
|
parts = line.split()
|
|
if not parts:
|
|
continue
|
|
bdf = parts[0]
|
|
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-f]$", bdf):
|
|
bdfs.append(bdf.lower())
|
|
return sorted(set(bdfs))
|
|
|
|
|
|
def rescan_bus():
|
|
r = as_root(["sh", "-c", "echo 1 > /sys/bus/pci/rescan"], timeout=15)
|
|
if r["rc"] == 0:
|
|
time.sleep(2)
|
|
return r["rc"] == 0
|
|
|
|
|
|
def sysfs(bdf):
|
|
return Path("/sys/bus/pci/devices") / bdf
|
|
|
|
|
|
def read_file(path, binary=False):
|
|
try:
|
|
with open(path, "rb" if binary else "r", errors="replace") as f:
|
|
return f.read()
|
|
except (PermissionError, FileNotFoundError, OSError):
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------- PCIe checks
|
|
|
|
def parse_link(text):
|
|
out = {"lnksta_speed": None, "lnksta_width": None, "lnksta_gen": None,
|
|
"lnkcap_speed": None, "lnkcap_width": None, "lnksta_raw": None}
|
|
for line in text.splitlines():
|
|
s = line.strip()
|
|
if s.startswith("LnkSta:") and not s.startswith("LnkSta2"):
|
|
out["lnksta_raw"] = s
|
|
m = re.search(r"Speed\s+([^,\s]+)", s)
|
|
if m:
|
|
out["lnksta_speed"] = m.group(1)
|
|
out["lnksta_gen"] = SPEED_TO_GEN.get(m.group(1))
|
|
m = re.search(r"Width\s+(x\d+)", s)
|
|
if m:
|
|
out["lnksta_width"] = m.group(1)
|
|
elif s.startswith("LnkCap:"):
|
|
m = re.search(r"Speed\s+([^,\s]+)", s)
|
|
if m:
|
|
out["lnkcap_speed"] = m.group(1)
|
|
m = re.search(r"Width\s+(x\d+)", s)
|
|
if m:
|
|
out["lnkcap_width"] = m.group(1)
|
|
return out
|
|
|
|
|
|
def parse_aer_flags(text):
|
|
res = {"CESta": [], "UESta": []}
|
|
for line in text.splitlines():
|
|
s = line.strip()
|
|
for key in ("CESta", "UESta"):
|
|
if s.startswith(key + ":"):
|
|
body = s.split(":", 1)[1]
|
|
res[key].extend(re.findall(r"([A-Za-z0-9_]+)\+", body))
|
|
return res
|
|
|
|
|
|
def parent_bdf(bdf):
|
|
path = Path(os.path.realpath(sysfs(bdf)))
|
|
while True:
|
|
path = path.parent
|
|
name = path.name
|
|
if re.match(r"^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-f]$", name):
|
|
return name.lower()
|
|
if name.startswith("pci") or path.parent == path:
|
|
return None
|
|
|
|
|
|
def aer_snapshot(bdf):
|
|
snap = {"aer_sysfs_correctable": None, "aer_sysfs_fatal": None,
|
|
"aer_correctable_sum": 0, "ce_active": [], "ue_active": [],
|
|
"parent_bdf": None, "parent_ce_active": []}
|
|
base = sysfs(bdf)
|
|
data = read_file(base / "aer_dev_correctable")
|
|
if data is not None:
|
|
snap["aer_sysfs_correctable"] = data.strip()
|
|
snap["aer_correctable_sum"] = sum(int(x) for x in re.findall(r"\d+", data))
|
|
fatal = read_file(base / "aer_dev_fatal")
|
|
if fatal is not None:
|
|
snap["aer_sysfs_fatal"] = fatal.strip()
|
|
r = run(["lspci", "-vvv", "-D", "-s", bdf], timeout=30)
|
|
if r["rc"] == 0:
|
|
aer = parse_aer_flags(r["out"])
|
|
snap["ce_active"] = aer["CESta"]
|
|
snap["ue_active"] = aer["UESta"]
|
|
parent = parent_bdf(bdf)
|
|
if parent:
|
|
snap["parent_bdf"] = parent
|
|
rp = run(["lspci", "-vvv", "-D", "-s", parent], timeout=30)
|
|
if rp["rc"] == 0:
|
|
snap["parent_ce_active"] = parse_aer_flags(rp["out"])["CESta"]
|
|
return snap
|
|
|
|
|
|
def link_gen_setpci(bdf):
|
|
r = as_root(["setpci", "-s", bdf, "CAP_EXP+12.w"], timeout=15)
|
|
val = r["out"].strip()
|
|
if re.match(r"^[0-9a-fA-F]{4}$", val):
|
|
return int(val, 16) & 0x0F
|
|
return None
|
|
|
|
|
|
def check_link(bdf):
|
|
r = run(["lspci", "-vvv", "-D", "-s", bdf], timeout=30)
|
|
link = parse_link(r["out"]) if r["rc"] == 0 else {}
|
|
link["setpci_current_gen"] = link_gen_setpci(bdf)
|
|
link["lspci_rc"] = r["rc"]
|
|
return link, r["out"]
|
|
|
|
|
|
def dump_config(bdf):
|
|
r = run(["lspci", "-nnvvxxxx", "-D", "-s", bdf], timeout=30)
|
|
return r["out"] if r["rc"] == 0 else f"lspci failed rc={r['rc']}: {r['err']}"
|
|
|
|
|
|
def retrain_test(bdf):
|
|
result = {"attempted": False, "ok": None, "note": "", "width_before": None, "width_after": None}
|
|
link_before, _ = check_link(bdf)
|
|
result["width_before"] = link_before.get("lnksta_width")
|
|
node = sysfs(bdf) / "link" / "retrain"
|
|
if not node.exists():
|
|
result["note"] = "sysfs link/retrain not available on this kernel"
|
|
return result
|
|
result["attempted"] = True
|
|
r = as_root(["sh", "-c", f"echo 1 > {node}"], timeout=15)
|
|
if r["rc"] != 0:
|
|
result["note"] = f"retrain write failed: {r['err'].strip()}"
|
|
return result
|
|
time.sleep(3)
|
|
if bdf not in detect_gpus():
|
|
result.update(ok=False, note="device vanished after retrain")
|
|
return result
|
|
link_after, _ = check_link(bdf)
|
|
result["width_after"] = link_after.get("lnksta_width")
|
|
result["ok"] = result["width_after"] == result["width_before"] and result["width_after"] is not None
|
|
result["note"] = f"{result['width_before']} -> {result['width_after']}"
|
|
return result
|
|
|
|
|
|
def rescan_test(bdf):
|
|
result = {"attempted": True, "ok": None, "note": ""}
|
|
if not rescan_bus():
|
|
result["note"] = "bus rescan not permitted (no root)"
|
|
return result
|
|
time.sleep(1)
|
|
present = bdf in detect_gpus()
|
|
result["ok"] = present
|
|
result["note"] = "re-enumerated at same BDF" if present else "device missing after rescan"
|
|
return result
|
|
|
|
|
|
def dmesg_capture(bdf):
|
|
text, source = "", None
|
|
r = as_root(["dmesg", "--ctime"], timeout=20)
|
|
if r["rc"] == 0 and r["out"]:
|
|
text, source = r["out"], "dmesg"
|
|
else:
|
|
r2 = run(["journalctl", "-k", "-b", "--no-pager", "-o", "short-precise"], timeout=25)
|
|
if r2["rc"] == 0 and r2["out"]:
|
|
text, source = r2["out"], "journalctl"
|
|
if not text:
|
|
return {"source": None, "related": [], "errors": [], "note": "kernel log unavailable (no root?)"}
|
|
short = bdf[5:]
|
|
related, errors = [], []
|
|
for line in text.splitlines():
|
|
low = line.lower()
|
|
if (bdf in line or short in line or "10de" in low or "nvrm" in low
|
|
or "xid" in low or "aer:" in low or "pcieport" in low):
|
|
related.append(line)
|
|
if any(k in low for k in ("xid", "error", "aer:", "fail", "corrupted", "fallen off")):
|
|
errors.append(line)
|
|
return {"source": source, "related": related[-200:], "errors": errors[-50:],
|
|
"note": f"{len(related)} related lines"}
|
|
|
|
|
|
# ---------------------------------------------------------------- BIOS / serial
|
|
|
|
def nvflash_bin(args):
|
|
if getattr(args, "nvflash", None):
|
|
return args.nvflash
|
|
return find_nvflash()
|
|
|
|
|
|
def nvflash_list(nf):
|
|
for flags in (["--list"], ["-l"]):
|
|
r = as_root([nf] + flags, timeout=40)
|
|
if r["rc"] == 0 and r["out"].strip():
|
|
return r["out"]
|
|
return None
|
|
|
|
|
|
def get_serial(nf, args):
|
|
if getattr(args, "serial", None):
|
|
return args.serial, "cli-override"
|
|
if nf:
|
|
listing = nvflash_list(nf)
|
|
if listing:
|
|
m = re.search(r"UUID:\s*(GPU-[0-9a-fA-F\-]+)", listing, re.I)
|
|
if m:
|
|
info(f"serial auto-read from nvflash UUID: {m.group(1)}")
|
|
return m.group(1), "nvflash-uuid"
|
|
m = re.search(r"Serial(?:Number)?\s*[:=]\s*(\S+)", listing, re.I)
|
|
if m:
|
|
info(f"serial auto-read from nvflash: {m.group(1)}")
|
|
return m.group(1), "nvflash-list"
|
|
q = as_root([nf, "--query"], timeout=40)
|
|
m = re.search(r"Serial(?:Number)?\s*[:=]\s*(\S+)", q["out"], re.I)
|
|
if m:
|
|
info(f"serial auto-read from nvflash --query: {m.group(1)}")
|
|
return m.group(1), "nvflash-query"
|
|
warn("nvflash present but exposes no serial/UUID for this card")
|
|
else:
|
|
warn("nvflash unavailable - cannot auto-read serial")
|
|
return "N/A", "unavailable"
|
|
|
|
|
|
def save_bios(nf, out_path, args):
|
|
result = {"method": None, "path": None, "sha256": None, "size": 0, "ok": False, "note": ""}
|
|
if getattr(args, "skip_bios", False):
|
|
result["note"] = "bios backup skipped (--skip-bios)"
|
|
return result
|
|
notes = []
|
|
if nf:
|
|
ok_save, note = try_nvflash_save(nf, out_path)
|
|
if ok_save:
|
|
data = out_path.read_bytes()
|
|
result.update(method="nvflash", path=str(out_path), size=len(data),
|
|
sha256=hashlib.sha256(data).hexdigest(), ok=True)
|
|
return result
|
|
notes.append(note)
|
|
else:
|
|
notes.append("nvflash unavailable")
|
|
|
|
for bdf in detect_gpus():
|
|
rom = sysfs(bdf) / "rom"
|
|
if not rom.exists():
|
|
notes.append(f"no sysfs rom node for {bdf} (ROM BAR not exposed - fallback unavailable)")
|
|
continue
|
|
if as_root(["sh", "-c", f"echo 1 > {rom}"], timeout=15)["rc"] != 0:
|
|
notes.append(f"sysfs rom enable denied for {bdf}")
|
|
continue
|
|
data = read_file(rom, binary=True)
|
|
as_root(["sh", "-c", f"echo 0 > {rom}"], timeout=15)
|
|
if data and len(data) > 512 and data.strip(b"\x00") != b"":
|
|
out_path.write_bytes(data)
|
|
result.update(method="sysfs-rom", path=str(out_path), size=len(data),
|
|
sha256=hashlib.sha256(data).hexdigest(), ok=True)
|
|
return result
|
|
notes.append(f"sysfs rom empty/all-zero for {bdf}")
|
|
if any("no sysfs rom node" in n for n in notes):
|
|
notes.append(f"host lockdown={lockdown_state()} secureboot={secure_boot_state()}")
|
|
result["note"] = " | ".join(notes)
|
|
|
|
# Opt-in last resort: unload the driver, retry nvflash --save, always reload.
|
|
if nf and any("driver loaded" in n for n in notes):
|
|
if getattr(args, "bios_unload", False) or (
|
|
sys.stdin.isatty()
|
|
and ask("Unload NVIDIA driver and retry nvflash --save now? (y/N)",
|
|
"n").lower().startswith("y")):
|
|
result["note"] += " | " + retry_save_unloaded(nf, out_path, result)
|
|
else:
|
|
result["note"] += (" | skipped driver-unload retry (use --bios-unload; "
|
|
"local console may freeze while the driver is out)")
|
|
return result
|
|
|
|
|
|
def try_nvflash_save(nf, out_path):
|
|
"""One nvflash --save attempt. Returns (ok, note)."""
|
|
try:
|
|
if out_path.exists():
|
|
out_path.unlink()
|
|
except OSError:
|
|
pass
|
|
r = as_root([nf, "--save", str(out_path)], timeout=90)
|
|
text = r["out"] + r["err"]
|
|
if r["rc"] == 0 and out_path.exists() and out_path.stat().st_size > 0:
|
|
data = out_path.read_bytes()
|
|
if data.strip(b"\x00") == b"":
|
|
return False, "nvflash wrote an all-zero image"
|
|
return True, ""
|
|
if "unload" in text.lower() and "kernel driver" in text.lower():
|
|
return False, (f"nvflash --save refused: NVIDIA driver loaded (rc={r['rc']}) - "
|
|
"unload it first or use --bios-unload")
|
|
return False, f"nvflash --save failed rc={r['rc']}: {text[-200:].strip()}"
|
|
|
|
|
|
def retry_save_unloaded(nf, out_path, result):
|
|
"""Unload NVIDIA modules, retry nvflash --save, always reload. Returns note."""
|
|
warn("unloading NVIDIA driver - local display may freeze until reload")
|
|
_, unload_log = unload_nvidia_modules()
|
|
try:
|
|
ok_save, note = try_nvflash_save(nf, out_path)
|
|
if ok_save:
|
|
data = out_path.read_bytes()
|
|
result.update(method="nvflash-unload", path=str(out_path), size=len(data),
|
|
sha256=hashlib.sha256(data).hexdigest(), ok=True)
|
|
return "saved via nvflash after driver unload"
|
|
return f"nvflash --save still failed after unload: {note}"
|
|
finally:
|
|
ok_reload, reload_log = load_nvidia_modules()
|
|
for line in unload_log + reload_log:
|
|
info(line)
|
|
if ok_reload and which("nvidia-smi"):
|
|
ok("NVIDIA driver reloaded")
|
|
else:
|
|
err("NVIDIA driver reload incomplete - reboot the host")
|
|
|
|
|
|
def unload_nvidia_modules():
|
|
"""Best-effort rmmod of the NVIDIA stack. Returns (all_ok, log_lines)."""
|
|
log, all_ok = [], True
|
|
for mod in NVIDIA_MODULES:
|
|
r = as_root(["rmmod", mod], timeout=60)
|
|
log.append(f"rmmod {mod}: rc={r['rc']} {(r['err'] or r['out']).strip()[:160]}")
|
|
if r["rc"] != 0:
|
|
all_ok = False
|
|
return all_ok, log
|
|
|
|
|
|
def load_nvidia_modules():
|
|
"""Reload the NVIDIA stack in reverse order. Returns (all_ok, log_lines)."""
|
|
log, all_ok = [], True
|
|
for mod in reversed(NVIDIA_MODULES):
|
|
r = as_root(["modprobe", mod], timeout=120)
|
|
log.append(f"modprobe {mod}: rc={r['rc']} {(r['err'] or r['out']).strip()[:160]}")
|
|
if r["rc"] != 0:
|
|
all_ok = False
|
|
return all_ok, log
|
|
|
|
|
|
def lockdown_state():
|
|
data = read_file("/sys/kernel/security/lockdown")
|
|
return data.strip() if data else "lockdown file unreadable"
|
|
|
|
|
|
def secure_boot_state():
|
|
if not which("mokutil"):
|
|
return "mokutil not installed"
|
|
r = run(["mokutil", "--sb-state"], timeout=15)
|
|
lines = (r["out"] or r["err"]).strip().splitlines()
|
|
return lines[0].strip() if lines else "mokutil gave no output"
|
|
|
|
|
|
# ---------------------------------------------------------------- nvidia / verify
|
|
|
|
def nvidia_smi_info():
|
|
if not which("nvidia-smi"):
|
|
return {"available": False}
|
|
q = run(["nvidia-smi", "--query-gpu=name,uuid,memory.total,pcie.link.gen.current,"
|
|
"pcie.link.gen.max,pcie.link.width.current,temperature.gpu,driver_version",
|
|
"--format=csv,noheader"], timeout=30)
|
|
if q["rc"] != 0:
|
|
return {"available": False, "error": (q["err"] or q["out"])[-300:]}
|
|
rows = [[f.strip() for f in line.split(",")] for line in q["out"].strip().splitlines()]
|
|
return {"available": True, "gpus": rows}
|
|
|
|
|
|
def get_gpu_uuid():
|
|
"""First GPU UUID from nvidia-smi (e.g. GPU-xxxx...), or None."""
|
|
if not which("nvidia-smi"):
|
|
return None
|
|
q = run(["nvidia-smi", "--query-gpu=uuid", "--format=csv,noheader"], timeout=30)
|
|
if q["rc"] != 0:
|
|
return None
|
|
for line in q["out"].splitlines():
|
|
line = line.strip()
|
|
if line.startswith("GPU-"):
|
|
return line
|
|
return None
|
|
|
|
|
|
def run_verify_unlocker():
|
|
if not VERIFY_SH.exists():
|
|
return {"ran": False, "note": "harness/cmpunlocker not cloned"}
|
|
if not which("nvidia-smi"):
|
|
return {"ran": False, "note": "nvidia-smi missing - unlock verify skipped"}
|
|
r = as_root(["bash", str(VERIFY_SH)], timeout=180)
|
|
lines = [l for l in (r["out"] + r["err"]).splitlines() if l.strip()]
|
|
status = "unknown"
|
|
if r["rc"] == 0 and any("unlocked memory" in l.lower() for l in lines):
|
|
status = "unlocked-ok"
|
|
elif "STOCK" in r["out"]:
|
|
status = "stock-memory"
|
|
elif "MISSING" in r["out"]:
|
|
status = "gpu-missing-in-smi"
|
|
elif r["rc"] != 0:
|
|
status = f"verify-failed-rc{r['rc']}"
|
|
return {"ran": True, "rc": r["rc"], "status": status, "tail": lines[-30:]}
|
|
|
|
|
|
# ---------------------------------------------------------------- unlock (opt-in install)
|
|
|
|
def update_cmpunlocker():
|
|
"""Bring harness/cmpunlocker up to date with upstream main before installing.
|
|
|
|
A failed `git pull --ff-only` (diverged checkout, offline host, ...) is not
|
|
fatal: we warn, keep the existing checkout, and let the install proceed.
|
|
"""
|
|
dest = HARNESS_DIR / "cmpunlocker"
|
|
if not (dest / ".git").exists():
|
|
info("harness/cmpunlocker missing - cloning ...")
|
|
if not ensure_repos():
|
|
return {"ok": False, "note": "harness/cmpunlocker not cloned and git clone failed"}
|
|
return {"ok": True, "note": "harness/cmpunlocker cloned"}
|
|
r = run(["git", "-C", str(dest), "pull", "--ff-only"], timeout=180)
|
|
if r["rc"] != 0:
|
|
msg = (r["err"] or r["out"]).strip().splitlines()
|
|
msg = msg[-1] if msg else f"rc={r['rc']}"
|
|
warn(f"git pull --ff-only failed: {msg} - continuing with the existing checkout")
|
|
return {"ok": False, "note": f"git pull --ff-only failed ({msg}) - using existing checkout"}
|
|
last = (r["out"].strip().splitlines() or ["already up to date"])[-1]
|
|
ok(f"cmpunlocker harness updated: {last}")
|
|
return {"ok": True, "note": f"updated: {last}"}
|
|
|
|
|
|
def run_installer(log_path):
|
|
"""Run install.sh from the harness clone (opt-in, root required).
|
|
|
|
Output streams live to the terminal and is fully saved to log_path.
|
|
Returns {"attempted", "ok", "rc", "note", "log"}; attempted=False when a
|
|
precondition (clone present, sudo usable) fails.
|
|
"""
|
|
log_path = Path(log_path)
|
|
result = {"attempted": False, "ok": False, "rc": None, "note": "", "log": str(log_path)}
|
|
if not INSTALL_SH.exists():
|
|
result["note"] = "harness/cmpunlocker not cloned"
|
|
warn(f"{result['note']} - run --preflight first")
|
|
return result
|
|
ok_sudo, why = sudo_precondition()
|
|
if not ok_sudo:
|
|
result["note"] = f"sudo precondition failed: {why}"
|
|
warn(result["note"])
|
|
return result
|
|
|
|
cmd = ["bash", str(INSTALL_SH)]
|
|
if os.geteuid() != 0:
|
|
cmd = ["sudo", "-n"] + cmd # credentials cached by sudo_precondition()
|
|
info(f"running install.sh in {INSTALL_SH.parent} - kernel module build takes 5-15 min, "
|
|
f"live output -> {log_path}")
|
|
r = run_live(cmd, cwd=str(INSTALL_SH.parent), timeout=1800, log_path=log_path)
|
|
result.update(attempted=True, rc=r["rc"])
|
|
if r["rc"] == 0:
|
|
result.update(ok=True, note="install.sh completed")
|
|
ok("cmpunlocker installed - cold reboot required: sudo shutdown -h now (then power on), "
|
|
"then re-run this script for the final PCIe check")
|
|
else:
|
|
tail = (r["err"] or r["out"]).strip().splitlines()
|
|
tail = tail[-1] if tail else ""
|
|
result["note"] = f"install.sh failed rc={r['rc']}" + (f": {tail}" if tail else "")
|
|
warn(f"{result['note']} (see {log_path})")
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------- Vulkan test
|
|
|
|
def vulkan_tool():
|
|
for t in ("vkmark", "vkcube", "vulkaninfo"):
|
|
if which(t):
|
|
return t
|
|
return None
|
|
|
|
|
|
def run_vulkan_test(seconds=VULKAN_TEST_SECONDS):
|
|
result = {"tool": None, "ran": False, "requested": True, "duration_s": seconds,
|
|
"rc": None, "note": "", "log_tail": []}
|
|
tool = vulkan_tool()
|
|
if not tool:
|
|
result["note"] = "no vulkan tool available (install vkmark or vulkan-tools)"
|
|
warn(result["note"])
|
|
return result
|
|
result["tool"] = tool
|
|
info(f"running {tool} for {seconds}s VRAM stress - press Ctrl+C to abort early")
|
|
output = []
|
|
deadline = time.time() + seconds
|
|
try:
|
|
if tool == "vkmark":
|
|
while time.time() < deadline:
|
|
r = run(["vkmark", "--benchmark"], timeout=60)
|
|
output.append(r["out"])
|
|
if r["rc"] not in (0, 124):
|
|
break
|
|
elif tool == "vkcube":
|
|
p = subprocess.Popen(["vkcube"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
try:
|
|
p.wait(timeout=seconds)
|
|
except subprocess.TimeoutExpired:
|
|
p.terminate()
|
|
try:
|
|
p.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
p.kill()
|
|
output.append(p.communicate()[0] or "")
|
|
result["rc"] = p.returncode
|
|
else:
|
|
r = run(["vulkaninfo"], timeout=seconds)
|
|
output.append(r["out"] + r["err"])
|
|
result["rc"] = r["rc"]
|
|
except KeyboardInterrupt:
|
|
result["note"] = "aborted by user"
|
|
warn("vulkan test aborted")
|
|
text = "\n".join(output)
|
|
result["ran"] = True
|
|
result["log_tail"] = [l for l in text.splitlines() if l.strip()][-40:]
|
|
bad = [l for l in text.splitlines()
|
|
if re.search(r"(error|fail|out of memory|oom|corrupt)", l, re.I)]
|
|
result["tool_errors"] = bad[-20:]
|
|
ok(f"{tool} stress finished ({seconds}s)")
|
|
return result
|