#!/usr/bin/env python3 """ run_amdsmi_build.py - Local runner for the AMDSMI build and install workflow. This script mirrors the build and install steps from .github/workflows/amdsmi-build.yml so that you can reproduce builds and installs directly on a local machine (or inside a Docker container) without GitHub Actions. Every command is logged to --log-dir, failures surface immediately with the path to the log file, and test results are written to --test-results-dir with well-known filenames for CI upload. Prerequisites ============= * Python 3.8+ * CMake >= 3.16 * A C/C++ toolchain (gcc/g++ or clang) * Root / sudo privileges (package install touches /opt/rocm) * For Debian/Ubuntu: apt * For RHEL/AlmaLinux/Fedora: dnf * For SLES: zypper Quick Start (Local) =================== Clone the repo, cd into it, and run the script. It will auto-detect the project root, package manager, and CPU count. sudo python3 projects/amdsmi/tests/amdsmi_build/run_amdsmi_build.py Per-OS Examples =============== Ubuntu 20.04 / 22.04 / 24.04 (Debian-based, apt) -------------------------------------------------- sudo python3 run_amdsmi_build.py \ --package-manager apt \ --os-label Ubuntu22 Debian 10 ------------------------------------ sudo python3 run_amdsmi_build.py \ --package-manager apt \ --debian10-sources \ --os-label Debian10 RHEL 8 / 9 (dnf) ----------------- sudo python3 run_amdsmi_build.py \ --package-manager dnf \ --package-format rpm \ --os-label RHEL9 RHEL 10 / AlmaLinux 8 (dnf - needs QA_RPATHS) ---------------------------------------------- sudo python3 run_amdsmi_build.py \ --package-manager dnf \ --package-format rpm \ --qa-rpaths \ --os-label RHEL10 SLES (zypper) ------------- sudo python3 run_amdsmi_build.py \ --package-manager zypper \ --package-format rpm \ --os-label SLES AzureLinux 3 (dnf - skip setuptools upgrade, install more_itertools) -------------------------------------------------------------------- sudo python3 run_amdsmi_build.py \ --package-manager dnf \ --package-format rpm \ --skip-setuptools-upgrade \ --install-more-itertools \ --os-label AzureLinux3 Skipping Stages =============== Use --skip-build or --skip-install to skip individual stages. For example, to re-run only the tests against an existing build directory: sudo python3 run_amdsmi_build.py --skip-build Output ====== * Logs: --log-dir (default: logs/amdsmi/) * Test results: --test-results-dir (default: /tmp/test-results-/) Run with --help for the full list of options. """ # --------------------------------------------------------------------------- # Bootstrap: ensure Python 3.7+ (required for dataclasses & type annotations). # On SLES the default python3 may be 3.6; this block auto-installs a newer # interpreter via zypper and re-execs. The code below intentionally avoids # any 3.7+ syntax so it can run on Python 3.6. # --------------------------------------------------------------------------- def _bootstrap_python(): import os import shutil import subprocess import sys if sys.version_info >= (3, 7): return pkg_mgr = None install_cmd = None pkg_candidates = None if shutil.which("zypper"): pkg_mgr = "zypper" install_cmd = ["zypper", "--non-interactive", "install"] pkg_candidates = [ ["python311", "python311-pip"], ["python310", "python310-pip"], ["python39", "python39-pip"], ["python38", "python38-pip"], ] elif shutil.which("dnf"): pkg_mgr = "dnf" install_cmd = ["dnf", "install", "-y"] pkg_candidates = [ ["python3.11", "python3.11-pip"], ["python3.9", "python3.9-pip"], ["python39", "python39-pip"], ["python3", "python3-pip"], ] elif shutil.which("apt"): pkg_mgr = "apt" install_cmd = ["apt-get", "install", "-y", "--no-install-recommends"] pkg_candidates = [ ["python3.11", "python3-pip"], ["python3.10", "python3-pip"], ["python3.9", "python3-pip"], ["python3", "python3-pip"], ] else: sys.exit( "ERROR: Python 3.7+ is required (have %d.%d). " "Install a newer interpreter or use a newer base image." % (sys.version_info[0], sys.version_info[1]) ) print( "Python %d.%d is too old - upgrading via %s..." % (sys.version_info[0], sys.version_info[1], pkg_mgr) ) if pkg_mgr == "apt": try: # Keep stdout quiet but let stderr surface install diagnostics. subprocess.check_call( ["apt-get", "update"], stdout=subprocess.DEVNULL, ) except subprocess.CalledProcessError: pass for pkgs in pkg_candidates: try: # Surface stderr so an operator can see *why* every candidate # failed if we fall through the loop with no interpreter installed. subprocess.check_call(install_cmd + pkgs, stdout=subprocess.DEVNULL) break except subprocess.CalledProcessError: continue new_py = None for name in ["python3.11", "python3.10", "python3.9", "python3.8"]: p = shutil.which(name) if p: new_py = p break if new_py is None: sys.exit("ERROR: could not find a Python 3.7+ interpreter after install") try: subprocess.check_call( ["alternatives", "--set", "python3", new_py], stdout=subprocess.DEVNULL, ) except (subprocess.CalledProcessError, FileNotFoundError, OSError): link = "/usr/bin/python3" if os.path.exists(link) or os.path.islink(link): os.unlink(link) os.symlink(new_py, link) print("Re-executing under %s ..." % new_py) os.execvp(new_py, [new_py] + sys.argv) _bootstrap_python() # --------------------------------------------------------------------------- import argparse import datetime as _dt import os import shutil import subprocess import sys from collections import deque from dataclasses import dataclass from pathlib import Path from typing import Iterable, List, Optional DEFAULT_LOG_DIR = Path("logs") / "amdsmi" class CommandError(RuntimeError): def __init__(self, name: str, cmd: List[str], code: int, log_path: Path): super().__init__(f"{name} failed with exit code {code}; see {log_path}") self.name = name self.cmd = cmd self.code = code self.log_path = log_path def _timestamp() -> str: return _dt.datetime.now(_dt.timezone.utc).strftime("%Y%m%d-%H%M%S") def run_command( cmd: Iterable[str], *, name: str, cwd: Optional[Path] = None, env: Optional[dict] = None, retries: int = 1, log_dir: Path = DEFAULT_LOG_DIR, result_file: Optional[Path] = None, ) -> Path: """Run a command, streaming output to stdout and a log file. If *result_file* is given the full output is also written there (using a well-known filename that the CI workflow can display later). """ log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{_timestamp()}-{name}.log" cmd_list = [str(part) for part in cmd] attempt = 0 while attempt < retries: attempt += 1 prefix = f"[{name}] attempt {attempt}/{retries}" print(f"{prefix}: {' '.join(cmd_list)}") outputs = [log_path] if result_file is not None: result_file.parent.mkdir(parents=True, exist_ok=True) outputs.append(result_file) handles = [p.open("a", encoding="utf-8") for p in outputs] try: for fh in handles: fh.write(f"{prefix}\n") proc = subprocess.Popen( cmd_list, cwd=str(cwd) if cwd else None, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, ) # proc.stdout is guaranteed non-None because stdout=PIPE above. for line in proc.stdout: sys.stdout.write(line) for fh in handles: fh.write(line) code = proc.wait() finally: for fh in handles: fh.close() if code == 0: return log_path if attempt >= retries: raise CommandError(name, cmd_list, code, log_path) print(f"{prefix} failed with exit code {code}; retrying...") return log_path def tail_log(log_path: Path, max_lines: int = 40) -> str: """Return the last max_lines from a log file for quick context.""" try: with log_path.open("r", encoding="utf-8", errors="replace") as fh: tail = deque(fh, maxlen=max_lines) return "".join(tail) except OSError as exc: return f"(could not read log {log_path}: {exc})" def read_log(log_path: Path) -> str: """Return the full contents of a log file.""" try: return log_path.read_text(encoding="utf-8", errors="replace") except OSError as exc: return f"(could not read log {log_path}: {exc})" def report_and_raise(stage: str, err: CommandError) -> None: print(f"\nAMDSMI failed to {stage}: step '{err.name}' exited with {err.code}") print(f"Log: {err.log_path}") print("Log tail (last ~40 lines):") print("----------------------------------------") print(tail_log(err.log_path)) print("----------------------------------------\n") raise err def find_project_dir(explicit: Optional[Path]) -> Path: if explicit: return explicit.resolve() def _repo_root(start: Path) -> Path: for ancestor in [start] + list(start.parents): if (ancestor / ".git").exists(): return ancestor return start.parents[1] repo_root = _repo_root(Path(__file__).resolve()) primary = repo_root / "projects" / "amdsmi" / "CMakeLists.txt" if primary.exists(): return primary.parent for candidate in repo_root.rglob("CMakeLists.txt"): return candidate.parent raise FileNotFoundError(f"Could not find CMakeLists.txt under {repo_root}") def detect_package_manager(explicit: Optional[str]) -> str: if explicit: return explicit for candidate in ("apt", "dnf", "zypper"): if shutil.which(candidate): return candidate raise RuntimeError("Could not detect a package manager (apt, dnf, zypper)") def detect_package_format(pkg_manager: str, explicit: Optional[str]) -> str: if explicit: return explicit if pkg_manager == "apt": return "deb" if pkg_manager in ("dnf", "zypper"): return "rpm" raise ValueError(f"Unsupported package manager {pkg_manager}") def detect_os_profile(os_release: Optional[Path] = None) -> dict: """Parse /etc/os-release and return a dict of derived runner settings. Returned keys: os_label, package_manager, package_format, debian10_sources, qa_rpaths, skip_setuptools_upgrade, install_more_itertools Returns an empty dict if /etc/os-release is missing/unrecognized. *os_release* may be overridden in tests. """ osr = os_release if os_release is not None else Path("/etc/os-release") if not osr.exists(): return {} fields = {} for line in osr.read_text(encoding="utf-8", errors="replace").splitlines(): if "=" not in line: continue k, v = line.split("=", 1) fields[k.strip()] = v.strip().strip('"').strip("'") os_id = fields.get("ID", "").lower() id_like = fields.get("ID_LIKE", "").lower() version_id = fields.get("VERSION_ID", "") major = version_id.split(".", 1)[0] if version_id else "" profile = { "debian10_sources": False, "qa_rpaths": False, "skip_setuptools_upgrade": False, "install_more_itertools": False, } # Debian/Ubuntu family -------------------------------------------------- if os_id == "ubuntu": profile["package_manager"] = "apt" profile["package_format"] = "deb" profile["os_label"] = f"Ubuntu{major}" if major else "Ubuntu" elif os_id == "debian": profile["package_manager"] = "apt" profile["package_format"] = "deb" profile["os_label"] = f"Debian{major}" if major else "Debian" if major == "10": profile["debian10_sources"] = True # RHEL family ----------------------------------------------------------- elif os_id in ("rhel", "redhat", "redhatenterpriseserver"): profile["package_manager"] = "dnf" profile["package_format"] = "rpm" profile["os_label"] = f"RHEL{major}" if major else "RHEL" if major in ("10",): profile["qa_rpaths"] = True elif os_id == "almalinux": profile["package_manager"] = "dnf" profile["package_format"] = "rpm" profile["os_label"] = f"AlmaLinux{major}" if major else "AlmaLinux" if major == "8": profile["qa_rpaths"] = True elif os_id in ("rocky", "centos"): profile["package_manager"] = "dnf" profile["package_format"] = "rpm" label_prefix = os_id.capitalize() profile["os_label"] = f"{label_prefix}{major}" if major else label_prefix elif os_id in ("mariner", "azurelinux"): profile["package_manager"] = "dnf" profile["package_format"] = "rpm" prefix = "AzureLinux" if os_id == "azurelinux" else "Mariner" profile["os_label"] = f"{prefix}{major}" if major else prefix profile["skip_setuptools_upgrade"] = True profile["install_more_itertools"] = True # SUSE family ----------------------------------------------------------- elif os_id in ("sles", "sled", "opensuse-leap", "opensuse-tumbleweed") or "suse" in id_like: profile["package_manager"] = "zypper" profile["package_format"] = "rpm" profile["os_label"] = "SLES" if os_id.startswith("sles") else os_id return profile def package_glob(package_format: str) -> str: if package_format == "deb": return "amd-smi-lib*99999-local_amd64.deb" return "amd-smi-lib-*99999-local*.rpm" # --------------------------------------------------------------------------- # Pre-build helpers # --------------------------------------------------------------------------- def install_build_prereqs(cfg: "RunnerConfig") -> None: """Ensure cmake + a basic build toolchain are installed. Several ROCm base images (notably rhel-9.x-bld, rhel-10.x-bld) do not ship cmake on PATH. This step installs cmake/gcc/g++/make on demand so the cmake-configure step doesn't die with FileNotFoundError. """ if shutil.which("cmake") and shutil.which("make") and ( shutil.which("gcc") or shutil.which("cc") ): return print("Installing build prerequisites (cmake/gcc/make)...") if cfg.package_manager == "apt": run_command( ["apt-get", "update"], name="apt-update-prereqs", retries=cfg.retries, log_dir=cfg.log_dir, ) run_command( [ "apt-get", "install", "-y", "--no-install-recommends", "cmake", "build-essential", "git", ], name="apt-install-prereqs", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "dnf": # gcc-c++ + make are provided via "Development Tools" group; install the # individual packages explicitly so this works on minimal images too. run_command( [ "dnf", "install", "-y", "--setopt=skip_if_unavailable=True", "cmake", "gcc", "gcc-c++", "make", "git", ], name="dnf-install-prereqs", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "zypper": run_command( [ "zypper", "--non-interactive", "install", "cmake", "gcc", "gcc-c++", "make", "git", ], name="zypper-install-prereqs", retries=cfg.retries, log_dir=cfg.log_dir, ) def install_netlink_deps(cfg: "RunnerConfig") -> None: """Install libnl-3 / libmnl development headers used by amdsmi's CMake. CMakeLists.txt requires libnl-3.0 and libmnl via pkg_check_modules. Most base container images do not ship these, so install them here. No-op if pkg-config can already locate both modules. """ pkg_config = shutil.which("pkg-config") if pkg_config: try: subprocess.check_call( [pkg_config, "--exists", "libnl-3.0", "libmnl"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) return except subprocess.CalledProcessError: pass print("Installing netlink development headers (libnl-3, libmnl)...") if cfg.package_manager == "apt": if cfg.refresh_apt: run_command( ["apt-get", "update"], name="apt-update-netlink", retries=cfg.retries, log_dir=cfg.log_dir, ) run_command( [ "apt-get", "install", "-y", "--no-install-recommends", "libnl-3-dev", "libnl-genl-3-dev", "libmnl-dev", ], name="apt-install-netlink", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "dnf": # AzureLinux 3 ships tdnf; prefer it when present, fall back to dnf. is_tdnf = bool(shutil.which("tdnf")) installer = "tdnf" if is_tdnf else "dnf" cmd = [installer, "install", "-y", "--setopt=skip_if_unavailable=True"] # AzureLinux 3 CI containers ship a tdnfrepogpgcheck plugin that # rejects all repo metadata when the Azure Linux GPG keys are # missing/expired, which disables every repo before install can run. # --noplugins skips that check; --nogpgcheck covers package-level # signing if that also trips. if is_tdnf: cmd += ["--noplugins", "--nogpgcheck"] cmd += ["libnl3-devel", "libmnl-devel"] run_command( cmd, name=f"{installer}-install-netlink", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "zypper": run_command( [ "zypper", "--non-interactive", "install", "libnl3-devel", "libmnl-devel", ], name="zypper-install-netlink", retries=cfg.retries, log_dir=cfg.log_dir, ) def clean_stale_artifacts(log_dir: Path) -> None: """Remove SWIG-based .so baked into Docker images. The package now uses ctypes; the stale extension references symbols removed from libamd_smi.so and causes 'undefined symbol' errors on import. """ stale = Path("/opt/rocm/share/amd_smi/amdsmi/libamd_smi_python.so") if stale.exists(): stale.unlink() print(f"Removed stale artifact: {stale}") # Refresh linker cache if shutil.which("ldconfig"): try: run_command(["ldconfig"], name="ldconfig", log_dir=log_dir) except CommandError: print("Warning: ldconfig failed (non-fatal)") def install_more_itertools(log_dir: Path, retries: int) -> None: """AzureLinux3 needs more_itertools installed separately.""" run_command( ["python3", "-m", "pip", "install", "more_itertools"], name="pip-more-itertools", retries=retries, log_dir=log_dir, ) def upgrade_setuptools(log_dir: Path, retries: int) -> None: run_command( ["python3", "-m", "pip", "install", "--upgrade", "pip", "setuptools", "wheel"], name="pip-upgrade", retries=retries, log_dir=log_dir, ) def repair_cmake(log_dir: Path) -> None: """Re-install the cmake pip package if the wrapper script is broken. On SLES the system cmake is a pip-installed Python wrapper. Upgrading pip/setuptools can break it (``ModuleNotFoundError: No module named 'cmake'``). This function detects the breakage and force-reinstalls the cmake package so the wrapper works again. """ try: subprocess.run( ["cmake", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True ) except (subprocess.CalledProcessError, FileNotFoundError): print("cmake is broken or missing — reinstalling via pip...") run_command( ["python3", "-m", "pip", "install", "--force-reinstall", "cmake"], name="pip-repair-cmake", retries=1, log_dir=log_dir, ) def update_debian10_sources(log_dir: Path, retries: int) -> None: # buster-backports provides newer linux-libc-dev (kernel >= 5.10) which # supplies that ualoe_lib requires. Without it the # build dies on Debian 10's 4.19-based headers. content = ( "deb http://archive.debian.org/debian buster main\n" "deb http://archive.debian.org/debian-security buster/updates main\n" "deb http://archive.debian.org/debian buster-backports main\n" ) sources_list = Path("/etc/apt/sources.list") print("Updating sources.list for Debian10 (archived repos + backports)") sources_list.write_text(content, encoding="utf-8") Path("/etc/apt/apt.conf.d/99-disable-check-valid-until").write_text( 'Acquire::Check-Valid-Until "false";\n', encoding="utf-8" ) run_command(["apt", "update"], name="apt-update", retries=retries, log_dir=log_dir) def install_debian10_kernel_headers(log_dir: Path, retries: int) -> None: """Pull linux-libc-dev from buster-backports for newer UAPI headers. Required so that ualoe_lib (which unconditionally includes , added to UAPI in kernel 5.1) builds on Debian 10. """ print("Installing linux-libc-dev from buster-backports for newer UAPI headers") run_command( [ "apt-get", "install", "-y", "--no-install-recommends", "-t", "buster-backports", "linux-libc-dev", ], name="apt-install-linux-libc-dev-backports", retries=retries, log_dir=log_dir, ) def _mark_safe_git_dir(path: Path) -> None: """Register *path* as a safe git directory so cmake's `git rev-parse` succeeds inside containers with bind-mounted repos. Without this, the package version string ends up as e.g. ``26.4.0+unknown``. """ git_bin = shutil.which("git") if not git_bin or not path.exists(): return subprocess.run( [git_bin, "config", "--global", "--add", "safe.directory", str(path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) def build_amdsmi(cfg: "RunnerConfig") -> None: if cfg.build_dir.exists(): shutil.rmtree(cfg.build_dir) cfg.build_dir.mkdir(parents=True, exist_ok=True) # Avoid cmake producing "+unknown" version hashes when the repo is # bind-mounted into a container with a different uid. _mark_safe_git_dir(cfg.project_dir) esmi_repo = cfg.project_dir / "esmi_ib_library" if esmi_repo.exists(): _mark_safe_git_dir(esmi_repo) env = os.environ.copy() env.setdefault("DEBIAN_FRONTEND", "noninteractive") env.setdefault("DEBCONF_NONINTERACTIVE_SEEN", "true") # RHEL10 / AlmaLinux8 need QA_RPATHS to ignore empty RPATHs if cfg.qa_rpaths: env["QA_RPATHS"] = str(0x0010 | 0x0002) print(f"QA_RPATHS set to {env['QA_RPATHS']}") cmake_args = [ "cmake", str(cfg.project_dir), "-DBUILD_TESTS=ON", "-DENABLE_ESMI_LIB=ON", ] run_command( cmake_args, name="cmake-configure", cwd=cfg.build_dir, retries=cfg.retries, env=env, log_dir=cfg.log_dir, ) run_command( ["make", "-j", str(cfg.jobs)], name="make", cwd=cfg.build_dir, retries=cfg.retries, env=env, log_dir=cfg.log_dir, ) wheel_dir = cfg.build_dir / "py-interface" / "python_package" # Clean stale wheel artifacts before building for pattern in ("*.whl", "*.egg-info"): for stale_path in wheel_dir.glob(pattern): if stale_path.is_dir(): shutil.rmtree(stale_path) else: stale_path.unlink() for subdir_name in ("dist", "build"): subdir = wheel_dir / subdir_name if subdir.exists(): shutil.rmtree(subdir) wheel_cmd = [ "python3", "-m", "pip", "wheel", "--no-deps", "--no-build-isolation", "-w", ".", ".", ] run_command( wheel_cmd, name="python-wheel", cwd=wheel_dir, retries=cfg.retries, env=env, log_dir=cfg.log_dir, ) run_command( ["make", "package"], name="make-package", cwd=cfg.build_dir, retries=cfg.retries, env=env, log_dir=cfg.log_dir, ) def locate_package(build_dir: Path, package_format: str) -> Path: matches = sorted(build_dir.glob(package_glob(package_format))) if not matches: raise FileNotFoundError(f"No {package_format} artifact found in {build_dir}") def _is_tests_package(p: Path) -> bool: return "tests" in p.name # Prefer the main package over the tests package if both exist. main_pkgs = [p for p in matches if not _is_tests_package(p)] if main_pkgs: return max(main_pkgs, key=lambda p: p.stat().st_mtime) return max(matches, key=lambda p: p.stat().st_mtime) def install_package(cfg: "RunnerConfig", package_path: Path) -> None: print(f"Installing package {package_path}") if cfg.package_manager == "apt": if cfg.refresh_apt: run_command( ["apt", "update"], name="apt-update", retries=cfg.retries, log_dir=cfg.log_dir ) # Install main package; if a tests package exists, install it together to satisfy deps. extra_pkg = package_path.parent / package_path.name.replace("lib_", "lib-tests_", 1) pkg_list = [str(package_path)] if extra_pkg.exists(): pkg_list.append(str(extra_pkg)) run_command( ["apt", "install", "--reinstall", "-y", *pkg_list], name="apt-install", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "dnf": run_command( [ "dnf", "install", "python3-setuptools", "python3-wheel", "-y", "--setopt=skip_if_unavailable=True", ], name="dnf-prep", retries=cfg.retries, log_dir=cfg.log_dir, ) # Install main package; if a tests package exists, install it too. tests_pkg = package_path.parent / package_path.name.replace( "amd-smi-lib-", "amd-smi-lib-tests-", 1 ) rpm_list = [str(package_path)] if tests_pkg.exists() and tests_pkg != package_path: rpm_list.append(str(tests_pkg)) run_command( ["dnf", "install", "-y", "--skip-broken", "--disablerepo=*", *rpm_list], name="dnf-install", retries=cfg.retries, log_dir=cfg.log_dir, ) elif cfg.package_manager == "zypper": # Install main package; if a tests package exists, install it too. tests_pkg = package_path.parent / package_path.name.replace( "amd-smi-lib-", "amd-smi-lib-tests-", 1 ) rpm_list = [str(package_path)] if tests_pkg.exists() and tests_pkg != package_path: rpm_list.append(str(tests_pkg)) # --no-gpg-checks is safe here because rpm_list contains local file # paths we just built ourselves, not repo-resolved package names. run_command( ["zypper", "--no-refresh", "--no-gpg-checks", "install", "-y", *rpm_list], name="zypper-install", retries=cfg.retries, log_dir=cfg.log_dir, ) else: raise ValueError(f"Unsupported package manager {cfg.package_manager}") rocm_binary = Path("/opt/rocm/bin/amd-smi") symlink_path = Path("/usr/local/bin/amd-smi") if rocm_binary.exists(): symlink_path.parent.mkdir(parents=True, exist_ok=True) if symlink_path.exists() or symlink_path.is_symlink(): symlink_path.unlink() symlink_path.symlink_to(rocm_binary) print(f"Linked {symlink_path} -> {rocm_binary}") # Verify installation: CLI version + Python import/init/shutdown under # the SYSTEM python. The system package installs amdsmi/ to the path # /usr/bin/python3 searches (see py-interface/CMakeLists.txt). The # test must use /usr/bin/python3 explicitly -- some build containers # (notably ubuntu-24.04-bld) put a venv ahead of /usr/bin on PATH, # and that venv has its own sys.path that does NOT include the # system dist-packages. Falls back to plain `python3` if /usr/bin/python3 # is absent. system_python = "/usr/bin/python3" if Path("/usr/bin/python3").exists() else "python3" import_smoke = ( "import amdsmi; " "print('amdsmi from:', amdsmi.__file__); " "amdsmi.amdsmi_init(); " "amdsmi.amdsmi_shut_down(); " "print('init/shutdown ok')" ) verify_commands = [ [str(rocm_binary), "version"], [system_python, "-c", import_smoke], ] for idx, verify_cmd in enumerate(verify_commands, start=1): try: run_command(verify_cmd, name=f"verify-{idx}", retries=1, log_dir=cfg.log_dir) except CommandError as exc: print(f"Verification command failed: {exc}") raise # --------------------------------------------------------------------------- # Wheel verification (matches the "Verify Wheel in Site-Packages" CI step) # --------------------------------------------------------------------------- def verify_wheel_site_packages(cfg: "RunnerConfig") -> None: """Install the built wheel via pip and verify it lands in site-packages.""" wheel_dir = cfg.build_dir / "py-interface" / "python_package" wheels = sorted(wheel_dir.glob("*.whl")) if not wheels: print(f"WARNING: No .whl found in {wheel_dir}; skipping wheel verification") return wheel = wheels[0] print(f"Found wheel: {wheel}") run_command( ["python3", "-m", "pip", "install", "--force-reinstall", str(wheel)], name="pip-install-wheel", retries=1, log_dir=cfg.log_dir, ) smoke_test = ( "import amdsmi\n" "print('PASS: import amdsmi OK')\n" "amdsmi.amdsmi_init()\n" "print('PASS: amdsmi_init() OK')\n" "devs = amdsmi.amdsmi_get_processor_handles()\n" "print('PASS: Found %d device(s)' % len(devs))\n" "amdsmi.amdsmi_shut_down()\n" "print('PASS: amdsmi_shut_down() OK')\n" "print('=== Wheel verification passed ===')\n" ) run_command( ["python3", "-c", smoke_test], name="wheel-smoke-test", cwd=Path("/tmp"), retries=1, log_dir=cfg.log_dir, ) run_command( ["python3", "-m", "pip", "show", "amdsmi"], name="pip-show-amdsmi", retries=1, log_dir=cfg.log_dir, ) # Check install location -- the wheel must land under site-packages # or dist-packages; the system DEB/RPM also installs to dist-packages, # so a coexisting system module is fine (whichever sys.path entry wins # is whichever is searched first by the active python). run_command( [ "python3", "-c", ( "import amdsmi; p = amdsmi.__file__; " "print('amdsmi imported from: ' + p); " "ok = 'site-packages' in p or 'dist-packages' in p or '/opt/rocm/' in p; " "assert ok, 'Unexpected install location: ' + p; " "print('PASS: Wheel correctly installed')" ), ], name="wheel-location-check", retries=1, log_dir=cfg.log_dir, ) @dataclass class RunnerConfig: project_dir: Path build_dir: Path package_manager: str package_format: str log_dir: Path test_results_dir: Path retries: int build_type: str jobs: int os_label: str refresh_apt: bool debian10_sources: bool skip_setuptools_upgrade: bool install_more_itertools: bool qa_rpaths: bool skip_build: bool skip_install: bool def parse_args() -> RunnerConfig: parser = argparse.ArgumentParser(description="Run AMDSMI build/install locally.") parser.add_argument("--project-dir", type=Path, help="Path to the AMDSMI project root") parser.add_argument("--build-dir", type=Path, help="Build directory (default: /build)") parser.add_argument( "--log-dir", type=Path, default=DEFAULT_LOG_DIR, help="Directory for command logs" ) parser.add_argument( "--test-results-dir", type=Path, help="Directory for well-known test result files (default: /tmp/test-results-)", ) parser.add_argument( "--package-manager", choices=["apt", "dnf", "zypper"], help="Package manager to use" ) parser.add_argument("--package-format", choices=["deb", "rpm"], help="Force package format") parser.add_argument("--retries", type=int, default=3, help="Retries for build/install steps") parser.add_argument("--build-type", default="Release", help="CMake build type") parser.add_argument( "--jobs", type=int, default=os.cpu_count() or 4, help="Parallel jobs for make" ) parser.add_argument("--os-label", default="local", help="Label used in log/result dir names") parser.add_argument( "--no-apt-update", action="store_true", help="Do not run apt update before install" ) parser.add_argument( "--debian10-sources", action="store_true", help="Rewrite apt sources for Debian10 archive" ) parser.add_argument( "--skip-setuptools-upgrade", action="store_true", help="Skip pip/setuptools/wheel upgrade (e.g. AzureLinux3)", ) parser.add_argument( "--install-more-itertools", action="store_true", help="Install more_itertools (e.g. AzureLinux3)", ) parser.add_argument( "--qa-rpaths", action="store_true", help="Set QA_RPATHS for RPM builds (RHEL10, AlmaLinux8)" ) parser.add_argument( "--skip-build", action="store_true", help="Skip build step (use existing build dir)" ) parser.add_argument("--skip-install", action="store_true", help="Skip package installation") parser.add_argument( "--no-autodetect", action="store_true", help="Disable auto-detection of OS profile from /etc/os-release", ) args = parser.parse_args() profile = {} if args.no_autodetect else detect_os_profile() if profile: print(f"Auto-detected OS profile: {profile}") project_dir = find_project_dir(args.project_dir) build_dir = args.build_dir or project_dir / "build" package_manager = detect_package_manager( args.package_manager or profile.get("package_manager") ) package_format = detect_package_format( package_manager, args.package_format or profile.get("package_format") ) # os-label CLI default is "local"; only treat that as user-supplied if # autodetect produced nothing, otherwise prefer the autodetected label. if args.os_label != "local": os_label = args.os_label else: os_label = profile.get("os_label", args.os_label) test_results_dir = args.test_results_dir or Path(f"/tmp/test-results-{os_label}") return RunnerConfig( project_dir=project_dir, build_dir=build_dir, package_manager=package_manager, package_format=package_format, log_dir=args.log_dir, test_results_dir=test_results_dir, retries=max(1, args.retries), build_type=args.build_type, jobs=max(1, args.jobs), os_label=os_label, refresh_apt=not args.no_apt_update, debian10_sources=args.debian10_sources or profile.get("debian10_sources", False), skip_setuptools_upgrade=args.skip_setuptools_upgrade or profile.get("skip_setuptools_upgrade", False), install_more_itertools=args.install_more_itertools or profile.get("install_more_itertools", False), qa_rpaths=args.qa_rpaths or profile.get("qa_rpaths", False), skip_build=args.skip_build, skip_install=args.skip_install, ) def _write_result(results_dir: Path, filename: str, message: str) -> None: """Write a status message to a well-known result file.""" results_dir.mkdir(parents=True, exist_ok=True) (results_dir / filename).write_text(message + "\n", encoding="utf-8") # --------------------------------------------------------------------------- # CI summary rendering (replaces the duplicated bash block in amdsmi-build.yml) # --------------------------------------------------------------------------- def _fenced(text: str) -> str: """Wrap *text* in a markdown fenced code block.""" return "```\n" + text + "\n```" def summarize_results(results_dir: Path, os_label: str, summary_file: Optional[Path]) -> int: """Render a CI summary for *results_dir*. Returns the number of failures detected (0 == clean). When *summary_file* is given the rendered markdown is appended to it (used with ``$GITHUB_STEP_SUMMARY``). """ failures: List[str] = [] details: List[str] = [] if not results_dir.exists(): print(f"summarize: results dir {results_dir} does not exist") # 1. build/install/verify stage results for result_file in sorted(results_dir.glob("*_result.txt")): content = result_file.read_text(encoding="utf-8", errors="replace") if "FAILED" in content: stage = result_file.stem.replace("_result", "").replace("_", " ") failures.append(stage) details.append(f"#### {stage}\n\n" + _fenced(content)) print(f"FAILED: {stage}") # 2. amd-smi command test logs -- logged but non-fatal. The workflow # appends "Error code: " (space + digit, same line) only on a real # non-zero exit. The CLI's debug logging also prints an unsupported-feature # status code, but wrapped onto the next line ("Error code:\n\t2 | ..."), # so restrict the match to a same-line digit to skip that benign noise. import re as _re err_code_re = _re.compile(r"Error code:[ \t]*\d") cmd_fails: List[str] = [] for log in sorted(results_dir.glob("amd-smi_*.log")): log_text = log.read_text(encoding="utf-8", errors="replace") if "Traceback" in log_text or "AmdSmiException" in log_text or err_code_re.search(log_text): cmd_fails.append(log.stem.replace("amd-smi_", "")) if cmd_fails: joined = " ".join(cmd_fails) details.append(f"#### Command Tests (non-fatal)\n\nFailed: `{joined}`") # 3. AMDSMI gtest output gtest_log = results_dir / "amdsmi_tests.log" if gtest_log.exists(): text = gtest_log.read_text(encoding="utf-8", errors="replace") gtest_fails = text.count("[ FAILED ]") if gtest_fails > 0: failures.append(f"AMDSMI Tests ({gtest_fails})") details.append( f"#### AMDSMI Tests \u2014 {gtest_fails} failure(s)\n\n" + _fenced(text) ) # 4. Python test outputs fail_re = _re.compile(r"^(FAIL|ERROR):", _re.MULTILINE) for test_file in ("integration_test_output.txt", "unit_test_output.txt", "perf_test_output.txt"): full = results_dir / test_file if not full.exists(): continue text = full.read_text(encoding="utf-8", errors="replace") py_fails = len(fail_re.findall(text)) if py_fails > 0: name = test_file.replace("_output.txt", "").replace("_", " ") failures.append(f"{name} ({py_fails})") details.append( f"#### {name} \u2014 {py_fails} failure(s)\n\n" + _fenced(text) ) # 5. example test results (segfault detection) crash_re = _re.compile(r"segfault|SIGSEGV|abort", _re.IGNORECASE) for ex_log in ("amd_smi_drm_ex.log", "amd_smi_nodrm_ex.log"): full = results_dir / ex_log if not full.exists(): continue text = full.read_text(encoding="utf-8", errors="replace") if crash_re.search(text): name = ex_log.replace(".log", "") failures.append(f"Example {name}") details.append(f"#### Example {name}\n\n" + _fenced(text)) # Render. Hard failures (build/install/verify/gtest/python/examples) drive # the exit status; command-test failures are surfaced only as a warning so # transient amd-smi CLI flakes do not fail the whole job. cmd_summary = "" if cmd_fails: cmd_summary = ( f"\n\n:warning: Command tests (non-fatal): `{' '.join(cmd_fails)}`\n" ) if failures: header = [ f"## :x: CI Failed \u2014 {os_label}", "", f"**{len(failures)} failure(s) detected:**", "", ] header += [f"- :red_circle: {f}" for f in failures] body = "\n".join(header) + cmd_summary + "\n\n" + "\n\n".join(details) + "\n" else: body = ( f"## :white_check_mark: CI Passed \u2014 {os_label}\n\n" "All stages and tests passed successfully.\n" ) if cmd_fails: body += cmd_summary + "\n" + "\n\n".join(details) + "\n" if summary_file is not None: with summary_file.open("a", encoding="utf-8") as fh: fh.write(body) # Always print to stdout so the step log is self-contained. print("=" * 42) print(f"CI SUMMARY for {os_label}") print("=" * 42) print(body) if failures: joined = ", ".join(failures) print(f"::error::{len(failures)} failure(s) for {os_label}: {joined}") elif cmd_fails: print(f"::warning::Command tests failed (non-fatal) for {os_label}: {' '.join(cmd_fails)}") print(f"All hard stages PASSED for {os_label}") else: print(f"All stages and tests PASSED for {os_label}") return len(failures) def _summarize_cli(argv: List[str]) -> int: parser = argparse.ArgumentParser( prog="run_amdsmi_build.py summarize", description="Render CI step summary from a test-results directory.", ) parser.add_argument("--results-dir", type=Path, required=True) parser.add_argument("--os-label", default="local") parser.add_argument( "--summary-file", type=Path, default=None, help="Append markdown to this file (e.g. $GITHUB_STEP_SUMMARY).", ) args = parser.parse_args(argv) return summarize_results(args.results_dir, args.os_label, args.summary_file) def main() -> None: # `summarize` subcommand short-circuits before parse_args/sudo logic. if len(sys.argv) > 1 and sys.argv[1] == "summarize": sys.exit(0 if _summarize_cli(sys.argv[2:]) == 0 else 1) cfg = parse_args() print(f"Using project directory: {cfg.project_dir}") print(f"Logs will be saved under: {cfg.log_dir}") print(f"Test results will be saved under: {cfg.test_results_dir}") print(f"Package manager: {cfg.package_manager} (format {cfg.package_format})") print(f"OS label: {cfg.os_label}") # 1. Debian10 archived repos if cfg.debian10_sources and cfg.package_manager != "apt": print("Warning: --debian10-sources ignored because package manager is not apt") if cfg.debian10_sources: update_debian10_sources(cfg.log_dir, cfg.retries) install_debian10_kernel_headers(cfg.log_dir, cfg.retries) # 2. Install more_itertools if requested (e.g. AzureLinux3) if cfg.install_more_itertools: install_more_itertools(cfg.log_dir, cfg.retries) # 3. Upgrade setuptools (skip on AzureLinux3) if not cfg.skip_setuptools_upgrade: upgrade_setuptools(cfg.log_dir, cfg.retries) # 3b. Repair cmake if the pip wrapper broke during setuptools upgrade (SLES) repair_cmake(cfg.log_dir) # 4. Clean stale ROCm Python artifacts from Docker image clean_stale_artifacts(cfg.log_dir) # 4b. Ensure cmake + toolchain exist (some base images ship without cmake) install_build_prereqs(cfg) # 4c. Install libnl-3 / libmnl headers required by amdsmi's CMake config. install_netlink_deps(cfg) # 5. Build if not cfg.skip_build: try: build_amdsmi(cfg) except CommandError as exc: _write_result( cfg.test_results_dir, "build_result.txt", f"BUILD FAILED: {exc.name} exited {exc.code}\n\n" f"Log ({exc.log_path}):\n{read_log(exc.log_path)}", ) report_and_raise("BUILD", exc) artifact = locate_package(cfg.build_dir, cfg.package_format) print(f"Build artifact: {artifact}") _write_result(cfg.test_results_dir, "build_result.txt", f"BUILD PASSED\nArtifact: {artifact}") # 6. Install if not cfg.skip_install: try: install_package(cfg, artifact) except CommandError as exc: _write_result( cfg.test_results_dir, "install_result.txt", f"INSTALL FAILED: {exc.name} exited {exc.code}\n\n" f"Log ({exc.log_path}):\n{read_log(exc.log_path)}", ) report_and_raise("INSTALL", exc) _write_result( cfg.test_results_dir, "install_result.txt", f"INSTALL PASSED\nPackage: {artifact}" ) # 7. Verify wheel in site-packages if not cfg.skip_install: try: verify_wheel_site_packages(cfg) except CommandError as exc: _write_result( cfg.test_results_dir, "verify_wheel_result.txt", f"VERIFY WHEEL FAILED: {exc.name} exited {exc.code}\n\n" f"Log ({exc.log_path}):\n{read_log(exc.log_path)}", ) report_and_raise("VERIFY WHEEL", exc) _write_result(cfg.test_results_dir, "verify_wheel_result.txt", "VERIFY WHEEL PASSED") print("AMDSMI workflow complete") if __name__ == "__main__": main()