From 0329d961dcef63f9f7708f80e5ae83ae26f4df4c Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Thu, 15 Oct 2026 10:33:58 +0000 Subject: [PATCH 01/19] fix(examine): report amdkfd_loaded when kfd is built into the kernel amdkfd_loaded was true only when an `amdkfd` module appeared in lsmod or /proc/modules. kfd is no longer a module of its own: HSA_AMD is a bool Kconfig option and amdkfd is linked into amdgpu, so the name never shows up and a working kfd was reported as absent. Also treat kfd as present when /sys/class/kfd/kfd or /dev/kfd exists, read through host_path so the probe can be tested against a planted root. Fixes #448 Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 76 ++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index d5174f975..2d78c092f 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -246,6 +246,8 @@ pub struct Examination { // driver / runtime (Linux) pub amdgpu_loaded: Option, pub amdgpu_blacklisted_in: Vec, + /// kfd is present: an `amdkfd` module, or the kernel's kfd interfaces + /// (`/sys/class/kfd/kfd`, `/dev/kfd`) when kfd is built into the kernel. pub amdkfd_loaded: Option, pub secure_boot: String, pub iommu_kernel_param: String, @@ -1824,6 +1826,21 @@ fn summarise_gpu_categories(e: &mut Examination) { // Kernel module / device probes (Linux) // --------------------------------------------------------------------------- +/// Whether the kernel's kfd is present on this host, however it got there. +/// +/// kfd is not a module of its own: `HSA_AMD` is a `bool` Kconfig option and +/// amdkfd is linked into `amdgpu`, so no `amdkfd` line ever appears in the +/// module list, built in or not. The kernel's own interfaces are the signal: +/// the `/sys/class/kfd/kfd` class device, or the `/dev/kfd` node. +fn kfd_present_on_host() -> bool { + crate::host_path("/sys/class/kfd/kfd").exists() || crate::host_path("/dev/kfd").exists() +} + +/// `amdkfd_loaded`: an `amdkfd` module in the list, or kfd present on the host. +fn amdkfd_present(modules: &[&str], kfd_on_host: bool) -> bool { + modules.contains(&"amdkfd") || kfd_on_host +} + fn probe_modules(e: &mut Examination) { let (rc, out, _) = run("lsmod", &[], SHORT); let module_text = if rc == 0 { @@ -1832,13 +1849,17 @@ fn probe_modules(e: &mut Examination) { let txt = read_host_text("/proc/modules"); if txt.is_empty() { None } else { Some(txt) } }; + let kfd_on_host = kfd_present_on_host(); if let Some(text) = module_text { let modules: Vec<&str> = text .lines() .filter_map(|line| line.split_whitespace().next()) .collect(); e.amdgpu_loaded = Some(modules.contains(&"amdgpu")); - e.amdkfd_loaded = Some(modules.contains(&"amdkfd")); + e.amdkfd_loaded = Some(amdkfd_present(&modules, kfd_on_host)); + } else if kfd_on_host { + // No module list to read, but kfd is demonstrably there. + e.amdkfd_loaded = Some(true); } for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { @@ -6418,6 +6439,59 @@ mod tests { } } + #[test] + fn amdkfd_is_present_when_listed_as_a_module() { + assert!(amdkfd_present(&["amdgpu", "amdkfd"], false)); + } + + /// kfd built into the kernel never appears in the module list. + #[test] + fn amdkfd_is_present_when_built_in_and_absent_from_the_module_list() { + assert!(amdkfd_present(&["amdgpu", "drm"], true)); + assert!(amdkfd_present(&[], true)); + } + + #[test] + fn amdkfd_is_absent_with_neither_a_module_nor_kfd_on_the_host() { + assert!(!amdkfd_present(&["amdgpu", "drm"], false)); + assert!(!amdkfd_present(&[], false)); + } + + /// The host side of the check, against a planted root: the sysfs class + /// device alone, the device node alone, and neither. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn kfd_present_on_host_reads_sysfs_and_the_device_node() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-kfd-present-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&root).expect("create root"); + let (neither, sysfs_only, dev_only) = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let neither = kfd_present_on_host(); + std::fs::create_dir_all(root.join("sys/class/kfd/kfd")).expect("plant sysfs"); + let sysfs_only = kfd_present_on_host(); + std::fs::remove_dir_all(root.join("sys")).expect("unplant sysfs"); + std::fs::create_dir_all(root.join("dev")).expect("plant dev"); + std::fs::write(root.join("dev/kfd"), "").expect("plant /dev/kfd"); + (neither, sysfs_only, kfd_present_on_host()) + }; + std::fs::remove_dir_all(&root).ok(); + assert!(!neither, "an empty root has no kfd"); + assert!(sysfs_only, "/sys/class/kfd/kfd alone counts"); + assert!(dev_only, "/dev/kfd alone counts"); + } + /// The probes themselves, not just `host_path`, answer from a simulated /// host root. Plants a bare-metal machine with one GPU, inside a Podman /// container, then runs the converted probes and asserts they describe it From 4a1414dad6f29dbd4277d7c5755926183f6cc0c3 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Thu, 15 Oct 2026 21:04:15 +0000 Subject: [PATCH 02/19] test(examine): cover the amdkfd_loaded wiring and the kfd scenarios Fold the module-list decision, including the unreadable-list fallback, into amdkfd_state so each branch is asserted directly. Add a probe_modules test against a planted host root so reverting the call site fails, and two examine scenarios (examine-29, examine-30) that drive the CLI with a lsmod that never lists amdkfd. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 66 +++++++++++++++---- tests/e2e-cucumber/features/examine.feature | 16 +++++ tests/e2e-cucumber/tests/e2e/examine_steps.rs | 54 +++++++++++++++ 3 files changed, 125 insertions(+), 11 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 2d78c092f..757593b65 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1837,8 +1837,14 @@ fn kfd_present_on_host() -> bool { } /// `amdkfd_loaded`: an `amdkfd` module in the list, or kfd present on the host. -fn amdkfd_present(modules: &[&str], kfd_on_host: bool) -> bool { - modules.contains(&"amdkfd") || kfd_on_host +/// +/// `modules` is `None` when the module list could not be read; kfd being +/// present on the host still answers `true` then, and nothing else is known. +fn amdkfd_state(modules: Option<&[&str]>, kfd_on_host: bool) -> Option { + match modules { + Some(modules) => Some(modules.contains(&"amdkfd") || kfd_on_host), + None => kfd_on_host.then_some(true), + } } fn probe_modules(e: &mut Examination) { @@ -1856,10 +1862,9 @@ fn probe_modules(e: &mut Examination) { .filter_map(|line| line.split_whitespace().next()) .collect(); e.amdgpu_loaded = Some(modules.contains(&"amdgpu")); - e.amdkfd_loaded = Some(amdkfd_present(&modules, kfd_on_host)); - } else if kfd_on_host { - // No module list to read, but kfd is demonstrably there. - e.amdkfd_loaded = Some(true); + e.amdkfd_loaded = amdkfd_state(Some(&modules), kfd_on_host); + } else { + e.amdkfd_loaded = amdkfd_state(None, kfd_on_host); } for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { @@ -6441,20 +6446,28 @@ mod tests { #[test] fn amdkfd_is_present_when_listed_as_a_module() { - assert!(amdkfd_present(&["amdgpu", "amdkfd"], false)); + assert_eq!(amdkfd_state(Some(&["amdgpu", "amdkfd"]), false), Some(true)); } /// kfd built into the kernel never appears in the module list. #[test] fn amdkfd_is_present_when_built_in_and_absent_from_the_module_list() { - assert!(amdkfd_present(&["amdgpu", "drm"], true)); - assert!(amdkfd_present(&[], true)); + assert_eq!(amdkfd_state(Some(&["amdgpu", "drm"]), true), Some(true)); + assert_eq!(amdkfd_state(Some(&[]), true), Some(true)); } #[test] fn amdkfd_is_absent_with_neither_a_module_nor_kfd_on_the_host() { - assert!(!amdkfd_present(&["amdgpu", "drm"], false)); - assert!(!amdkfd_present(&[], false)); + assert_eq!(amdkfd_state(Some(&["amdgpu", "drm"]), false), Some(false)); + assert_eq!(amdkfd_state(Some(&[]), false), Some(false)); + } + + /// With no module list, kfd on the host still answers; without it the + /// field stays unknown rather than claiming `false`. + #[test] + fn amdkfd_with_an_unreadable_module_list_follows_the_host() { + assert_eq!(amdkfd_state(None, true), Some(true)); + assert_eq!(amdkfd_state(None, false), None); } /// The host side of the check, against a planted root: the sysfs class @@ -6492,6 +6505,37 @@ mod tests { assert!(dev_only, "/dev/kfd alone counts"); } + /// The wiring, not just the helpers: `probe_modules` must consult the host + /// root. On every machine this runs on, no `amdkfd` module line exists, so + /// `Some(true)` can only come from the planted `/dev/kfd`. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn probe_modules_reports_amdkfd_when_kfd_is_on_the_host() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-probe-kfd-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(root.join("dev")).expect("plant dev"); + std::fs::write(root.join("dev/kfd"), "").expect("plant /dev/kfd"); + let amdkfd_loaded = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let mut e = Examination::default(); + probe_modules(&mut e); + e.amdkfd_loaded + }; + std::fs::remove_dir_all(&root).ok(); + assert_eq!(amdkfd_loaded, Some(true)); + } + /// The probes themselves, not just `host_path`, answer from a simulated /// host root. Plants a bare-metal machine with one GPU, inside a Podman /// container, then runs the converted probes and asserts they describe it diff --git a/tests/e2e-cucumber/features/examine.feature b/tests/e2e-cucumber/features/examine.feature index 34c732c3c..f6c0fc2f7 100644 --- a/tests/e2e-cucumber/features/examine.feature +++ b/tests/e2e-cucumber/features/examine.feature @@ -464,3 +464,19 @@ Feature: GPU detection and system inspection Given a machine with a ROCm install that was not set up by the CLI When the user inspects the system in machine-readable form Then the inspection reports a current install-method name + + # kfd is linked into `amdgpu` (`HSA_AMD` is a `bool` Kconfig option), so a + # healthy machine never lists an `amdkfd` module. The report used to read + # that as "kfd missing". Each scenario plants a module list naming `amdgpu` + # only, so the answer can come only from the kernel's kfd interfaces. + @id:examine-reports-kfd-built-into-the-kernel @requires-os:linux + Scenario: examine-31 - The inspection reports kfd on a machine whose module list has no amdkfd + Given a machine whose kernel exposes kfd but lists no amdkfd module + When the user inspects that machine in machine-readable form + Then the inspection reports kfd as loaded + + @id:examine-reports-kfd-absent-without-kfd-interfaces @requires-os:linux + Scenario: examine-32 - The inspection reports kfd as not loaded on a machine without it + Given a machine whose kernel has no kfd and lists no amdkfd module + When the user inspects that machine in machine-readable form + Then the inspection reports kfd as not loaded diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index 67898e407..76c84ca43 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1490,6 +1490,60 @@ async fn gpu_machine_with_unreadable_release_file(world: &mut E2eWorld) { plant_gpu_fixtures(world); } +/// A simulated host root, plus a `lsmod` on `PATH` that lists `amdgpu` and no +/// `amdkfd`, so the module list never answers for kfd. `with_kfd` plants the +/// `/dev/kfd` node. +fn plant_machine_without_amdkfd_module(world: &mut E2eWorld, with_kfd: bool) { + let root = world + .isolated_root + .as_ref() + .expect("no isolated root") + .path() + .to_owned(); + let host = root.join("host-root"); + std::fs::create_dir_all(host.join("dev")).expect("failed to create planted /dev"); + if with_kfd { + std::fs::write(host.join("dev/kfd"), "").expect("failed to plant /dev/kfd"); + } + let bin = root.join("fake-bin"); + std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); + rocm_core::fake_executable::write_fake_executable( + &bin.join("lsmod"), + "#!/bin/sh\nprintf 'Module Size Used by\\namdgpu 1 0\\ndrm 1 1 amdgpu\\n'\n", + ) + .expect("failed to write the fake lsmod"); + let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .expect("PATH entries cannot hold a separator"); + world.command_env.push(("PATH", path)); + world + .command_env + .push(("ROCM_CLI_TEST_HOST_ROOT", host.into_os_string())); +} + +#[given("a machine whose kernel exposes kfd but lists no amdkfd module")] +async fn machine_with_built_in_kfd(world: &mut E2eWorld) { + plant_machine_without_amdkfd_module(world, true); +} + +#[given("a machine whose kernel has no kfd and lists no amdkfd module")] +async fn machine_without_kfd(world: &mut E2eWorld) { + plant_machine_without_amdkfd_module(world, false); +} + +#[then("the inspection reports kfd as loaded")] +async fn assert_kfd_loaded(world: &mut E2eWorld) { + let json = parsed_json(world); + assert_eq!(json["amdkfd_loaded"], serde_json::json!(true), "{json:#}"); +} + +#[then("the inspection reports kfd as not loaded")] +async fn assert_kfd_not_loaded(world: &mut E2eWorld) { + let json = parsed_json(world); + assert_eq!(json["amdkfd_loaded"], serde_json::json!(false), "{json:#}"); +} + #[when("the user inspects that machine in machine-readable form")] async fn user_inspects_planted_machine(world: &mut E2eWorld) { let (stdout, stderr, rc) = From 8ecd070d7592b5c8bef8aef31dd6a4164eafdd1b Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Thu, 15 Oct 2026 21:46:14 +0000 Subject: [PATCH 03/19] refactor(examine): decide amdkfd_loaded at a single call site probe_modules had two call sites for amdkfd_state, one per module-list outcome, so a test reached only whichever branch the host's lsmod selected. Parse the list into an Option once and make one call. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 757593b65..f943abea8 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1855,17 +1855,15 @@ fn probe_modules(e: &mut Examination) { let txt = read_host_text("/proc/modules"); if txt.is_empty() { None } else { Some(txt) } }; - let kfd_on_host = kfd_present_on_host(); - if let Some(text) = module_text { - let modules: Vec<&str> = text - .lines() + let modules: Option> = module_text.as_deref().map(|text| { + text.lines() .filter_map(|line| line.split_whitespace().next()) - .collect(); + .collect() + }); + if let Some(modules) = &modules { e.amdgpu_loaded = Some(modules.contains(&"amdgpu")); - e.amdkfd_loaded = amdkfd_state(Some(&modules), kfd_on_host); - } else { - e.amdkfd_loaded = amdkfd_state(None, kfd_on_host); } + e.amdkfd_loaded = amdkfd_state(modules.as_deref(), kfd_present_on_host()); for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { let Ok(entries) = std::fs::read_dir(crate::host_path(dir)) else { From ea4b420cdee004092e1267f08355b97a46c31450 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Fri, 16 Oct 2026 09:10:57 +0000 Subject: [PATCH 04/19] docs(examine): say what amdkfd_loaded means now The field is true when kfd is available: the separate amdkfd module on kernels before 4.20, or /sys/class/kfd/kfd or /dev/kfd on current ones. The name is kept for schema compatibility. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index f943abea8..cfa821715 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -246,8 +246,9 @@ pub struct Examination { // driver / runtime (Linux) pub amdgpu_loaded: Option, pub amdgpu_blacklisted_in: Vec, - /// kfd is present: an `amdkfd` module, or the kernel's kfd interfaces - /// (`/sys/class/kfd/kfd`, `/dev/kfd`) when kfd is built into the kernel. + /// kfd is available: as a separate `amdkfd` module on kernels before 4.20 + /// (where kfd was merged into `amdgpu.ko`), or through `/sys/class/kfd/kfd` + /// or `/dev/kfd` on current ones. The name predates the merge. pub amdkfd_loaded: Option, pub secure_boot: String, pub iommu_kernel_param: String, From 7a366fa93273847949a6b196fe9ac5e74450c8c4 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Fri, 16 Oct 2026 09:45:34 +0000 Subject: [PATCH 05/19] fix(examine): report amdgpu_loaded when amdgpu is built into the kernel amdgpu_loaded was false whenever amdgpu is built in, because a built-in driver is absent from lsmod. diagnose scored that as a DKMS failure, a missing module and a Secure Boot problem. It is now true when amdgpu is in the module list or /sys/module/amdgpu exists (read through host_path), which holds for a loaded module and a built-in driver alike. An unreadable module list with sysfs present is true rather than unknown. Covered by unit tests for the four module-list states, host-root tests, and examine-31, which plants a built-in amdgpu with a ROCm install and Secure Boot enabled and checks diagnose cites none of the three lines. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 112 +++++++++++++++++- tests/e2e-cucumber/features/examine.feature | 13 ++ tests/e2e-cucumber/tests/e2e/examine_steps.rs | 87 ++++++++++++++ 3 files changed, 209 insertions(+), 3 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index cfa821715..70684a6a4 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -244,6 +244,9 @@ pub struct Examination { pub has_discrete_amd: bool, // driver / runtime (Linux) + /// amdgpu is present: listed as a module, or `/sys/module/amdgpu` exists, + /// which it does whether amdgpu is loaded as a module or built into the + /// kernel (where it never appears in `lsmod`). pub amdgpu_loaded: Option, pub amdgpu_blacklisted_in: Vec, /// kfd is available: as a separate `amdkfd` module on kernels before 4.20 @@ -1837,6 +1840,25 @@ fn kfd_present_on_host() -> bool { crate::host_path("/sys/class/kfd/kfd").exists() || crate::host_path("/dev/kfd").exists() } +/// Whether the amdgpu driver is present on this host, loaded or built in. +/// +/// A built-in driver is absent from the module list, but the kernel still +/// registers `/sys/module/amdgpu` for it, exactly as it does for a loaded one. +fn amdgpu_present_on_host() -> bool { + crate::host_path("/sys/module/amdgpu").exists() +} + +/// `amdgpu_loaded`: an `amdgpu` module in the list, or amdgpu present on the host. +/// +/// `modules` is `None` when the module list could not be read; amdgpu being +/// present on the host still answers `true` then, and nothing else is known. +fn amdgpu_state(modules: Option<&[&str]>, amdgpu_on_host: bool) -> Option { + match modules { + Some(modules) => Some(modules.contains(&"amdgpu") || amdgpu_on_host), + None => amdgpu_on_host.then_some(true), + } +} + /// `amdkfd_loaded`: an `amdkfd` module in the list, or kfd present on the host. /// /// `modules` is `None` when the module list could not be read; kfd being @@ -1861,9 +1883,7 @@ fn probe_modules(e: &mut Examination) { .filter_map(|line| line.split_whitespace().next()) .collect() }); - if let Some(modules) = &modules { - e.amdgpu_loaded = Some(modules.contains(&"amdgpu")); - } + e.amdgpu_loaded = amdgpu_state(modules.as_deref(), amdgpu_present_on_host()); e.amdkfd_loaded = amdkfd_state(modules.as_deref(), kfd_present_on_host()); for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { @@ -6469,6 +6489,92 @@ mod tests { assert_eq!(amdkfd_state(None, false), None); } + #[test] + fn amdgpu_is_present_when_listed_as_a_module() { + assert_eq!(amdgpu_state(Some(&["amdgpu", "drm"]), false), Some(true)); + } + + /// amdgpu built into the kernel never appears in the module list. + #[test] + fn amdgpu_is_present_when_built_in_and_absent_from_the_module_list() { + assert_eq!(amdgpu_state(Some(&["drm"]), true), Some(true)); + assert_eq!(amdgpu_state(Some(&[]), true), Some(true)); + } + + #[test] + fn amdgpu_is_absent_with_neither_a_module_nor_sysfs_on_the_host() { + assert_eq!(amdgpu_state(Some(&["drm"]), false), Some(false)); + assert_eq!(amdgpu_state(Some(&[]), false), Some(false)); + } + + /// With no module list, sysfs on the host still answers; without it the + /// field stays unknown rather than claiming `false`. + #[test] + fn amdgpu_with_an_unreadable_module_list_follows_the_host() { + assert_eq!(amdgpu_state(None, true), Some(true)); + assert_eq!(amdgpu_state(None, false), None); + } + + /// The host side of the check, against a planted root. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn amdgpu_present_on_host_reads_sysfs_module_dir() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-amdgpu-present-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&root).expect("create root"); + let (neither, planted) = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let neither = amdgpu_present_on_host(); + std::fs::create_dir_all(root.join("sys/module/amdgpu")).expect("plant sysfs"); + (neither, amdgpu_present_on_host()) + }; + std::fs::remove_dir_all(&root).ok(); + assert!(!neither, "an empty root has no amdgpu"); + assert!(planted, "/sys/module/amdgpu counts"); + } + + /// The wiring, not just the helpers: `probe_modules` must consult the host + /// root. The `amdgpu` module line cannot be planted away on a machine that + /// has one, so this only asserts the positive: with `/sys/module/amdgpu` + /// planted the field is `Some(true)` whatever the module list says. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn probe_modules_reports_amdgpu_when_it_is_on_the_host() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-probe-amdgpu-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(root.join("sys/module/amdgpu")).expect("plant sysfs"); + let amdgpu_loaded = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let mut e = Examination::default(); + probe_modules(&mut e); + e.amdgpu_loaded + }; + std::fs::remove_dir_all(&root).ok(); + assert_eq!(amdgpu_loaded, Some(true)); + } + /// The host side of the check, against a planted root: the sysfs class /// device alone, the device node alone, and neither. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] diff --git a/tests/e2e-cucumber/features/examine.feature b/tests/e2e-cucumber/features/examine.feature index f6c0fc2f7..e35981d4e 100644 --- a/tests/e2e-cucumber/features/examine.feature +++ b/tests/e2e-cucumber/features/examine.feature @@ -480,3 +480,16 @@ Feature: GPU detection and system inspection Given a machine whose kernel has no kfd and lists no amdkfd module When the user inspects that machine in machine-readable form Then the inspection reports kfd as not loaded + + # amdgpu built into the kernel is absent from `lsmod` too, so it was read as + # "module not loaded" and `rocm diagnose` scored that, plus a DKMS failure + # and Secure Boot, as evidence. `/sys/module/amdgpu` exists for a loaded + # module and a built-in driver alike. The machine also has a ROCm install + # and Secure Boot enabled, so the evidence lines are reachable if the flag + # regresses. + @id:examine-reports-amdgpu-built-into-the-kernel @requires-os:linux + Scenario: examine-31 - A built-in amdgpu is reported as loaded and diagnose finds no driver fault + Given a machine whose kernel has amdgpu built in and lists no amdgpu module + When the user inspects and diagnoses that machine + Then the inspection reports amdgpu as loaded + And the diagnosis cites no missing amdgpu module, DKMS failure or Secure Boot evidence diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index 76c84ca43..a12f0e362 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1544,6 +1544,93 @@ async fn assert_kfd_not_loaded(world: &mut E2eWorld) { assert_eq!(json["amdkfd_loaded"], serde_json::json!(false), "{json:#}"); } +/// A simulated host root with `/sys/module/amdgpu` and a `lsmod` that lists +/// neither `amdgpu` nor `amdkfd`: amdgpu built into the kernel. A ROCm install +/// (`ROCM_PATH`) and a `mokutil` reporting Secure Boot enabled are planted too, +/// so the three `rocm diagnose` evidence lines that read `amdgpu_loaded` have +/// everything else they need to fire. +#[given("a machine whose kernel has amdgpu built in and lists no amdgpu module")] +async fn machine_with_built_in_amdgpu(world: &mut E2eWorld) { + let root = world + .isolated_root + .as_ref() + .expect("no isolated root") + .path() + .to_owned(); + let host = root.join("host-root"); + std::fs::create_dir_all(host.join("sys/module/amdgpu")) + .expect("failed to plant /sys/module/amdgpu"); + let rocm = root.join("rocm"); + std::fs::create_dir_all(rocm.join(".info")).expect("failed to plant a ROCm install"); + std::fs::write(rocm.join(".info/version"), "6.4.1\n").expect("failed to plant its version"); + let bin = root.join("fake-bin"); + std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); + rocm_core::fake_executable::write_fake_executable( + &bin.join("lsmod"), + "#!/bin/sh\nprintf 'Module Size Used by\\ndrm 1 0\\n'\n", + ) + .expect("failed to write the fake lsmod"); + rocm_core::fake_executable::write_fake_executable( + &bin.join("mokutil"), + "#!/bin/sh\necho 'SecureBoot enabled'\n", + ) + .expect("failed to write the fake mokutil"); + let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .expect("PATH entries cannot hold a separator"); + world.command_env.push(("PATH", path)); + world.command_env.push(("ROCM_PATH", rocm.into_os_string())); + world + .command_env + .push(("ROCM_CLI_TEST_HOST_ROOT", host.into_os_string())); +} + +/// Both commands read the same planted machine, so the scenario environment is +/// restored between them: `run_rocm_with_scenario_env` consumes it. +#[when("the user inspects and diagnoses that machine")] +async fn user_inspects_and_diagnoses_planted_machine(world: &mut E2eWorld) { + let env = world.command_env.clone(); + let (stdout, stderr, rc) = + crate::run_rocm_with_scenario_env(world, &["examine", "--framework", "skip", "--json"]); + world.cli_output = Some(stdout); + world.cli_stderr = Some(stderr); + world.cli_rc = Some(rc); + world.command_env = env; + let (diagnosis, _, diagnose_rc) = + crate::run_rocm_with_scenario_env(world, &["diagnose", "--json"]); + assert_eq!(diagnose_rc, 0, "diagnose is a query and exits 0"); + world.cli_outputs = Some(vec![diagnosis]); +} + +#[then("the inspection reports amdgpu as loaded")] +async fn assert_amdgpu_loaded(world: &mut E2eWorld) { + let json = parsed_json(world); + assert_eq!(json["amdgpu_loaded"], serde_json::json!(true), "{json:#}"); +} + +#[then("the diagnosis cites no missing amdgpu module, DKMS failure or Secure Boot evidence")] +async fn assert_no_amdgpu_fault_evidence(world: &mut E2eWorld) { + let diagnosis = &world.cli_outputs.as_ref().expect("no diagnosis was run")[0]; + let report: serde_json::Value = serde_json::from_str(diagnosis) + .unwrap_or_else(|e| panic!("diagnose --json is not JSON ({e}):\n{diagnosis}")); + let evidence: Vec<&str> = report["matched"] + .as_array() + .unwrap_or_else(|| panic!("diagnose --json has no matched array:\n{report:#}")) + .iter() + .flat_map(|m| m["evidence"].as_array().into_iter().flatten()) + .filter_map(serde_json::Value::as_str) + .collect(); + for line in evidence { + assert!( + !line.contains("amdgpu module is not loaded") + && !line.contains("DKMS failed") + && !line.contains("Secure Boot is enabled and amdgpu didn't load"), + "amdgpu was reported missing though /sys/module/amdgpu exists: {line}" + ); + } +} + #[when("the user inspects that machine in machine-readable form")] async fn user_inspects_planted_machine(world: &mut E2eWorld) { let (stdout, stderr, rc) = From a02b0afdd556280cc357dfd6ea53cd77428d37e0 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Mon, 26 Oct 2026 16:34:07 +0000 Subject: [PATCH 06/19] refactor(examine): share one module-or-host check; kfd on the host implies amdgpu amdgpu_state and amdkfd_state collapse into module_or_host_state, whose host probe runs only when the module list has not already answered. kfd is linked into amdgpu, so kfd on the host now also answers amdgpu_loaded: a container passing /dev/kfd through without /sys/module no longer reports amdkfd up and amdgpu down. hardware_root's path list names /sys/class/kfd/kfd. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 114 +++++++++++++------------- crates/rocm-core/src/hardware_root.rs | 3 +- 2 files changed, 60 insertions(+), 57 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 70684a6a4..9eea8738d 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1848,25 +1848,20 @@ fn amdgpu_present_on_host() -> bool { crate::host_path("/sys/module/amdgpu").exists() } -/// `amdgpu_loaded`: an `amdgpu` module in the list, or amdgpu present on the host. +/// Whether `name` is loaded or built in: listed in the module list, or present +/// on the host. /// -/// `modules` is `None` when the module list could not be read; amdgpu being -/// present on the host still answers `true` then, and nothing else is known. -fn amdgpu_state(modules: Option<&[&str]>, amdgpu_on_host: bool) -> Option { - match modules { - Some(modules) => Some(modules.contains(&"amdgpu") || amdgpu_on_host), - None => amdgpu_on_host.then_some(true), - } -} - -/// `amdkfd_loaded`: an `amdkfd` module in the list, or kfd present on the host. -/// -/// `modules` is `None` when the module list could not be read; kfd being -/// present on the host still answers `true` then, and nothing else is known. -fn amdkfd_state(modules: Option<&[&str]>, kfd_on_host: bool) -> Option { +/// `modules` is `None` when the module list could not be read; the host +/// answering `true` still answers `true` then, and nothing else is known. The +/// host probe runs only when the module list has not already answered. +fn module_or_host_state( + modules: Option<&[&str]>, + name: &str, + on_host: impl FnOnce() -> bool, +) -> Option { match modules { - Some(modules) => Some(modules.contains(&"amdkfd") || kfd_on_host), - None => kfd_on_host.then_some(true), + Some(modules) => Some(modules.contains(&name) || on_host()), + None => on_host().then_some(true), } } @@ -1883,8 +1878,13 @@ fn probe_modules(e: &mut Examination) { .filter_map(|line| line.split_whitespace().next()) .collect() }); - e.amdgpu_loaded = amdgpu_state(modules.as_deref(), amdgpu_present_on_host()); - e.amdkfd_loaded = amdkfd_state(modules.as_deref(), kfd_present_on_host()); + // kfd is linked into amdgpu, so kfd on the host proves amdgpu too: a + // container that passes `/dev/kfd` through without `/sys/module` must not + // report kfd up and amdgpu down. + e.amdgpu_loaded = module_or_host_state(modules.as_deref(), "amdgpu", || { + amdgpu_present_on_host() || kfd_present_on_host() + }); + e.amdkfd_loaded = module_or_host_state(modules.as_deref(), "amdkfd", kfd_present_on_host); for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { let Ok(entries) = std::fs::read_dir(crate::host_path(dir)) else { @@ -6464,55 +6464,57 @@ mod tests { } #[test] - fn amdkfd_is_present_when_listed_as_a_module() { - assert_eq!(amdkfd_state(Some(&["amdgpu", "amdkfd"]), false), Some(true)); - } - - /// kfd built into the kernel never appears in the module list. - #[test] - fn amdkfd_is_present_when_built_in_and_absent_from_the_module_list() { - assert_eq!(amdkfd_state(Some(&["amdgpu", "drm"]), true), Some(true)); - assert_eq!(amdkfd_state(Some(&[]), true), Some(true)); - } - - #[test] - fn amdkfd_is_absent_with_neither_a_module_nor_kfd_on_the_host() { - assert_eq!(amdkfd_state(Some(&["amdgpu", "drm"]), false), Some(false)); - assert_eq!(amdkfd_state(Some(&[]), false), Some(false)); - } - - /// With no module list, kfd on the host still answers; without it the - /// field stays unknown rather than claiming `false`. - #[test] - fn amdkfd_with_an_unreadable_module_list_follows_the_host() { - assert_eq!(amdkfd_state(None, true), Some(true)); - assert_eq!(amdkfd_state(None, false), None); + fn a_module_is_present_when_listed() { + assert_eq!( + module_or_host_state(Some(&["amdgpu", "amdkfd"]), "amdkfd", || false), + Some(true) + ); + assert_eq!( + module_or_host_state(Some(&["amdgpu", "drm"]), "amdgpu", || false), + Some(true) + ); } + /// Built into the kernel, a driver never appears in the module list. #[test] - fn amdgpu_is_present_when_listed_as_a_module() { - assert_eq!(amdgpu_state(Some(&["amdgpu", "drm"]), false), Some(true)); + fn a_module_is_present_when_built_in_and_absent_from_the_module_list() { + assert_eq!( + module_or_host_state(Some(&["drm"]), "amdkfd", || true), + Some(true) + ); + assert_eq!( + module_or_host_state(Some(&[]), "amdgpu", || true), + Some(true) + ); } - /// amdgpu built into the kernel never appears in the module list. #[test] - fn amdgpu_is_present_when_built_in_and_absent_from_the_module_list() { - assert_eq!(amdgpu_state(Some(&["drm"]), true), Some(true)); - assert_eq!(amdgpu_state(Some(&[]), true), Some(true)); + fn a_module_is_absent_with_neither_a_listing_nor_the_host() { + assert_eq!( + module_or_host_state(Some(&["drm"]), "amdkfd", || false), + Some(false) + ); + assert_eq!( + module_or_host_state(Some(&[]), "amdgpu", || false), + Some(false) + ); } + /// With no module list, the host still answers; without it the field + /// stays unknown rather than claiming `false`. #[test] - fn amdgpu_is_absent_with_neither_a_module_nor_sysfs_on_the_host() { - assert_eq!(amdgpu_state(Some(&["drm"]), false), Some(false)); - assert_eq!(amdgpu_state(Some(&[]), false), Some(false)); + fn an_unreadable_module_list_follows_the_host() { + assert_eq!(module_or_host_state(None, "amdgpu", || true), Some(true)); + assert_eq!(module_or_host_state(None, "amdgpu", || false), None); } - /// With no module list, sysfs on the host still answers; without it the - /// field stays unknown rather than claiming `false`. + /// The host probe is skipped once the module list has answered. #[test] - fn amdgpu_with_an_unreadable_module_list_follows_the_host() { - assert_eq!(amdgpu_state(None, true), Some(true)); - assert_eq!(amdgpu_state(None, false), None); + fn the_host_probe_is_skipped_when_the_module_list_already_answers() { + assert_eq!( + module_or_host_state(Some(&["amdgpu"]), "amdgpu", || unreachable!()), + Some(true) + ); } /// The host side of the check, against a planted root. diff --git a/crates/rocm-core/src/hardware_root.rs b/crates/rocm-core/src/hardware_root.rs index 86a5e3d9e..4fe1e7cf7 100644 --- a/crates/rocm-core/src/hardware_root.rs +++ b/crates/rocm-core/src/hardware_root.rs @@ -9,7 +9,8 @@ //! directly. That is every one of these reads, and only these: //! //! - device nodes: `/dev/kfd`, the render nodes under `/dev/dri`, `/dev/dxg`; -//! - `sysfs`: the KFD topology under `/sys/class/kfd/kfd/topology/nodes`, the +//! - `sysfs`: the `/sys/class/kfd/kfd` class device and the KFD topology under +//! `/sys/class/kfd/kfd/topology/nodes`, the //! DRM cards under `/sys/class/drm`, `/sys/module/amdgpu` and //! `/sys/module/amdgpu/version`; //! - `procfs`: `/proc/version`, `/proc/cpuinfo`, `/proc/meminfo`, From bf58a98c79fb582efee30da94cc959fc8c1fb40b Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 10:24:44 +0000 Subject: [PATCH 07/19] test(examine): renumber the built-in amdgpu scenario to examine-33 main took examine-29 for the Windows driver scenario and examine-30 for the install-method scenario, which moved the kfd scenarios to 31 and 32. Signed-off-by: Tomas Saaristola --- tests/e2e-cucumber/features/examine.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e-cucumber/features/examine.feature b/tests/e2e-cucumber/features/examine.feature index e35981d4e..1db18c686 100644 --- a/tests/e2e-cucumber/features/examine.feature +++ b/tests/e2e-cucumber/features/examine.feature @@ -488,7 +488,7 @@ Feature: GPU detection and system inspection # and Secure Boot enabled, so the evidence lines are reachable if the flag # regresses. @id:examine-reports-amdgpu-built-into-the-kernel @requires-os:linux - Scenario: examine-31 - A built-in amdgpu is reported as loaded and diagnose finds no driver fault + Scenario: examine-33 - A built-in amdgpu is reported as loaded and diagnose finds no driver fault Given a machine whose kernel has amdgpu built in and lists no amdgpu module When the user inspects and diagnoses that machine Then the inspection reports amdgpu as loaded From 70712100bedabdb173117328549fe6ba0c6a9bc3 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 11:02:10 +0000 Subject: [PATCH 08/19] fix(examine): take a built-in amdgpu from the PCI driver, not /sys/module The kernel creates /sys/module/amdgpu for a built-in driver at an early initcall, before amdgpu_init can bail out under nomodeset or amdgpu.modeset=0, so it reported amdgpu_loaded=true for a driver that never came up. /sys/bus/pci/drivers/amdgpu exists only once the driver registered. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 35 ++++++++++++------- crates/rocm-core/src/hardware_root.rs | 3 +- tests/e2e-cucumber/features/examine.feature | 2 +- tests/e2e-cucumber/tests/e2e/examine_steps.rs | 8 ++--- 4 files changed, 30 insertions(+), 18 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 9eea8738d..51416002f 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -244,9 +244,10 @@ pub struct Examination { pub has_discrete_amd: bool, // driver / runtime (Linux) - /// amdgpu is present: listed as a module, or `/sys/module/amdgpu` exists, - /// which it does whether amdgpu is loaded as a module or built into the - /// kernel (where it never appears in `lsmod`). + /// amdgpu is present: listed as a module, or registered as a PCI driver + /// (`/sys/bus/pci/drivers/amdgpu`), which it is whether amdgpu is loaded + /// as a module or built into the kernel (where it never appears in + /// `lsmod`). pub amdgpu_loaded: Option, pub amdgpu_blacklisted_in: Vec, /// kfd is available: as a separate `amdkfd` module on kernels before 4.20 @@ -1842,10 +1843,12 @@ fn kfd_present_on_host() -> bool { /// Whether the amdgpu driver is present on this host, loaded or built in. /// -/// A built-in driver is absent from the module list, but the kernel still -/// registers `/sys/module/amdgpu` for it, exactly as it does for a loaded one. +/// A built-in driver is absent from the module list. `/sys/module/amdgpu` is +/// no answer: the kernel creates it for a built-in driver at an early initcall, +/// before `amdgpu_init` can bail out (`nomodeset`, `amdgpu.modeset=0`). The PCI +/// driver directory exists only once `amdgpu_init` registered the driver. fn amdgpu_present_on_host() -> bool { - crate::host_path("/sys/module/amdgpu").exists() + crate::host_path("/sys/bus/pci/drivers/amdgpu").exists() } /// Whether `name` is loaded or built in: listed in the module list, or present @@ -6520,7 +6523,7 @@ mod tests { /// The host side of the check, against a planted root. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] - fn amdgpu_present_on_host_reads_sysfs_module_dir() { + fn amdgpu_present_on_host_reads_the_pci_driver_dir() { use crate::hardware_root::TEST_HOST_ROOT_ENV; use crate::test_env::RestoredEnvVar; @@ -6532,23 +6535,31 @@ mod tests { .map_or(0, |d| d.as_nanos()) )); std::fs::create_dir_all(&root).expect("create root"); - let (neither, planted) = { + let (neither, module_dir_only, planted) = { let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); let neither = amdgpu_present_on_host(); + // The module directory a built-in driver keeps even when it never + // bound: not evidence. std::fs::create_dir_all(root.join("sys/module/amdgpu")).expect("plant sysfs"); - (neither, amdgpu_present_on_host()) + let module_dir_only = amdgpu_present_on_host(); + std::fs::create_dir_all(root.join("sys/bus/pci/drivers/amdgpu")).expect("plant sysfs"); + (neither, module_dir_only, amdgpu_present_on_host()) }; std::fs::remove_dir_all(&root).ok(); assert!(!neither, "an empty root has no amdgpu"); - assert!(planted, "/sys/module/amdgpu counts"); + assert!( + !module_dir_only, + "/sys/module/amdgpu alone is not a bound driver" + ); + assert!(planted, "/sys/bus/pci/drivers/amdgpu counts"); } /// The wiring, not just the helpers: `probe_modules` must consult the host /// root. The `amdgpu` module line cannot be planted away on a machine that - /// has one, so this only asserts the positive: with `/sys/module/amdgpu` + /// has one, so this only asserts the positive: with `/sys/bus/pci/drivers/amdgpu` /// planted the field is `Some(true)` whatever the module list says. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] @@ -6563,7 +6574,7 @@ mod tests { .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_nanos()) )); - std::fs::create_dir_all(root.join("sys/module/amdgpu")).expect("plant sysfs"); + std::fs::create_dir_all(root.join("sys/bus/pci/drivers/amdgpu")).expect("plant sysfs"); let amdgpu_loaded = { let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK .lock() diff --git a/crates/rocm-core/src/hardware_root.rs b/crates/rocm-core/src/hardware_root.rs index 4fe1e7cf7..1f7fd66fe 100644 --- a/crates/rocm-core/src/hardware_root.rs +++ b/crates/rocm-core/src/hardware_root.rs @@ -11,7 +11,8 @@ //! - device nodes: `/dev/kfd`, the render nodes under `/dev/dri`, `/dev/dxg`; //! - `sysfs`: the `/sys/class/kfd/kfd` class device and the KFD topology under //! `/sys/class/kfd/kfd/topology/nodes`, the -//! DRM cards under `/sys/class/drm`, `/sys/module/amdgpu` and +//! DRM cards under `/sys/class/drm`, `/sys/bus/pci/drivers/amdgpu`, +//! `/sys/module/amdgpu` and //! `/sys/module/amdgpu/version`; //! - `procfs`: `/proc/version`, `/proc/cpuinfo`, `/proc/meminfo`, //! `/proc/cmdline`, `/proc/modules` (the fallback when `lsmod` cannot run) diff --git a/tests/e2e-cucumber/features/examine.feature b/tests/e2e-cucumber/features/examine.feature index 1db18c686..290b0a20c 100644 --- a/tests/e2e-cucumber/features/examine.feature +++ b/tests/e2e-cucumber/features/examine.feature @@ -483,7 +483,7 @@ Feature: GPU detection and system inspection # amdgpu built into the kernel is absent from `lsmod` too, so it was read as # "module not loaded" and `rocm diagnose` scored that, plus a DKMS failure - # and Secure Boot, as evidence. `/sys/module/amdgpu` exists for a loaded + # and Secure Boot, as evidence. `/sys/bus/pci/drivers/amdgpu` exists for a loaded # module and a built-in driver alike. The machine also has a ROCm install # and Secure Boot enabled, so the evidence lines are reachable if the flag # regresses. diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index a12f0e362..0db415680 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1544,7 +1544,7 @@ async fn assert_kfd_not_loaded(world: &mut E2eWorld) { assert_eq!(json["amdkfd_loaded"], serde_json::json!(false), "{json:#}"); } -/// A simulated host root with `/sys/module/amdgpu` and a `lsmod` that lists +/// A simulated host root with `/sys/bus/pci/drivers/amdgpu` and a `lsmod` that lists /// neither `amdgpu` nor `amdkfd`: amdgpu built into the kernel. A ROCm install /// (`ROCM_PATH`) and a `mokutil` reporting Secure Boot enabled are planted too, /// so the three `rocm diagnose` evidence lines that read `amdgpu_loaded` have @@ -1558,8 +1558,8 @@ async fn machine_with_built_in_amdgpu(world: &mut E2eWorld) { .path() .to_owned(); let host = root.join("host-root"); - std::fs::create_dir_all(host.join("sys/module/amdgpu")) - .expect("failed to plant /sys/module/amdgpu"); + std::fs::create_dir_all(host.join("sys/bus/pci/drivers/amdgpu")) + .expect("failed to plant /sys/bus/pci/drivers/amdgpu"); let rocm = root.join("rocm"); std::fs::create_dir_all(rocm.join(".info")).expect("failed to plant a ROCm install"); std::fs::write(rocm.join(".info/version"), "6.4.1\n").expect("failed to plant its version"); @@ -1626,7 +1626,7 @@ async fn assert_no_amdgpu_fault_evidence(world: &mut E2eWorld) { !line.contains("amdgpu module is not loaded") && !line.contains("DKMS failed") && !line.contains("Secure Boot is enabled and amdgpu didn't load"), - "amdgpu was reported missing though /sys/module/amdgpu exists: {line}" + "amdgpu was reported missing though the amdgpu PCI driver is registered: {line}" ); } } From d7652d063266900eab59eab266199b04171a5504 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 11:21:54 +0000 Subject: [PATCH 09/19] test(examine): pin that kfd on the host alone implies amdgpu_loaded Name the composed host probe amdgpu_implied_by_host so a test can reach it without depending on the module list of the machine running the suite. Dropping the kfd disjunct now fails kfd_alone_on_the_host_implies_amdgpu. Also say so in the amdgpu_loaded doc comment. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 52 ++++++++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 51416002f..612b4ff3d 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -247,7 +247,7 @@ pub struct Examination { /// amdgpu is present: listed as a module, or registered as a PCI driver /// (`/sys/bus/pci/drivers/amdgpu`), which it is whether amdgpu is loaded /// as a module or built into the kernel (where it never appears in - /// `lsmod`). + /// `lsmod`), or kfd is present on the host (kfd is linked into amdgpu). pub amdgpu_loaded: Option, pub amdgpu_blacklisted_in: Vec, /// kfd is available: as a separate `amdkfd` module on kernels before 4.20 @@ -1851,6 +1851,14 @@ fn amdgpu_present_on_host() -> bool { crate::host_path("/sys/bus/pci/drivers/amdgpu").exists() } +/// What the host says about `amdgpu_loaded`: the amdgpu PCI driver is +/// registered, or kfd is present. kfd is linked into amdgpu, so kfd on the +/// host proves amdgpu too: a container that passes `/dev/kfd` through without +/// `/sys/bus/pci` must not report amdkfd up and amdgpu down. +fn amdgpu_implied_by_host() -> bool { + amdgpu_present_on_host() || kfd_present_on_host() +} + /// Whether `name` is loaded or built in: listed in the module list, or present /// on the host. /// @@ -1881,12 +1889,7 @@ fn probe_modules(e: &mut Examination) { .filter_map(|line| line.split_whitespace().next()) .collect() }); - // kfd is linked into amdgpu, so kfd on the host proves amdgpu too: a - // container that passes `/dev/kfd` through without `/sys/module` must not - // report kfd up and amdgpu down. - e.amdgpu_loaded = module_or_host_state(modules.as_deref(), "amdgpu", || { - amdgpu_present_on_host() || kfd_present_on_host() - }); + e.amdgpu_loaded = module_or_host_state(modules.as_deref(), "amdgpu", amdgpu_implied_by_host); e.amdkfd_loaded = module_or_host_state(modules.as_deref(), "amdkfd", kfd_present_on_host); for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { @@ -6623,6 +6626,41 @@ mod tests { assert!(dev_only, "/dev/kfd alone counts"); } + /// kfd on the host alone answers `amdgpu_loaded`: no amdgpu PCI driver + /// directory, only a kfd signal, either one. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn kfd_alone_on_the_host_implies_amdgpu() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-kfd-implies-amdgpu-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&root).expect("create root"); + let (neither, sysfs_only, dev_only) = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let neither = amdgpu_implied_by_host(); + std::fs::create_dir_all(root.join("sys/class/kfd/kfd")).expect("plant sysfs"); + let sysfs_only = amdgpu_implied_by_host(); + std::fs::remove_dir_all(root.join("sys")).expect("unplant sysfs"); + std::fs::create_dir_all(root.join("dev")).expect("plant dev"); + std::fs::write(root.join("dev/kfd"), "").expect("plant /dev/kfd"); + (neither, sysfs_only, amdgpu_implied_by_host()) + }; + std::fs::remove_dir_all(&root).ok(); + assert!(!neither, "an empty root implies no amdgpu"); + assert!(sysfs_only, "/sys/class/kfd/kfd alone implies amdgpu"); + assert!(dev_only, "/dev/kfd alone implies amdgpu"); + } + /// The wiring, not just the helpers: `probe_modules` must consult the host /// root. On every machine this runs on, no `amdkfd` module line exists, so /// `Some(true)` can only come from the planted `/dev/kfd`. From 22bc07f13faf9d0589b8a9007a15156615f29192 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 12:16:09 +0000 Subject: [PATCH 10/19] docs(examine): say kfd is linked into amdgpu on current kernels only Before 4.20 amdkfd was a separate module, as the amdkfd_loaded field doc already says. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 612b4ff3d..33902f348 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1833,10 +1833,12 @@ fn summarise_gpu_categories(e: &mut Examination) { /// Whether the kernel's kfd is present on this host, however it got there. /// -/// kfd is not a module of its own: `HSA_AMD` is a `bool` Kconfig option and -/// amdkfd is linked into `amdgpu`, so no `amdkfd` line ever appears in the -/// module list, built in or not. The kernel's own interfaces are the signal: -/// the `/sys/class/kfd/kfd` class device, or the `/dev/kfd` node. +/// On current kernels kfd is not a module of its own: `HSA_AMD` is a `bool` +/// Kconfig option and amdkfd is linked into `amdgpu`, so no `amdkfd` line +/// appears in the module list, built in or not. (Before 4.20 it was a separate +/// `amdkfd` module, which the module-list check still covers.) The kernel's own +/// interfaces are the signal: the `/sys/class/kfd/kfd` class device, or the +/// `/dev/kfd` node. fn kfd_present_on_host() -> bool { crate::host_path("/sys/class/kfd/kfd").exists() || crate::host_path("/dev/kfd").exists() } From c5ebdd80a4e0d979f9b3a74e3938853c09660961 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 14:29:28 +0000 Subject: [PATCH 11/19] test(examine): match each amdgpu-missing evidence line verbatim check_3 says "the amdgpu kernel module is not loaded", which the "amdgpu module is not loaded" phrase taken from check_5 does not match. check_3 was only caught through its "DKMS failed" clause. List one phrase per check, copied from that check's evidence text. Signed-off-by: Tomas Saaristola --- tests/e2e-cucumber/tests/e2e/examine_steps.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index 0db415680..00e8ad6ed 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1621,11 +1621,18 @@ async fn assert_no_amdgpu_fault_evidence(world: &mut E2eWorld) { .flat_map(|m| m["evidence"].as_array().into_iter().flatten()) .filter_map(serde_json::Value::as_str) .collect(); + // One phrase per evidence line that `amdgpu_loaded == Some(false)` produces in + // diagnose.rs, each copied verbatim from that check so it keeps guarding it. + let amdgpu_missing_evidence = [ + // check_3_rocm_kernel_unsupported + "the amdgpu kernel module is not loaded", + // check_5_amdgpu_blacklisted + "amdgpu module is not loaded", + "Secure Boot is enabled and amdgpu didn't load", + ]; for line in evidence { assert!( - !line.contains("amdgpu module is not loaded") - && !line.contains("DKMS failed") - && !line.contains("Secure Boot is enabled and amdgpu didn't load"), + !amdgpu_missing_evidence.iter().any(|p| line.contains(p)), "amdgpu was reported missing though the amdgpu PCI driver is registered: {line}" ); } From 4a758f7c0310f867192e526f3dc62a87dae22984 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 15:43:40 +0000 Subject: [PATCH 12/19] test(examine): pin which host signal answers which driver field probe_modules' wiring tests each asserted one field, so swapping the host checks behind amdgpu_loaded and amdkfd_loaded passed every test. The two fields now come from driver_states, which takes the module list directly; its test plants one signal at a time, with and without a module list, and both wiring tests now assert the other field too. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 108 +++++++++++++++++++++++++++++--- 1 file changed, 99 insertions(+), 9 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 33902f348..1791bee23 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1878,6 +1878,16 @@ fn module_or_host_state( } } +/// `amdgpu_loaded` and `amdkfd_loaded`, in that order, from the module list +/// and the host. kfd on the host answers both; the amdgpu PCI driver answers +/// only `amdgpu_loaded`. +fn driver_states(modules: Option<&[&str]>) -> (Option, Option) { + ( + module_or_host_state(modules, "amdgpu", amdgpu_implied_by_host), + module_or_host_state(modules, "amdkfd", kfd_present_on_host), + ) +} + fn probe_modules(e: &mut Examination) { let (rc, out, _) = run("lsmod", &[], SHORT); let module_text = if rc == 0 { @@ -1891,8 +1901,7 @@ fn probe_modules(e: &mut Examination) { .filter_map(|line| line.split_whitespace().next()) .collect() }); - e.amdgpu_loaded = module_or_host_state(modules.as_deref(), "amdgpu", amdgpu_implied_by_host); - e.amdkfd_loaded = module_or_host_state(modules.as_deref(), "amdkfd", kfd_present_on_host); + (e.amdgpu_loaded, e.amdkfd_loaded) = driver_states(modules.as_deref()); for dir in ["/etc/modprobe.d", "/usr/lib/modprobe.d", "/run/modprobe.d"] { let Ok(entries) = std::fs::read_dir(crate::host_path(dir)) else { @@ -6564,8 +6573,12 @@ mod tests { /// The wiring, not just the helpers: `probe_modules` must consult the host /// root. The `amdgpu` module line cannot be planted away on a machine that - /// has one, so this only asserts the positive: with `/sys/bus/pci/drivers/amdgpu` - /// planted the field is `Some(true)` whatever the module list says. + /// has one, so this asserts `amdgpu_loaded` only positively: with + /// `/sys/bus/pci/drivers/amdgpu` planted it is `Some(true)` whatever the + /// module list says. It assumes, like the kfd test below, a runner whose + /// kernel lists no `amdkfd` module (none since 4.20); then the driver + /// directory alone must not make `amdkfd_loaded` true. `driver_states` + /// is tested for the same case without that assumption. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] fn probe_modules_reports_amdgpu_when_it_is_on_the_host() { @@ -6580,17 +6593,22 @@ mod tests { .map_or(0, |d| d.as_nanos()) )); std::fs::create_dir_all(root.join("sys/bus/pci/drivers/amdgpu")).expect("plant sysfs"); - let amdgpu_loaded = { + let (amdgpu_loaded, amdkfd_loaded) = { let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); let mut e = Examination::default(); probe_modules(&mut e); - e.amdgpu_loaded + (e.amdgpu_loaded, e.amdkfd_loaded) }; std::fs::remove_dir_all(&root).ok(); assert_eq!(amdgpu_loaded, Some(true)); + assert_ne!( + amdkfd_loaded, + Some(true), + "the amdgpu PCI driver does not imply kfd" + ); } /// The host side of the check, against a planted root: the sysfs class @@ -6663,9 +6681,80 @@ mod tests { assert!(dev_only, "/dev/kfd alone implies amdgpu"); } + /// Which host signal answers which field. Each case plants one signal into + /// an otherwise empty root and hands over the module list directly, so the + /// answer cannot come from the machine the test runs on: kfd alone answers + /// both fields, the amdgpu PCI driver alone answers only `amdgpu_loaded`, + /// and with the module list unreadable the host still answers. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn driver_states_answer_each_field_from_its_own_host_signal() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = std::env::temp_dir().join(format!( + "rocm-core-driver-states-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()) + )); + std::fs::create_dir_all(&root).expect("create root"); + let (neither, unread_neither, kfd_only, unread_kfd_only, pci_only, unread_pci_only) = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); + let neither = driver_states(Some(&[])); + let unread_neither = driver_states(None); + std::fs::create_dir_all(root.join("dev")).expect("plant dev"); + std::fs::write(root.join("dev/kfd"), "").expect("plant /dev/kfd"); + let kfd_only = driver_states(Some(&[])); + let unread_kfd_only = driver_states(None); + std::fs::remove_dir_all(root.join("dev")).expect("unplant dev"); + std::fs::create_dir_all(root.join("sys/bus/pci/drivers/amdgpu")).expect("plant sysfs"); + ( + neither, + unread_neither, + kfd_only, + unread_kfd_only, + driver_states(Some(&[])), + driver_states(None), + ) + }; + std::fs::remove_dir_all(&root).ok(); + assert_eq!( + neither, + (Some(false), Some(false)), + "nothing listed, nothing on the host" + ); + assert_eq!( + unread_neither, + (None, None), + "nothing read, nothing on the host" + ); + assert_eq!(kfd_only, (Some(true), Some(true)), "kfd implies amdgpu"); + assert_eq!( + unread_kfd_only, + (Some(true), Some(true)), + "kfd answers without a module list" + ); + assert_eq!( + pci_only, + (Some(true), Some(false)), + "amdgpu does not imply kfd" + ); + assert_eq!( + unread_pci_only, + (Some(true), None), + "amdgpu does not imply kfd" + ); + } + /// The wiring, not just the helpers: `probe_modules` must consult the host /// root. On every machine this runs on, no `amdkfd` module line exists, so - /// `Some(true)` can only come from the planted `/dev/kfd`. + /// `Some(true)` can only come from the planted `/dev/kfd`, which answers + /// `amdgpu_loaded` too. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] fn probe_modules_reports_amdkfd_when_kfd_is_on_the_host() { @@ -6681,17 +6770,18 @@ mod tests { )); std::fs::create_dir_all(root.join("dev")).expect("plant dev"); std::fs::write(root.join("dev/kfd"), "").expect("plant /dev/kfd"); - let amdkfd_loaded = { + let (amdgpu_loaded, amdkfd_loaded) = { let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); let mut e = Examination::default(); probe_modules(&mut e); - e.amdkfd_loaded + (e.amdgpu_loaded, e.amdkfd_loaded) }; std::fs::remove_dir_all(&root).ok(); assert_eq!(amdkfd_loaded, Some(true)); + assert_eq!(amdgpu_loaded, Some(true), "kfd implies amdgpu"); } /// The probes themselves, not just `host_path`, answer from a simulated From be07af99f2b6290480f94552d0e6f93cdbe28279 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 15:43:40 +0000 Subject: [PATCH 13/19] test(examine): cover kfd answering both drivers with no module list When lsmod fails and /proc/modules is empty, /dev/kfd still reports amdkfd_loaded and amdgpu_loaded as true. examine-34 pins that through examine --json. Signed-off-by: Tomas Saaristola --- tests/e2e-cucumber/features/examine.feature | 10 +++++++ tests/e2e-cucumber/tests/e2e/examine_steps.rs | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/tests/e2e-cucumber/features/examine.feature b/tests/e2e-cucumber/features/examine.feature index 290b0a20c..fe6d47985 100644 --- a/tests/e2e-cucumber/features/examine.feature +++ b/tests/e2e-cucumber/features/examine.feature @@ -493,3 +493,13 @@ Feature: GPU detection and system inspection When the user inspects and diagnoses that machine Then the inspection reports amdgpu as loaded And the diagnosis cites no missing amdgpu module, DKMS failure or Secure Boot evidence + + # With no module list at all (`lsmod` fails and `/proc/modules` is empty), + # the kernel's kfd interfaces still answer: kfd is linked into amdgpu, so + # `/dev/kfd` reports both drivers as loaded rather than leaving them unknown. + @id:examine-reports-drivers-from-kfd-without-a-module-list @requires-os:linux + Scenario: examine-34 - The inspection reports kfd and amdgpu from the kernel's kfd node when no module list can be read + Given a machine whose kernel exposes kfd but whose module list cannot be read + When the user inspects that machine in machine-readable form + Then the inspection reports kfd as loaded + And the inspection reports amdgpu as loaded diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index 00e8ad6ed..856903ea9 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1532,6 +1532,33 @@ async fn machine_without_kfd(world: &mut E2eWorld) { plant_machine_without_amdkfd_module(world, false); } +/// A simulated host root with `/dev/kfd`, no `/proc/modules`, and a `lsmod` +/// on `PATH` that fails: no module list can be read at all. +#[given("a machine whose kernel exposes kfd but whose module list cannot be read")] +async fn machine_with_kfd_and_no_module_list(world: &mut E2eWorld) { + let root = world + .isolated_root + .as_ref() + .expect("no isolated root") + .path() + .to_owned(); + let host = root.join("host-root"); + std::fs::create_dir_all(host.join("dev")).expect("failed to create planted /dev"); + std::fs::write(host.join("dev/kfd"), "").expect("failed to plant /dev/kfd"); + let bin = root.join("fake-bin"); + std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); + rocm_core::fake_executable::write_fake_executable(&bin.join("lsmod"), "#!/bin/sh\nexit 1\n") + .expect("failed to write the fake lsmod"); + let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .expect("PATH entries cannot hold a separator"); + world.command_env.push(("PATH", path)); + world + .command_env + .push(("ROCM_CLI_TEST_HOST_ROOT", host.into_os_string())); +} + #[then("the inspection reports kfd as loaded")] async fn assert_kfd_loaded(world: &mut E2eWorld) { let json = parsed_json(world); From b99e9ac69a462e45951c7902d718e45839a78a6c Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 15:43:40 +0000 Subject: [PATCH 14/19] docs: list the driver-presence reads behind the simulated host root release-trust.md's path inventory and the CI comment on planted-root coverage predated /sys/bus/pci/drivers/amdgpu and the /sys/class/kfd/kfd class device reads. Signed-off-by: Tomas Saaristola --- .github/workflows/ci.yml | 6 +++++- docs/release-trust.md | 10 ++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2fd0c4c7..52efb32a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -561,7 +561,11 @@ jobs: # fields and the distro name, in # `os_release_reads_follow_the_simulated_host_root` and, for the # `/usr/lib/os-release` fallback, in - # `os_release_reads_fall_back_to_usr_lib_when_etc_is_absent`). The other routed + # `os_release_reads_fall_back_to_usr_lib_when_etc_is_absent`), and the + # driver-presence reads `/sys/class/kfd/kfd`, `/dev/kfd` and + # `/sys/bus/pci/drivers/amdgpu` (`kfd_present_on_host`, + # `amdgpu_present_on_host`, `driver_states` and the two + # `probe_modules_reports_*` tests). The other routed # reads (`/dev/dri`, `/sys/class/drm`, `/sys/module/amdgpu`, the other # `/proc` files, the modprobe directories, the `/usr/lib/wsl/lib` and # ROCDXG checks) have no planted-root unit assertion; they are covered diff --git a/docs/release-trust.md b/docs/release-trust.md index b5d4429b9..b70a77085 100644 --- a/docs/release-trust.md +++ b/docs/release-trust.md @@ -312,10 +312,12 @@ is already trusted. ## Simulated Host Root rocm-cli decides what hardware it is running on by reading fixed host paths: -`/dev/kfd`, the render nodes under `/dev/dri`, `/dev/dxg`, the KFD topology -under `/sys/class/kfd`, the DRM cards under `/sys/class/drm`, -`/sys/module/amdgpu` and its `version` file, `/proc/version`, `/proc/cpuinfo`, -`/proc/meminfo`, `/proc/cmdline`, `/proc/modules`, `/proc/1/cgroup`, the +`/dev/kfd`, the render nodes under `/dev/dri`, `/dev/dxg`, the +`/sys/class/kfd/kfd` class device and the KFD topology under it, the DRM cards +under `/sys/class/drm`, the amdgpu PCI driver directory +`/sys/bus/pci/drivers/amdgpu`, `/sys/module/amdgpu` and its `version` file, +`/proc/version`, `/proc/cpuinfo`, `/proc/meminfo`, `/proc/cmdline`, +`/proc/modules`, `/proc/1/cgroup`, the container markers `/.dockerenv` and `/run/.containerenv`, the `/etc/os-release` (falling back to `/usr/lib/os-release` when absent, per `os-release(5)`) behind the distro fields and name `rocm examine` reports, the modprobe From 66c3734abe263cd575936c68d4f438d1f2338808 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Tue, 27 Oct 2026 16:21:33 +0000 Subject: [PATCH 15/19] test(examine): share the fake-executable PATH fixture Four e2e fixtures each wrote fake binaries into fake-bin and prepended it to PATH by hand. put_fake_executables_on_path does that once. Signed-off-by: Tomas Saaristola --- tests/e2e-cucumber/tests/e2e/examine_steps.rs | 87 +++++++++---------- 1 file changed, 40 insertions(+), 47 deletions(-) diff --git a/tests/e2e-cucumber/tests/e2e/examine_steps.rs b/tests/e2e-cucumber/tests/e2e/examine_steps.rs index 856903ea9..928794863 100644 --- a/tests/e2e-cucumber/tests/e2e/examine_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/examine_steps.rs @@ -1432,6 +1432,27 @@ async fn os_release_with_a_command(world: &mut E2eWorld) { const RUNS_A_COMMAND_ON_LINE_3: &str = "ID=ubuntu\nVERSION_ID=\"24.04\"\nexport ID=debian\nPRETTY_NAME=\"Ubuntu 24.04 LTS\"\n"; +/// Writes each `(name, script)` as an executable in the isolated root's +/// `fake-bin` and puts that directory first on the scenario's `PATH`. +fn put_fake_executables_on_path(world: &mut E2eWorld, executables: &[(&str, &str)]) { + let bin = world + .isolated_root + .as_ref() + .expect("no isolated root") + .path() + .join("fake-bin"); + std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); + for (name, script) in executables { + rocm_core::fake_executable::write_fake_executable(&bin.join(name), script) + .unwrap_or_else(|e| panic!("failed to write the fake {name}: {e}")); + } + let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( + &std::env::var_os("PATH").unwrap_or_default(), + ))) + .expect("PATH entries cannot hold a separator"); + world.command_env.push(("PATH", path)); +} + /// One GPU node in the kernel topology: a gfx1101 with no PCI address, so it /// matches no `lspci` entry and is listed from the topology alone. Plus an /// `lspci` that lists an empty bus, ahead of any real one, so the PCI probe @@ -1453,15 +1474,7 @@ fn plant_gpu_fixtures(world: &mut E2eWorld) { "cpu_cores_count 0\nsimd_count 120\ngfx_target_version 110001\n", ) .expect("failed to write planted KFD node properties"); - let bin = root.join("fake-bin"); - std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); - rocm_core::fake_executable::write_fake_executable(&bin.join("lspci"), "#!/bin/sh\nexit 0\n") - .expect("failed to write the fake lspci"); - let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - ))) - .expect("PATH entries cannot hold a separator"); - world.command_env.push(("PATH", path)); + put_fake_executables_on_path(world, &[("lspci", "#!/bin/sh\nexit 0\n")]); } #[given("a GPU machine whose release file runs a command on line 3")] @@ -1505,18 +1518,13 @@ fn plant_machine_without_amdkfd_module(world: &mut E2eWorld, with_kfd: bool) { if with_kfd { std::fs::write(host.join("dev/kfd"), "").expect("failed to plant /dev/kfd"); } - let bin = root.join("fake-bin"); - std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); - rocm_core::fake_executable::write_fake_executable( - &bin.join("lsmod"), - "#!/bin/sh\nprintf 'Module Size Used by\\namdgpu 1 0\\ndrm 1 1 amdgpu\\n'\n", - ) - .expect("failed to write the fake lsmod"); - let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - ))) - .expect("PATH entries cannot hold a separator"); - world.command_env.push(("PATH", path)); + put_fake_executables_on_path( + world, + &[( + "lsmod", + "#!/bin/sh\nprintf 'Module Size Used by\\namdgpu 1 0\\ndrm 1 1 amdgpu\\n'\n", + )], + ); world .command_env .push(("ROCM_CLI_TEST_HOST_ROOT", host.into_os_string())); @@ -1545,15 +1553,7 @@ async fn machine_with_kfd_and_no_module_list(world: &mut E2eWorld) { let host = root.join("host-root"); std::fs::create_dir_all(host.join("dev")).expect("failed to create planted /dev"); std::fs::write(host.join("dev/kfd"), "").expect("failed to plant /dev/kfd"); - let bin = root.join("fake-bin"); - std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); - rocm_core::fake_executable::write_fake_executable(&bin.join("lsmod"), "#!/bin/sh\nexit 1\n") - .expect("failed to write the fake lsmod"); - let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - ))) - .expect("PATH entries cannot hold a separator"); - world.command_env.push(("PATH", path)); + put_fake_executables_on_path(world, &[("lsmod", "#!/bin/sh\nexit 1\n")]); world .command_env .push(("ROCM_CLI_TEST_HOST_ROOT", host.into_os_string())); @@ -1590,23 +1590,16 @@ async fn machine_with_built_in_amdgpu(world: &mut E2eWorld) { let rocm = root.join("rocm"); std::fs::create_dir_all(rocm.join(".info")).expect("failed to plant a ROCm install"); std::fs::write(rocm.join(".info/version"), "6.4.1\n").expect("failed to plant its version"); - let bin = root.join("fake-bin"); - std::fs::create_dir_all(&bin).expect("failed to create the fake PATH dir"); - rocm_core::fake_executable::write_fake_executable( - &bin.join("lsmod"), - "#!/bin/sh\nprintf 'Module Size Used by\\ndrm 1 0\\n'\n", - ) - .expect("failed to write the fake lsmod"); - rocm_core::fake_executable::write_fake_executable( - &bin.join("mokutil"), - "#!/bin/sh\necho 'SecureBoot enabled'\n", - ) - .expect("failed to write the fake mokutil"); - let path = std::env::join_paths(std::iter::once(bin).chain(std::env::split_paths( - &std::env::var_os("PATH").unwrap_or_default(), - ))) - .expect("PATH entries cannot hold a separator"); - world.command_env.push(("PATH", path)); + put_fake_executables_on_path( + world, + &[ + ( + "lsmod", + "#!/bin/sh\nprintf 'Module Size Used by\\ndrm 1 0\\n'\n", + ), + ("mokutil", "#!/bin/sh\necho 'SecureBoot enabled'\n"), + ], + ); world.command_env.push(("ROCM_PATH", rocm.into_os_string())); world .command_env From a2ac4d1fb896dea2702012ea0b392421ff47f163 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Wed, 28 Oct 2026 11:27:01 +0000 Subject: [PATCH 16/19] test(examine): hand the wiring tests their module list The two probe_modules wiring tests ran the machine's own lsmod, so the amdgpu one could only assert amdkfd_loaded was not true, and held only on a kernel with no amdkfd module. probe_modules now reads the module list and hands it to probe_modules_from; the tests hand in a list naming neither driver, so both answers come from the planted host root, and the amdgpu test asserts amdkfd_loaded is false. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 50 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 1791bee23..6c10c7ea4 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -1888,15 +1888,26 @@ fn driver_states(modules: Option<&[&str]>) -> (Option, Option) { ) } -fn probe_modules(e: &mut Examination) { +/// The kernel's module list, one module per line with its name first: `lsmod` +/// without its header, else `/proc/modules`. `None` when neither can be read. +fn read_module_list() -> Option { let (rc, out, _) = run("lsmod", &[], SHORT); - let module_text = if rc == 0 { + if rc == 0 { Some(out.lines().skip(1).collect::>().join("\n")) } else { let txt = read_host_text("/proc/modules"); if txt.is_empty() { None } else { Some(txt) } - }; - let modules: Option> = module_text.as_deref().map(|text| { + } +} + +fn probe_modules(e: &mut Examination) { + probe_modules_from(e, read_module_list().as_deref()); +} + +/// `probe_modules` given the module list, so a test can hand one in instead +/// of reading the machine's own. +fn probe_modules_from(e: &mut Examination, module_text: Option<&str>) { + let modules: Option> = module_text.map(|text| { text.lines() .filter_map(|line| line.split_whitespace().next()) .collect() @@ -6571,14 +6582,17 @@ mod tests { assert!(planted, "/sys/bus/pci/drivers/amdgpu counts"); } - /// The wiring, not just the helpers: `probe_modules` must consult the host - /// root. The `amdgpu` module line cannot be planted away on a machine that - /// has one, so this asserts `amdgpu_loaded` only positively: with - /// `/sys/bus/pci/drivers/amdgpu` planted it is `Some(true)` whatever the - /// module list says. It assumes, like the kfd test below, a runner whose - /// kernel lists no `amdkfd` module (none since 4.20); then the driver - /// directory alone must not make `amdkfd_loaded` true. `driver_states` - /// is tested for the same case without that assumption. + /// The wiring, not just the helpers: `probe_modules_from` must consult the + /// host root. The module list it is handed names neither driver, so + /// `amdgpu_loaded` can only come from the planted + /// `/sys/bus/pci/drivers/amdgpu`, and that directory must not make + /// `amdkfd_loaded` true. + /// A module list, as `read_module_list` returns it, naming neither + /// `amdgpu` nor `amdkfd`. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + const NO_AMD_MODULES: &str = "snd_hda_intel 61440 3 - Live 0x0000000000000000\n\ + nvme 61440 2 - Live 0x0000000000000000"; + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] fn probe_modules_reports_amdgpu_when_it_is_on_the_host() { @@ -6599,14 +6613,14 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner); let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); let mut e = Examination::default(); - probe_modules(&mut e); + probe_modules_from(&mut e, Some(NO_AMD_MODULES)); (e.amdgpu_loaded, e.amdkfd_loaded) }; std::fs::remove_dir_all(&root).ok(); assert_eq!(amdgpu_loaded, Some(true)); - assert_ne!( + assert_eq!( amdkfd_loaded, - Some(true), + Some(false), "the amdgpu PCI driver does not imply kfd" ); } @@ -6751,8 +6765,8 @@ mod tests { ); } - /// The wiring, not just the helpers: `probe_modules` must consult the host - /// root. On every machine this runs on, no `amdkfd` module line exists, so + /// The wiring, not just the helpers: `probe_modules_from` must consult the + /// host root. The module list it is handed names neither driver, so /// `Some(true)` can only come from the planted `/dev/kfd`, which answers /// `amdgpu_loaded` too. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] @@ -6776,7 +6790,7 @@ mod tests { .unwrap_or_else(std::sync::PoisonError::into_inner); let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, &root); let mut e = Examination::default(); - probe_modules(&mut e); + probe_modules_from(&mut e, Some(NO_AMD_MODULES)); (e.amdgpu_loaded, e.amdkfd_loaded) }; std::fs::remove_dir_all(&root).ok(); From 7e406afeab30e49d89ce1c7c1124189b0ca19439 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Fri, 30 Oct 2026 09:50:51 +0000 Subject: [PATCH 17/19] test(examine): pin the module-list names in driver_states Every driver_states and probe_modules_from test handed in a list naming neither driver, so misspelling "amdgpu" or "amdkfd" in driver_states, or breaking the name parse in probe_modules_from, left them all green. That is the path every machine with the drivers as modules takes. The new test hands probe_modules_from lists naming both, only amdgpu and only amdkfd, against an empty host root, so each field must come from its own name. Also move the wiring test's doc comment back onto the test; a2ac4d1 had left it above NO_AMD_MODULES. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 50 +++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index 6c10c7ea4..df804a94d 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -6582,17 +6582,57 @@ mod tests { assert!(planted, "/sys/bus/pci/drivers/amdgpu counts"); } - /// The wiring, not just the helpers: `probe_modules_from` must consult the - /// host root. The module list it is handed names neither driver, so - /// `amdgpu_loaded` can only come from the planted - /// `/sys/bus/pci/drivers/amdgpu`, and that directory must not make - /// `amdkfd_loaded` true. /// A module list, as `read_module_list` returns it, naming neither /// `amdgpu` nor `amdkfd`. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] const NO_AMD_MODULES: &str = "snd_hda_intel 61440 3 - Live 0x0000000000000000\n\ nvme 61440 2 - Live 0x0000000000000000"; + /// The module list alone, against an empty host root: on a machine where + /// the drivers really are modules, each field must come from its own name + /// in the list. Both named answers both fields; `amdgpu` alone answers + /// only `amdgpu_loaded`, and `amdkfd` alone only `amdkfd_loaded`, so a + /// misspelt or swapped name in `driver_states` fails here. + #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] + #[test] + fn probe_modules_reports_each_driver_its_module_list_names() { + use crate::hardware_root::TEST_HOST_ROOT_ENV; + use crate::test_env::RestoredEnvVar; + + let root = tempfile::tempdir().expect("create host root"); + let probe = |module_text: &str| { + let mut e = Examination::default(); + probe_modules_from(&mut e, Some(module_text)); + (e.amdgpu_loaded, e.amdkfd_loaded) + }; + let (both, amdgpu_only, amdkfd_only) = { + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let _root = RestoredEnvVar::set(TEST_HOST_ROOT_ENV, root.path()); + ( + probe( + "amdgpu 19378176 12 - Live 0x0000000000000000\n\ + amdkfd 380928 1 amdgpu, Live 0x0000000000000000\n\ + nvme 61440 2 - Live 0x0000000000000000", + ), + probe( + "snd_hda_intel 61440 3 - Live 0x0000000000000000\n\ + amdgpu 19378176 12 - Live 0x0000000000000000", + ), + probe("amdkfd 380928 0 - Live 0x0000000000000000"), + ) + }; + assert_eq!(both, (Some(true), Some(true)), "both drivers listed"); + assert_eq!(amdgpu_only, (Some(true), Some(false)), "only amdgpu listed"); + assert_eq!(amdkfd_only, (Some(false), Some(true)), "only amdkfd listed"); + } + + /// The wiring, not just the helpers: `probe_modules_from` must consult the + /// host root. The module list it is handed names neither driver, so + /// `amdgpu_loaded` can only come from the planted + /// `/sys/bus/pci/drivers/amdgpu`, and that directory must not make + /// `amdkfd_loaded` true. #[cfg(all(feature = "e2e-test-hooks", target_os = "linux"))] #[test] fn probe_modules_reports_amdgpu_when_it_is_on_the_host() { From 6c8661c83638918aa5a3c0c79a4e408a5d1f6892 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Fri, 30 Oct 2026 09:50:51 +0000 Subject: [PATCH 18/19] test(examine): hold the host-root lock in the repo-scan tests live_rocm_repo_paths_in_dir_skips_a_real_bak_file_on_disk and merge_live_repo_paths_scans_every_family_dir_including_zypp scan through host_path without HOST_ROOT_TEST_LOCK. Under plain cargo test, a test that has set ROCM_CLI_TEST_HOST_ROOT re-roots their scan and they fail intermittently. Signed-off-by: Tomas Saaristola --- crates/rocm-core/src/examine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/rocm-core/src/examine.rs b/crates/rocm-core/src/examine.rs index df804a94d..19686e156 100644 --- a/crates/rocm-core/src/examine.rs +++ b/crates/rocm-core/src/examine.rs @@ -3476,6 +3476,11 @@ mod tests { .expect("write quarantined repo"); std::fs::write(dir.path().join("unrelated.list"), "deb ...\n").expect("write unrelated"); + // The scan reads through `host_path`; hold the lock so another test's + // host root cannot re-root it. + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let mut seen = live_rocm_repo_paths_in_dir(&dir.path().to_string_lossy()); seen.sort(); @@ -3513,6 +3518,11 @@ mod tests { yum_dir.path().to_string_lossy().into_owned(), zypp_dir.path().to_string_lossy().into_owned(), ]; + // The scan reads through `host_path`; hold the lock so another test's + // host root cannot re-root it. + let _guard = crate::hardware_root::HOST_ROOT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let dir_refs: Vec<&str> = dirs.iter().map(String::as_str).collect(); merge_live_repo_paths(&mut e, &dir_refs); From 50224ab4f359280c0cf9a025615d8faac8d56522 Mon Sep 17 00:00:00 2001 From: Tomas Saaristola Date: Fri, 30 Oct 2026 10:31:14 +0000 Subject: [PATCH 19/19] ci: count the three probe_modules_reports tests in the host-root comment The comment named two probe_modules_reports_* tests; there are three. Signed-off-by: Tomas Saaristola --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52efb32a7..66447a0fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -564,7 +564,7 @@ jobs: # `os_release_reads_fall_back_to_usr_lib_when_etc_is_absent`), and the # driver-presence reads `/sys/class/kfd/kfd`, `/dev/kfd` and # `/sys/bus/pci/drivers/amdgpu` (`kfd_present_on_host`, - # `amdgpu_present_on_host`, `driver_states` and the two + # `amdgpu_present_on_host`, `driver_states` and the three # `probe_modules_reports_*` tests). The other routed # reads (`/dev/dri`, `/sys/class/drm`, `/sys/module/amdgpu`, the other # `/proc` files, the modprobe directories, the `/usr/lib/wsl/lib` and