Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion tests/e2e-cucumber/expectations.toml
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,6 @@ flaky = true
when = { is_wsl = true, therock_family = "gfx*" }
bug = "EAI-7998"
reason = "`examine --json` reports has_amd_gpu:false on a WSL2 host whose own summary names a gfx target."

# --- EAI-8031 (public mirror: ROCm/rocm-cli#260): on a Strix Halo WINDOWS host,
# serving a canonical Hugging Face checkpoint through the `owner/repo:variant`
# direct-serve path (`unsloth/Qwen3-0.6B-GGUF:Q4_0`) exits 0 but `/v1/models`
Expand Down Expand Up @@ -208,3 +207,29 @@ when = { os = "windows", therock_family = "gfx*", has_amd_gpu = true }
bug = "EAI-8031"
reason = "Managed lemonade serve of a Hugging Face `owner/repo:variant` checkpoint exits 0 but the endpoint never becomes ready on Windows."
serve_timeout_secs = 240

# --- EAI-8053: `install driver --dkms` prefixes every plan command with a literal
# `sudo `, unconditionally. As root that prefix is unnecessary, and on a root host
# with no `sudo` binary it is fatal: `sh -c "sudo apt-get update"` dies with
# `sudo: not found` before any driver work. The fix is uid-aware (prepend `sudo`
# only when NOT root), so the scenario is @requires-root (it SKIPS off root, where
# the prefix is correct); on a root runner the "no sudo prefix" contract fails
# today. Unconditional there, so `when = {}`. Remove when the plan stops prefixing
# `sudo` while already root. ---
[["driver-install-as-root-does-not-require-sudo"]]
when = {}
bug = "EAI-8053"
reason = "install driver --dkms prefixes sudo even as root, so it fails with `sudo: not found` on a root host without sudo."

# --- EAI-8051: `comfyui install` installs ComfyUI's dependencies into the managed
# ROCm runtime with no package-index scoping. torch/torchvision/torchaudio are
# filtered out, but a transitive dependency can still pull CUDA `nvidia-*` wheels
# into the runtime and displace its ROCm torch, so installing an optional app
# leaves the machine's base runtime a CUDA build with no AMD GPU support. Runs only
# on the nightly GPU lane (@requires-gpu @nightly) against an isolated, throwaway
# runtime it may corrupt. Unconditional where it runs, so `when = {}`.
# Remove when the install can no longer alter the runtime's torch stack. ---
[["comfyui-install-preserves-the-rocm-runtime"]]
when = {}
bug = "EAI-8051"
reason = "comfyui install can pull CUDA nvidia-* wheels into the managed ROCm runtime, replacing its ROCm torch."
31 changes: 31 additions & 0 deletions tests/e2e-cucumber/features/comfyui.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Feature: ComfyUI application management

# EAI-8051: `rocm comfyui install` installs ComfyUI's dependencies INTO the
# machine's managed ROCm runtime. It filters torch/torchvision/torchaudio out of
# ComfyUI's requirements, but nothing scopes the package index, so a transitive
# dependency can still pull CUDA `nvidia-*` wheels into the runtime and displace
# its ROCm torch. The runtime the whole machine serves models with is then a CUDA
# build with no AMD GPU support — installing an optional app broke the base.
#
# The contract: after installing an optional app, the machine's ROCm runtime must
# still be a ROCm runtime — its torch stays a ROCm (HIP) build and no `nvidia-*`
# CUDA distributions appear in it. We assert the runtime's health, NOT that the
# ComfyUI install exits 0: the real install exits non-zero AND still leaves the
# damage, so an exit-code assertion would miss the defect.
#
# Genuinely destructive and expensive: it needs a real managed runtime (a
# multi-GiB SDK install) and mutates it, so it runs ONLY on a GPU host, behind
# @nightly, against this scenario's own isolated runtime prefix (it must never
# share a runtime tree with other scenarios — it may corrupt it). Gated
# @requires-gpu @nightly, matching runtime-install-sdk-active, the other scenario
# that does a real `install sdk`. NOT @lifecycle: that tag is for OS-mutating
# release scenarios and no lane sets E2E_INCLUDE_LIFECYCLE on a GPU host, so
# combining it with @nightly would make this scenario unreachable on every lane;
# this mutates only its own isolated runtime prefix, not the OS.
@id:comfyui-install-preserves-the-rocm-runtime @requires-gpu @nightly
Scenario: 1 - Installing ComfyUI does not replace the ROCm runtime with a CUDA one
Given an isolated machine with a managed ROCm runtime
And the runtime's torch is a ROCm build
When the user installs ComfyUI
Then the runtime's torch is still a ROCm build
And no CUDA nvidia packages were added to the runtime
19 changes: 19 additions & 0 deletions tests/e2e-cucumber/features/driver.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feature: Native driver installation

# EAI-8053: `rocm install driver --dkms` builds its command plan with a literal
# `sudo ` prefix on every step, unconditionally. When the CLI already runs as
# root the prefix is unnecessary, and on a root host that has no `sudo` binary it
# is actively harmful: the very first command dies with `sudo: not found` before
# any driver work happens. The contract is that being root — the state where the
# commands could otherwise succeed — must not be the thing that breaks the run.
#
# Root-gated: the fix is uid-aware (prepend `sudo` only when NOT root), so the
# "no sudo prefix" contract only has a premise where the runner is actually root.
# Off root the sudo prefix is correct, so @requires-root skips there rather than
# letting the row falsely pass. Linux-only: the plan and its `sh -c` execution
# are the Linux DKMS path.
@id:driver-install-as-root-does-not-require-sudo @requires-os:linux @requires-root
Scenario: 1 - Installing the driver as root does not depend on sudo being present
Given a root machine with no sudo command available
When the user installs the native driver with dkms
Then the install does not fail merely because sudo is missing
24 changes: 24 additions & 0 deletions tests/e2e-cucumber/src/capability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ pub struct HostCapability {
pub os_family: String,
/// `examine`'s `wsl:` line.
pub is_wsl: bool,
/// Whether the test process runs as root (effective UID 0). Always false off
/// Unix. Gates `@requires-root` scenarios whose contract only holds when the
/// runner is root (see [`ScenarioDecl::requires_root`]).
pub is_root: bool,
/// First AMD GPU's gfx target from `examine`'s `detected_gfx_target:` line
/// (e.g. "gfx942", "gfx1151"), if a real one was reported.
pub gfx_target: Option<String>,
Expand Down Expand Up @@ -348,6 +352,7 @@ fn probe_host_capability() -> HostCapability {
HostCapability {
os_family,
is_wsl,
is_root: process_is_root(),
gfx_target,
has_amd_gpu,
available_engines,
Expand All @@ -356,6 +361,23 @@ fn probe_host_capability() -> HostCapability {
}
}

/// Whether the current test process runs as root (effective UID 0). Read from
/// `/proc/self/status` rather than `geteuid` so the check stays inside this
/// crate's `deny(unsafe_code)` policy. The `Uid:` line lists real, effective,
/// saved and filesystem uids; the effective uid (the second field) is the one the
/// driver-install `sudo`-prefix contract turns on. False on any non-Linux host or
/// if the status file cannot be read — off Linux the contract does not apply.
fn process_is_root() -> bool {
let Ok(status) = std::fs::read_to_string("/proc/self/status") else {
return false;
};
status
.lines()
.find_map(|line| line.strip_prefix("Uid:"))
.and_then(|rest| rest.split_whitespace().nth(1))
.is_some_and(|euid| euid == "0")
}

/// Run `rocm <args>` with an isolated config/data/cache root, returning stdout
/// (empty string on any failure — the probe must never panic the suite).
fn run_probe(root: &std::path::Path, args: &[&str]) -> String {
Expand Down Expand Up @@ -600,6 +622,7 @@ mod tests {
let strix = HostCapability {
os_family: "windows".to_owned(),
is_wsl: false,
is_root: false,
gfx_target: Some("gfx1151".to_owned()),
has_amd_gpu: true,
available_engines: vec!["lemonade".to_owned(), "vllm".to_owned()],
Expand All @@ -613,6 +636,7 @@ mod tests {
let mi300x = HostCapability {
os_family: "linux".to_owned(),
is_wsl: false,
is_root: false,
gfx_target: Some("gfx942".to_owned()),
has_amd_gpu: true,
available_engines: vec!["lemonade".to_owned(), "vllm".to_owned()],
Expand Down
64 changes: 62 additions & 2 deletions tests/e2e-cucumber/src/expectation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const REQUIRES_GPU_TAG: &str = "requires-gpu";
const REQUIRES_NO_GPU_TAG: &str = "requires-no-gpu";
const REQUIRES_BARE_METAL_TAG: &str = "requires-bare-metal";
const REQUIRES_WSL_TAG: &str = "requires-wsl";
const REQUIRES_ROOT_TAG: &str = "requires-root";
const SERVE_TIMEOUT_PREFIX: &str = "serve-timeout:";
const NIGHTLY_TAG: &str = "nightly";
const LIFECYCLE_TAG: &str = "lifecycle";
Expand Down Expand Up @@ -82,6 +83,15 @@ pub struct ScenarioDecl {
/// host, so it is skipped on native Linux, native Windows and everything
/// else. Same reason `@requires-os:linux` cannot stand in for it.
pub requires_wsl: bool,
/// `@requires-root`: the scenario's premise is a process running as root
/// (effective UID 0), so it is skipped where the test process is not root.
/// Needed by the driver-install-as-root contract (EAI-8053): the fix is
/// uid-aware (prepend `sudo` only when NOT root), so the "no `sudo` prefix"
/// contract only holds where the runner is actually root. None of the four
/// `expectations.toml` condition keys can express "running as root", so — like
/// `@requires-wsl` — this is a tag, not a row, keeping the xfail row honest on
/// non-root lanes (they SKIP rather than falsely PASS).
pub requires_root: bool,
/// Engine the scenario pins via `@requires-engine:<e>` (if any).
pub requires_engine: Option<String>,
/// OS the scenario requires via `@requires-os:<os>` (e.g. "linux"), if any —
Expand Down Expand Up @@ -120,6 +130,7 @@ impl ScenarioDecl {
let mut requires_no_gpu = false;
let mut requires_bare_metal = false;
let mut requires_wsl = false;
let mut requires_root = false;
let mut requires_engine = None;
let mut requires_os = None;
let mut serve_timeout_secs = None;
Expand Down Expand Up @@ -147,6 +158,8 @@ impl ScenarioDecl {
requires_bare_metal = true;
} else if tag == REQUIRES_WSL_TAG {
requires_wsl = true;
} else if tag == REQUIRES_ROOT_TAG {
requires_root = true;
} else if tag == NIGHTLY_TAG {
nightly = true;
} else if tag == LIFECYCLE_TAG {
Expand All @@ -161,6 +174,7 @@ impl ScenarioDecl {
requires_no_gpu,
requires_bare_metal,
requires_wsl,
requires_root,
requires_engine,
requires_os,
serve_timeout_secs,
Expand Down Expand Up @@ -363,8 +377,8 @@ pub struct PlatformManifest<'a> {
/// 1. Not-applicable → `Skip`: a `@nightly` scenario when nightly isn't included,
/// a `@merge-queue` scenario outside the merge queue, a `@requires-gpu`
/// scenario on a host with no AMD GPU, a `@requires-bare-metal` scenario on
/// WSL2, a `@requires-os:<os>` scenario on a different OS, or a scenario whose
/// effective engine can't start.
/// WSL2, a `@requires-root` scenario off root, a `@requires-os:<os>` scenario
/// on a different OS, or a scenario whose effective engine can't start.
/// 2. First matching `expectations.toml` condition → `ExpectXfail`.
/// 3. Otherwise → `ExpectPass`.
///
Expand Down Expand Up @@ -420,6 +434,11 @@ pub fn resolve(
reason: "requires WSL; this host is not running under WSL".to_owned(),
};
}
if decl.requires_root && !cap.is_root {
return Expectation::Skip {
reason: "requires the runner to be root; this process is not root".to_owned(),
};
}
if let Some(os) = &decl.requires_os
&& !os.eq_ignore_ascii_case(&cap.os_family)
{
Expand Down Expand Up @@ -500,6 +519,7 @@ mod tests {
"mi300x" => HostCapability {
os_family: "linux".into(),
is_wsl: false,
is_root: false,
gfx_target: Some("gfx942".into()),
has_amd_gpu: true,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand All @@ -509,6 +529,7 @@ mod tests {
"strix-ubuntu" => HostCapability {
os_family: "linux".into(),
is_wsl: false,
is_root: false,
gfx_target: Some("gfx1151".into()),
has_amd_gpu: true,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand All @@ -518,6 +539,7 @@ mod tests {
"strix-windows" => HostCapability {
os_family: "windows".into(),
is_wsl: false,
is_root: false,
gfx_target: Some("gfx1151".into()),
has_amd_gpu: true,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand All @@ -531,6 +553,7 @@ mod tests {
"wsl2" => HostCapability {
os_family: "linux".into(),
is_wsl: true,
is_root: false,
gfx_target: Some("gfx1151".into()),
has_amd_gpu: true,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand All @@ -543,6 +566,7 @@ mod tests {
"wsl" => HostCapability {
os_family: "linux".into(),
is_wsl: true,
is_root: false,
gfx_target: None,
has_amd_gpu: false,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand All @@ -554,15 +578,29 @@ mod tests {
"wsl-no-passthrough" => HostCapability {
os_family: "linux".into(),
is_wsl: true,
is_root: false,
gfx_target: Some("gfx1151".into()),
has_amd_gpu: false,
available_engines: vec!["lemonade".into(), "vllm".into()],
effective_serve_engine: "lemonade".into(),
platform_slug: "strix-halo-wsl".into(),
},
// A no-GPU host running as root — the mock CI lane's shape, where the
// driver-install sudo-prefix contract (EAI-8053) has a premise.
"mock-root" => HostCapability {
os_family: "linux".into(),
is_wsl: false,
is_root: true,
gfx_target: None,
has_amd_gpu: false,
available_engines: vec!["lemonade".into(), "vllm".into()],
effective_serve_engine: "lemonade".into(),
platform_slug: "mock".into(),
},
_ => HostCapability {
os_family: "other".into(),
is_wsl: false,
is_root: false,
gfx_target: None,
has_amd_gpu: false,
available_engines: vec!["lemonade".into(), "vllm".into()],
Expand Down Expand Up @@ -622,6 +660,28 @@ serve_timeout_secs = 90
assert!(!decl(&["id:x", "requires-gpu"]).requires_bare_metal);
}

#[test]
fn root_tag_parses_and_gates_on_root() {
// Parses in both shapes; absent by default so no existing scenario changes.
assert!(decl(&["id:x", "requires-root"]).requires_root);
assert!(decl(&["@id:x", "@requires-root"]).requires_root);
assert!(!decl(&["id:x", "requires-gpu"]).requires_root);

let m = Expectations::default();
let d = decl(&["id:driver-root", "requires-os:linux", "requires-root"]);
// Root host: the premise holds, so it resolves (here, expected-pass with
// an empty matrix — an xfail row is layered on separately).
assert_eq!(
resolve(&d, &cap("mock-root"), &m, false, false, false),
Expectation::ExpectPass
);
// Non-root host: skipped, so a `when = {}` xfail row can't XPASS there.
assert!(matches!(
resolve(&d, &cap("mock"), &m, false, false, false),
Expectation::Skip { .. }
));
}

#[test]
fn serve_timeout_tag_parses_seconds() {
let d = decl(&["id:serve-large-model-inference", "serve-timeout:2400"]);
Expand Down
Loading
Loading