From 1734d5a7a62d34524e33d0e19dfa87445e504f7a Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Wed, 9 Sep 2026 09:49:34 -0700 Subject: [PATCH 1/8] feat(therock): extend canonical resolver for ROCm 10 Signed-off-by: Michael Roy --- README.md | 18 + apps/rocm/src/comfyui.rs | 3 + apps/rocm/src/main.rs | 5 + apps/rocm/src/storage.rs | 1 + apps/rocm/src/therock.rs | 818 ++++++++++++++++-- crates/rocm-core/src/lib.rs | 32 +- docs/release-trust.md | 27 + .../features/therock_next_generation.feature | 71 ++ tests/e2e-cucumber/tests/e2e.rs | 1 + tests/e2e-cucumber/tests/e2e/therock_steps.rs | 478 ++++++++++ tests/e2e-cucumber/tests/feature_naming.rs | 1 + 11 files changed, 1369 insertions(+), 86 deletions(-) create mode 100644 tests/e2e-cucumber/features/therock_next_generation.feature create mode 100644 tests/e2e-cucumber/tests/e2e/therock_steps.rs diff --git a/README.md b/README.md index 26edffe22..3c814925f 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,24 @@ by rocm-cli. `install driver` installs the AMD kernel driver on Linux (DKMS or native package). `update` checks for a newer ROCm package; pass `--apply` to install it. +ROCm 10 and newer ship from a different source layout. It is opt-in, and asking +for it takes two things together: pin the version with `--version`, and name the +exact GPU arch — the raw `gfx` code, not a family label: + +``` +rocm install sdk --version 10.0.0 --family gfx1200 --dry-run +``` + +A family label such as `--family gfx120X-all` is rejected for those versions +rather than resolved to a guess, because the ROCm 10 packages publish one +payload per exact arch and there is no bucket payload to fall back to. Run +`rocm examine` to see the arch this machine reports. + +Nothing about this happens on its own. Without a `--version` of 10 or newer, +`install sdk` resolves the same release and nightly sources it always has, and +it never quietly retries against the ROCm 10 sources when a lookup comes up +empty — it tells you what it could not find instead. + ### Runtime management Manage multiple side-by-side ROCm installs: diff --git a/apps/rocm/src/comfyui.rs b/apps/rocm/src/comfyui.rs index 0f021b31a..6bfd5bef4 100644 --- a/apps/rocm/src/comfyui.rs +++ b/apps/rocm/src/comfyui.rs @@ -2105,6 +2105,7 @@ mod tests { version: "7.13.0a20260511".to_owned(), install_root: runtime_root, selected_artifact_url: "https://example.invalid/simple".to_owned(), + source_layout_generation: None, index_url: None, tarball_file_name: None, python_launcher: None, @@ -2179,6 +2180,7 @@ mod tests { version: "7.13.0a20260511".to_owned(), install_root: runtime_root.clone(), selected_artifact_url: "https://example.invalid/simple".to_owned(), + source_layout_generation: None, index_url: None, tarball_file_name: None, python_launcher: None, @@ -2270,6 +2272,7 @@ mod tests { version: "7.13.0a20260511".to_owned(), install_root: runtime_root, selected_artifact_url: "https://example.invalid/simple".to_owned(), + source_layout_generation: None, index_url: None, tarball_file_name: None, python_launcher: None, diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index f6c166207..ca3e2629a 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -9288,6 +9288,7 @@ fn adopt_runtime_from_probe( version, install_root: install_root.clone(), selected_artifact_url: "adopted-read-only".to_owned(), + source_layout_generation: None, index_url: None, tarball_file_name: None, python_launcher: None, @@ -16013,6 +16014,7 @@ fn apply_runtime_update( &source.format, &source.family, plan.device_target.as_deref(), + plan.source_layout_generation.as_deref(), true, )?; let _ = writeln!(output, " install_plan:"); @@ -16028,6 +16030,7 @@ fn apply_runtime_update( &source.format, &source.family, plan.device_target.as_deref(), + plan.source_layout_generation.as_deref(), false, )?; let manifests_after = therock::load_runtime_manifests(paths)?; @@ -30639,6 +30642,7 @@ ID_LIKE="suse opensuse" version: version.to_owned(), install_root: install_root.clone(), selected_artifact_url: "https://example.invalid/therock".to_owned(), + source_layout_generation: None, index_url: Some("https://example.invalid/therock".to_owned()), tarball_file_name: None, python_launcher: Some("python".to_owned()), @@ -30697,6 +30701,7 @@ ID_LIKE="suse opensuse" version: version.to_owned(), install_root: PathBuf::from("runtime-root"), selected_artifact_url: "https://example.invalid/therock".to_owned(), + source_layout_generation: None, index_url: Some("https://example.invalid/therock".to_owned()), tarball_file_name: None, python_launcher: Some("python".to_owned()), diff --git a/apps/rocm/src/storage.rs b/apps/rocm/src/storage.rs index 7c42e4fa4..efaac9b36 100644 --- a/apps/rocm/src/storage.rs +++ b/apps/rocm/src/storage.rs @@ -962,6 +962,7 @@ mod tests { version: version.to_owned(), install_root: PathBuf::from("rocm-storage-test").join(runtime_key), selected_artifact_url: "https://example.invalid/therock".to_owned(), + source_layout_generation: None, index_url: None, tarball_file_name: None, python_launcher: None, diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index e6514bb64..e54bc92f9 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -5,9 +5,9 @@ use anyhow::{Context, Result, bail}; use rocm_core::{ AppPaths, ManagedToolConfig, RocmCliConfig, detect_host_gfx_target, - detect_host_gpu_diagnostics, detect_host_therock_family, detect_managed_therock_family, - disk_space, ensure_uv_binary, extract_first_gfx_token, known_therock_families, - managed_tools_dir, normalize_runtime_path_for_host, normalize_runtime_path_for_storage, + detect_host_gpu_diagnostics, detect_managed_therock_family, disk_space, ensure_uv_binary, + extract_first_gfx_token, known_therock_families, managed_tools_dir, + normalize_runtime_path_for_host, normalize_runtime_path_for_storage, normalize_runtime_path_text_for_host, normalize_runtime_path_text_for_storage, normalize_therock_family, runtime_is_windows, runtime_os_name, runtime_path_for_windows_child, runtime_path_list_split, runtime_python_executable_in_env, unix_time_millis, uv_command_env, @@ -32,7 +32,23 @@ const THEROCK_NIGHTLY_PIP_INDEX_BASE: &str = "https://rocm.nightlies.amd.com/whl const THEROCK_RELEASE_PIP_INDEX_BASE: &str = "https://repo.amd.com/rocm/whl-multi-arch"; const THEROCK_RELEASE_TARBALL_BASE: &str = "https://repo.amd.com/rocm/tarball/"; const THEROCK_NIGHTLY_TARBALL_BASE: &str = "https://rocm.nightlies.amd.com/tarball/"; +/// ROCm 10's aggregate pip index. Same simple-index shape the canonical +/// multi-arch stream publishes, on a separate host, with its own package +/// generation; it is not a mirror of, or a fallback for, the canonical index. +const THEROCK_NEXT_PIP_INDEX_BASE: &str = "https://stable.repo.amd.com/rocm/whl-next"; +/// ROCm 10's tarball catalog. Same scrapeable listing the canonical catalog +/// uses, but it publishes non-release sibling archives beside the real dist +/// archive (see [`select_tarball_candidate`]). +const THEROCK_NEXT_TARBALL_BASE: &str = "https://stable.repo.amd.com/rocm/core/tarball/"; const THEROCK_SOURCE_LAYOUT_GENERATION: &str = "multi-arch-v2"; +/// Recorded in a [`WheelRuntimeComposition`] installed from the ROCm 10 layout, +/// so a later update reads back the layout that produced the runtime instead of +/// guessing it from the version. +const THEROCK_NEXT_LAYOUT_GENERATION: &str = "next-v1"; +/// The first ROCm major version published only in the next layout. A pin at or +/// past it is the one request the canonical stream provably cannot serve, and +/// therefore the only thing that selects [`SourceLayout::Next`]. +const THEROCK_NEXT_MIN_MAJOR: u32 = 10; const DEFAULT_MANAGED_PYTHON_VERSION: &str = "3.12"; const STARTUP_UPDATE_CHECK_INTERVAL_MS: u128 = 12 * 60 * 60 * 1_000; const STARTUP_UPDATE_CHECK_TIMEOUT_SECS: u64 = 2; @@ -54,11 +70,54 @@ enum TheRockChannel { Nightly, } +/// Which published hosting layout a resolution reads from. +/// +/// Orthogonal to [`TheRockChannel`]. `Canonical` is the release/nightly stream +/// the CLI has always installed and remains the answer for every request that +/// does not explicitly ask for something else. `Next` is ROCm 10's separately +/// hosted layout — an extension, never a fallback: nothing degrades into it and +/// nothing degrades out of it, so a canonical install resolves exactly the URLs, +/// device target, and package specs it resolved before the layout existed. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct CanonicalSource { - wheel_index: &'static str, - tarball_catalog: &'static str, - layout_generation: &'static str, +enum SourceLayout { + Canonical, + Next, +} + +impl SourceLayout { + const fn generation(self) -> &'static str { + match self { + Self::Canonical => THEROCK_SOURCE_LAYOUT_GENERATION, + Self::Next => THEROCK_NEXT_LAYOUT_GENERATION, + } + } + + /// The layout a recorded [`WheelRuntimeComposition`] was installed from. + /// + /// Manifests written before compositions existed carry no generation and + /// therefore came from the canonical stream. A non-empty generation is an + /// explicit provenance contract: reject values this build cannot reproduce + /// instead of silently redirecting an update to the canonical source. + fn from_generation(generation: Option<&str>) -> Result { + match generation { + None | Some(THEROCK_SOURCE_LAYOUT_GENERATION) => Ok(Self::Canonical), + Some(THEROCK_NEXT_LAYOUT_GENERATION) => Ok(Self::Next), + Some(other) => bail!( + "runtime records unsupported TheRock source layout generation `{other}`; update rocm-cli before updating this runtime" + ), + } + } +} + +/// The concrete bases one (channel, layout) pair resolves artifacts from. +/// +/// Owns its strings because every base is overridable for fixture testing; see +/// [`env_override_base`]. +#[derive(Clone, Debug, Eq, PartialEq)] +struct ResolvedSource { + wheel_index: String, + tarball_catalog: String, + layout: SourceLayout, } #[derive(Debug, Clone, Eq, PartialEq)] @@ -119,27 +178,135 @@ impl TheRockChannel { Self::Nightly => "nightly", } } +} + +/// Whether a `ROCM_CLI_THEROCK_*_BASE` override is honoured at all. +/// +/// Every artifact base is a trust boundary: a stray override left in a shell +/// profile would silently redirect a real install to an untrusted host. Reading +/// them requires a second, deliberate opt-in that no normal install sets, so the +/// overrides exist for fixture servers and operators who mean it, and are inert +/// otherwise. See `docs/release-trust.md`. +fn therock_base_override_allowed() -> bool { + std::env::var("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE") + .ok() + .is_some_and(|value| !value.trim().is_empty()) +} - const fn canonical_source(self) -> CanonicalSource { - canonical_source(self) +/// The base to use, given the opt-in flag and whatever the per-base variable +/// says. Pure so the trust rule is stated once and can be checked without +/// mutating process environment from a test. +fn select_base_override(allowed: bool, override_value: Option<&str>, default: &str) -> String { + if !allowed { + return default.to_owned(); } + override_value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map_or_else(|| default.to_owned(), str::to_owned) } -const fn canonical_source(channel: TheRockChannel) -> CanonicalSource { - match channel { - TheRockChannel::Release => CanonicalSource { - wheel_index: THEROCK_RELEASE_PIP_INDEX_BASE, - tarball_catalog: THEROCK_RELEASE_TARBALL_BASE, - layout_generation: THEROCK_SOURCE_LAYOUT_GENERATION, +fn env_override_base(env_var: &str, default: &str) -> String { + select_base_override( + therock_base_override_allowed(), + std::env::var(env_var).ok().as_deref(), + default, + ) +} + +/// The bases `channel` publishes under `layout`. +/// +/// The next layout is hosted per-layout rather than per-channel: it has no +/// nightly stream, and [`select_source_layout`] refuses to reach it from one, so +/// there is nothing here to branch on. +fn resolve_source(channel: TheRockChannel, layout: SourceLayout) -> ResolvedSource { + match layout { + SourceLayout::Canonical => match channel { + TheRockChannel::Release => ResolvedSource { + wheel_index: env_override_base( + "ROCM_CLI_THEROCK_RELEASE_PIP_BASE", + THEROCK_RELEASE_PIP_INDEX_BASE, + ), + tarball_catalog: env_override_base( + "ROCM_CLI_THEROCK_RELEASE_TARBALL_BASE", + THEROCK_RELEASE_TARBALL_BASE, + ), + layout, + }, + TheRockChannel::Nightly => ResolvedSource { + wheel_index: env_override_base( + "ROCM_CLI_THEROCK_NIGHTLY_PIP_BASE", + THEROCK_NIGHTLY_PIP_INDEX_BASE, + ), + tarball_catalog: env_override_base( + "ROCM_CLI_THEROCK_NIGHTLY_TARBALL_BASE", + THEROCK_NIGHTLY_TARBALL_BASE, + ), + layout, + }, }, - TheRockChannel::Nightly => CanonicalSource { - wheel_index: THEROCK_NIGHTLY_PIP_INDEX_BASE, - tarball_catalog: THEROCK_NIGHTLY_TARBALL_BASE, - layout_generation: THEROCK_SOURCE_LAYOUT_GENERATION, + SourceLayout::Next => ResolvedSource { + wheel_index: env_override_base( + "ROCM_CLI_THEROCK_NEXT_PIP_BASE", + THEROCK_NEXT_PIP_INDEX_BASE, + ), + tarball_catalog: env_override_base( + "ROCM_CLI_THEROCK_NEXT_TARBALL_BASE", + THEROCK_NEXT_TARBALL_BASE, + ), + layout, }, } } +/// Whether `selector` names a ROCm release that only the next layout publishes. +/// +/// An exact pin is the only signal that qualifies. A build date names a nightly +/// build, an unparseable string names nothing this CLI can reason about, and +/// neither is evidence that the canonical stream cannot serve the request. +fn next_layout_requested(selector: &RuntimeVersionSelector) -> bool { + let RuntimeVersionSelector::Version(version) = selector else { + return false; + }; + parse_version(version).is_some_and(|parsed| parsed.major >= THEROCK_NEXT_MIN_MAJOR) +} + +/// The layout an install must read from, and the refusal when it cannot. +/// +/// Only an explicit pin at ROCm >= [`THEROCK_NEXT_MIN_MAJOR`] selects `Next`, +/// because that is the one request the canonical stream provably cannot serve. +/// No selector, a build date, an unparseable pin, or an older pin all stay +/// canonical, so nothing that resolves today starts resolving somewhere else. +/// A pin that does reach `Next` still needs an exact arch, because that layout +/// picks its device payload by exact arch and a grouped family names none. +fn select_source_layout( + channel: TheRockChannel, + family_resolution: &FamilyResolution, + version_selector: Option<&RuntimeVersionSelector>, +) -> Result { + let Some(selector) = version_selector.filter(|selector| next_layout_requested(selector)) else { + return Ok(SourceLayout::Canonical); + }; + let RuntimeVersionSelector::Version(version) = selector else { + return Ok(SourceLayout::Canonical); + }; + let major = parse_version(version).map_or(THEROCK_NEXT_MIN_MAJOR, |parsed| parsed.major); + if !matches!(channel, TheRockChannel::Release) { + bail!( + "ROCm {major} and newer is published only on the release channel; re-run `rocm install sdk --channel release --version {version}`" + ); + } + if family_resolution.raw_arch.is_none() { + bail!( + "installing ROCm {major} requires an exact GPU arch, but the resolved target family `{}` names a group of them.\n\ + ROCm {major} selects its device payload by exact arch, so re-run with the one this host has, for example `rocm install sdk --version {version} --family gfx1200`.\n\n{}", + family_resolution.family, + detect_host_gpu_diagnostics() + ); + } + Ok(SourceLayout::Next) +} + fn render_canonical_provenance( output: &mut String, channel: TheRockChannel, @@ -245,7 +412,8 @@ enum AggregateDeviceTarget { #[derive(Debug, Clone, Eq, PartialEq)] struct ResolvedAggregateWheelSource { - index_url: &'static str, + index_url: String, + layout: SourceLayout, device_target: AggregateDeviceTarget, published_device_targets: Vec, } @@ -307,6 +475,13 @@ impl AggregateDeviceTarget { struct FamilyResolution { family: String, source: String, + /// The exact GFX arch behind `family`, when one is genuinely known — + /// `gfx1200`, never the group `gfx120X-all`. + /// + /// A grouped family names no single arch, and the next layout selects its + /// device payload by exact arch, so `None` here is what makes a ROCm 10 pin + /// refuse instead of installing a runtime with the wrong kernels. + raw_arch: Option, } #[derive(Debug, Clone)] @@ -314,12 +489,15 @@ struct PipRuntimeResolution { family: String, family_source: String, index_url: String, + /// The layout `index_url` belongs to, carried forward so the composition + /// this resolution produces records the stream that produced it. + layout: SourceLayout, latest_version: String, package_versions: TheRockPipPackageVersions, - /// The device payload the canonical source must supply for this host, + /// The device payload the resolved source must supply for this host, /// decided against the targets that source actually publishes. device_target: AggregateDeviceTarget, - /// Exact device payloads advertised by the canonical aggregate source. + /// Exact device payloads advertised by the resolved aggregate source. published_device_targets: Vec, } @@ -342,6 +520,11 @@ struct WheelCompatibility { struct TarballArtifact { family: String, family_source: String, + /// The catalog this archive was listed in, and which layout that catalog + /// is, so provenance reports the stream the artifact actually came from + /// rather than re-deriving it from the channel. + catalog_url: String, + layout: SourceLayout, file_name: String, version: String, url: String, @@ -434,6 +617,9 @@ pub(crate) struct RuntimeUpdatePlan { pub target_runtime_key: String, /// Exact device payload encoded in the planned wheel composition. pub device_target: Option, + /// The layout this runtime was installed from, carried into the apply so a + /// runtime never silently migrates between streams when it updates. + pub source_layout_generation: Option, pub repair_required: bool, pub update_available: bool, } @@ -480,6 +666,7 @@ struct ResolvedRuntimeUpdate { latest_source: String, target_runtime_key: String, format: String, + source_layout_generation: String, wheel_composition: Option, } @@ -494,6 +681,15 @@ pub(crate) struct InstalledRuntimeManifest { pub version: String, pub install_root: PathBuf, pub selected_artifact_url: String, + /// Which published layout this runtime's artifact came from. + /// + /// Provenance, not identity: an update reads it so a runtime resolves the + /// stream it was installed from instead of whichever stream the channel + /// happens to default to. Absent on every manifest written before the + /// layout existed, which reads back as the canonical stream those runtimes + /// came from. + #[serde(default)] + pub source_layout_generation: Option, #[serde(default)] pub index_url: Option, #[serde(default)] @@ -711,6 +907,12 @@ enum VersionStage { Stable, } +#[derive(Clone, Copy, Debug, Default)] +struct InstallSourceOverride<'a> { + family: Option<&'a str>, + device_target: Option<&'a str>, + layout: Option, +} pub(crate) fn install_sdk( paths: &AppPaths, channel: &str, @@ -727,43 +929,67 @@ pub(crate) fn install_sdk( paths, channel, prefix, - family_override, - None, + InstallSourceOverride { + family: family_override, + ..InstallSourceOverride::default() + }, version_selector.as_ref(), dry_run, ), "tarball" => { - if version_selector.is_some() { + // A tarball catalog lists whole archives, not a resolvable package + // graph, so there is nothing to pin a version against — except the + // ROCm 10 catalog, which is reachable *only* by naming a version, + // because nothing else distinguishes it from the canonical one. + if let Some(selector) = version_selector.as_ref() + && !next_layout_requested(selector) + { bail!("specific TheRock version selection is only supported for wheel installs") } - install_tarball_runtime(paths, channel, prefix, family_override, dry_run) + install_tarball_runtime( + paths, + channel, + prefix, + family_override, + version_selector.as_ref(), + None, + dry_run, + ) } other => bail!("unsupported install format: {other}"), } } -/// Apply an update using the exact family and device payload resolved by its plan. +/// Apply an update using the exact family, device payload, and source layout +/// resolved by its plan. pub(crate) fn install_sdk_for_update( paths: &AppPaths, channel: &str, format: &str, family: &str, device_target: Option<&str>, + source_layout_generation: Option<&str>, dry_run: bool, ) -> Result { let channel = TheRockChannel::parse(channel)?; ensure_install_format_supported(format)?; + let layout = Some(SourceLayout::from_generation(source_layout_generation)?); match format { "wheel" => install_wheel_runtime( paths, channel, None, - Some(family), - device_target, + InstallSourceOverride { + family: Some(family), + device_target, + layout, + }, None, dry_run, ), - "tarball" => install_tarball_runtime(paths, channel, None, Some(family), dry_run), + "tarball" => { + install_tarball_runtime(paths, channel, None, Some(family), None, layout, dry_run) + } other => bail!("unsupported install format: {other}"), } } @@ -974,17 +1200,35 @@ pub(crate) fn runtime_update_plan( status: freshness.status().to_owned(), target_runtime_key: latest.target_runtime_key, device_target, + source_layout_generation: Some(latest.source_layout_generation), repair_required: freshness == RuntimeFreshness::RepairAvailable, update_available: freshness.update_available(), }) } +/// The layout a manifest's artifact came from. +/// +/// Manifests written before the field existed fall back to the generation their +/// wheel composition recorded, and manifests older than compositions fall back +/// to the canonical stream, which is where they came from. No URL is inspected: +/// a base can be overridden or rehosted, and the recorded generation is the only +/// statement about the stream that survives that. +fn manifest_source_layout(manifest: &InstalledRuntimeManifest) -> Result { + SourceLayout::from_generation(manifest.source_layout_generation.as_deref().or_else(|| { + manifest + .wheel_composition + .as_ref() + .map(|composition| composition.source_layout_generation.as_str()) + })) +} + fn resolve_latest_for_manifest( paths: &AppPaths, manifest: &InstalledRuntimeManifest, download_timeout_secs: Option, ) -> Result { let channel = TheRockChannel::parse(&manifest.channel)?; + let layout = manifest_source_layout(manifest)?; match manifest.format.as_str() { "wheel" => { let manifest_python = manifest @@ -1008,6 +1252,7 @@ fn resolve_latest_for_manifest( Some(manifest.family.as_str()), &wheel_compatibility, None, + Some(layout), download_timeout_secs, )?; // Prefer the device payload this runtime was actually built with over @@ -1043,6 +1288,7 @@ fn resolve_latest_for_manifest( latest_source: resolution.index_url, target_runtime_key, format: "wheel".to_owned(), + source_layout_generation: resolution.layout.generation().to_owned(), wheel_composition, }) } @@ -1051,6 +1297,8 @@ fn resolve_latest_for_manifest( paths, channel, Some(manifest.family.as_str()), + None, + Some(layout), download_timeout_secs, )?; let target_runtime_key = runtime_key( @@ -1064,6 +1312,7 @@ fn resolve_latest_for_manifest( latest_source: artifact.url, target_runtime_key, format: "tarball".to_owned(), + source_layout_generation: artifact.layout.generation().to_owned(), wheel_composition: None, }) } @@ -1206,11 +1455,15 @@ fn install_wheel_runtime( paths: &AppPaths, channel: TheRockChannel, prefix: Option, - family_override: Option<&str>, - device_target_override: Option<&str>, + source_override: InstallSourceOverride<'_>, version_selector: Option<&RuntimeVersionSelector>, dry_run: bool, ) -> Result { + let InstallSourceOverride { + family: family_override, + device_target: device_target_override, + layout: layout_override, + } = source_override; progress_line(format!( "Checking Python for the ROCm install; if needed, ROCm CLI will prepare Python {}.", managed_python_version() @@ -1245,6 +1498,7 @@ fn install_wheel_runtime( family_override, &wheel_compatibility, version_selector, + layout_override, )?; let device_target = device_target_override.map_or_else( || resolution.device_target.clone(), @@ -1276,12 +1530,11 @@ fn install_wheel_runtime( output, " summary: rocm-cli will install the ROCm SDK and matching PyTorch packages for this Python and operating system" ); - let source = channel.canonical_source(); render_canonical_provenance( &mut output, channel, - source.wheel_index, - source.layout_generation, + &resolution.index_url, + resolution.layout.generation(), &resolution.latest_version, ); let _ = writeln!(output, " format: wheel"); @@ -1436,6 +1689,7 @@ fn install_wheel_runtime( version: installed_version.clone(), install_root: install_root.clone(), selected_artifact_url: resolution.index_url.clone(), + source_layout_generation: Some(resolution.layout.generation().to_owned()), index_url: Some(resolution.index_url.clone()), tarball_file_name: None, python_launcher: Some(python_launcher.executable.display().to_string()), @@ -1506,7 +1760,7 @@ fn wheel_runtime_composition( device_target: &AggregateDeviceTarget, ) -> WheelRuntimeComposition { WheelRuntimeComposition { - source_layout_generation: THEROCK_SOURCE_LAYOUT_GENERATION.to_owned(), + source_layout_generation: resolution.layout.generation().to_owned(), package_specs: therock_pip_package_specs( &resolution.package_versions, device_target.as_str(), @@ -1552,9 +1806,17 @@ fn install_tarball_runtime( channel: TheRockChannel, prefix: Option, family_override: Option<&str>, + version_selector: Option<&RuntimeVersionSelector>, + layout_override: Option, dry_run: bool, ) -> Result { - let artifact = resolve_tarball_artifact(paths, channel, family_override)?; + let artifact = resolve_tarball_artifact( + paths, + channel, + family_override, + version_selector, + layout_override, + )?; let runtime_key = runtime_key( channel, "tarball", @@ -1567,12 +1829,11 @@ fn install_tarball_runtime( let mut output = String::new(); let _ = writeln!(output, "sdk install"); - let source = channel.canonical_source(); render_canonical_provenance( &mut output, channel, - source.tarball_catalog, - source.layout_generation, + &artifact.catalog_url, + artifact.layout.generation(), &artifact.version, ); let _ = writeln!(output, " format: tarball"); @@ -1626,6 +1887,7 @@ fn install_tarball_runtime( version: artifact.version.clone(), install_root: install_root.clone(), selected_artifact_url: artifact.url.clone(), + source_layout_generation: Some(artifact.layout.generation().to_owned()), index_url: None, tarball_file_name: Some(artifact.file_name.clone()), python_launcher: None, @@ -1651,6 +1913,7 @@ fn resolve_pip_runtime( family_override: Option<&str>, wheel_compatibility: &WheelCompatibility, version_selector: Option<&RuntimeVersionSelector>, + layout_override: Option, ) -> Result { resolve_pip_runtime_with_timeout( paths, @@ -1658,28 +1921,38 @@ fn resolve_pip_runtime( family_override, wheel_compatibility, version_selector, + layout_override, None, ) } +/// `layout_override` is how an update keeps a runtime on the stream it was +/// installed from; a fresh install passes `None` and lets the request decide. +#[allow(clippy::too_many_arguments)] fn resolve_pip_runtime_with_timeout( paths: &AppPaths, channel: TheRockChannel, family_override: Option<&str>, wheel_compatibility: &WheelCompatibility, version_selector: Option<&RuntimeVersionSelector>, + layout_override: Option, download_timeout_secs: Option, ) -> Result { let family_resolution = resolve_family(paths, family_override)?; - let source = channel.canonical_source(); + let layout = match layout_override { + Some(layout) => layout, + None => select_source_layout(channel, &family_resolution, version_selector)?, + }; + let source = resolve_source(channel, layout); let root_url = format!("{}/", source.wheel_index.trim_end_matches('/')); - let root_html = download_text_cached( - paths, - &format!("canonical-wheel-root-{}", channel.as_str()), - &root_url, - download_timeout_secs, - )? - .text; + // Distinct cache keys per layout: the two streams answer the same request + // with different package sets, so one must never serve the other's listing. + let root_cache_key = match layout { + SourceLayout::Canonical => format!("canonical-wheel-root-{}", channel.as_str()), + SourceLayout::Next => format!("next-wheel-root-{}", channel.as_str()), + }; + let root_html = + download_text_cached(paths, &root_cache_key, &root_url, download_timeout_secs)?.text; validate_aggregate_index_layout(&root_html).with_context(|| { format!( "failed to resolve TheRock {} wheel runtime from canonical source {}", @@ -1688,10 +1961,20 @@ fn resolve_pip_runtime_with_timeout( ) })?; let published_device_targets = parse_aggregate_device_targets(&root_html); + // The canonical stream validates against what this host reports, exactly as + // before. The next layout is reachable only through an explicit pin that + // already carried a validated exact arch, and that arch — not a second probe + // of whatever card happens to be plugged in — is what the pin asked to + // install for. + let detected_target = match layout { + SourceLayout::Canonical => detect_host_gfx_target(), + SourceLayout::Next => family_resolution.raw_arch.clone(), + }; let source = ResolvedAggregateWheelSource { index_url: source.wheel_index, + layout, device_target: AggregateDeviceTarget::resolve( - detect_host_gfx_target().as_deref(), + detected_target.as_deref(), &family_resolution.family, &published_device_targets, ), @@ -1725,7 +2008,7 @@ fn resolve_pip_runtime_from_index( version_selector: Option<&RuntimeVersionSelector>, download_timeout_secs: Option, ) -> Result { - let index_url = source.index_url; + let index_url = source.index_url.as_str(); let rocm_versions = load_simple_index_versions(paths, index_url, "rocm", None, download_timeout_secs)?; if matches!(channel, TheRockChannel::Release) @@ -1778,6 +2061,7 @@ fn resolve_pip_runtime_from_index( family: family_resolution.family.clone(), family_source: family_resolution.source.clone(), index_url: index_url.to_owned(), + layout: source.layout, latest_version, package_versions, device_target: source.device_target.clone(), @@ -1789,22 +2073,41 @@ fn resolve_tarball_artifact( paths: &AppPaths, channel: TheRockChannel, family_override: Option<&str>, + version_selector: Option<&RuntimeVersionSelector>, + layout_override: Option, ) -> Result { - resolve_tarball_artifact_with_timeout(paths, channel, family_override, None) + resolve_tarball_artifact_with_timeout( + paths, + channel, + family_override, + version_selector, + layout_override, + None, + ) } fn resolve_tarball_artifact_with_timeout( paths: &AppPaths, channel: TheRockChannel, family_override: Option<&str>, + version_selector: Option<&RuntimeVersionSelector>, + layout_override: Option, download_timeout_secs: Option, ) -> Result { let family_resolution = resolve_family(paths, family_override)?; - let source = channel.canonical_source(); + let layout = match layout_override { + Some(layout) => layout, + None => select_source_layout(channel, &family_resolution, version_selector)?, + }; + let source = resolve_source(channel, layout); + let catalog_cache_key = match layout { + SourceLayout::Canonical => format!("tarball-index-{}", channel.as_str()), + SourceLayout::Next => format!("next-tarball-index-{}", channel.as_str()), + }; let html = download_text_cached( paths, - &format!("tarball-index-{}", channel.as_str()), - source.tarball_catalog, + &catalog_cache_key, + &source.tarball_catalog, download_timeout_secs, )? .text; @@ -1814,19 +2117,25 @@ fn resolve_tarball_artifact_with_timeout( source.tarball_catalog ) })?; - let (file, version) = select_tarball_candidate(&files, channel, &family_resolution.family) - .with_context(|| { - format!( - "canonical TheRock {} tarball stream is incomplete for the resolved GPU family\n\n{}", - channel.as_str(), - family_resolution_hint( - &family_resolution.source, - &family_resolution.family, - channel, - "tarball", - ) + let (file, version) = select_tarball_candidate( + &files, + channel, + layout, + &family_resolution.family, + version_selector, + ) + .with_context(|| { + format!( + "canonical TheRock {} tarball stream is incomplete for the resolved GPU family\n\n{}", + channel.as_str(), + family_resolution_hint( + &family_resolution.source, + &family_resolution.family, + channel, + "tarball", ) - })?; + ) + })?; Ok(TarballArtifact { family: family_resolution.family, family_source: family_resolution.source, @@ -1835,17 +2144,48 @@ fn resolve_tarball_artifact_with_timeout( source.tarball_catalog.trim_end_matches('/'), file.name ), + catalog_url: source.tarball_catalog, + layout, file_name: file.name, version, }) } +/// The filename token a catalog spells `family` with. +/// +/// ROCm 10's catalog renamed exactly one family's archive token; every other +/// family keeps its canonical spelling. Confined here rather than pushed into +/// [`normalize_therock_family`] so a filename quirk cannot leak into the family +/// a manifest records or the extras a wheel install requests. +fn tarball_family_token(layout: SourceLayout, family: &str) -> &str { + if matches!(layout, SourceLayout::Next) && family == "gfx103X-dgpu" { + "gfx103X-all" + } else { + family + } +} + +/// The newest archive in `files` that this request can actually install. +/// +/// The release channel's stable-version filter does double duty on the next +/// catalog: that catalog publishes non-release siblings beside the real dist +/// archive (`...-tests-10.0.0.tar.gz`) whose leftover suffix is not a version at +/// all, and whose mtime is *later* than the archive they shadow, so an unfiltered +/// "newest wins" would pick the wrong file. Requiring a parseable stable version +/// excludes them without inventing a second grammar for the canonical catalogs, +/// which publish no such siblings and have never been held to one. fn select_tarball_candidate( files: &[TarballIndexFile], channel: TheRockChannel, + layout: SourceLayout, family: &str, + version_selector: Option<&RuntimeVersionSelector>, ) -> Option<(TarballIndexFile, String)> { - let prefix = format!("therock-dist-{}-{family}-", platform_tarball_token()); + let prefix = format!( + "therock-dist-{}-{}-", + platform_tarball_token(), + tarball_family_token(layout, family) + ); let mut candidates = files .iter() .filter_map(|file| { @@ -1857,6 +2197,9 @@ fn select_tarball_candidate( if matches!(channel, TheRockChannel::Release) && !is_stable_runtime_version(&version) { return None; } + if version_selector.is_some_and(|selector| !selector.matches_version(&version)) { + return None; + } Some((file.clone(), version)) }) .collect::>(); @@ -1870,6 +2213,43 @@ fn select_tarball_candidate( candidates.pop() } +/// Whether `value` is an exact GFX arch code (`gfx1200`, `gfx90a`) rather than +/// a grouped family label (`gfx120X-all`) or prose with a gfx token in it. +/// +/// Deliberately stricter than [`extract_first_gfx_token`], which digs a target +/// out of noisy KFD output: a `--family` value is only evidence of an exact arch +/// when the user typed exactly one, and `gfx120X-all` normalizing to a family +/// must not be mistaken for naming a member of it. +fn is_raw_gfx_arch_code(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let Some(rest) = lower.strip_prefix("gfx") else { + return false; + }; + let Some(last) = rest.len().checked_sub(1) else { + return false; + }; + rest.bytes() + .enumerate() + .all(|(index, byte)| byte.is_ascii_digit() || (index == last && byte.is_ascii_lowercase())) +} + +fn family_override_raw_arch(value: &str) -> Option { + let trimmed = value.trim(); + is_raw_gfx_arch_code(trimmed).then(|| trimmed.to_ascii_lowercase()) +} + +/// A host-probed arch, kept only when it belongs to the family some more +/// authoritative source already resolved. +/// +/// A managed runtime's manifest records the family but never the arch, so the +/// arch can only be recovered by probing this host. That probe is trustworthy +/// exactly when it agrees: a second card, or a machine the runtime was moved to, +/// reports an arch from a different family, and installing its device payload +/// would produce a runtime that cannot run the family's kernels. +fn raw_arch_agreeing_with_family(raw_arch: Option, family: &str) -> Option { + raw_arch.filter(|raw| normalize_therock_family(raw).as_deref() == Some(family)) +} + fn resolve_family(paths: &AppPaths, family_override: Option<&str>) -> Result { if let Some(value) = family_override && let Some(family) = normalize_therock_family(value) @@ -1877,6 +2257,7 @@ fn resolve_family(paths: &AppPaths, family_override: Option<&str>) -> Result) -> Result = std::sync::Mutex::new(()); + fn family_for_test(family: &str, raw_arch: Option<&str>) -> FamilyResolution { + FamilyResolution { + family: family.to_owned(), + source: "manifest".to_owned(), + raw_arch: raw_arch.map(str::to_owned), + } + } + + /// Everything a user can ask for today must keep resolving the canonical + /// stream. The next layout is an extension, so no existing request may start + /// answering from somewhere else. + #[test] + fn only_a_rocm_10_pin_leaves_the_canonical_layout() { + let exact = family_for_test("gfx120X-all", Some("gfx1200")); + let build_date = RuntimeVersionSelector::build_date("2026-06-05").unwrap(); + let older_pin = RuntimeVersionSelector::version("7.14.0").unwrap(); + let unparseable = RuntimeVersionSelector::version("main").unwrap(); + + for selector in [ + None, + Some(&build_date), + Some(&older_pin), + Some(&unparseable), + ] { + assert_eq!( + select_source_layout(TheRockChannel::Release, &exact, selector).unwrap(), + SourceLayout::Canonical, + "selector {selector:?} must stay canonical" + ); + } + + let pin = RuntimeVersionSelector::version("10.0.0").unwrap(); + assert_eq!( + select_source_layout(TheRockChannel::Release, &exact, Some(&pin)).unwrap(), + SourceLayout::Next + ); + } + + /// A grouped family names no single arch, and the next layout has no + /// grouped device payload to fall back to, so the refusal has to name the + /// flag and a value that would work. + #[test] + fn rocm_10_pin_without_an_exact_arch_refuses_actionably() { + let grouped = family_for_test("gfx120X-all", None); + let pin = RuntimeVersionSelector::version("10.0.0").unwrap(); + + let error = select_source_layout(TheRockChannel::Release, &grouped, Some(&pin)) + .expect_err("a grouped family cannot select a device payload") + .to_string(); + + assert!(error.contains("requires an exact GPU arch"), "{error}"); + assert!(error.contains("--family gfx1200"), "{error}"); + } + + #[test] + fn rocm_10_pin_on_nightly_refuses_instead_of_resolving() { + let exact = family_for_test("gfx120X-all", Some("gfx1200")); + let pin = RuntimeVersionSelector::version("10.0.0").unwrap(); + + let error = select_source_layout(TheRockChannel::Nightly, &exact, Some(&pin)) + .expect_err("the next layout has no nightly stream") + .to_string(); + + assert!(error.contains("--channel release"), "{error}"); + } + + #[test] + fn next_layout_resolves_its_own_bases() { + let _guard = PROCESS_ENV_TEST_LOCK.lock().unwrap(); + let next = resolve_source(TheRockChannel::Release, SourceLayout::Next); + + assert_eq!( + next.wheel_index, + "https://stable.repo.amd.com/rocm/whl-next" + ); + assert_eq!( + next.tarball_catalog, + "https://stable.repo.amd.com/rocm/core/tarball/" + ); + assert_eq!(next.layout.generation(), "next-v1"); + } + + /// A base override is a redirect to an arbitrary host, so naming the + /// variable must not be enough on its own. + #[test] + fn base_overrides_are_inert_without_the_explicit_opt_in() { + let default = THEROCK_NEXT_PIP_INDEX_BASE; + let fixture = "http://127.0.0.1:9/whl"; + + // Naming the variable is not enough; without the opt-in it is ignored. + assert_eq!(select_base_override(false, Some(fixture), default), default); + assert_eq!( + select_base_override(true, Some(fixture), default), + fixture, + "an opted-in override must be honoured" + ); + // A variable that is present but blank is not a redirect. + assert_eq!(select_base_override(true, Some(" "), default), default); + assert_eq!(select_base_override(true, None, default), default); + } + + #[test] + fn family_override_raw_arch_accepts_only_exact_arch_codes() { + assert_eq!( + family_override_raw_arch("gfx1200"), + Some("gfx1200".to_owned()) + ); + assert_eq!( + family_override_raw_arch(" gfx90a "), + Some("gfx90a".to_owned()) + ); + assert_eq!( + family_override_raw_arch("GFX1250"), + Some("gfx1250".to_owned()) + ); + assert_eq!(family_override_raw_arch("gfx120X-all"), None); + assert_eq!(family_override_raw_arch("gfx103X-dgpu"), None); + assert_eq!( + family_override_raw_arch("gpu target gfx1030 detected"), + None + ); + assert_eq!(family_override_raw_arch("gfx"), None); + assert_eq!(family_override_raw_arch(""), None); + } + + /// A managed runtime records only its family, so the arch has to come from + /// a host probe — and is worth nothing when the host is a different card. + #[test] + fn managed_family_keeps_only_a_host_arch_from_the_same_family() { + assert_eq!( + raw_arch_agreeing_with_family(Some("gfx1030".to_owned()), "gfx103X-dgpu"), + Some("gfx1030".to_owned()) + ); + assert_eq!( + raw_arch_agreeing_with_family(Some("gfx90a".to_owned()), "gfx103X-dgpu"), + None + ); + assert_eq!(raw_arch_agreeing_with_family(None, "gfx103X-dgpu"), None); + } + + /// The next catalog lists a `-tests-` archive beside the real one with a + /// *later* mtime, so plain "newest wins" picks the wrong file. Its leftover + /// suffix is not a version, which is what excludes it. + #[test] + fn next_tarball_selection_skips_the_tests_sibling() { + let platform = platform_tarball_token(); + let prefix = format!("therock-dist-{platform}-gfx90a-"); + let files = vec![ + TarballIndexFile { + name: format!("{prefix}10.0.0.tar.gz"), + mtime: 1_787_612_008.0, + }, + TarballIndexFile { + name: format!("{prefix}tests-10.0.0.tar.gz"), + mtime: 1_787_612_032.0, + }, + ]; + + let (file, version) = select_tarball_candidate( + &files, + TheRockChannel::Release, + SourceLayout::Next, + "gfx90a", + None, + ) + .expect("the real dist archive must be selected"); + + assert_eq!(file.name, format!("{prefix}10.0.0.tar.gz")); + assert_eq!(version, "10.0.0"); + } + + /// A pinned tarball install must install what it named, not whatever is + /// newest in the catalog. + #[test] + fn pinned_tarball_selection_installs_the_named_version() { + let platform = platform_tarball_token(); + let prefix = format!("therock-dist-{platform}-gfx120X-all-"); + let files = vec![ + TarballIndexFile { + name: format!("{prefix}10.0.0.tar.gz"), + mtime: 1.0, + }, + TarballIndexFile { + name: format!("{prefix}10.1.0.tar.gz"), + mtime: 2.0, + }, + ]; + let pin = RuntimeVersionSelector::version("10.0.0").unwrap(); + + assert_eq!( + select_tarball_candidate( + &files, + TheRockChannel::Release, + SourceLayout::Next, + "gfx120X-all", + Some(&pin) + ) + .map(|(_, version)| version), + Some("10.0.0".to_owned()) + ); + } + + /// The next catalog spells exactly one family differently; the canonical + /// catalog and every other family are untouched, and the family a manifest + /// records never changes. + #[test] + fn next_tarball_token_renames_only_the_gfx103x_family() { + assert_eq!( + tarball_family_token(SourceLayout::Next, "gfx103X-dgpu"), + "gfx103X-all" + ); + assert_eq!( + tarball_family_token(SourceLayout::Canonical, "gfx103X-dgpu"), + "gfx103X-dgpu" + ); + assert_eq!( + tarball_family_token(SourceLayout::Next, "gfx120X-all"), + "gfx120X-all" + ); + } + + /// An installed runtime must keep resolving the stream it came from, and a + /// manifest written before the field existed must still be readable. + #[test] + fn manifest_layout_survives_older_manifests() { + let mut manifest = test_runtime_manifest("runtime-key", "therock-release:gfx120X-all", 0); + manifest.source_layout_generation = Some("next-v1".to_owned()); + assert_eq!( + manifest_source_layout(&manifest).unwrap(), + SourceLayout::Next + ); + + manifest.source_layout_generation = None; + manifest.wheel_composition = Some(WheelRuntimeComposition { + source_layout_generation: "next-v1".to_owned(), + package_specs: vec!["rocm[libraries,devel,device-gfx1200]==10.0.0".to_owned()], + rocm_sdk_target: Some("gfx1200".to_owned()), + }); + assert_eq!( + manifest_source_layout(&manifest).unwrap(), + SourceLayout::Next + ); + + manifest.wheel_composition = None; + assert_eq!( + manifest_source_layout(&manifest).unwrap(), + SourceLayout::Canonical + ); + + manifest.source_layout_generation = Some("some-future-generation".to_owned()); + let error = manifest_source_layout(&manifest).unwrap_err().to_string(); + assert!(error.contains("unsupported TheRock source layout generation")); + } + #[test] fn tarball_space_preflight_skips_when_the_download_size_is_unknown() { // No HEAD response (unroutable host) must not block an install. @@ -5002,7 +5643,7 @@ mod tests { #[test] fn canonical_channels_use_only_their_aggregate_streams() { - let release = canonical_source(TheRockChannel::Release); + let release = resolve_source(TheRockChannel::Release, SourceLayout::Canonical); assert_eq!( release.wheel_index, "https://repo.amd.com/rocm/whl-multi-arch" @@ -5011,9 +5652,9 @@ mod tests { release.tarball_catalog, "https://repo.amd.com/rocm/tarball/" ); - assert_eq!(release.layout_generation, "multi-arch-v2"); + assert_eq!(release.layout.generation(), "multi-arch-v2"); - let nightly = canonical_source(TheRockChannel::Nightly); + let nightly = resolve_source(TheRockChannel::Nightly, SourceLayout::Canonical); assert_eq!( nightly.wheel_index, "https://rocm.nightlies.amd.com/whl-multi-arch" @@ -5022,7 +5663,7 @@ mod tests { nightly.tarball_catalog, "https://rocm.nightlies.amd.com/tarball/" ); - assert_eq!(nightly.layout_generation, "multi-arch-v2"); + assert_eq!(nightly.layout.generation(), "multi-arch-v2"); } #[test] @@ -5055,26 +5696,38 @@ mod tests { ]; assert_eq!( - select_tarball_candidate(&files, TheRockChannel::Release, "gfx120X-all") - .map(|(_, version)| version), + select_tarball_candidate( + &files, + TheRockChannel::Release, + SourceLayout::Canonical, + "gfx120X-all", + None + ) + .map(|(_, version)| version), Some("7.14.0".to_owned()) ); assert_eq!( - select_tarball_candidate(&files, TheRockChannel::Nightly, "gfx120X-all") - .map(|(_, version)| version), + select_tarball_candidate( + &files, + TheRockChannel::Nightly, + SourceLayout::Canonical, + "gfx120X-all", + None + ) + .map(|(_, version)| version), Some("10.1.0a20260822".to_owned()) ); } #[test] fn canonical_provenance_reports_required_dry_run_fields() { - let source = canonical_source(TheRockChannel::Nightly); + let source = resolve_source(TheRockChannel::Nightly, SourceLayout::Canonical); let mut output = String::new(); render_canonical_provenance( &mut output, TheRockChannel::Nightly, - source.wheel_index, - source.layout_generation, + &source.wheel_index, + source.layout.generation(), "10.1.0a20260822", ); @@ -6091,13 +6744,13 @@ echo Python 3.12.10 #[test] fn stable_provenance_uses_neutral_build_date_wording() { - let source = canonical_source(TheRockChannel::Release); + let source = resolve_source(TheRockChannel::Release, SourceLayout::Canonical); let mut output = String::new(); render_canonical_provenance( &mut output, TheRockChannel::Release, - source.wheel_index, - source.layout_generation, + &source.wheel_index, + source.layout.generation(), "7.14.0", ); assert!(output.contains("build_date: not encoded in stable version")); @@ -6844,6 +7497,7 @@ echo Python 3.12.10 version: "7.13.0a20260416".to_owned(), install_root: PathBuf::from("runtime-root"), selected_artifact_url: "https://example.invalid/rocm".to_owned(), + source_layout_generation: None, index_url: Some("https://example.invalid/simple".to_owned()), tarball_file_name: None, python_launcher: Some("python".to_owned()), diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 44e385c00..539dc60cd 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -3429,10 +3429,6 @@ pub fn require_nonempty(value: &str, field_name: &str) -> Result<()> { Ok(()) } -pub fn detect_host_therock_family() -> Option { - detect_host_gfx_target().and_then(|target| normalize_therock_family(&target)) -} - pub fn detect_host_gpu_summary(paths: Option<&AppPaths>) -> HostGpuSummary { detect_host_gpu_summary_fast(paths) } @@ -4017,6 +4013,7 @@ pub fn normalize_therock_family(value: &str) -> Option { value if value.starts_with("gfx1152") => Some("gfx1152".to_owned()), value if value.starts_with("gfx1153") => Some("gfx1153".to_owned()), "gfx1200" | "gfx1201" => Some("gfx120X-all".to_owned()), + value if value.starts_with("gfx125") => Some("gfx125X-dcgpu".to_owned()), value if value.starts_with("gfx900") => Some("gfx900".to_owned()), value if value.starts_with("gfx906") => Some("gfx906".to_owned()), value if value.starts_with("gfx908") => Some("gfx908".to_owned()), @@ -4063,6 +4060,7 @@ pub const fn known_therock_families() -> &'static [&'static str] { "gfx1152", "gfx1153", "gfx120X-all", + "gfx125X-dcgpu", ] } @@ -9302,6 +9300,32 @@ mod tests { ); } + /// ROCm 10's next layout publishes a `gfx125X-dcgpu` family; the raw + /// `gfx1250` arch a host reports has to land on it. + #[test] + fn normalize_therock_family_maps_gfx1250_to_gfx125x_dcgpu() { + assert_eq!( + normalize_therock_family("gfx1250"), + Some("gfx125X-dcgpu".to_owned()) + ); + } + + /// The next tarball catalog names this family `gfx103X-all` while the + /// canonical catalog names it `gfx103X-dgpu`. Both spellings, and the raw + /// arch, have to resolve to the one canonical label the rest of the CLI + /// stores, so a `--family gfx103X-all` install is not a different runtime. + #[test] + fn normalize_therock_family_aliases_gfx103x_all_to_canonical_dgpu_label() { + assert_eq!( + normalize_therock_family("gfx103X-all"), + Some("gfx103X-dgpu".to_owned()) + ); + assert_eq!( + normalize_therock_family("gfx1030"), + Some("gfx103X-dgpu".to_owned()) + ); + } + #[test] fn known_therock_families_all_round_trip() { for family in known_therock_families() { diff --git a/docs/release-trust.md b/docs/release-trust.md index 1294749b7..1dc14e70b 100644 --- a/docs/release-trust.md +++ b/docs/release-trust.md @@ -185,6 +185,33 @@ sha256 and size metadata, Hugging Face host scoping, Hugging Face token approval, or manual-only blocking. Existing signed indexes without `source_policy` keep the older explicit-review behavior. +## TheRock Source Base Overrides + +rocm-cli hardcodes the pip index and tarball catalog bases it resolves TheRock +SDK artifacts from. Six of those bases can be overridden, for fixture-server +testing only: + +```text +ROCM_CLI_THEROCK_RELEASE_PIP_BASE +ROCM_CLI_THEROCK_NIGHTLY_PIP_BASE +ROCM_CLI_THEROCK_RELEASE_TARBALL_BASE +ROCM_CLI_THEROCK_NIGHTLY_TARBALL_BASE +ROCM_CLI_THEROCK_NEXT_PIP_BASE +ROCM_CLI_THEROCK_NEXT_TARBALL_BASE +``` + +The last two address the ROCm 10 ("next") source layout, which is a layout +rather than a channel: it has no nightly counterpart and is never resolved +unless an install explicitly pins a ROCm version of 10 or newer. + +None of these take effect unless `ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE` is also +set to a non-empty value. Without that gate, a stray override left in a +developer's shell environment cannot silently redirect a release install to an +untrusted host; rocm-cli always resolves the hardcoded default base instead. +Set the gate and the specific base variable together, and only in tests or +deliberate manual QA against a fixture server. The `therock-next` E2E scenarios +use exactly this pairing to exercise next-layout dispatch hermetically. + ## Remaining Owner Step The repo still needs a real project-owned public signing key and matching diff --git a/tests/e2e-cucumber/features/therock_next_generation.feature b/tests/e2e-cucumber/features/therock_next_generation.feature new file mode 100644 index 000000000..118b936ed --- /dev/null +++ b/tests/e2e-cucumber/features/therock_next_generation.feature @@ -0,0 +1,71 @@ +Feature: TheRock "next" ROCm 10 install layout + + # ROCm 10 ships from a different source layout than the canonical multi-arch + # release/nightly streams: a separate pip index and a separate tarball catalog + # whose listing mixes non-release `-tests-` siblings in with the real dist + # archive. The next layout is an explicit extension, never a default: it is + # reachable only by pinning a ROCm version whose major is 10 or newer AND + # naming an exact raw GFX arch, and the canonical streams keep resolving + # exactly as they do today when neither is asked for. + # + # Every scenario points the ROCM_CLI_THEROCK_*_BASE overrides at a loopback + # fixture server (gated by ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE), so dispatch + # and selection are exercised with no live network dependency. + + # The regression this pins: a "next-first" dispatch that probes the ROCm 10 + # sources before the canonical ones. An unpinned release install must never + # look at the next layout at all, so the next fixture is served and asserted + # untouched rather than left unconfigured. + @id:therock-next-01-unpinned-release-stays-canonical + Scenario: therock-next-01 - An unpinned release install stays on the canonical multi-arch source + Given a canonical release pip index fixture and a ROCm 10 pip index fixture + When the user previews a wheel SDK install for arch gfx1200 with no version pin + Then the preview resolves the canonical release pip index + And the preview reports the canonical multi-arch source layout generation + And the preview never mentions the ROCm 10 pip index + + # `--version 10.0.0` plus a raw arch is the whole opt-in. The device payload is + # requested as an exact `device-gfx1200` extra on rocm, torch and torchvision; + # torchaudio carries no device extra because it publishes none. + @id:therock-next-02-wheel-pins-raw-device-extras + Scenario: therock-next-02 - A pinned ROCm 10 wheel install resolves the next index with raw device extras + Given a canonical release pip index fixture and a ROCm 10 pip index fixture + When the user previews a wheel SDK install for arch gfx1200 pinned to ROCm 10.0.0 + Then the preview resolves the ROCm 10 pip index + And the preview reports the next source layout generation + And the preview requests the gfx1200 device extras + + # No silent fallback: a group label carries no exact arch, and the next layout + # cannot guess one. The install must refuse and say which flag to pass, rather + # than dropping back to the canonical stream or to a bucket payload. + @id:therock-next-03-wheel-group-family-refused + Scenario: therock-next-03 - A pinned ROCm 10 wheel install refuses a group family label + Given a canonical release pip index fixture and a ROCm 10 pip index fixture + When the user previews a wheel SDK install for family gfx120X-all pinned to ROCm 10.0.0 + Then the install fails + And the failure asks for an exact GPU arch and names --family gfx1200 + + # Linux-only: `--format tarball` is rejected outright on Windows (native + # tarball installs are not supported there), so this scenario's premise does + # not hold on that platform. + # + # The `-tests-` sibling carries a LATER mtime than the real dist archive on the + # live AMD index, so highest-mtime selection alone picks the wrong file. + @id:therock-next-04-tarball-skips-tests-sibling @requires-os:linux + Scenario: therock-next-04 - A pinned ROCm 10 tarball install skips the tests sibling + Given a canonical release tarball fixture and a ROCm 10 tarball fixture with a tests sibling + When the user previews a tarball SDK install for arch gfx1200 pinned to ROCm 10.0.0 + Then the preview resolves the ROCm 10 tarball catalog + And the preview reports the next source layout generation + And the preview selects the real tarball artifact + And the preview does not select the tests artifact + + # Same refusal on the tarball path, which is a separate dispatch: pinning a + # version was rejected outright for tarball installs before ROCm 10, so the + # newly reachable path needs its own proof that it still demands a raw arch. + @id:therock-next-05-tarball-group-family-refused @requires-os:linux + Scenario: therock-next-05 - A pinned ROCm 10 tarball install refuses a group family label + Given a canonical release tarball fixture and a ROCm 10 tarball fixture with a tests sibling + When the user previews a tarball SDK install for family gfx120X-all pinned to ROCm 10.0.0 + Then the install fails + And the failure asks for an exact GPU arch and names --family gfx1200 diff --git a/tests/e2e-cucumber/tests/e2e.rs b/tests/e2e-cucumber/tests/e2e.rs index fa81680af..80dd078a2 100644 --- a/tests/e2e-cucumber/tests/e2e.rs +++ b/tests/e2e-cucumber/tests/e2e.rs @@ -32,6 +32,7 @@ mod e2e { pub mod runtime_lifecycle_steps; pub mod runtime_steps; pub mod serving_steps; + pub mod therock_steps; pub mod tui_driver; pub mod update_steps; } diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs new file mode 100644 index 000000000..65d0dd38e --- /dev/null +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -0,0 +1,478 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Steps for `therock_next_generation.feature`. +//! +//! Every scenario serves both the canonical release layout and the ROCm 10 +//! ("next") layout from one loopback server, under `current/` and `next/` +//! prefixes, and points the gated `ROCM_CLI_THEROCK_*_BASE` overrides at them. +//! Serving both — rather than only the one a scenario expects to be used — is +//! what makes "the canonical stream is still canonical" and "the next stream is +//! only reached when explicitly pinned" assertable: a dispatch regression +//! resolves the *other* fixture instead of failing to resolve anything. + +use std::path::Path; + +use cucumber::{given, then, when}; +use e2e_cucumber::cli_failure_report; +use e2e_cucumber::loopback_http::LoopbackServer; + +use crate::E2eWorld; + +/// The exact GFX arch the next layout needs. Not a group label: the aggregate +/// source publishes one `rocm-sdk-device-` payload per arch, and +/// `device-gfx120X-all` is not an extra it declares at all. +const RAW_ARCH: &str = "gfx1200"; +/// The family label `RAW_ARCH` normalizes to, used for the tarball file names +/// and the canonical (non-next) resolution. +const GROUP_FAMILY: &str = "gfx120X-all"; + +const NEXT_ROCM_VERSION: &str = "10.0.0"; +const NEXT_TORCH_VERSION: &str = "2.10.0+rocm10.0.0"; +const NEXT_TORCHVISION_VERSION: &str = "0.25.0+rocm10.0.0"; +const NEXT_TORCHAUDIO_VERSION: &str = "2.10.0+rocm10.0.0"; + +const CURRENT_ROCM_VERSION: &str = "7.10.0"; +const CURRENT_TORCH_VERSION: &str = "2.9.0+rocm7.10.0"; +const CURRENT_TORCHVISION_VERSION: &str = "0.24.0+rocm7.10.0"; +const CURRENT_TORCHAUDIO_VERSION: &str = "2.9.0+rocm7.10.0"; + +const NEXT_REAL_TARBALL: &str = "therock-dist-linux-gfx120X-all-10.0.0.tar.gz"; +/// The non-release sibling the live catalog publishes beside the real archive. +const NEXT_TESTS_TARBALL: &str = "therock-dist-linux-gfx120X-all-tests-10.0.0.tar.gz"; +const CURRENT_TARBALL: &str = "therock-dist-linux-gfx120X-all-7.10.0.tar.gz"; + +/// The `current/` fixture's served base, i.e. what the canonical release +/// overrides are pointed at. +fn current_pip_base(world: &E2eWorld) -> String { + format!("{}/current", server_base(world)) +} + +/// The `next/` fixture's served base, i.e. what the ROCm 10 overrides are +/// pointed at. +fn next_pip_base(world: &E2eWorld) -> String { + format!("{}/next", server_base(world)) +} + +fn next_tarball_base(world: &E2eWorld) -> String { + format!("{}/tarball/next/", server_base(world)) +} + +fn server_base(world: &E2eWorld) -> String { + world + .artifact_server + .as_ref() + .expect("scenario started no fixture server") + .base_url() +} + +fn root(world: &E2eWorld) -> &Path { + world + .isolated_root + .as_ref() + .expect("scenario has no isolated root") + .path() +} + +fn write_fixture(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("failed to create fixture directory"); + } + std::fs::write(path, contents).expect("failed to write fixture file"); +} + +/// A PEP 503 style root listing. `validate_aggregate_index_layout` requires the +/// four stack packages, and `parse_aggregate_device_targets` reads the exact +/// published arches out of the `rocm-sdk-device-*` links — so the fixture has to +/// publish `rocm-sdk-device-gfx1200` for `device-gfx1200` to be requestable. +fn aggregate_root_html() -> String { + [ + "rocm", + "torch", + "torchvision", + "torchaudio", + "rocm-sdk-device-gfx1200", + ] + .iter() + .map(|name| format!("{name}\n")) + .collect() +} + +/// One package page. `py3-none-any` keeps the wheel compatible with whatever +/// interpreter the runner resolves, so the scenario asserts selection logic +/// rather than the host's Python tag. +fn wheel_index_html(package: &str, version: &str) -> String { + format!( + "{package}-{version}-py3-none-any.whl\n" + ) +} + +fn write_pip_index(served: &Path, versions: [(&str, &str); 4]) { + write_fixture(&served.join("index.html"), &aggregate_root_html()); + for (package, version) in versions { + write_fixture( + &served.join(package).join("index.html"), + &wheel_index_html(package, version), + ); + } +} + +/// The scrapeable listing the tarball catalog publishes: a JS array of +/// `{name, mtime}` records. +fn tarball_index_html(files: &[(&str, f64)]) -> String { + let entries = files + .iter() + .map(|(name, mtime)| format!("{{\"name\": \"{name}\", \"mtime\": {mtime:?}}}")) + .collect::>() + .join(", "); + format!("") +} + +fn allow_base_overrides(world: &mut E2eWorld) { + world + .command_env + .push(("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE", "1".into())); +} + +#[given("a canonical release pip index fixture and a ROCm 10 pip index fixture")] +async fn pip_index_fixtures(world: &mut E2eWorld) { + let served = root(world).join("therock-pip-fixtures"); + write_pip_index( + &served.join("current"), + [ + ("rocm", CURRENT_ROCM_VERSION), + ("torch", CURRENT_TORCH_VERSION), + ("torchvision", CURRENT_TORCHVISION_VERSION), + ("torchaudio", CURRENT_TORCHAUDIO_VERSION), + ], + ); + write_pip_index( + &served.join("next"), + [ + ("rocm", NEXT_ROCM_VERSION), + ("torch", NEXT_TORCH_VERSION), + ("torchvision", NEXT_TORCHVISION_VERSION), + ("torchaudio", NEXT_TORCHAUDIO_VERSION), + ], + ); + world.artifact_server = Some(LoopbackServer::start(&served)); + allow_base_overrides(world); + let current = current_pip_base(world); + let next = next_pip_base(world); + world + .command_env + .push(("ROCM_CLI_THEROCK_RELEASE_PIP_BASE", current.into())); + world + .command_env + .push(("ROCM_CLI_THEROCK_NEXT_PIP_BASE", next.into())); +} + +#[given("a canonical release tarball fixture and a ROCm 10 tarball fixture with a tests sibling")] +async fn tarball_index_fixtures(world: &mut E2eWorld) { + let served = root(world).join("therock-tarball-fixtures"); + write_fixture( + &served.join("tarball").join("current").join("index.html"), + &tarball_index_html(&[(CURRENT_TARBALL, 1_787_000_000.0)]), + ); + // The real archive is OLDER than its `-tests-` sibling, exactly as the live + // catalog publishes them, so mtime alone selects the wrong file. + write_fixture( + &served.join("tarball").join("next").join("index.html"), + &tarball_index_html(&[ + (NEXT_REAL_TARBALL, 1_787_612_008.0), + (NEXT_TESTS_TARBALL, 1_787_612_032.0), + ]), + ); + world.artifact_server = Some(LoopbackServer::start(&served)); + allow_base_overrides(world); + let base = server_base(world); + let next = next_tarball_base(world); + world.command_env.push(( + "ROCM_CLI_THEROCK_RELEASE_TARBALL_BASE", + format!("{base}/tarball/current/").into(), + )); + world + .command_env + .push(("ROCM_CLI_THEROCK_NEXT_TARBALL_BASE", next.into())); +} + +fn preview(world: &mut E2eWorld, args: &[&str]) -> i32 { + let (stdout, stderr, rc) = crate::run_rocm_with_scenario_env(world, args); + world.cli_output = Some(stdout); + world.cli_stderr = Some(stderr); + world.cli_rc = Some(rc); + rc +} + +fn preview_ok(world: &mut E2eWorld, args: &[&str]) { + let rc = preview(world, args); + assert!( + rc == 0, + "{}", + cli_failure_report( + args, + rc, + world.cli_output.as_deref().unwrap_or_default(), + world.cli_stderr.as_deref().unwrap_or_default(), + ) + ); +} + +#[when("the user previews a wheel SDK install for arch gfx1200 with no version pin")] +async fn preview_unpinned_wheel_install(world: &mut E2eWorld) { + preview_ok( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--family", + RAW_ARCH, + "--dry-run", + ], + ); +} + +#[when("the user previews a wheel SDK install for arch gfx1200 pinned to ROCm 10.0.0")] +async fn preview_pinned_wheel_install(world: &mut E2eWorld) { + preview_ok( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--family", + RAW_ARCH, + "--version", + NEXT_ROCM_VERSION, + "--dry-run", + ], + ); +} + +#[when("the user previews a wheel SDK install for family gfx120X-all pinned to ROCm 10.0.0")] +async fn preview_pinned_wheel_install_with_group_family(world: &mut E2eWorld) { + preview( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--family", + GROUP_FAMILY, + "--version", + NEXT_ROCM_VERSION, + "--dry-run", + ], + ); +} + +#[when("the user previews a tarball SDK install for arch gfx1200 pinned to ROCm 10.0.0")] +async fn preview_pinned_tarball_install(world: &mut E2eWorld) { + preview_ok( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "tarball", + "--family", + RAW_ARCH, + "--version", + NEXT_ROCM_VERSION, + "--dry-run", + ], + ); +} + +#[when("the user previews a tarball SDK install for family gfx120X-all pinned to ROCm 10.0.0")] +async fn preview_pinned_tarball_install_with_group_family(world: &mut E2eWorld) { + preview( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "tarball", + "--family", + GROUP_FAMILY, + "--version", + NEXT_ROCM_VERSION, + "--dry-run", + ], + ); +} + +fn stdout(world: &E2eWorld) -> &str { + world.cli_output.as_deref().unwrap_or_default() +} + +fn assert_contains(world: &E2eWorld, needle: &str, what: &str) { + assert!( + stdout(world).contains(needle), + "{what}: expected {needle:?} in the preview:\n{}", + stdout(world) + ); +} + +#[then("the preview resolves the canonical release pip index")] +async fn preview_resolves_canonical_pip_index(world: &mut E2eWorld) { + let base = current_pip_base(world); + assert_contains( + world, + &format!("canonical_source: {base}"), + "canonical source", + ); + assert_contains(world, &format!("index_url: {base}"), "resolved index"); + assert_contains( + world, + &format!("latest_compatible_version: {CURRENT_ROCM_VERSION}"), + "resolved canonical version", + ); +} + +#[then("the preview reports the canonical multi-arch source layout generation")] +async fn preview_reports_canonical_generation(world: &mut E2eWorld) { + assert_contains( + world, + "source_layout_generation: multi-arch-v2", + "canonical layout generation", + ); +} + +#[then("the preview never mentions the ROCm 10 pip index")] +async fn preview_never_mentions_next_index(world: &mut E2eWorld) { + let next = next_pip_base(world); + assert!( + !stdout(world).contains(&next), + "an unpinned release install reached the ROCm 10 index {next}:\n{}", + stdout(world) + ); + assert!( + !stdout(world).contains("next-v1"), + "an unpinned release install selected the next source layout:\n{}", + stdout(world) + ); +} + +#[then("the preview resolves the ROCm 10 pip index")] +async fn preview_resolves_next_pip_index(world: &mut E2eWorld) { + let base = next_pip_base(world); + assert_contains(world, &format!("canonical_source: {base}"), "next source"); + assert_contains(world, &format!("index_url: {base}"), "resolved index"); + assert_contains( + world, + &format!("latest_compatible_version: {NEXT_ROCM_VERSION}"), + "resolved next version", + ); +} + +#[then("the preview reports the next source layout generation")] +async fn preview_reports_next_generation(world: &mut E2eWorld) { + assert_contains( + world, + "source_layout_generation: next-v1", + "next layout generation", + ); +} + +#[then("the preview requests the gfx1200 device extras")] +async fn preview_requests_device_extras(world: &mut E2eWorld) { + // The exact line, not four substring checks: the value of this assertion is + // that the install would request one exact device payload for rocm, torch + // and torchvision and none for torchaudio, which only the whole spec list + // shows. A group-bucket or `device-all` regression still matches any subset. + let expected = format!( + "package_specs: rocm[libraries,devel,device-{RAW_ARCH}]=={NEXT_ROCM_VERSION} \ + torch[device-{RAW_ARCH}]=={NEXT_TORCH_VERSION} \ + torchvision[device-{RAW_ARCH}]=={NEXT_TORCHVISION_VERSION} \ + torchaudio=={NEXT_TORCHAUDIO_VERSION}" + ); + assert_contains(world, &expected, "device extras"); + assert_contains( + world, + &format!("device_target: {RAW_ARCH}"), + "device target", + ); +} + +#[then("the preview resolves the ROCm 10 tarball catalog")] +async fn preview_resolves_next_tarball_catalog(world: &mut E2eWorld) { + let base = next_tarball_base(world); + assert_contains( + world, + &format!("canonical_source: {base}"), + "next tarball catalog", + ); + assert_contains( + world, + &format!("tarball_url: {base}{NEXT_REAL_TARBALL}"), + "selected artifact url", + ); +} + +#[then("the preview selects the real tarball artifact")] +async fn preview_selects_real_tarball(world: &mut E2eWorld) { + assert_contains( + world, + &format!("tarball: {NEXT_REAL_TARBALL}"), + "selected artifact", + ); + assert_contains( + world, + &format!("latest_version: {NEXT_ROCM_VERSION}"), + "selected artifact version", + ); +} + +#[then("the preview does not select the tests artifact")] +async fn preview_does_not_select_tests_tarball(world: &mut E2eWorld) { + assert!( + !stdout(world).contains(NEXT_TESTS_TARBALL), + "the preview selected the non-release tests sibling:\n{}", + stdout(world) + ); +} + +#[then("the install fails")] +async fn install_fails(world: &mut E2eWorld) { + let rc = world.cli_rc.expect("no recorded exit code"); + assert!( + rc != 0, + "expected a refusal, but the install succeeded:\n{}", + stdout(world) + ); +} + +#[then("the failure asks for an exact GPU arch and names --family gfx1200")] +async fn failure_names_the_exact_arch_flag(world: &mut E2eWorld) { + // Both streams: what matters is that the user is told how to fix it, not + // which file descriptor carried the sentence. + let reported = format!( + "{}\n{}", + stdout(world), + world.cli_stderr.as_deref().unwrap_or_default() + ); + assert!( + reported.contains("requires an exact GPU arch"), + "the refusal did not say an exact arch is required:\n{reported}" + ); + assert!( + reported.contains(&format!("--family {RAW_ARCH}")), + "the refusal did not name the flag that fixes it:\n{reported}" + ); +} diff --git a/tests/e2e-cucumber/tests/feature_naming.rs b/tests/e2e-cucumber/tests/feature_naming.rs index 03600b912..189d845e0 100644 --- a/tests/e2e-cucumber/tests/feature_naming.rs +++ b/tests/e2e-cucumber/tests/feature_naming.rs @@ -39,6 +39,7 @@ const FEATURE_KEYS: &[(&str, &str)] = &[ // sharing one key would collide on every index (`runtime-01` in both). ("runtime_lifecycle.feature", "runtime-lifecycle"), ("runtime_setup.feature", "runtime"), + ("therock_next_generation.feature", "therock-next"), ("update.feature", "update"), ]; From c2ca4a178d20f2e09834e21449d3213ab689d9b5 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Wed, 9 Sep 2026 10:48:45 -0700 Subject: [PATCH 2/8] fix(install): bound and validate remote artifacts Signed-off-by: Michael Roy --- apps/rocm/src/therock.rs | 60 ++++++++++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index e54bc92f9..6b56b388c 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -24,7 +24,7 @@ use std::ffi::{OsStr, OsString}; use std::fmt::Write as _; use std::fs; use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use std::process::{Command, Output, Stdio}; use std::time::Duration; @@ -64,6 +64,8 @@ const THEROCK_DOWNLOAD_TIMEOUT: Duration = Duration::from_hours(1); /// misconfigured proxy or a hostile header rather than a real artifact, and /// must not be allowed to refuse an install on its own authority. const THEROCK_MAX_PLAUSIBLE_TARBALL_BYTES: u64 = 256 * 1024 * 1024 * 1024; +/// Maximum size accepted for index, catalog, and detached-signature responses. +const THEROCK_MAX_METADATA_BYTES: u64 = 16 * 1024 * 1024; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum TheRockChannel { Release, @@ -3114,11 +3116,12 @@ fn download_file( .parent() .context("download destination has no parent directory")?; fs::create_dir_all(parent)?; - rocm_core::download_file_streaming_with_progress( - &rocm_core::DownloadRequest::new(url, destination, THEROCK_DOWNLOAD_TIMEOUT), - on_progress, - ) - .with_context(|| format!("failed to fetch {url}"))?; + let request = rocm_core::DownloadRequest { + max_bytes: Some(THEROCK_MAX_PLAUSIBLE_TARBALL_BYTES), + ..rocm_core::DownloadRequest::new(url, destination, THEROCK_DOWNLOAD_TIMEOUT) + }; + rocm_core::download_file_streaming_with_progress(&request, on_progress) + .with_context(|| format!("failed to fetch {url}"))?; Ok(()) } @@ -3225,11 +3228,18 @@ fn http_get( }) .collect::>() .join("\n"); - let mut reader = response.into_reader(); + let mut reader = response + .into_reader() + .take(THEROCK_MAX_METADATA_BYTES.saturating_add(1)); let mut body = Vec::new(); reader .read_to_end(&mut body) .with_context(|| format!("failed to read HTTP response body for {url}"))?; + if body.len() as u64 > THEROCK_MAX_METADATA_BYTES { + bail!( + "HTTP response body for {url} exceeded the approved metadata limit of {THEROCK_MAX_METADATA_BYTES} bytes" + ); + } Ok(HttpResponseBody { status, headers, @@ -4606,7 +4616,24 @@ fn parse_tarball_index_html(html: &str) -> Result> { .find("];") .context("tarball index did not contain the end of the embedded file list")?; let json = format!("{}]", &rest[..end]); - serde_json::from_str(&json).context("failed to parse TheRock tarball index file list") + let files: Vec = + serde_json::from_str(&json).context("failed to parse TheRock tarball index file list")?; + for file in &files { + validate_tarball_file_name(&file.name)?; + } + Ok(files) +} + +fn validate_tarball_file_name(name: &str) -> Result<()> { + let mut components = Path::new(name).components(); + if name.contains(['/', '\\']) + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + || name.chars().any(char::is_control) + { + bail!("tarball catalog contains unsafe file name `{name}`"); + } + Ok(()) } fn compare_version_strings(left: &str, right: &str) -> Ordering { @@ -5029,6 +5056,23 @@ mod tests { assert_eq!(select_base_override(true, None, default), default); } + #[test] + fn tarball_catalog_rejects_paths_and_control_characters() { + for name in [ + "../escape.tar.gz", + "folder/escape.tar.gz", + r"folder\escape.tar.gz", + "/absolute.tar.gz", + "control\nname.tar.gz", + ] { + assert!( + validate_tarball_file_name(name).is_err(), + "unsafe catalog name was accepted: {name:?}" + ); + } + assert!(validate_tarball_file_name("therock-dist-linux-gfx120X-all-10.0.0.tar.gz").is_ok()); + } + #[test] fn family_override_raw_arch_accepts_only_exact_arch_codes() { assert_eq!( From a89d7782c3a6e49249899f0e1fadc99aef569774 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Wed, 9 Sep 2026 11:12:43 -0700 Subject: [PATCH 3/8] test(e2e): build Next fixtures without format collect Signed-off-by: Michael Roy --- tests/e2e-cucumber/tests/e2e/therock_steps.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs index 65d0dd38e..8d2836b3a 100644 --- a/tests/e2e-cucumber/tests/e2e/therock_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -12,6 +12,7 @@ //! only reached when explicitly pinned" assertable: a dispatch regression //! resolves the *other* fixture instead of failing to resolve anything. +use std::fmt::Write as _; use std::path::Path; use cucumber::{given, then, when}; @@ -95,8 +96,10 @@ fn aggregate_root_html() -> String { "rocm-sdk-device-gfx1200", ] .iter() - .map(|name| format!("{name}\n")) - .collect() + .fold(String::new(), |mut html, name| { + writeln!(html, "{name}").expect("write fixture HTML"); + html + }) } /// One package page. `py3-none-any` keeps the wheel compatible with whatever From 7a324f1797e1e759656c7992554b75b210ba8603 Mon Sep 17 00:00:00 2001 From: Juho Vainio Date: Wed, 9 Sep 2026 21:10:38 +0300 Subject: [PATCH 4/8] test(therock): add a live GPU install scenario that auto-detects the ROCm 10 arch The scenarios above prove dispatch and the group-family refusal for a *supplied* exact arch, all against fixtures. None of them prove a real install from the ROCm 10 preview source completes, and none prove the exact arch it needs can come from the host itself: every fixture scenario passes --family gfx1200 explicitly, since the fixtures serve gfx1200 regardless of what GPU the runner actually has. resolve_family already falls back to detect_host_gfx_target() when nothing overrides it (the same auto-detection the canonical path has always used), so a pinned ROCm 10 install with no --family at all should resolve through that host probe into the exact arch the next layout needs. therock-next-06 proves it for real: installs against the live stable.repo.amd.com on the self-hosted GPU nightly lane (@requires-gpu @nightly, same tags as runtime-01), with no family override, and asserts the resolved device_target matches what `rocm examine` reports as this host's detected_gfx_target. The version to pin is discovered by fetching the live index at run time rather than hardcoded, since the preview source is a moving target; the `rocm` aggregate meta-package publishes as an sdist (rocm-10.0.0.tar.gz), not a wheel, confirmed live, so the discovery step checks both suffixes the same way the CLI's own parse_simple_index_version_candidate does. Jira: EAI-8431 Signed-off-by: Juho Vainio --- .../features/therock_next_generation.feature | 18 +++ tests/e2e-cucumber/tests/e2e/therock_steps.rs | 137 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/tests/e2e-cucumber/features/therock_next_generation.feature b/tests/e2e-cucumber/features/therock_next_generation.feature index 118b936ed..331e14d9b 100644 --- a/tests/e2e-cucumber/features/therock_next_generation.feature +++ b/tests/e2e-cucumber/features/therock_next_generation.feature @@ -69,3 +69,21 @@ Feature: TheRock "next" ROCm 10 install layout When the user previews a tarball SDK install for family gfx120X-all pinned to ROCm 10.0.0 Then the install fails And the failure asks for an exact GPU arch and names --family gfx1200 + + # Not hermetic like the scenarios above: this one installs for real, against + # the live stable.repo.amd.com, on a self-hosted GPU runner, with no + # --family override at all. `resolve_family` already falls back to + # `detect_host_gfx_target()` when nothing overrides it, so a pinned ROCm 10 + # install should resolve the runner's real GPU into the exact arch the next + # layout needs without the user ever typing a raw GFX code. The fixture + # scenarios above can't prove this: their fixtures serve a fixed gfx1200 + # regardless of what GPU the runner actually has. + @id:therock-next-06-live-install-auto-detects-arch @requires-gpu @nightly + Scenario: therock-next-06 - Installing the SDK from the live ROCm 10 preview source auto-detects the exact arch + Given a machine with no CLI-managed runtimes + When the user installs the SDK from the ROCm 10 preview source with no family override + Then the install used the ROCm 10 preview source + And the install requested the device extras for this host's detected GPU + And a runtime is registered + And the runtime is set as active + And the runtime includes an inference engine diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs index 8d2836b3a..bbfd7ed40 100644 --- a/tests/e2e-cucumber/tests/e2e/therock_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -11,6 +11,16 @@ //! what makes "the canonical stream is still canonical" and "the next stream is //! only reached when explicitly pinned" assertable: a dispatch regression //! resolves the *other* fixture instead of failing to resolve anything. +//! +//! One scenario (`therock-next-06`) is not part of that hermetic pattern: it +//! installs for real, against the live `stable.repo.amd.com`, on a +//! self-hosted GPU runner, with no `--family` override at all. The fixture +//! scenarios above prove dispatch and refusal logic on wiring the CLI already +//! has for a *supplied* exact arch; this one proves the exact arch itself can +//! come from `resolve_family`'s existing host-probe auto-detection +//! (`detect_host_gfx_target`) rather than requiring the user to already know +//! and type their raw GFX code, and that a real install with that +//! auto-detected arch actually completes. use std::fmt::Write as _; use std::path::Path; @@ -479,3 +489,130 @@ async fn failure_names_the_exact_arch_flag(world: &mut E2eWorld) { "the refusal did not name the flag that fixes it:\n{reported}" ); } + +/// The default (un-overridden) ROCm 10 preview pip index base. Matches +/// `THEROCK_NEXT_RELEASE_PIP_INDEX_BASE` in `apps/rocm/src/therock.rs`. +const DEFAULT_NEXT_RELEASE_PIP_BASE: &str = "https://stable.repo.amd.com/rocm/whl-next"; + +/// The newest `rocm` version the live ROCm 10 preview source actually +/// publishes right now, discovered at run time rather than hardcoded: the +/// preview source is a moving target, and a version this scenario doesn't +/// control would go stale the moment a newer one is published. +async fn discover_latest_next_rocm_version() -> String { + let url = format!("{DEFAULT_NEXT_RELEASE_PIP_BASE}/rocm/"); + let html = reqwest::get(&url) + .await + .unwrap_or_else(|error| { + panic!("failed to fetch the ROCm 10 preview pip index at {url}: {error}") + }) + .text() + .await + .unwrap_or_else(|error| { + panic!("failed to read the ROCm 10 preview pip index body from {url}: {error}") + }); + let mut versions = Vec::new(); + let marker = "rocm-"; + let mut rest = html.as_str(); + while let Some(start) = rest.find(marker) { + let after = &rest[start + marker.len()..]; + // The `rocm` aggregate meta-package publishes as an sdist (`.tar.gz`), + // not a wheel (confirmed live) — check both suffixes, whichever comes + // first, same as the CLI's own `parse_simple_index_version_candidate`. + let end = match (after.find(".tar.gz"), after.find(".whl")) { + (Some(tar), Some(whl)) => Some(tar.min(whl)), + (Some(tar), None) => Some(tar), + (None, Some(whl)) => Some(whl), + (None, None) => None, + }; + let Some(end) = end else { + // No recognized suffix after this "rocm-": advance past just the + // marker itself (not the whole rest of the page) so the next + // search still finds a later real "rocm-" occurrence instead of + // skipping the page outright. + rest = &after[1.min(after.len())..]; + continue; + }; + // A wheel's stem is `rocm---...`; only the sdist + // form's `end` names the version directly, so drop anything past the + // first remaining `-` for the wheel case. + let candidate = &after[..end]; + let version = candidate.split('-').next().unwrap_or(candidate); + versions.push(version.to_owned()); + rest = &after[end..]; + } + versions.sort_by(|left, right| compare_dotted_versions(left, right)); + versions + .into_iter() + .next_back() + .unwrap_or_else(|| panic!("no `rocm-*` package versions were found at {url}:\n{html}")) +} + +/// Loose dotted-version comparison good enough to pick "the newest" out of a +/// live index: numeric components first, falling back to plain string order +/// for anything a simple `x.y.z` split doesn't cover (e.g. a `+local` suffix). +fn compare_dotted_versions(left: &str, right: &str) -> std::cmp::Ordering { + fn numeric_prefix(value: &str) -> Vec { + value + .split(['.', '+']) + .map_while(|part| part.parse().ok()) + .collect() + } + numeric_prefix(left) + .cmp(&numeric_prefix(right)) + .then_with(|| left.cmp(right)) +} + +#[when("the user installs the SDK from the ROCm 10 preview source with no family override")] +async fn user_installs_sdk_from_next_source_auto_detected(world: &mut E2eWorld) { + let version = discover_latest_next_rocm_version().await; + // No `--family`: this is the whole point of the scenario. `resolve_family` + // falls back to `detect_host_gfx_target()` when nothing overrides it, so + // the runner's real GPU is what picks the exact arch the next layout needs. + let args = [ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--version", + &version, + ]; + let (stdout, stderr, rc) = crate::run_rocm_with_scenario_env(world, &args); + assert!( + rc == 0, + "{}", + cli_failure_report(&args, rc, &stdout, &stderr) + ); + world.cli_output = Some(stdout); +} + +#[then("the install used the ROCm 10 preview source")] +async fn assert_install_used_next_source(world: &mut E2eWorld) { + assert_contains( + world, + "source_layout_generation: next-v1", + "next layout generation", + ); + assert_contains( + world, + &format!("canonical_source: {DEFAULT_NEXT_RELEASE_PIP_BASE}"), + "live next source", + ); +} + +#[then("the install requested the device extras for this host's detected GPU")] +async fn assert_install_used_detected_device_target(world: &mut E2eWorld) { + let (examine_stdout, _, _) = crate::run_rocm(world, &["examine"]); + let detected = examine_stdout + .lines() + .find_map(|line| line.trim().strip_prefix("detected_gfx_target: ")) + .unwrap_or_else(|| { + panic!("host reported no detected_gfx_target, so auto-detection had nothing to sniff:\n{examine_stdout}") + }); + assert_contains( + world, + &format!("device_target: {detected}"), + "install did not target this host's auto-detected GPU", + ); +} From 086e41c0ab4883b4c7e98d0dd6e70eba0ad32d65 Mon Sep 17 00:00:00 2001 From: Michael Roy Date: Wed, 9 Sep 2026 16:36:24 -0700 Subject: [PATCH 5/8] feat(therock): resolve ROCm X stack metadata Signed-off-by: Michael Roy --- README.md | 5 + apps/rocm/src/therock.rs | 378 +++++++++++++++--- .../features/therock_next_generation.feature | 21 +- tests/e2e-cucumber/tests/e2e/therock_steps.rs | 145 +++++-- 4 files changed, 472 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 3c814925f..16f470c1a 100644 --- a/README.md +++ b/README.md @@ -275,6 +275,11 @@ rather than resolved to a guess, because the ROCm 10 packages publish one payload per exact arch and there is no bucket payload to fall back to. Run `rocm examine` to see the arch this machine reports. +For ROCm 10, `install sdk` asks `uv` to resolve Torch, torchvision, and +torchaudio from their published dependency metadata, then validates that every +selected framework package carries the same ROCm build identifier before it +creates or changes a managed runtime. + Nothing about this happens on its own. Without a `--version` of 10 or newer, `install sdk` resolves the same release and nightly sources it always has, and it never quietly retries against the ROCm 10 sources when a lookup comes up diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index 6b56b388c..50fea6479 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -1589,7 +1589,7 @@ fn install_wheel_runtime( ); let _ = writeln!( output, - " package_policy: find the newest TheRock ROCm SDK version that has a matching PyTorch stack in the same index, then install pinned target-complete rocm, torch, torchvision, and torchaudio versions in one uv transaction" + " package_policy: resolve the pinned target-complete rocm, torch, torchvision, and torchaudio plan from published package metadata, then install it in one uv transaction" ); if dry_run { let env_python = venv_python_path(&install_root); @@ -2023,41 +2023,70 @@ fn resolve_pip_runtime_from_index( "release channel only installs stable TheRock wheel versions, but no stable `rocm` package versions were found in {index_url}; try `rocm install sdk --channel release --format tarball` for stable release artifacts, or use `--channel nightly --format wheel` for preview builds" ); } - let torch_versions = load_simple_index_versions( - paths, - index_url, - "torch", - Some(wheel_compatibility), - download_timeout_secs, - )?; - let torchvision_versions = load_simple_index_versions( - paths, - index_url, - "torchvision", - Some(wheel_compatibility), - download_timeout_secs, - )?; - let torchaudio_versions = load_simple_index_versions( - paths, - index_url, - "torchaudio", - Some(wheel_compatibility), - download_timeout_secs, - )?; - let package_versions = select_matching_pip_package_versions( - channel, - &rocm_versions, - &torch_versions, - &torchvision_versions, - &torchaudio_versions, - version_selector, - ) - .with_context(|| { - let requested = version_selector.map_or_else(|| "latest compatible version".to_owned(), RuntimeVersionSelector::describe); - format!( - "no mutually compatible TheRock rocm, torch, torchvision, and torchaudio versions were found for {requested} in {index_url}" + let package_versions = if matches!(source.layout, SourceLayout::Next) { + let device_target = match &source.device_target { + AggregateDeviceTarget::Exact(target) => target.as_str(), + AggregateDeviceTarget::Undetermined(reason) => { + bail!( + "cannot resolve ROCm X package metadata without an exact device target: {reason}" + ) + } + }; + let rocm_version = select_rocm_version(channel, &rocm_versions, version_selector) + .with_context(|| { + let requested = version_selector.map_or_else( + || "latest compatible version".to_owned(), + RuntimeVersionSelector::describe, + ); + format!("no TheRock rocm package was found for {requested} in {index_url}") + })?; + resolve_published_pip_package_versions( + paths, + index_url, + &rocm_version, + device_target, + wheel_compatibility, + )? + } else { + let torch_versions = load_simple_index_versions( + paths, + index_url, + "torch", + Some(wheel_compatibility), + download_timeout_secs, + )?; + let torchvision_versions = load_simple_index_versions( + paths, + index_url, + "torchvision", + Some(wheel_compatibility), + download_timeout_secs, + )?; + let torchaudio_versions = load_simple_index_versions( + paths, + index_url, + "torchaudio", + Some(wheel_compatibility), + download_timeout_secs, + )?; + select_matching_pip_package_versions( + channel, + &rocm_versions, + &torch_versions, + &torchvision_versions, + &torchaudio_versions, + version_selector, ) - })?; + .with_context(|| { + let requested = version_selector.map_or_else( + || "latest compatible version".to_owned(), + RuntimeVersionSelector::describe, + ); + format!( + "no mutually compatible TheRock rocm, torch, torchvision, and torchaudio versions were found for {requested} in {index_url}" + ) + })? + }; let latest_version = package_versions.rocm.clone(); Ok(PipRuntimeResolution { family: family_resolution.family.clone(), @@ -2301,6 +2330,158 @@ fn resolve_family(paths: &AppPaths, family_override: Option<&str>) -> Result, +) -> Option { + let mut candidates = if version_selector.is_some() { + rocm_versions.to_vec() + } else { + channel_rocm_candidates(rocm_versions, channel) + }; + if let Some(selector) = version_selector { + candidates.retain(|version| selector.matches_version(version)); + } + candidates.sort_by(|left, right| compare_version_strings(left, right)); + candidates.pop() +} + +fn uv_python_version(compatibility: &WheelCompatibility) -> Result { + let digits = compatibility + .python_tag + .strip_prefix("cp") + .context("managed Python reported an unsupported wheel tag")?; + if digits.len() < 2 || !digits.chars().all(|ch| ch.is_ascii_digit()) { + bail!( + "managed Python reported unsupported wheel tag `{}`", + compatibility.python_tag + ); + } + Ok(format!("{}.{}", &digits[..1], &digits[1..])) +} + +fn uv_python_platform(compatibility: &WheelCompatibility) -> Result<&'static str> { + if compatibility + .platform_tags + .iter() + .any(|tag| tag == "win_amd64") + { + Ok("x86_64-pc-windows-msvc") + } else if compatibility + .platform_tags + .iter() + .any(|tag| tag == "linux_x86_64") + { + Ok("x86_64-unknown-linux-gnu") + } else if compatibility + .platform_tags + .iter() + .any(|tag| tag == "linux_aarch64") + { + Ok("aarch64-unknown-linux-gnu") + } else { + bail!( + "managed Python reported unsupported platform wheel tags: {}", + compatibility.platform_tags.join(",") + ) + } +} + +fn parse_uv_compiled_package_versions(output: &str) -> Result { + let mut versions = std::collections::HashMap::new(); + for line in output.lines().map(str::trim) { + let Some((name, version)) = line.split_once("==") else { + continue; + }; + versions.insert(name.to_ascii_lowercase(), version.to_owned()); + } + let required = |name: &str| { + versions + .get(name) + .cloned() + .with_context(|| format!("uv metadata resolution did not pin `{name}`")) + }; + let rocm = required("rocm")?; + let torch = required("torch")?; + let torchvision = required("torchvision")?; + let torchaudio = required("torchaudio")?; + for (name, version) in [ + ("torch", torch.as_str()), + ("torchvision", torchvision.as_str()), + ("torchaudio", torchaudio.as_str()), + ] { + if package_rocm_suffix(version).as_deref() != Some(rocm.as_str()) { + bail!( + "published package metadata selected {name} {version}, which does not share ROCm build {rocm}" + ); + } + } + Ok(TheRockPipPackageVersions { + compatibility_key: rocm.clone(), + rocm, + torch, + torchvision, + torchaudio, + }) +} + +fn resolve_published_pip_package_versions( + paths: &AppPaths, + index_url: &str, + rocm_version: &str, + device_target: &str, + compatibility: &WheelCompatibility, +) -> Result { + let uv = + ensure_uv_binary(paths).context("failed to acquire uv for ROCm X metadata resolution")?; + let python_version = uv_python_version(compatibility)?; + let python_platform = uv_python_platform(compatibility)?; + let device_extra = format!("device-{device_target}"); + let requirements = format!( + "rocm[libraries,devel,{device_extra}]=={rocm_version}\ntorch[{device_extra}]\ntorchvision[{device_extra}]\ntorchaudio\n" + ); + let mut child = Command::new(&uv) + .args([ + "pip", + "compile", + "-", + "--index-url", + index_url, + "--python-version", + &python_version, + "--python-platform", + python_platform, + "--no-header", + "--no-annotate", + ]) + .envs(uv_command_env(paths)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| { + format!( + "failed to launch {} for ROCm X metadata resolution", + uv.display() + ) + })?; + child + .stdin + .take() + .context("uv metadata resolver stdin was unavailable")? + .write_all(requirements.as_bytes()) + .context("failed to send ROCm X requirements to uv")?; + let output = child + .wait_with_output() + .context("failed to wait for ROCm X metadata resolution")?; + if !output.status.success() { + let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + bail!("published ROCm X package metadata is not jointly installable: {detail}"); + } + parse_uv_compiled_package_versions(&String::from_utf8_lossy(&output.stdout)) +} + fn select_matching_pip_package_versions( channel: TheRockChannel, rocm_versions: &[String], @@ -4626,7 +4807,38 @@ fn parse_tarball_index_html(html: &str) -> Result> { fn validate_tarball_file_name(name: &str) -> Result<()> { let mut components = Path::new(name).components(); - if name.contains(['/', '\\']) + let stem = name + .split_once('.') + .map_or(name, |(stem, _)| stem) + .trim_end_matches([' ', '.']); + let windows_reserved = matches!( + stem.to_ascii_uppercase().as_str(), + "CON" + | "PRN" + | "AUX" + | "NUL" + | "COM1" + | "COM2" + | "COM3" + | "COM4" + | "COM5" + | "COM6" + | "COM7" + | "COM8" + | "COM9" + | "LPT1" + | "LPT2" + | "LPT3" + | "LPT4" + | "LPT5" + | "LPT6" + | "LPT7" + | "LPT8" + | "LPT9" + ); + if name.contains(['/', '\\', ':']) + || name.ends_with([' ', '.']) + || windows_reserved || !matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some() || name.chars().any(char::is_control) @@ -5057,20 +5269,32 @@ mod tests { } #[test] - fn tarball_catalog_rejects_paths_and_control_characters() { + fn tarball_catalog_rejects_unsafe_file_names_at_the_parser_boundary() { for name in [ "../escape.tar.gz", "folder/escape.tar.gz", r"folder\escape.tar.gz", "/absolute.tar.gz", "control\nname.tar.gz", + "archive.tar.gz:payload", + "CON.tar.gz", + "archive.tar.gz.", + "archive.tar.gz ", ] { + let html = + format!(r#""#); assert!( - validate_tarball_file_name(name).is_err(), + parse_tarball_index_html(&html).is_err(), "unsafe catalog name was accepted: {name:?}" ); } - assert!(validate_tarball_file_name("therock-dist-linux-gfx120X-all-10.0.0.tar.gz").is_ok()); + let html = r#""#; + assert_eq!( + parse_tarball_index_html(html) + .expect("safe catalog name must parse") + .len(), + 1 + ); } #[test] @@ -5112,34 +5336,45 @@ mod tests { assert_eq!(raw_arch_agreeing_with_family(None, "gfx103X-dgpu"), None); } - /// The next catalog lists a `-tests-` archive beside the real one with a - /// *later* mtime, so plain "newest wins" picks the wrong file. Its leftover - /// suffix is not a version, which is what excludes it. + /// The next catalog's gfx103X token differs from the canonical token, and + /// its later `-tests-` sibling must not win selection. #[test] - fn next_tarball_selection_skips_the_tests_sibling() { + fn next_gfx103x_tarball_selection_uses_alias_and_skips_tests_sibling() { let platform = platform_tarball_token(); - let prefix = format!("therock-dist-{platform}-gfx90a-"); + let next_prefix = format!("therock-dist-{platform}-gfx103X-all-"); let files = vec![ TarballIndexFile { - name: format!("{prefix}10.0.0.tar.gz"), + name: format!("{next_prefix}10.0.0.tar.gz"), mtime: 1_787_612_008.0, }, TarballIndexFile { - name: format!("{prefix}tests-10.0.0.tar.gz"), + name: format!("{next_prefix}tests-10.0.0.tar.gz"), mtime: 1_787_612_032.0, }, ]; + assert!( + select_tarball_candidate( + &files, + TheRockChannel::Release, + SourceLayout::Canonical, + "gfx103X-dgpu", + None, + ) + .is_none(), + "the canonical layout must not recognize the next-only family alias" + ); + let (file, version) = select_tarball_candidate( &files, TheRockChannel::Release, SourceLayout::Next, - "gfx90a", + "gfx103X-dgpu", None, ) - .expect("the real dist archive must be selected"); + .expect("the next alias must select the real dist archive"); - assert_eq!(file.name, format!("{prefix}10.0.0.tar.gz")); + assert_eq!(file.name, format!("{next_prefix}10.0.0.tar.gz")); assert_eq!(version, "10.0.0"); } @@ -6145,6 +6380,51 @@ mod tests { ); } + #[test] + fn uv_metadata_plan_accepts_non_arithmetic_audio_version() -> Result<()> { + let plan = parse_uv_compiled_package_versions( + "rocm==10.0.0\ntorch==2.13.0+rocm10.0.0\ntorchvision==0.28.0+rocm10.0.0\ntorchaudio==2.11.0.2+rocm10.0.0\n", + )?; + + assert_eq!(plan.rocm, "10.0.0"); + assert_eq!(plan.torch, "2.13.0+rocm10.0.0"); + assert_eq!(plan.torchvision, "0.28.0+rocm10.0.0"); + assert_eq!(plan.torchaudio, "2.11.0.2+rocm10.0.0"); + Ok(()) + } + + #[test] + fn uv_metadata_plan_rejects_mixed_rocm_builds() { + let error = parse_uv_compiled_package_versions( + "rocm==10.0.0\ntorch==2.13.0+rocm10.0.0\ntorchvision==0.28.0+rocm10.1.0\ntorchaudio==2.11.0.2+rocm10.0.0\n", + ) + .expect_err("a mixed ROCm build must fail closed") + .to_string(); + + assert!(error.contains("torchvision"), "{error}"); + assert!( + error.contains("does not share ROCm build 10.0.0"), + "{error}" + ); + } + + #[test] + fn uv_metadata_resolver_maps_supported_python_platforms() -> Result<()> { + let windows = WheelCompatibility { + python_tag: "cp312".to_owned(), + platform_tags: vec!["win_amd64".to_owned(), "any".to_owned()], + }; + assert_eq!(uv_python_version(&windows)?, "3.12"); + assert_eq!(uv_python_platform(&windows)?, "x86_64-pc-windows-msvc"); + + let linux = WheelCompatibility { + python_tag: "cp312".to_owned(), + platform_tags: vec!["linux_x86_64".to_owned(), "any".to_owned()], + }; + assert_eq!(uv_python_platform(&linux)?, "x86_64-unknown-linux-gnu"); + Ok(()) + } + /// The downloaded archive is removed once it has been unpacked; keeping it /// would double the disk cost of every installed SDK version. #[test] diff --git a/tests/e2e-cucumber/features/therock_next_generation.feature b/tests/e2e-cucumber/features/therock_next_generation.feature index 331e14d9b..180e69b25 100644 --- a/tests/e2e-cucumber/features/therock_next_generation.feature +++ b/tests/e2e-cucumber/features/therock_next_generation.feature @@ -8,9 +8,9 @@ Feature: TheRock "next" ROCm 10 install layout # naming an exact raw GFX arch, and the canonical streams keep resolving # exactly as they do today when neither is asked for. # - # Every scenario points the ROCM_CLI_THEROCK_*_BASE overrides at a loopback - # fixture server (gated by ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE), so dispatch - # and selection are exercised with no live network dependency. + # Scenarios 01-05 point source overrides at loopback fixtures through the + # explicit trust gate. Scenario 06 proves the same overrides are inert when + # the trust gate is absent; scenario 07 supplies the live GPU evidence. # The regression this pins: a "next-first" dispatch that probes the ROCm 10 # sources before the canonical ones. An unpinned release install must never @@ -70,6 +70,16 @@ Feature: TheRock "next" ROCm 10 install layout Then the install fails And the failure asks for an exact GPU arch and names --family gfx1200 + # Artifact base overrides are a trust boundary. Merely naming an override + # must not redirect the CLI; the separate opt-in is required. This scenario + # reaches the live default index, so it runs on the scheduled network lane. + @id:therock-next-06-base-override-requires-opt-in @nightly + Scenario: therock-next-06 - A ROCm 10 source override is ignored without explicit trust + Given an untrusted ROCm 10 pip base override + When the user previews a wheel SDK install for arch gfx1200 pinned to ROCm 10.0.0 + Then the preview resolves the default ROCm 10 pip index + And the preview never mentions the untrusted ROCm 10 pip index + # Not hermetic like the scenarios above: this one installs for real, against # the live stable.repo.amd.com, on a self-hosted GPU runner, with no # --family override at all. `resolve_family` already falls back to @@ -78,8 +88,8 @@ Feature: TheRock "next" ROCm 10 install layout # layout needs without the user ever typing a raw GFX code. The fixture # scenarios above can't prove this: their fixtures serve a fixed gfx1200 # regardless of what GPU the runner actually has. - @id:therock-next-06-live-install-auto-detects-arch @requires-gpu @nightly - Scenario: therock-next-06 - Installing the SDK from the live ROCm 10 preview source auto-detects the exact arch + @id:therock-next-07-live-install-auto-detects-arch @requires-gpu @nightly + Scenario: therock-next-07 - Installing the SDK from the live ROCm 10 preview source auto-detects the exact arch Given a machine with no CLI-managed runtimes When the user installs the SDK from the ROCm 10 preview source with no family override Then the install used the ROCm 10 preview source @@ -87,3 +97,4 @@ Feature: TheRock "next" ROCm 10 install layout And a runtime is registered And the runtime is set as active And the runtime includes an inference engine + And the ROCm 10 runtime passes SDK and Torch GPU probes diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs index bbfd7ed40..78ccdd861 100644 --- a/tests/e2e-cucumber/tests/e2e/therock_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -4,23 +4,22 @@ //! Steps for `therock_next_generation.feature`. //! -//! Every scenario serves both the canonical release layout and the ROCm 10 +//! Scenarios 01-05 serve both the canonical release layout and the ROCm 10 //! ("next") layout from one loopback server, under `current/` and `next/` -//! prefixes, and points the gated `ROCM_CLI_THEROCK_*_BASE` overrides at them. +//! prefixes, and point the gated `ROCM_CLI_THEROCK_*_BASE` overrides at them. //! Serving both — rather than only the one a scenario expects to be used — is //! what makes "the canonical stream is still canonical" and "the next stream is //! only reached when explicitly pinned" assertable: a dispatch regression //! resolves the *other* fixture instead of failing to resolve anything. //! -//! One scenario (`therock-next-06`) is not part of that hermetic pattern: it -//! installs for real, against the live `stable.repo.amd.com`, on a -//! self-hosted GPU runner, with no `--family` override at all. The fixture -//! scenarios above prove dispatch and refusal logic on wiring the CLI already -//! has for a *supplied* exact arch; this one proves the exact arch itself can -//! come from `resolve_family`'s existing host-probe auto-detection -//! (`detect_host_gfx_target`) rather than requiring the user to already know -//! and type their raw GFX code, and that a real install with that -//! auto-detected arch actually completes. +//! Scenario 06 sets an override without the trust opt-in and resolves the live +//! default index, proving the child CLI ignores the untrusted value end to end. +//! +//! Scenario 07 installs for real against `stable.repo.amd.com` on a self-hosted +//! GPU runner, with no `--family` override. The fixture scenarios prove dispatch +//! and refusal logic for a supplied exact arch; this one proves the exact arch +//! can come from `resolve_family`'s host probe (`detect_host_gfx_target`) and that +//! the resulting managed SDK/Torch stack executes a real GPU kernel. use std::fmt::Write as _; use std::path::Path; @@ -42,7 +41,7 @@ const GROUP_FAMILY: &str = "gfx120X-all"; const NEXT_ROCM_VERSION: &str = "10.0.0"; const NEXT_TORCH_VERSION: &str = "2.10.0+rocm10.0.0"; const NEXT_TORCHVISION_VERSION: &str = "0.25.0+rocm10.0.0"; -const NEXT_TORCHAUDIO_VERSION: &str = "2.10.0+rocm10.0.0"; +const NEXT_TORCHAUDIO_VERSION: &str = "2.9.1+rocm10.0.0"; const CURRENT_ROCM_VERSION: &str = "7.10.0"; const CURRENT_TORCH_VERSION: &str = "2.9.0+rocm7.10.0"; @@ -112,22 +111,39 @@ fn aggregate_root_html() -> String { }) } -/// One package page. `py3-none-any` keeps the wheel compatible with whatever -/// interpreter the runner resolves, so the scenario asserts selection logic -/// rather than the host's Python tag. -fn wheel_index_html(package: &str, version: &str) -> String { - format!( - "{package}-{version}-py3-none-any.whl\n" - ) +/// One PEP 503 package page with PEP 658 metadata. The metadata lets uv resolve +/// the published dependency contract without downloading a wheel. +fn write_wheel_index(served: &Path, package: &str, version: &str, requires: &[String]) { + let file_name = format!("{package}-{version}-py3-none-any.whl"); + let html = + format!("{file_name}\n"); + write_fixture(&served.join("index.html"), &html); + let mut metadata = format!("Metadata-Version: 2.3\nName: {package}\nVersion: {version}\n"); + if matches!(package, "rocm" | "torch" | "torchvision") { + metadata.push_str("Provides-Extra: device-gfx1200\n"); + } + if package == "rocm" { + metadata.push_str("Provides-Extra: libraries\nProvides-Extra: devel\n"); + } + for requirement in requires { + writeln!(metadata, "Requires-Dist: {requirement}").expect("write fixture metadata"); + } + write_fixture(&served.join(format!("{file_name}.metadata")), &metadata); } fn write_pip_index(served: &Path, versions: [(&str, &str); 4]) { write_fixture(&served.join("index.html"), &aggregate_root_html()); + let torch_version = versions + .iter() + .find_map(|(package, version)| (*package == "torch").then_some(*version)) + .expect("fixture must include torch"); for (package, version) in versions { - write_fixture( - &served.join(package).join("index.html"), - &wheel_index_html(package, version), - ); + let requires = if matches!(package, "torchvision" | "torchaudio") { + vec![format!("torch=={torch_version}")] + } else { + Vec::new() + }; + write_wheel_index(&served.join(package), package, version, &requires); } } @@ -181,6 +197,25 @@ async fn pip_index_fixtures(world: &mut E2eWorld) { .push(("ROCM_CLI_THEROCK_NEXT_PIP_BASE", next.into())); } +#[given("an untrusted ROCm 10 pip base override")] +async fn untrusted_pip_index_fixture(world: &mut E2eWorld) { + let served = root(world).join("untrusted-therock-pip-fixture"); + write_pip_index( + &served, + [ + ("rocm", NEXT_ROCM_VERSION), + ("torch", NEXT_TORCH_VERSION), + ("torchvision", NEXT_TORCHVISION_VERSION), + ("torchaudio", NEXT_TORCHAUDIO_VERSION), + ], + ); + world.artifact_server = Some(LoopbackServer::start(&served)); + let untrusted = server_base(world); + world + .command_env + .push(("ROCM_CLI_THEROCK_NEXT_PIP_BASE", untrusted.into())); +} + #[given("a canonical release tarball fixture and a ROCm 10 tarball fixture with a tests sibling")] async fn tarball_index_fixtures(world: &mut E2eWorld) { let served = root(world).join("therock-tarball-fixtures"); @@ -394,6 +429,30 @@ async fn preview_resolves_next_pip_index(world: &mut E2eWorld) { ); } +#[then("the preview resolves the default ROCm 10 pip index")] +async fn preview_resolves_default_next_pip_index(world: &mut E2eWorld) { + assert_contains( + world, + &format!("canonical_source: {DEFAULT_NEXT_RELEASE_PIP_BASE}"), + "default next source", + ); + assert_contains( + world, + &format!("index_url: {DEFAULT_NEXT_RELEASE_PIP_BASE}"), + "default resolved index", + ); +} + +#[then("the preview never mentions the untrusted ROCm 10 pip index")] +async fn preview_never_mentions_untrusted_next_index(world: &mut E2eWorld) { + let untrusted = server_base(world); + assert!( + !stdout(world).contains(&untrusted), + "an override without explicit trust redirected the install to {untrusted}:\n{}", + stdout(world) + ); +} + #[then("the preview reports the next source layout generation")] async fn preview_reports_next_generation(world: &mut E2eWorld) { assert_contains( @@ -616,3 +675,43 @@ async fn assert_install_used_detected_device_target(world: &mut E2eWorld) { "install did not target this host's auto-detected GPU", ); } + +#[then("the ROCm 10 runtime passes SDK and Torch GPU probes")] +async fn assert_next_runtime_passes_sdk_and_gpu_probes(world: &mut E2eWorld) { + let install_output = stdout(world); + for field in ["rocm_sdk_version:", "rocm_sdk_root:", "rocm_sdk_bin:"] { + assert!( + install_output + .lines() + .any(|line| line.trim().starts_with(field)), + "live install did not report {field}\n{install_output}" + ); + } + let python = install_output + .lines() + .find_map(|line| line.trim().strip_prefix("python_executable: ")) + .expect("live install did not report its managed Python executable"); + let script = r#" +import json +import rocm_sdk +rocm_sdk.initialize_process() +import torch +assert torch.cuda.is_available(), "torch reports no usable AMD GPU" +assert torch.cuda.device_count() > 0, "torch reports zero AMD GPUs" +probe = torch.ones(1, device="cuda") +probe.add_(1) +torch.cuda.synchronize() +print(json.dumps({"rocm_sdk": rocm_sdk.__version__, "torch": torch.__version__, "hip": torch.version.hip, "devices": torch.cuda.device_count()})) +"#; + let result = std::process::Command::new(python) + .args(["-c", script]) + .output() + .unwrap_or_else(|error| panic!("failed to launch managed ROCm X Python {python}: {error}")); + assert!( + result.status.success(), + "managed ROCm X SDK/Torch GPU probe failed (status {}):\nstdout:\n{}\nstderr:\n{}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); +} From 59998d3b4518f13d5344f6d3cd3c2c0d99d9f34a Mon Sep 17 00:00:00 2001 From: Juho Vainio Date: Thu, 10 Sep 2026 09:15:18 +0300 Subject: [PATCH 6/8] fix(therock): make the gfx103X family-alias test discriminating normalize_therock_family_aliases_gfx103x_all_to_canonical_dgpu_label passed on unpatched main: the pre-existing `starts_with("gfx103")` arm already maps both gfx103X-all and gfx1030 to gfx103X-dgpu, so it proved nothing this PR introduced. Delete it and add a round-trip test where the actual new behavior lives: tarball_family_token's next-layout rename must map back through normalize_therock_family to gfx103X-dgpu, which only holds if the rename and the normalizer agree. Addresses the last open blocking finding from the automated review. Signed-off-by: Juho Vainio --- apps/rocm/src/therock.rs | 12 ++++++++++++ crates/rocm-core/src/lib.rs | 16 ---------------- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index 50fea6479..41b7e8cc9 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -5378,6 +5378,18 @@ mod tests { assert_eq!(version, "10.0.0"); } + /// The rename `tarball_family_token` applies for the next catalog must not + /// drift from what `normalize_therock_family` maps back to `gfx103X-dgpu`; + /// a family manifests under is worthless if the catalog's own alias for it + /// no longer round-trips. + #[test] + fn next_tarball_family_token_round_trips_through_normalize_therock_family() { + assert_eq!( + normalize_therock_family(tarball_family_token(SourceLayout::Next, "gfx103X-dgpu")), + Some("gfx103X-dgpu".to_owned()) + ); + } + /// A pinned tarball install must install what it named, not whatever is /// newest in the catalog. #[test] diff --git a/crates/rocm-core/src/lib.rs b/crates/rocm-core/src/lib.rs index 539dc60cd..358a65da0 100644 --- a/crates/rocm-core/src/lib.rs +++ b/crates/rocm-core/src/lib.rs @@ -9310,22 +9310,6 @@ mod tests { ); } - /// The next tarball catalog names this family `gfx103X-all` while the - /// canonical catalog names it `gfx103X-dgpu`. Both spellings, and the raw - /// arch, have to resolve to the one canonical label the rest of the CLI - /// stores, so a `--family gfx103X-all` install is not a different runtime. - #[test] - fn normalize_therock_family_aliases_gfx103x_all_to_canonical_dgpu_label() { - assert_eq!( - normalize_therock_family("gfx103X-all"), - Some("gfx103X-dgpu".to_owned()) - ); - assert_eq!( - normalize_therock_family("gfx1030"), - Some("gfx103X-dgpu".to_owned()) - ); - } - #[test] fn known_therock_families_all_round_trip() { for family in known_therock_families() { From ee21da52b2fdb2eeb779fda83831701aa5ee3b3b Mon Sep 17 00:00:00 2001 From: Juho Vainio Date: Fri, 11 Sep 2026 11:47:31 +0300 Subject: [PATCH 7/8] fix(therock): address rominf's review findings on ROCm 10 next-layout support - A pinned nightly ROCm 10.x prerelease (e.g. `10.1.0a20260822`) was refused outright: `next_layout_requested` gated on major >= 10 alone, so a nightly alpha pin tripped the next-layout dispatch and was told to retry on the release channel, which the stable-version filter would then also reject. Gate on a *stable* pin instead, matching what the canonical nightly stream already promises for future majors. - Updating a ROCm 10 wheel runtime whose family is a group label (every family this PR adds, including gfx125X-dcgpu) always failed: the manifest only records the family, and resolving with it as the override deliberately produces no raw arch, so the next layout's device target resolution came up undetermined on the very host the runtime is installed on. Recover the installed arch from the runtime's recorded wheel composition and use it as the override instead, when it agrees with the manifest's family. - The 2-second startup update-check budget was silently bypassed on the ROCm 10 path: `uv pip compile` was spawned and waited on unbounded, with no connection to `resolve_pip_runtime_from_index`'s `download_timeout_secs`. Add a bounded child-process wait and thread the timeout through. - `therock-next-06` is real network dependent (proving the *default* resolves requires reaching it), so add a hermetic unit test that exercises the actual env-reading trust-gate path (`env_override_base`/`resolve_source`) on the blocking lane, rather than leaving that boundary covered only by a `@nightly`-gated scenario. Also stop hardcoding `10.0.0` in that scenario's pin, reusing the same live version discovery `therock-next-07` already relies on. Verified against the live sources: `stable.repo.amd.com/rocm/whl-next` publishes `rocm-sdk-device-*` extras at its root, and neither tarball catalog (`repo.amd.com/rocm/tarball/`, `stable.repo.amd.com/rocm/core/tarball/`) currently contains a filename `validate_tarball_file_name` would reject. Signed-off-by: Juho Vainio --- apps/rocm/src/therock.rs | 233 +++++++++++++++++- .../features/therock_next_generation.feature | 9 +- tests/e2e-cucumber/tests/e2e/therock_steps.rs | 29 +++ 3 files changed, 260 insertions(+), 11 deletions(-) diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index 41b7e8cc9..bd790f53c 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -25,8 +25,9 @@ use std::fmt::Write as _; use std::fs; use std::io::{self, Read, Write}; use std::path::{Component, Path, PathBuf}; -use std::process::{Command, Output, Stdio}; -use std::time::Duration; +use std::process::{Child, Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; const THEROCK_NIGHTLY_PIP_INDEX_BASE: &str = "https://rocm.nightlies.amd.com/whl-multi-arch"; const THEROCK_RELEASE_PIP_INDEX_BASE: &str = "https://repo.amd.com/rocm/whl-multi-arch"; @@ -263,14 +264,21 @@ fn resolve_source(channel: TheRockChannel, layout: SourceLayout) -> ResolvedSour /// Whether `selector` names a ROCm release that only the next layout publishes. /// -/// An exact pin is the only signal that qualifies. A build date names a nightly -/// build, an unparseable string names nothing this CLI can reason about, and -/// neither is evidence that the canonical stream cannot serve the request. +/// An exact *stable* pin is the only signal that qualifies. A build date names +/// a nightly build, an unparseable string names nothing this CLI can reason +/// about, and neither is evidence that the canonical stream cannot serve the +/// request. Nor is a pinned prerelease of a future major: the canonical +/// nightly stream already serves those (see +/// `nightly_accepts_future_prerelease_major_without_cli_changes`), so gating +/// on major alone would route a nightly alpha pin like `10.1.0a20260822` into +/// a refusal the release-channel retry it suggests could never satisfy. fn next_layout_requested(selector: &RuntimeVersionSelector) -> bool { let RuntimeVersionSelector::Version(version) = selector else { return false; }; - parse_version(version).is_some_and(|parsed| parsed.major >= THEROCK_NEXT_MIN_MAJOR) + parse_version(version).is_some_and(|parsed| { + parsed.stage == VersionStage::Stable && parsed.major >= THEROCK_NEXT_MIN_MAJOR + }) } /// The layout an install must read from, and the refusal when it cannot. @@ -1224,6 +1232,21 @@ fn manifest_source_layout(manifest: &InstalledRuntimeManifest) -> Result String { + let recorded_arch = + wheel_composition_device_target(manifest.wheel_composition.as_ref()).map(str::to_owned); + raw_arch_agreeing_with_family(recorded_arch, &manifest.family) + .unwrap_or_else(|| manifest.family.clone()) +} + fn resolve_latest_for_manifest( paths: &AppPaths, manifest: &InstalledRuntimeManifest, @@ -1248,10 +1271,11 @@ fn resolve_latest_for_manifest( }; let wheel_compatibility = wheel_compatibility_for_python(&python_executable.executable)?; + let family_override = manifest_wheel_family_override(manifest); let resolution = resolve_pip_runtime_with_timeout( paths, channel, - Some(manifest.family.as_str()), + Some(family_override.as_str()), &wheel_compatibility, None, Some(layout), @@ -2046,6 +2070,7 @@ fn resolve_pip_runtime_from_index( &rocm_version, device_target, wheel_compatibility, + download_timeout_secs, )? } else { let torch_versions = load_simple_index_versions( @@ -2426,12 +2451,68 @@ fn parse_uv_compiled_package_versions(output: &str) -> Result) -> Result { + let Some(timeout) = timeout else { + return child + .wait_with_output() + .context("failed to wait for child process"); + }; + let stdout_reader = child.stdout.take().map(|mut stdout| { + thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stdout.read_to_end(&mut bytes); + bytes + }) + }); + let stderr_reader = child.stderr.take().map(|mut stderr| { + thread::spawn(move || { + let mut bytes = Vec::new(); + let _ = stderr.read_to_end(&mut bytes); + bytes + }) + }); + let deadline = Instant::now() + timeout; + loop { + if let Some(status) = child + .try_wait() + .context("failed to poll child process status")? + { + let stdout = stdout_reader + .map(|reader| reader.join().unwrap_or_default()) + .unwrap_or_default(); + let stderr = stderr_reader + .map(|reader| reader.join().unwrap_or_default()) + .unwrap_or_default(); + return Ok(Output { + status, + stdout, + stderr, + }); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + bail!( + "timed out after {}s waiting for child process", + timeout.as_secs() + ); + } + thread::sleep(Duration::from_millis(20)); + } +} + fn resolve_published_pip_package_versions( paths: &AppPaths, index_url: &str, rocm_version: &str, device_target: &str, compatibility: &WheelCompatibility, + download_timeout_secs: Option, ) -> Result { let uv = ensure_uv_binary(paths).context("failed to acquire uv for ROCm X metadata resolution")?; @@ -2472,8 +2553,8 @@ fn resolve_published_pip_package_versions( .context("uv metadata resolver stdin was unavailable")? .write_all(requirements.as_bytes()) .context("failed to send ROCm X requirements to uv")?; - let output = child - .wait_with_output() + let timeout = download_timeout_secs.map(Duration::from_secs); + let output = wait_with_output_bounded(child, timeout) .context("failed to wait for ROCm X metadata resolution")?; if !output.status.success() { let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned(); @@ -5233,6 +5314,22 @@ mod tests { assert!(error.contains("--channel release"), "{error}"); } + /// A pinned nightly prerelease of a future major (e.g. a ROCm 10 alpha + /// build) is not evidence the canonical stream can't serve it — only a + /// *stable* pin is. Gating on major alone would refuse this pin and then + /// suggest a release-channel retry that the stable-version filter would + /// also reject, leaving no working path. + #[test] + fn nightly_prerelease_pin_of_a_future_major_stays_canonical() { + let exact = family_for_test("gfx120X-all", Some("gfx1200")); + let pin = RuntimeVersionSelector::version("10.1.0a20260822").unwrap(); + + assert_eq!( + select_source_layout(TheRockChannel::Nightly, &exact, Some(&pin)).unwrap(), + SourceLayout::Canonical + ); + } + #[test] fn next_layout_resolves_its_own_bases() { let _guard = PROCESS_ENV_TEST_LOCK.lock().unwrap(); @@ -5268,6 +5365,43 @@ mod tests { assert_eq!(select_base_override(true, None, default), default); } + /// The test above only proves the pure decision rule; it never reads + /// process environment, so it can't catch `env_override_base` or + /// `resolve_source` failing to wire that rule to the real variables. The + /// only thing that does today is `therock-next-06`, an e2e scenario gated + /// `@nightly` because it has to reach the live default index — so on the + /// blocking lane this trust boundary otherwise has zero coverage of the + /// actual env-reading path. Exercise it here instead, with no network. + #[test] + #[allow(unsafe_code)] // std::env::set_var is unsafe in edition 2024 + fn env_override_is_ignored_end_to_end_without_the_opt_in() { + let _guard = PROCESS_ENV_TEST_LOCK.lock().unwrap(); + let old_override = std::env::var_os("ROCM_CLI_THEROCK_NEXT_PIP_BASE"); + let old_allow = std::env::var_os("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE"); + unsafe { + std::env::set_var("ROCM_CLI_THEROCK_NEXT_PIP_BASE", "http://127.0.0.1:9/whl"); + std::env::remove_var("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE"); + } + + let next = resolve_source(TheRockChannel::Release, SourceLayout::Next); + + unsafe { + match old_override { + Some(value) => std::env::set_var("ROCM_CLI_THEROCK_NEXT_PIP_BASE", value), + None => std::env::remove_var("ROCM_CLI_THEROCK_NEXT_PIP_BASE"), + } + match old_allow { + Some(value) => std::env::set_var("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE", value), + None => std::env::remove_var("ROCM_CLI_THEROCK_ALLOW_BASE_OVERRIDE"), + } + } + + assert_eq!( + next.wheel_index, THEROCK_NEXT_PIP_INDEX_BASE, + "an override named without the trust opt-in must not redirect resolution" + ); + } + #[test] fn tarball_catalog_rejects_unsafe_file_names_at_the_parser_boundary() { for name in [ @@ -5336,6 +5470,37 @@ mod tests { assert_eq!(raw_arch_agreeing_with_family(None, "gfx103X-dgpu"), None); } + /// Updating a next-layout manifest whose family is a group label (as + /// `gfx125X-dcgpu` always is) must recover the exact arch this runtime was + /// installed with from its composition, not resolve an undetermined + /// device target on the very host that arch came from. + #[test] + fn manifest_wheel_family_override_recovers_the_installed_arch() { + let mut manifest = test_runtime_manifest("next-v1:gfx125X-dcgpu", "next:gfx125X-dcgpu", 0); + manifest.wheel_composition = Some(test_wheel_composition("gfx1250")); + + assert_eq!(manifest_wheel_family_override(&manifest), "gfx1250"); + } + + /// A composition recorded for a different family (e.g. moved to another + /// host) must not be trusted — the plain family label is the safe fallback. + #[test] + fn manifest_wheel_family_override_ignores_a_disagreeing_composition() { + let mut manifest = test_runtime_manifest("next-v1:gfx125X-dcgpu", "next:gfx125X-dcgpu", 0); + manifest.wheel_composition = Some(test_wheel_composition("gfx90a")); + + assert_eq!(manifest_wheel_family_override(&manifest), "gfx125X-dcgpu"); + } + + /// No recorded composition at all (an older manifest, or an undetermined + /// install) falls back to the plain family label, same as before this fix. + #[test] + fn manifest_wheel_family_override_falls_back_without_a_composition() { + let manifest = test_runtime_manifest("v1:gfx120X-all", "canonical:gfx120X-all", 0); + + assert_eq!(manifest_wheel_family_override(&manifest), "gfx120X-all"); + } + /// The next catalog's gfx103X token differs from the canonical token, and /// its later `-tests-` sibling must not win selection. #[test] @@ -6437,6 +6602,56 @@ mod tests { Ok(()) } + #[cfg(not(windows))] + fn spawn_test_sleep(seconds: u64) -> std::process::Child { + Command::new("sleep") + .arg(seconds.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn sleep") + } + + #[cfg(windows)] + fn spawn_test_sleep(seconds: u64) -> std::process::Child { + Command::new("cmd") + .args(["/C", "timeout", "/t", &seconds.to_string(), "/nobreak"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn timeout") + } + + /// A budget-constrained caller (the startup update check) must get its + /// child back, not blocked past the budget it asked for. + #[test] + fn wait_with_output_bounded_kills_a_slow_child_at_the_deadline() { + let child = spawn_test_sleep(30); + let started = Instant::now(); + + let error = wait_with_output_bounded(child, Some(Duration::from_millis(200))) + .expect_err("a 30s sleep must not complete inside a 200ms budget"); + + assert!( + started.elapsed() < Duration::from_secs(5), + "kill was not prompt" + ); + assert!(error.to_string().contains("timed out"), "{error}"); + } + + /// An unbounded wait (an explicit user-invoked install, not a budgeted + /// startup check) still returns the child's real output. + #[test] + fn wait_with_output_bounded_waits_unbounded_when_no_timeout_is_given() { + let child = spawn_test_sleep(1); + + let output = wait_with_output_bounded(child, None).expect("child should exit"); + + assert!(output.status.success(), "{output:?}"); + } + /// The downloaded archive is removed once it has been unpacked; keeping it /// would double the disk cost of every installed SDK version. #[test] diff --git a/tests/e2e-cucumber/features/therock_next_generation.feature b/tests/e2e-cucumber/features/therock_next_generation.feature index 180e69b25..21898b88e 100644 --- a/tests/e2e-cucumber/features/therock_next_generation.feature +++ b/tests/e2e-cucumber/features/therock_next_generation.feature @@ -72,11 +72,16 @@ Feature: TheRock "next" ROCm 10 install layout # Artifact base overrides are a trust boundary. Merely naming an override # must not redirect the CLI; the separate opt-in is required. This scenario - # reaches the live default index, so it runs on the scheduled network lane. + # reaches the live default index (not a fixture, since proving the *default* + # is what resolves requires the real default), so it runs on the scheduled + # network lane rather than the hermetic ones above. The unit test + # `env_override_is_ignored_end_to_end_without_the_opt_in` in + # `apps/rocm/src/therock.rs` covers the same trust boundary on the blocking + # lane, without depending on the live network. @id:therock-next-06-base-override-requires-opt-in @nightly Scenario: therock-next-06 - A ROCm 10 source override is ignored without explicit trust Given an untrusted ROCm 10 pip base override - When the user previews a wheel SDK install for arch gfx1200 pinned to ROCm 10.0.0 + When the user previews a wheel SDK install for arch gfx1200 pinned to the latest published ROCm 10 version Then the preview resolves the default ROCm 10 pip index And the preview never mentions the untrusted ROCm 10 pip index diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs index 78ccdd861..7120f9544 100644 --- a/tests/e2e-cucumber/tests/e2e/therock_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -305,6 +305,35 @@ async fn preview_pinned_wheel_install(world: &mut E2eWorld) { ); } +/// Distinct from [`preview_pinned_wheel_install`]: this scenario's whole point +/// is that the override is ignored and resolution falls through to the real +/// `DEFAULT_NEXT_RELEASE_PIP_BASE`, so the pin has to be a version that source +/// actually publishes right now — a hardcoded `10.0.0` would go stale the +/// moment it is superseded, same reasoning as `discover_latest_next_rocm_version`'s +/// other caller. +#[when( + "the user previews a wheel SDK install for arch gfx1200 pinned to the latest published ROCm 10 version" +)] +async fn preview_pinned_wheel_install_latest_next_version(world: &mut E2eWorld) { + let version = discover_latest_next_rocm_version().await; + preview_ok( + world, + &[ + "install", + "sdk", + "--channel", + "release", + "--format", + "wheel", + "--family", + RAW_ARCH, + "--version", + &version, + "--dry-run", + ], + ); +} + #[when("the user previews a wheel SDK install for family gfx120X-all pinned to ROCm 10.0.0")] async fn preview_pinned_wheel_install_with_group_family(world: &mut E2eWorld) { preview( From fa95d0ca7dfdb202e82c50a69470fac78700501e Mon Sep 17 00:00:00 2001 From: Juho Vainio Date: Fri, 11 Sep 2026 13:29:44 +0300 Subject: [PATCH 8/8] fix(therock): fix ROCm 10 update-apply path and address automated review findings - The prior fix for grouped-family updates (manifest_wheel_family_override) only covered planning (resolve_latest_for_manifest); applying the update still passed the raw manifest family straight through, so `rocm update --apply` on any ROCm 10 wheel runtime still bailed with "cannot resolve ROCm X package metadata without an exact device target" - the whole feature's update path, since every family this PR adds is a group label. Extract family_override_or_recovered_arch and use it in install_wheel_runtime itself (seeded from device_target_override before resolve_pip_runtime is called), so the fix lives where every current and future caller of the wheel install path benefits, not just the two call sites that happen to pass a device target today. Added therock-next-08, a hermetic e2e scenario that plants a next-layout manifest with a grouped family and asserts `update --apply --dry-run` resolves past it; confirmed it fails with the exact reported error when the fix is reverted. - spawn_test_sleep's Windows branch used `cmd /C timeout`, which refuses to run at all with redirected stdin and exits immediately instead of sleeping - both new wait_with_output_bounded tests would fail on Windows for the wrong reason. Switched to `powershell -Command Start-Sleep`. - next_tarball_family_token_round_trips_through_normalize_therock_family asserted only the round-trip's final value, which normalize_therock_family produces identically whether or not the gfx103X rename happens (that's the point of the alias), so it couldn't detect a reverted rename. Split it into two assertions, the first pinning the rename itself. - The uv pip compile bound for ROCm X metadata resolution reused download_timeout_secs verbatim, the same per-fetch budget the canonical path applies once per package (rocm, torch, torchvision, torchaudio: 4 sequential fetches). uv resolves that same four-package set in one call, so give it a comparable aggregate budget instead of one that reliably times out a startup update check before it can ever succeed. - Reworded the tarball version-selector refusal: it previously read as if no tarball install could ever pin a version, when a stable ROCm 10+ pin can. - Noted (ponytail comment) that the uv requirements payload is written to stdin before wait_with_output_bounded's reader threads start, so a larger payload could deadlock outside the timeout. Harmless at today's size. Verified: cargo test -p rocm --bin rocm therock (125 passed), cargo clippy --workspace --all-targets -D warnings (clean), cargo fmt --check (clean), and the 6 hermetic therock-next e2e scenarios plus therock-next-06 against the live index all pass. Signed-off-by: Juho Vainio --- apps/rocm/src/therock.rs | 79 +++++++++++++++---- .../features/therock_next_generation.feature | 15 ++++ tests/e2e-cucumber/tests/e2e/therock_steps.rs | 68 ++++++++++++++++ 3 files changed, 147 insertions(+), 15 deletions(-) diff --git a/apps/rocm/src/therock.rs b/apps/rocm/src/therock.rs index bd790f53c..c3ccd2108 100644 --- a/apps/rocm/src/therock.rs +++ b/apps/rocm/src/therock.rs @@ -954,7 +954,9 @@ pub(crate) fn install_sdk( if let Some(selector) = version_selector.as_ref() && !next_layout_requested(selector) { - bail!("specific TheRock version selection is only supported for wheel installs") + bail!( + "specific TheRock version selection for tarball installs is only supported for a stable ROCm {THEROCK_NEXT_MIN_MAJOR}+ pin (e.g. `--version 10.0.0`); for any other version, use `--format wheel`" + ) } install_tarball_runtime( paths, @@ -1232,19 +1234,31 @@ fn manifest_source_layout(manifest: &InstalledRuntimeManifest) -> Result) -> String { + raw_arch_agreeing_with_family(candidate_arch.map(str::to_owned), family) + .unwrap_or_else(|| family.to_owned()) +} + +/// The family override to re-resolve a wheel manifest's update *plan* with. +/// See [`family_override_or_recovered_arch`] — the composition recorded at +/// install time is this call's source for the exact arch. fn manifest_wheel_family_override(manifest: &InstalledRuntimeManifest) -> String { - let recorded_arch = - wheel_composition_device_target(manifest.wheel_composition.as_ref()).map(str::to_owned); - raw_arch_agreeing_with_family(recorded_arch, &manifest.family) - .unwrap_or_else(|| manifest.family.clone()) + let recorded_arch = wheel_composition_device_target(manifest.wheel_composition.as_ref()); + family_override_or_recovered_arch(&manifest.family, recorded_arch) } fn resolve_latest_for_manifest( @@ -1518,10 +1532,16 @@ fn install_wheel_runtime( "Checking TheRock {} packages for this AMD GPU...", channel.as_str() )); + // `device_target_override` (an update apply's exact recorded arch) is only + // otherwise consulted below, after `resolve_pip_runtime` returns — too late + // for the next layout, which needs an exact arch before it can query + // package metadata at all. Recover it into the family override up front. + let recovered_family_override = family_override + .map(|family| family_override_or_recovered_arch(family, device_target_override)); let resolution = resolve_pip_runtime( paths, channel, - family_override, + recovered_family_override.as_deref(), &wheel_compatibility, version_selector, layout_override, @@ -2064,13 +2084,20 @@ fn resolve_pip_runtime_from_index( ); format!("no TheRock rocm package was found for {requested} in {index_url}") })?; + // The canonical branch below budgets one `download_timeout_secs` per + // package (rocm, then torch, torchvision, torchaudio: 4 sequential HTTP + // fetches). `uv pip compile` resolves that same four-package metadata + // set in one subprocess call, so it needs a comparable aggregate + // budget, not the single-fetch one — reusing the bare per-fetch value + // here would make a startup check that budgets 2s per fetch reliably + // time out a call doing 4 fetches' worth of work. resolve_published_pip_package_versions( paths, index_url, &rocm_version, device_target, wheel_compatibility, - download_timeout_secs, + download_timeout_secs.map(|secs| secs.saturating_mul(4)), )? } else { let torch_versions = load_simple_index_versions( @@ -2547,6 +2574,11 @@ fn resolve_published_pip_package_versions( uv.display() ) })?; + // ponytail: written before `wait_with_output_bounded` spawns its stdout/ + // stderr reader threads, so a child that fills its stdout pipe before this + // write returns would deadlock outside the timeout below. `requirements` + // is a handful of short lines today; move the write onto its own thread + // (or start the readers first) if it ever grows enough to matter. child .stdin .take() @@ -5547,10 +5579,19 @@ mod tests { /// drift from what `normalize_therock_family` maps back to `gfx103X-dgpu`; /// a family manifests under is worthless if the catalog's own alias for it /// no longer round-trips. + /// + /// The first assertion is the one that actually pins the rename: + /// `normalize_therock_family` already maps *both* `gfx103X-dgpu` and its + /// `gfx103X-all` alias back to `gfx103X-dgpu` (that is the whole point of + /// the alias), so asserting only the round-trip's final value passes + /// identically whether or not `tarball_family_token` renames anything — + /// it does not distinguish the rename from its absence. #[test] fn next_tarball_family_token_round_trips_through_normalize_therock_family() { + let token = tarball_family_token(SourceLayout::Next, "gfx103X-dgpu"); + assert_eq!(token, "gfx103X-all"); assert_eq!( - normalize_therock_family(tarball_family_token(SourceLayout::Next, "gfx103X-dgpu")), + normalize_therock_family(token), Some("gfx103X-dgpu".to_owned()) ); } @@ -6613,15 +6654,23 @@ mod tests { .expect("spawn sleep") } + // `timeout.exe` refuses to run at all with its stdin redirected (it demands + // a real console even with `/nobreak`), exiting immediately instead of + // sleeping — which used to make both tests below pass or fail for the + // wrong reason. `Start-Sleep` has no such requirement. #[cfg(windows)] fn spawn_test_sleep(seconds: u64) -> std::process::Child { - Command::new("cmd") - .args(["/C", "timeout", "/t", &seconds.to_string(), "/nobreak"]) + Command::new("powershell") + .args([ + "-NoProfile", + "-Command", + &format!("Start-Sleep -Seconds {seconds}"), + ]) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() - .expect("spawn timeout") + .expect("spawn Start-Sleep") } /// A budget-constrained caller (the startup update check) must get its diff --git a/tests/e2e-cucumber/features/therock_next_generation.feature b/tests/e2e-cucumber/features/therock_next_generation.feature index 21898b88e..df22c56d9 100644 --- a/tests/e2e-cucumber/features/therock_next_generation.feature +++ b/tests/e2e-cucumber/features/therock_next_generation.feature @@ -103,3 +103,18 @@ Feature: TheRock "next" ROCm 10 install layout And the runtime is set as active And the runtime includes an inference engine And the ROCm 10 runtime passes SDK and Torch GPU probes + + # The regression this pins is distinct from therock-next-02's: that scenario + # proves a *fresh* install resolves an exact arch when the user supplies one. + # This one proves *updating* an already-installed next-layout runtime works + # when all the CLI has on hand is the manifest's group family (`gfx120X-all`) + # — exactly what every installed ROCm 10 runtime's manifest carries, since + # `--family` is only ever typed once. A fix that recovers the exact arch for + # planning (deciding a newer version exists) but not for applying (actually + # resolving and installing it) leaves this scenario red. + @id:therock-next-08-update-apply-recovers-exact-arch-from-grouped-family + Scenario: therock-next-08 - Updating a ROCm 10 wheel runtime resolves past its grouped family + Given a canonical release pip index fixture and a ROCm 10 pip index fixture + And a registered ROCm 10 wheel runtime with a grouped family + When the user previews applying the pending update to that runtime + Then the preview requests the gfx1200 device extras diff --git a/tests/e2e-cucumber/tests/e2e/therock_steps.rs b/tests/e2e-cucumber/tests/e2e/therock_steps.rs index 7120f9544..18d1c80d6 100644 --- a/tests/e2e-cucumber/tests/e2e/therock_steps.rs +++ b/tests/e2e-cucumber/tests/e2e/therock_steps.rs @@ -197,6 +197,74 @@ async fn pip_index_fixtures(world: &mut E2eWorld) { .push(("ROCM_CLI_THEROCK_NEXT_PIP_BASE", next.into())); } +/// Registry key for the planted manifest below. Filename-safe (no `:`), same +/// reasoning as `runtime_lifecycle_steps.rs`'s planted keys. +const NEXT_GROUPED_FAMILY_RUNTIME_KEY: &str = "release-wheel-next-v1-gfx120x-all-9-0-0"; + +/// Plants a `wheel`/`next-v1` runtime manifest whose family is a *group* label +/// (`gfx120X-all` — every family this PR adds is one), with the exact arch it +/// was installed for recorded in `wheel_composition.rocm_sdk_target`, exactly +/// the shape a real ROCm 10 install produces. `apply_runtime_update` -> +/// `install_sdk_for_update` -> `install_wheel_runtime` must resolve past this +/// without the user ever typing `--family gfx1200` again. +/// +/// Version `9.0.0` (older than the fixture's published `NEXT_ROCM_VERSION`) so +/// the update plan finds one available; nothing about this scenario depends on +/// a real ROCm 9 having existed, since the layout is read from +/// `source_layout_generation`, not derived from the version string. +#[given("a registered ROCm 10 wheel runtime with a grouped family")] +async fn registered_next_wheel_runtime_with_grouped_family(world: &mut E2eWorld) { + let install_root = root(world).join("runtime-next-grouped-family"); + std::fs::create_dir_all(&install_root).expect("failed to create install root"); + + let registry = root(world).join("data").join("runtimes").join("registry"); + std::fs::create_dir_all(®istry).expect("failed to create registry dir"); + let manifest = serde_json::to_string_pretty(&serde_json::json!({ + "runtime_key": NEXT_GROUPED_FAMILY_RUNTIME_KEY, + "runtime_id": format!("therock-release:{GROUP_FAMILY}"), + "channel": "release", + "format": "wheel", + "family": GROUP_FAMILY, + "family_source": "manual", + "version": "9.0.0", + "install_root": install_root, + "selected_artifact_url": "https://example.invalid/rocm", + "source_layout_generation": "next-v1", + "read_only": false, + "wheel_composition": { + "source_layout_generation": "next-v1", + "package_specs": [ + format!("rocm[libraries,devel,device-{RAW_ARCH}]==9.0.0"), + "torch==2.9.0+rocm9.0.0", + "torchvision==0.24.0+rocm9.0.0", + "torchaudio==2.9.0+rocm9.0.0", + ], + "rocm_sdk_target": RAW_ARCH, + }, + "installed_at_unix_ms": 1_700_000_000_000u64, + })) + .expect("failed to serialize runtime manifest"); + std::fs::write( + registry.join(format!("{NEXT_GROUPED_FAMILY_RUNTIME_KEY}.json")), + manifest, + ) + .expect("failed to write runtime manifest"); +} + +#[when("the user previews applying the pending update to that runtime")] +async fn preview_apply_pending_update(world: &mut E2eWorld) { + preview_ok( + world, + &[ + "update", + "--apply", + "--dry-run", + "--runtime", + NEXT_GROUPED_FAMILY_RUNTIME_KEY, + ], + ); +} + #[given("an untrusted ROCm 10 pip base override")] async fn untrusted_pip_index_fixture(world: &mut E2eWorld) { let served = root(world).join("untrusted-therock-pip-fixture");