diff --git a/.github/workflows/test_native_linux_packages_install.yml b/.github/workflows/test_native_linux_packages_install.yml index b0ca8972e54..56c5fc95d28 100644 --- a/.github/workflows/test_native_linux_packages_install.yml +++ b/.github/workflows/test_native_linux_packages_install.yml @@ -67,6 +67,11 @@ on: set it manually. type: string default: "" + run_uninstall: + description: "Enable uninstall verification: remove installed ROCm metapackages and assert clean teardown (sanity/full only). Disabled by default." + required: false + type: boolean + default: false workflow_dispatch: inputs: package_install_url: @@ -132,6 +137,11 @@ on: set it manually. type: string default: "" + run_uninstall: + description: "Enable uninstall verification: remove installed ROCm metapackages and assert clean teardown (sanity/full only). Disabled by default." + required: false + type: boolean + default: false permissions: id-token: write @@ -207,6 +217,7 @@ jobs: INSTALL_PREFIX: ${{ inputs.install_prefix || '/opt/rocm/core' }} TEST_TYPE: ${{ inputs.test_type }} BUILD_VARIANT: ${{ inputs.build_variant }} + RUN_UNINSTALL: ${{ inputs.run_uninstall || '' }} steps: - name: Install System Prerequisites run: | diff --git a/build_tools/packaging/linux/native_linux_package_install_test.py b/build_tools/packaging/linux/native_linux_package_install_test.py index 2c2be944309..5a15eda0dae 100644 --- a/build_tools/packaging/linux/native_linux_package_install_test.py +++ b/build_tools/packaging/linux/native_linux_package_install_test.py @@ -20,6 +20,15 @@ 2. Basic verification: install prefix, key components, installed packages list, rocminfo. (Run for both sanity and full.) 3. Full verification: rdhc.py / RDHC test. (Run only for full.) + 4. Uninstall (optional, ``--with-uninstall`` / ``RUN_UNINSTALL=1``): after install + verification succeeds, remove installed metapackages in reverse install order + and assert no ROCm packages remain. Step 4a uninstall commands: deb uses + ``apt remove`` then ``apt autoremove``; RHEL-family rpm uses ``dnf remove`` + (dependency cleanup is automatic); SLES uses ``zypper remove --clean-deps`` + (required so dependency packages are removed). Step 4b queries the package + manager and fails if any installed package name contains ``rocm`` or + ``amdrocm``. Runs only for ``sanity`` and ``full`` test types; ignored for + ``simulate`` and ``install``. - comprehensive: CI alias for full. - install: Repo-based install only (step 1). No rocminfo or component checks. Used by release workflows that dispatch install tests off the critical path. @@ -54,6 +63,7 @@ Workflow/container ``env`` maps to CLI flags via :func:`_argv_from_ci_env` + ``test_native_linux_package_install``. For versioned metapackage names only, set ``NATIVE_LINUX_INSTALL_ROCM_VERSION`` and omit ``--rocm-version`` when unversioned packages are desired. For multiple arches from CI, set ``GFX_ARCH`` to whitespace-separated tokens (e.g. ``gfx94x gfx1100``), semicolon-separated (e.g. ``gfx94x;gfx1100``), or a single comma-separated value (e.g. ``gfx94x,gfx1100``); optional ``NATIVE_LINUX_INSTALL_ROCM_VERSION`` pairs with ``GFX_ARCH`` like ``--rocm-version`` with ``--gfx-arch`` on the CLI. +Optional Step 4 uninstall: set ``RUN_UNINSTALL`` to ``1`` (or ``true``/``yes``) in CI, or pass ``--with-uninstall`` on the CLI (``sanity``/``full`` only). Use ``0``/``false``/``no`` to disable; any other non-empty value is a configuration error. You can still invoke this file as a script for ad-hoc runs (no pytest required). Example invocations: @@ -98,6 +108,21 @@ --repo-url https://nightly.repo.amd.com/rocm/core/packages/deb/20260204-21658678136/ \\ --gfx-arch gfx94x --release-type nightly --install-prefix /opt/rocm/core + # --with-uninstall (Step 4): after sanity/full succeed, remove metapackages and verify clean teardown + python3 native_linux_package_install_test.py --test-type sanity \\ + --os-profile ubuntu2404 \\ + --repo-url https://rocm.nightlies.amd.com/deb/20260204-21658678136/ \\ + --gfx-arch gfx94x --release-type nightly --install-prefix /opt/rocm/core \\ + --with-uninstall + + # SLES: zypper remove --clean-deps is required for dependency cleanup during Step 4 + python3 native_linux_package_install_test.py --test-type sanity \\ + --os-profile sles16 \\ + --repo-url https://rocm.prereleases.amd.com/packages/sles16/x86_64/ \\ + --release-type prerelease --install-prefix /opt/rocm/core \\ + --gpg-key-url https://rocm.prereleases.amd.com/packages/gpg/rocm.gpg \\ + --with-uninstall + # --test-type install: repo install only (no verification) python3 native_linux_package_install_test.py --test-type install \\ --os-profile ubuntu2404 \\ @@ -182,6 +207,12 @@ def _env(key: str, default: str) -> str: # Pytest/CI only: becomes ``--rocm-version``. ENV_NATIVE_LINUX_INSTALL_ROCM_VERSION = "NATIVE_LINUX_INSTALL_ROCM_VERSION" +# Pytest/CI only: workflow ``run_uninstall: true`` sets this env (typically ``true``), +# which adds ``--with-uninstall`` (Step 4). Accepted: 1/true/yes (enable), 0/false/no (disable), +# unset (disable). Any other non-empty value raises ValueError (fail-fast). +ENV_RUN_UNINSTALL = "RUN_UNINSTALL" +_RUN_UNINSTALL_ENABLE = frozenset({"1", "true", "yes"}) +_RUN_UNINSTALL_DISABLE = frozenset({"0", "false", "no"}) # Timeouts (seconds) and verification threshold GPG_MKDIR_TIMEOUT_SEC = 10 @@ -191,6 +222,7 @@ def _env(key: str, default: str) -> str: ZYPP_REFRESH_TIMEOUT_SEC = 120 DNF_CLEAN_TIMEOUT_SEC = 60 INSTALL_TIMEOUT_SEC = 1800 # 30 minutes +UNINSTALL_TIMEOUT_SEC = 600 # 10 minutes; large stacks may install hundreds of packages ROCMINFO_TIMEOUT_SEC = 30 # rdhc.py ``--all`` runs the full ROCm deployment health check suite; 30s was too # short in container CI (timeouts under load). Optional cluster checks are skipped @@ -209,6 +241,26 @@ def _env(key: str, default: str) -> str: } +def _parse_run_uninstall_ci_env() -> bool: + """Return whether CI env enables Step 4 uninstall. + + Raises: + ValueError: If ``RUN_UNINSTALL`` is set to an unrecognized value. + """ + raw = (os.environ.get(ENV_RUN_UNINSTALL) or "").strip() + if not raw: + return False + normalized = raw.lower() + if normalized in _RUN_UNINSTALL_ENABLE: + return True + if normalized in _RUN_UNINSTALL_DISABLE: + return False + raise ValueError( + f"Invalid RUN_UNINSTALL value: {raw!r}. " + "Expected: 1/true/yes (enable) or 0/false/no (disable)." + ) + + def _normalize_test_type(test_type: str | None) -> str: """Map shared CI test types to native package install test modes. @@ -278,6 +330,17 @@ def run_simulate_install_test(pkg_type: str, packages_dir: str) -> bool: return False +def _is_rocm_related_package_name(name: str) -> bool: + """Return True if ``name`` looks like a native Linux ROCm package. + + Matches TheRock metapackage and component naming (``amdrocm*``, ``rocm*``). + Used when filtering ``dpkg -l`` / ``rpm -qa`` output during uninstall + verification. + """ + lower = name.lower() + return "rocm" in lower or "amdrocm" in lower + + def _run_streaming(cmd: list[str], timeout_sec: int) -> int: """Run a command with streaming stdout/stderr and return its exit code. @@ -1270,6 +1333,195 @@ def run_full_verification(self) -> bool: print("=" * 80) return self.test_rdhc() + def list_installed_rocm_packages(self) -> list[str]: + """Query the system package manager for installed ROCm packages. + + deb: parses ``dpkg -l`` lines in installed (``ii``) state. + rpm: parses ``rpm -qa`` output (NEVRA strings; matched by substring). + + Returns: + Sorted list of matching entries. Empty on query failure. + """ + try: + if self.package_type == "deb": + result = subprocess.run( + ["dpkg", "-l"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + names: list[str] = [] + for line in result.stdout.splitlines(): + if not line.startswith("ii"): + continue + parts = line.split() + if len(parts) >= 2 and _is_rocm_related_package_name(parts[1]): + names.append(parts[1]) + return sorted(names) + + result = subprocess.run( + ["rpm", "-qa"], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + return sorted( + line.strip() + for line in result.stdout.splitlines() + if line.strip() and _is_rocm_related_package_name(line.strip()) + ) + except subprocess.CalledProcessError as e: + print(f"[WARN] Could not query installed packages: {e}") + return [] + except OSError as e: + print(f"[WARN] Could not query installed packages: {e}") + return [] + + def uninstall_packages(self) -> bool: + """Step 4a: remove installed metapackages in reverse install order. + + deb: ``sudo apt remove -y`` then ``sudo apt autoremove -y``. + RHEL-family rpm: ``dnf remove -y`` (unused dependencies are removed + automatically). + SLES: ``zypper --non-interactive remove -y --clean-deps`` — ``--clean-deps`` + is required; without it only the metapackages are removed and hundreds of + dependency packages can remain installed. + + Returns: + True if uninstall commands succeeded, False otherwise. + """ + print("\n" + "=" * 80) + print("STEP 4a: UNINSTALL PACKAGES") + print("=" * 80) + + packages_to_remove = list(reversed(self.package_names)) + if not packages_to_remove: + print("[WARN] No package names configured for uninstall") + return True + + print(f"\nPackages to remove (reverse install order): {packages_to_remove}") + + if self.package_type == "deb": + remove_cmd = ["sudo", "apt", "remove", "-y"] + packages_to_remove + autoremove_cmd = ["sudo", "apt", "autoremove", "-y"] + elif self._is_sles(): + # SLES has no apt-style autoremove; --clean-deps removes unneeded deps. + remove_cmd = [ + "zypper", + "--non-interactive", + "remove", + "-y", + "--clean-deps", + ] + packages_to_remove + autoremove_cmd = None + else: + remove_cmd = ["dnf", "remove", "-y"] + packages_to_remove + autoremove_cmd = None + + print(f"\nRunning: {' '.join(remove_cmd)}") + print("=" * 80) + print("Uninstall progress (streaming output):\n") + + try: + return_code = _run_streaming(remove_cmd, UNINSTALL_TIMEOUT_SEC) + if return_code != 0: + print("\n" + "=" * 80) + print(f"[FAIL] Failed to remove packages (exit code: {return_code})") + return False + + if autoremove_cmd: + print(f"\nRunning: {' '.join(autoremove_cmd)}") + print("=" * 80) + print("Autoremove progress (streaming output):\n") + return_code = _run_streaming(autoremove_cmd, UNINSTALL_TIMEOUT_SEC) + if return_code != 0: + print("\n" + "=" * 80) + print(f"[FAIL] apt autoremove failed (exit code: {return_code})") + return False + + print("\n" + "=" * 80) + print("[PASS] Package uninstall completed successfully") + return True + except subprocess.TimeoutExpired: + print("\n" + "=" * 80) + print( + f"[FAIL] Uninstall timed out after {UNINSTALL_TIMEOUT_SEC // 60} minutes" + ) + return False + except OSError as e: + print(f"\n[FAIL] Error during uninstall: {e}") + return False + + def run_uninstall_verification(self) -> bool: + """Step 4b: verify no ROCm packages remain after uninstall. + + Pass/fail is determined solely by the package-manager query in + ``list_installed_rocm_packages()``. Install-prefix directory checks are + informational only (the prefix directory may remain empty after removal). + + Returns: + True if zero ROCm-related packages remain installed. + """ + print("\n" + "=" * 80) + print("STEP 4b: UNINSTALL VERIFICATION") + print("=" * 80) + + remaining = self.list_installed_rocm_packages() + if remaining: + print(f"\n[FAIL] {len(remaining)} ROCm package(s) still installed:") + for pkg in remaining[:10]: + print(f" {pkg}") + if len(remaining) > 10: + print(f" ... and {len(remaining) - 10} more") + return False + + print("\n[PASS] No ROCm packages remain installed") + + install_path = Path(self.install_prefix) + if not install_path.exists(): + print(f"[PASS] Install prefix removed: {self.install_prefix}") + else: + leftover = [ + component + for component in VERIFY_KEY_COMPONENTS + if (install_path / component).exists() + ] + if leftover: + print( + f"[WARN] Install prefix still contains key components: {leftover}" + ) + else: + print( + f"[INFO] Install prefix exists but key ROCm components are gone: " + f"{self.install_prefix}" + ) + + print("\n[PASS] Uninstall verification PASSED") + return True + + def run_uninstall_and_verify(self) -> bool: + """Step 4: orchestrate uninstall (4a) and post-uninstall verification (4b). + + Returns: + True if both uninstall and verification succeeded. + """ + print("\n" + "=" * 80) + print("STEP 4: UNINSTALL AND VERIFY") + print("=" * 80) + + before = self.list_installed_rocm_packages() + print(f"\nROCm packages before uninstall: {len(before)}") + if before: + print(" Sample packages (first 5):") + for pkg in before[:5]: + print(f" {pkg}") + + if not self.uninstall_packages(): + return False + return self.run_uninstall_verification() + def test_rdhc(self) -> bool: """Test rdhc.py binary in libexec/rocm-core/. @@ -1490,6 +1742,19 @@ def _fixed_rpath_entries(readelf_output: str) -> list[str]: --repo-url https://nightly.repo.amd.com/rocm/core/packages/deb/20260204-21658678136/ \\ --gfx-arch gfx94x --release-type nightly --install-prefix /opt/rocm/core + # --with-uninstall (Step 4): after sanity/full succeed, remove metapackages and verify clean teardown + python native_linux_package_install_test.py --test-type sanity --os-profile ubuntu2404 \\ + --repo-url https://rocm.nightlies.amd.com/deb/20260204-21658678136/ \\ + --gfx-arch gfx94x --release-type nightly --install-prefix /opt/rocm/core \\ + --with-uninstall + + # SLES: zypper remove --clean-deps is required for dependency cleanup during Step 4 + python native_linux_package_install_test.py --test-type sanity --os-profile sles16 \\ + --repo-url https://rocm.prereleases.amd.com/packages/sles16/x86_64/ \\ + --release-type prerelease --install-prefix /opt/rocm/core \\ + --gpg-key-url https://rocm.prereleases.amd.com/packages/gpg/rocm.gpg \\ + --with-uninstall + # --test-type install: install only python native_linux_package_install_test.py --test-type install --os-profile ubuntu2404 \\ --repo-url https://therock-dev-artifacts.s3.amazonaws.com/26299074718-linux/packages/deb \\ @@ -1606,6 +1871,16 @@ def _build_argument_parser(*, exit_on_error: bool = True) -> ArgumentParser: default="sanity", help="Test type: 'install' = repo install only; 'sanity' = install + basic verification; 'full' = sanity + rdhc; 'simulate' = dry-run local packages (requires --packages-dir). Also accepts CI test types: quick, standard, comprehensive.", ) + parser.add_argument( + "--with-uninstall", + action="store_true", + help=( + "After install verification succeeds (sanity or full), run Step 4: " + "remove metapackages and verify no ROCm packages remain. deb: apt " + "remove + autoremove; RHEL: dnf remove; SLES: zypper remove " + "--clean-deps. Ignored for simulate and install test types." + ), + ) parser.add_argument( "--packages-dir", type=str, @@ -1679,7 +1954,14 @@ def _raise(msg: str) -> None: def run_tests(args: Namespace) -> int: - """Run simulate or repo-based install test from parsed CLI args. Returns exit code (0 success).""" + """Run simulate or repo-based install test from parsed CLI args. + + Repo-based flows run Steps 1–2 (sanity) or 1–3 (full). When + ``args.with_uninstall`` is set, Step 4 runs after those steps succeed. + + Returns: + Exit code (0 success). + """ if args.test_type == "simulate": pkg_type = args.pkg_type or NativeLinuxPackageInstallTest._derive_package_type( args.os_profile @@ -1743,6 +2025,8 @@ def run_tests(args: Namespace) -> int: print("ROCm version (for package names): (not set)") print(f"Install Prefix: {args.install_prefix}") print(f"Test Type: {args.test_type}") + if args.with_uninstall: + print("With Uninstall: yes") if args.gpg_key_url: print(f"GPG Key URL: {args.gpg_key_url}") print("=" * 80) @@ -1783,10 +2067,22 @@ def run_tests(args: Namespace) -> int: if not test_runner.run_full_verification(): print("\n[FAIL] Step 3 (full verification) failed.") return 1 + if args.with_uninstall and args.test_type in ("sanity", "full"): + if not test_runner.run_uninstall_and_verify(): + print("\n[FAIL] Step 4 (uninstall and verify) failed.") + return 1 print("\n" + "=" * 80) print("[PASS] INSTALLATION TEST PASSED") if args.test_type == "sanity": - print("(sanity: basic verification completed)") + msg = "(sanity: basic verification completed" + if args.with_uninstall: + msg += " + uninstall verified" + print(msg + ")") + elif args.test_type == "full": + msg = "ROCm has been successfully installed from repository and verified" + if args.with_uninstall: + msg += " and uninstalled cleanly" + print(msg + "!") else: print("ROCm has been successfully installed from repository and verified!") print("=" * 80 + "\n") @@ -1801,9 +2097,12 @@ def _argv_from_ci_env() -> list[str] | None: """Build CLI argv from workflow/container env (see ``test_native_linux_packages_install.yml``). Required for sanity/full: OS_PROFILE, REPO_URL, RELEASE_TYPE, INSTALL_PREFIX. - Optional: GFX_ARCH, GPG_KEY_URL; ``NATIVE_LINUX_INSTALL_ROCM_VERSION`` maps to ``--rocm-version`` - only when versioned package names are needed (omit for unversioned installs). + Optional: GFX_ARCH, GPG_KEY_URL, BUILD_VARIANT; ``NATIVE_LINUX_INSTALL_ROCM_VERSION`` + maps to ``--rocm-version`` when versioned package names are needed; + ``RUN_UNINSTALL`` (1/true/yes) maps to ``--with-uninstall`` for Step 4; + 0/false/no disables; any other non-empty value raises ``ValueError``. """ + with_uninstall = _parse_run_uninstall_ci_env() test_type = (os.environ.get("TEST_TYPE") or "sanity").strip().lower() or "sanity" if test_type == "simulate": @@ -1862,6 +2161,8 @@ def _argv_from_ci_env() -> list[str] | None: build_variant = (os.environ.get("BUILD_VARIANT") or "").strip() if build_variant: argv.extend(["--build-variant", build_variant]) + if with_uninstall: + argv.append("--with-uninstall") return argv @@ -1875,11 +2176,14 @@ def test_native_linux_package_install() -> None: pytest.fail( "Missing required environment variables for native install test " "(expected OS_PROFILE, REPO_URL, RELEASE_TYPE, INSTALL_PREFIX; " - "optional GFX_ARCH, NATIVE_LINUX_INSTALL_ROCM_VERSION; or for simulate: PACKAGES_DIR)." + "optional GFX_ARCH, GPG_KEY_URL, BUILD_VARIANT, " + "NATIVE_LINUX_INSTALL_ROCM_VERSION, RUN_UNINSTALL; " + "or for simulate: PACKAGES_DIR)." ) pytest.skip( "Set workflow env vars (OS_PROFILE, REPO_URL, RELEASE_TYPE, INSTALL_PREFIX); " - "optional GFX_ARCH and NATIVE_LINUX_INSTALL_ROCM_VERSION." + "optional GFX_ARCH, GPG_KEY_URL, BUILD_VARIANT, " + "NATIVE_LINUX_INSTALL_ROCM_VERSION, RUN_UNINSTALL." ) args = parse_cli_arguments(argv, raise_instead_of_exit=True) diff --git a/build_tools/packaging/linux/tests/native_linux_package_install_ut_test.py b/build_tools/packaging/linux/tests/native_linux_package_install_ut_test.py index c2392f95304..9d2ecd78182 100644 --- a/build_tools/packaging/linux/tests/native_linux_package_install_ut_test.py +++ b/build_tools/packaging/linux/tests/native_linux_package_install_ut_test.py @@ -2,8 +2,9 @@ # SPDX-License-Identifier: MIT # Unit test coverage for native_linux_package_install_test.py: -# All testable behaviour is covered with unit tests (pure logic or mocked I/O/subprocess). -# Integration-only (real apt/rpm/zypper, network, root): main() execution path after validation. +# All testable behaviour is covered with unit tests (pure logic or mocked I/O/subprocess), +# including optional Step 4 uninstall (--with-uninstall / RUN_UNINSTALL). +# Integration-only (real apt/rpm/zypper, network, root): main() and pytest CI entry paths. import contextlib import importlib.util @@ -18,10 +19,6 @@ from pathlib import Path from unittest.mock import patch, MagicMock -# Used only by the real-ELF fixture tests (VerifyNoRunpathRealElfTest) below. -import shutil -import subprocess - # Load the module: look in same dir as this file, then parent (covers linux/ or linux/tests/ layout). _this_file = Path(__file__).resolve() _search_dirs = [_this_file.parent, _this_file.parent.parent] @@ -974,6 +971,67 @@ def test_returns_none_when_required_env_missing(self): with patch.dict(os.environ, {"TEST_TYPE": "install"}, clear=True): self.assertIsNone(native_linux_package_install_test._argv_from_ci_env()) + def test_adds_with_uninstall_when_run_uninstall_set(self): + env = { + "TEST_TYPE": "sanity", + "OS_PROFILE": "ubuntu2404", + "REPO_URL": "https://example.com/deb", + "RELEASE_TYPE": "dev", + "INSTALL_PREFIX": "/opt/rocm/core", + "RUN_UNINSTALL": "1", + } + with patch.dict(os.environ, env, clear=False): + argv = native_linux_package_install_test._argv_from_ci_env() + self.assertIsNotNone(argv) + self.assertIn("--with-uninstall", argv) + + def test_adds_with_uninstall_for_true_and_yes_values(self): + base_env = { + "TEST_TYPE": "sanity", + "OS_PROFILE": "ubuntu2404", + "REPO_URL": "https://example.com/deb", + "RELEASE_TYPE": "dev", + "INSTALL_PREFIX": "/opt/rocm/core", + } + for value in ("true", "YES"): + with self.subTest(value=value): + with patch.dict( + os.environ, {**base_env, "RUN_UNINSTALL": value}, clear=False + ): + argv = native_linux_package_install_test._argv_from_ci_env() + self.assertIsNotNone(argv) + self.assertIn("--with-uninstall", argv) + + def test_run_uninstall_false_values_do_not_add_flag(self): + base_env = { + "TEST_TYPE": "sanity", + "OS_PROFILE": "ubuntu2404", + "REPO_URL": "https://example.com/deb", + "RELEASE_TYPE": "dev", + "INSTALL_PREFIX": "/opt/rocm/core", + } + for value in ("0", "false", "NO"): + with self.subTest(value=value): + with patch.dict( + os.environ, {**base_env, "RUN_UNINSTALL": value}, clear=False + ): + argv = native_linux_package_install_test._argv_from_ci_env() + self.assertIsNotNone(argv) + self.assertNotIn("--with-uninstall", argv) + + def test_raises_for_invalid_run_uninstall_value(self): + env = { + "TEST_TYPE": "sanity", + "OS_PROFILE": "ubuntu2404", + "REPO_URL": "https://example.com/deb", + "RELEASE_TYPE": "dev", + "INSTALL_PREFIX": "/opt/rocm/core", + "RUN_UNINSTALL": "maybe", + } + with patch.dict(os.environ, env, clear=False): + with self.assertRaisesRegex(ValueError, "Invalid RUN_UNINSTALL value"): + native_linux_package_install_test._argv_from_ci_env() + class RunTestsTestTypeTest(unittest.TestCase): """Tests for run_tests() early exit paths for install.""" @@ -993,8 +1051,18 @@ def _base_args(self, test_type: str): pkg_type=None, rocm_version=None, build_variant="", + with_uninstall=False, ) + def _args_for_uninstall_run_tests( + self, test_type: str, *, with_uninstall: bool = False + ): + """Build args for ``run_tests()`` uninstall integration tests.""" + args = self._base_args(test_type) + args.rocm_version = None + args.with_uninstall = with_uninstall + return args + @patch.object( native_linux_package_install_test.NativeLinuxPackageInstallTest, "run_repo_setup_and_install", @@ -1023,6 +1091,95 @@ def test_install_fails_when_repo_setup_fails(self, mock_repo_setup): rc = native_linux_package_install_test.run_tests(args) self.assertEqual(rc, 1) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_uninstall_and_verify", + return_value=True, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_basic_verification", + return_value=True, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_repo_setup_and_install", + return_value=True, + ) + def test_sanity_runs_uninstall_when_flag_set( + self, mock_repo_setup, mock_basic, mock_uninstall + ): + args = self._args_for_uninstall_run_tests("sanity", with_uninstall=True) + with _suppress_script_output(): + rc = native_linux_package_install_test.run_tests(args) + self.assertEqual(rc, 0) + mock_uninstall.assert_called_once() + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_uninstall_and_verify", + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_basic_verification", + return_value=True, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_repo_setup_and_install", + return_value=True, + ) + def test_sanity_skips_uninstall_when_flag_not_set( + self, mock_repo_setup, mock_basic, mock_uninstall + ): + args = self._args_for_uninstall_run_tests("sanity") + with _suppress_script_output(): + rc = native_linux_package_install_test.run_tests(args) + self.assertEqual(rc, 0) + mock_uninstall.assert_not_called() + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_uninstall_and_verify", + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_basic_verification", + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_repo_setup_and_install", + return_value=True, + ) + def test_install_skips_uninstall_even_when_flag_set( + self, mock_repo_setup, mock_basic, mock_uninstall + ): + args = self._args_for_uninstall_run_tests("install", with_uninstall=True) + with _suppress_script_output(): + rc = native_linux_package_install_test.run_tests(args) + self.assertEqual(rc, 0) + mock_uninstall.assert_not_called() + mock_basic.assert_not_called() + + def test_parse_cli_with_uninstall_flag(self): + args = native_linux_package_install_test.parse_cli_arguments( + [ + "--test-type", + "sanity", + "--os-profile", + "ubuntu2404", + "--repo-url", + "https://repo_url.com", + "--release-type", + "nightly", + "--install-prefix", + "/opt/rocm/core", + "--with-uninstall", + ], + raise_instead_of_exit=True, + ) + self.assertTrue(args.with_uninstall) + def test_parse_cli_rocm_version_with_multiple_gfx_arch(self): args = native_linux_package_install_test.parse_cli_arguments( [ @@ -2036,6 +2193,218 @@ def test_returns_false_when_rdhc_fails(self, mock_run): self.assertFalse(t.test_rdhc()) +class ListInstalledRocmPackagesTest(unittest.TestCase): + """Tests for Step 4b query helper ``list_installed_rocm_packages()``.""" + + @patch("native_linux_package_install_test.subprocess.run") + def test_deb_parses_installed_package_names(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout=( + "Desired=Unknown/Install/Remove/Purge/Hold\n" + "ii amdrocm 1.0 amd64 ROCm metapackage\n" + "ii libc6 2.35 amd64 GNU C Library\n" + "ii rocm-dev 1.0 amd64 ROCm dev\n" + ), + ) + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + ) + names = t.list_installed_rocm_packages() + self.assertEqual(names, ["amdrocm", "rocm-dev"]) + + @patch("native_linux_package_install_test.subprocess.run") + def test_rpm_parses_installed_package_names(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout="amdrocm-7.13-1.x86_64\nkernel-5.14-1.x86_64\n", + ) + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="rhel8", + ) + names = t.list_installed_rocm_packages() + self.assertEqual(names, ["amdrocm-7.13-1.x86_64"]) + + @patch("native_linux_package_install_test.subprocess.run") + def test_returns_empty_list_on_query_failure(self, mock_run): + import subprocess + + mock_run.side_effect = subprocess.CalledProcessError(1, "dpkg") + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + ) + self.assertEqual(t.list_installed_rocm_packages(), []) + + +class UninstallDebPackagesTest(unittest.TestCase): + """Tests for Step 4a ``uninstall_packages()`` on deb (apt remove + autoremove).""" + + @patch("native_linux_package_install_test._run_streaming") + def test_remove_and_autoremove_in_reverse_order(self, mock_streaming): + mock_streaming.return_value = 0 + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + gfx_arch=["gfx94x", "gfx1100"], + rocm_version="7.13", + ) + with _suppress_script_output(): + self.assertTrue(t.uninstall_packages()) + self.assertEqual(mock_streaming.call_count, 2) + remove_cmd = mock_streaming.call_args_list[0][0][0] + autoremove_cmd = mock_streaming.call_args_list[1][0][0] + self.assertEqual(remove_cmd[:4], ["sudo", "apt", "remove", "-y"]) + self.assertEqual( + remove_cmd[4:], + [ + "amdrocm-core-sdk7.13-gfx1100", + "amdrocm7.13-gfx1100", + "amdrocm-core-sdk7.13-gfx94x", + "amdrocm7.13-gfx94x", + ], + ) + self.assertEqual(autoremove_cmd, ["sudo", "apt", "autoremove", "-y"]) + + @patch("native_linux_package_install_test._run_streaming") + def test_returns_false_when_remove_fails(self, mock_streaming): + mock_streaming.return_value = 1 + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + gfx_arch="gfx94x", + ) + with _suppress_script_output(): + self.assertFalse(t.uninstall_packages()) + + +class UninstallRpmPackagesTest(unittest.TestCase): + """Tests for Step 4a ``uninstall_packages()`` on rpm (dnf / zypper --clean-deps).""" + + @patch("native_linux_package_install_test._run_streaming") + def test_dnf_remove_for_rhel(self, mock_streaming): + mock_streaming.return_value = 0 + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="rhel8", + gfx_arch="gfx94x", + ) + with _suppress_script_output(): + self.assertTrue(t.uninstall_packages()) + cmd = mock_streaming.call_args[0][0] + self.assertEqual(cmd[:3], ["dnf", "remove", "-y"]) + self.assertIn("amdrocm-core-sdk", cmd) + self.assertIn("amdrocm", cmd) + + @patch("native_linux_package_install_test._run_streaming") + def test_zypper_remove_for_sles(self, mock_streaming): + """SLES must pass --clean-deps so dependency packages are removed.""" + mock_streaming.return_value = 0 + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="sles16", + gfx_arch="gfx94x", + ) + with _suppress_script_output(): + self.assertTrue(t.uninstall_packages()) + cmd = mock_streaming.call_args[0][0] + self.assertEqual( + cmd[:5], + ["zypper", "--non-interactive", "remove", "-y", "--clean-deps"], + ) + self.assertEqual(mock_streaming.call_count, 1) + + +class RunUninstallVerificationTest(unittest.TestCase): + """Tests for Step 4b ``run_uninstall_verification()``.""" + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "list_installed_rocm_packages", + return_value=[], + ) + def test_passes_when_no_packages_remain(self, mock_list): + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + install_prefix="/nonexistent/prefix", + ) + with _suppress_script_output(): + self.assertTrue(t.run_uninstall_verification()) + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "list_installed_rocm_packages", + return_value=["amdrocm"], + ) + def test_fails_when_packages_remain(self, mock_list): + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + ) + with _suppress_script_output(): + self.assertFalse(t.run_uninstall_verification()) + + +class RunUninstallAndVerifyTest(unittest.TestCase): + """Tests for Step 4 ``run_uninstall_and_verify()`` orchestration.""" + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_uninstall_verification", + return_value=True, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "uninstall_packages", + return_value=True, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "list_installed_rocm_packages", + return_value=["amdrocm"], + ) + def test_orchestrates_uninstall_and_verify( + self, mock_list, mock_uninstall, mock_verify + ): + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + ) + with _suppress_script_output(): + self.assertTrue(t.run_uninstall_and_verify()) + mock_list.assert_called() + mock_uninstall.assert_called_once() + mock_verify.assert_called_once() + + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "run_uninstall_verification", + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "uninstall_packages", + return_value=False, + ) + @patch.object( + native_linux_package_install_test.NativeLinuxPackageInstallTest, + "list_installed_rocm_packages", + return_value=[], + ) + def test_returns_false_when_uninstall_fails( + self, mock_list, mock_uninstall, mock_verify + ): + t = native_linux_package_install_test.NativeLinuxPackageInstallTest( + repo_url="https://example.com", + os_profile="ubuntu2404", + ) + with _suppress_script_output(): + self.assertFalse(t.run_uninstall_and_verify()) + mock_verify.assert_not_called() + + class RunStreamingTest(unittest.TestCase): """Tests for _run_streaming()."""