diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 23046984d8..0501e10e99 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -1,4 +1,5 @@ import abc +import re import shlex from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Callable, Generic, Optional, TypeVar, Union @@ -240,6 +241,10 @@ class PackageManager(tmt.utils.Common, Generic[PackageManagerEngineT]): _engine_class: type[PackageManagerEngineT] engine: PackageManagerEngineT + #: Patterns for extracting failed package names from error output. + #: Subclasses override this with their own specific patterns. + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS: list[re.Pattern[str]] = [] + #: If set, this package manager can be used for building derived #: images under the hood of the ``bootc`` package manager. bootc_builder: bool = False @@ -270,6 +275,17 @@ def check_presence(self, *installables: Installable) -> dict[Installable, bool]: raise NotImplementedError + def extract_package_name_from_package_manager_output(self, output: str) -> Iterator[str]: + """ + Extract failed package names from package manager error output. + + :param output: Error output (stdout or stderr) from the package manager. + :returns: An iterator of package names that failed to install. + """ + for pattern in self._PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS: + for match in pattern.finditer(output): + yield match.group(1) + def install( self, *installables: Installable, diff --git a/tmt/package_managers/apk.py b/tmt/package_managers/apk.py index d97c11ecbd..61cb80cf82 100644 --- a/tmt/package_managers/apk.py +++ b/tmt/package_managers/apk.py @@ -34,6 +34,11 @@ } +# Compiled regex patterns for APK error messages +_UNABLE_TO_LOCATE_PATTERN = re.compile(r'unable to locate package\s+([^\s]+)', re.IGNORECASE) +_NO_SUCH_PACKAGE_PATTERN = re.compile(r'ERROR:\s+([^\s:]+):\s+No such package', re.IGNORECASE) + + class ApkEngine(PackageManagerEngine): install_command = Command('add') @@ -155,6 +160,8 @@ class Apk(PackageManager[ApkEngine]): _engine_class = ApkEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [_UNABLE_TO_LOCATE_PATTERN, _NO_SUCH_PACKAGE_PATTERN] + probe_command = Command('apk', '--version') def check_presence(self, *installables: Installable) -> dict[Installable, bool]: diff --git a/tmt/package_managers/apt.py b/tmt/package_managers/apt.py index 02d139a817..a5467dfac4 100644 --- a/tmt/package_managers/apt.py +++ b/tmt/package_managers/apt.py @@ -79,6 +79,12 @@ """ # noqa: E501 +# Compiled regex patterns for APT error messages +_UNABLE_TO_LOCATE_PATTERN = re.compile( + r'(?:E:\s+)?Unable to locate package\s+([^\s]+)', re.IGNORECASE +) + + class AptEngine(PackageManagerEngine): install_command = Command('install') @@ -242,6 +248,8 @@ class Apt(PackageManager[AptEngine]): _engine_class = AptEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [_UNABLE_TO_LOCATE_PATTERN] + probe_command = Command('apt', '--version') def check_presence(self, *installables: Installable) -> dict[Installable, bool]: diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py index 4e75a67d3b..f4473befc7 100644 --- a/tmt/package_managers/dnf.py +++ b/tmt/package_managers/dnf.py @@ -201,6 +201,13 @@ def create_repository(self, directory: Path) -> ShellScript: return ShellScript(f"createrepo {directory}") +# Compiled regex patterns for DNF/YUM error messages +_NO_PACKAGE_PROVIDES_PATTERN = re.compile(r'no package provides\s+([^\s\n]+)', re.IGNORECASE) +_UNABLE_TO_FIND_MATCH_PATTERN = re.compile(r'Unable to find a match:\s+([^\s\n]+)', re.IGNORECASE) +_NO_MATCH_FOR_ARGUMENT_PATTERN = re.compile(r'No match for argument:\s+([^\s\n]+)', re.IGNORECASE) +_NO_PACKAGE_AVAILABLE_PATTERN = re.compile(r'No package\s+([^\s\n]+)\s+available', re.IGNORECASE) + + # ignore[type-arg]: TypeVar in package manager registry annotations is # puzzling for type checkers. And not a good idea in general, probably. @provides_package_manager('dnf') # type: ignore[arg-type] @@ -209,6 +216,13 @@ class Dnf(PackageManager[DnfEngine]): _engine_class = DnfEngine + _PACKAGE_NAME_IN_PM_OUTPUT_PATTERNS = [ + _NO_PACKAGE_PROVIDES_PATTERN, + _UNABLE_TO_FIND_MATCH_PATTERN, + _NO_MATCH_FOR_ARGUMENT_PATTERN, + _NO_PACKAGE_AVAILABLE_PATTERN, + ] + bootc_builder = True probe_command = ShellScript( diff --git a/tmt/steps/discover/__init__.py b/tmt/steps/discover/__init__.py index 7f1bab1071..64b52123c7 100644 --- a/tmt/steps/discover/__init__.py +++ b/tmt/steps/discover/__init__.py @@ -1,4 +1,5 @@ import abc +from collections import defaultdict from collections.abc import Iterator from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast @@ -280,6 +281,26 @@ def required_tests(self) -> list[TestOrigin]: ] return tests + @property + def dependencies_to_tests(self) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + """ + A tuple containing two dictionaries mapping dependencies to tests (required & recommended) + """ + + required_dependencies_to_tests: dict[str, list[str]] = defaultdict(list) + recommended_dependencies_to_tests: dict[str, list[str]] = defaultdict(list) + + for test_origin in self.tests(enabled=True): + test = test_origin.test + test_name = test.name + # Collect dependencies separately for required and recommended + for dependency in test.require: + required_dependencies_to_tests[str(dependency)].append(test_name) + for dependency in test.recommend: + recommended_dependencies_to_tests[(str(dependency))].append(test_name) + + return required_dependencies_to_tests, recommended_dependencies_to_tests + def load(self) -> None: """ Load step data from the workdir diff --git a/tmt/steps/prepare/install.py b/tmt/steps/prepare/install.py index 39c77f86d7..dd285cee33 100644 --- a/tmt/steps/prepare/install.py +++ b/tmt/steps/prepare/install.py @@ -1,3 +1,4 @@ +import itertools import re import shutil from collections.abc import Iterator @@ -50,6 +51,8 @@ class InstallBase(tmt.utils.Common): package_directory: Path + install_outputs: list[tmt.utils.CommandOutput] + def __init__( self, *, @@ -74,6 +77,7 @@ def __init__( self.exclude = [Package(package) for package in exclude] self.skip_missing = bool(parent.get('missing') == 'skip') + self.install_outputs = [] # Prepare package lists and installation command self.prepare_installables(dependencies, directories) @@ -345,26 +349,28 @@ def install_from_url(self) -> None: Install packages stored on a remote URL """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_from_repository(self) -> None: """ Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -517,12 +523,13 @@ def install_local(self) -> None: PackagePath(self.package_directory / filename) for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables('local packages', *filelist), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, check_first=False ), ) + self.install_outputs.append(output) summary = fmf.utils.listed([str(path) for path in self.local_packages], 'local package') self.info('total', f"{summary} installed", 'green') @@ -532,26 +539,28 @@ def install_from_url(self) -> None: Install packages stored on a remote URL """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_from_repository(self) -> None: """ Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -687,13 +696,14 @@ def _install(self) -> None: class InstallMock(InstallBase): # TODO this really looks like it should be a subclass of InstallDnf def install_from_repository(self) -> None: - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_local(self) -> None: from tmt.steps.provision.mock import GuestMock @@ -714,7 +724,7 @@ def install_local(self) -> None: for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *filelist, options=Options( excluded_packages=self.exclude, @@ -722,6 +732,7 @@ def install_local(self) -> None: check_first=False, ), ) + self.install_outputs.append(output) self.guest.package_manager.reinstall( *filelist, @@ -736,13 +747,14 @@ def install_local(self) -> None: self.info('total', f"{summary} installed", 'green') def install_from_url(self) -> None: - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("remote package", *self.remote_packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) class InstallApk(InstallBase): @@ -759,7 +771,7 @@ def install_local(self) -> None: PackagePath(self.package_directory / filename) for filename in self.local_packages ] - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables('local packages', *filelist), options=Options( excluded_packages=self.exclude, @@ -768,6 +780,7 @@ def install_local(self) -> None: check_first=False, ), ) + self.install_outputs.append(output) summary = fmf.utils.listed([str(path) for path in self.local_packages], 'local package') self.info('total', f"{summary} installed", 'green') @@ -787,13 +800,14 @@ def install_from_repository(self) -> None: Install packages from a repository """ - self.guest.package_manager.install( + output = self.guest.package_manager.install( *self.list_installables("package", *self.packages), options=Options( excluded_packages=self.exclude, skip_missing=self.skip_missing, ), ) + self.install_outputs.append(output) def install_debuginfo(self) -> None: """ @@ -950,6 +964,84 @@ class PrepareInstall(tmt.steps.prepare.PreparePlugin[PrepareInstallData]): _data_class = PrepareInstallData + def _extract_failed_packages_from_outputs( + self, outputs: list[tmt.utils.CommandOutput], guest: 'Guest' + ) -> set[str]: + """ + Extract package names from installation outputs. + + Returns a set of package names that failed to install. + """ + + def _extract_from_output(output: tmt.utils.CommandOutput) -> Iterator[str]: + if output.stderr: + yield from guest.package_manager.extract_package_name_from_package_manager_output( + output.stderr + ) + + if output.stdout: + yield from guest.package_manager.extract_package_name_from_package_manager_output( + output.stdout + ) + + return set( + itertools.chain.from_iterable(_extract_from_output(output) for output in outputs) + ) + + def _show_failed_packages_with_tests(self, failed_packages: set[str]) -> None: + """ + Show failed packages and which tests require them. + """ + # Get test dependencies from discover step + required_dependencies_to_tests, recommended_dependencies_to_tests = ( + self.step.plan.discover.dependencies_to_tests + ) + + failed_required_packages: dict[str, list[str]] = {} + failed_recommended_packages: dict[str, list[str]] = {} + failed_unattributed_packages: set[str] = set() + + for failed_package in failed_packages: + if tests := required_dependencies_to_tests.get(failed_package): + failed_required_packages[failed_package] = sorted(tests) + + elif tests := recommended_dependencies_to_tests.get(failed_package): + failed_recommended_packages[failed_package] = sorted(tests) + + else: + failed_unattributed_packages.add(failed_package) + + self.info('') + + if failed_required_packages: + self.info('Required packages failed to install, aborting:', color='red', shift=1) + for pkg, tests in failed_required_packages.items(): + self.info( + pkg, + f'required by: {", ".join(tests)}', + color='red', + shift=2, + ) + + if failed_recommended_packages: + self.info( + 'Recommended packages failed to install, continuing regardless:', + color='yellow', + shift=1, + ) + for pkg, tests in failed_recommended_packages.items(): + self.info( + pkg, + f'recommended by: {", ".join(tests)}', + color='yellow', + shift=2, + ) + + if failed_unattributed_packages: + self.info('Other failed packages:', color='red', shift=1) + for pkg in sorted(failed_unattributed_packages): + self.info(pkg, color='red', shift=2) + def go( self, *, @@ -1067,6 +1159,24 @@ def go( installer.enable_copr(self.data.copr) # ... and install packages. - installer.install() + try: + installer.install() + except Exception as exc: + # Extract and show failed packages if this is a RunError + # Convert exception to CommandOutput and use the unified extraction method + if isinstance(exc, tmt.utils.RunError): + failed_packages = self._extract_failed_packages_from_outputs([exc.output], guest) + if failed_packages: + self._show_failed_packages_with_tests(failed_packages) + raise + + # For recommended packages (skip_missing=True), check output even if no exception + # was raised, since --skip-broken makes the command succeed but packages still fail + if installer.skip_missing: + failed_packages = self._extract_failed_packages_from_outputs( + installer.install_outputs, guest + ) + if failed_packages: + self._show_failed_packages_with_tests(failed_packages) return outcome