diff --git a/tests/prepare/verify-installation/data/main.fmf b/tests/prepare/verify-installation/data/main.fmf index 179bc64bdf..7f894e57b8 100644 --- a/tests/prepare/verify-installation/data/main.fmf +++ b/tests/prepare/verify-installation/data/main.fmf @@ -23,7 +23,7 @@ prepare+: - how: verify-installation verify: - centpkg: tmt-artifact-shared + /usr/bin/centpkg: tmt-artifact-shared /plan/failure: prepare+: diff --git a/tmt/package_managers/__init__.py b/tmt/package_managers/__init__.py index 2ef2ca8f44..112fbedb36 100644 --- a/tmt/package_managers/__init__.py +++ b/tmt/package_managers/__init__.py @@ -1,5 +1,6 @@ import abc import configparser +import contextlib import enum import re import shlex @@ -399,6 +400,22 @@ def get_package_origin(self, packages: Iterable[str]) -> ShellScript: """ raise NotImplementedError + def resolve_capabilities(self, *capabilities: str) -> ShellScript: + """ + Resolve each capability to the NEVRA of the installed package providing it. + + The script must emit one line per capability in the same order as the input. + Each line is either a valid NEVRA string for a found capability, or an error + message (e.g. ``no package provides ``) for one that is not provided by + any installed package. + + :param capabilities: Capabilities to resolve — package names, file paths, or + virtual provides (e.g. ``make``, ``/usr/bin/make``, ``pkgconfig(openssl)``). + :returns: A shell script whose stdout contains one NEVRA line per capability. + :raises NotImplementedError: If the package manager does not support this query. + """ + raise NotImplementedError + def create_repository(self, directory: Path) -> ShellScript: """ Create repository metadata for package files in the given directory. @@ -534,6 +551,35 @@ def get_package_origin(self, packages: Iterable[str]) -> 'dict[str, PackageOrigi result[package] = parts[1] if len(parts) == 2 else SpecialPackageOrigin.UNKNOWN return result + def resolve_capabilities(self, capabilities: Iterable[str]) -> 'dict[str, Optional[Version]]': + """ + Map each capability to the :py:class:`Version` of the installed package providing it. + + :param capabilities: Capabilities to resolve — package names, file paths, or + virtual provides (e.g. ``make``, ``/usr/bin/make``, ``pkgconfig(openssl)``). + :returns: Mapping from each capability to its providing package :py:class:`Version`, + or ``None`` when no installed package provides it. + :raises NotImplementedError: If the package manager does not support this query. + """ + from tmt.package_managers._rpm import RpmVersion + + caps = list(capabilities) + if not caps: + return {} + + script = self.engine.resolve_capabilities(*caps) + try: + output = self.guest.execute(script) + stdout = output.stdout + except tmt.utils.RunError as exc: + stdout = exc.stdout + + result: dict[str, Optional[Version]] = dict.fromkeys(caps, None) + for cap, line in zip(caps, (stdout or '').splitlines()): + with contextlib.suppress(ValueError): + result[cap] = RpmVersion.from_nevra(line.strip()) + return result + def create_repository(self, directory: Path) -> CommandOutput: """ Wrapper of :py:meth:`PackageManagerEngine.create_repository`. diff --git a/tmt/package_managers/dnf.py b/tmt/package_managers/dnf.py index c4ababcc12..0f71822a45 100644 --- a/tmt/package_managers/dnf.py +++ b/tmt/package_managers/dnf.py @@ -15,6 +15,7 @@ escape_installables, provides_package_manager, ) +from tmt.package_managers._rpm import RpmVersion from tmt.utils import Command, CommandOutput, GeneralError, RunError, ShellScript @@ -208,6 +209,10 @@ def get_package_origin(self, packages: Iterable[str]) -> ShellScript: ) ).to_script() + def resolve_capabilities(self, *capabilities: str) -> ShellScript: + # Reuse the existing rpm --whatprovides script; output is one NEVRA per line, + return self._construct_presence_script(*[Package(c) for c in capabilities]) + def create_repository(self, directory: Path) -> ShellScript: """ Create repository metadata for package files in the given directory. @@ -248,7 +253,6 @@ class Dnf(PackageManager[DnfEngine]): probe_priority = 50 def list_packages(self, repository: Repository) -> list[Version]: - from tmt.package_managers._rpm import RpmVersion script = self.engine.list_packages(repository) output = self.guest.execute(script) diff --git a/tmt/steps/prepare/artifact/__init__.py b/tmt/steps/prepare/artifact/__init__.py index d94830abea..f46786c497 100644 --- a/tmt/steps/prepare/artifact/__init__.py +++ b/tmt/steps/prepare/artifact/__init__.py @@ -356,6 +356,13 @@ def _inject_verify_phase(self, providers: list[ArtifactProvider], guest: Guest) pkg_names.add(str(pkg)) # pyright: ignore[reportUnknownArgumentType] # Build package → set of valid repo_ids, filtering to only required packages. + # TODO: Path-based or virtual-provide requirements (e.g. /usr/bin/createrepo, + # /usr/bin/make) in pkg_names will NOT match artifact.version.name (e.g. 'createrepo', + # 'make') because rpm --whatprovides cannot be used at this point — requirements are + # not yet installed when _inject_verify_phase runs (only artifact repos are set up). + # Consequently such artifacts are silently skipped from verification even when the + # providing package is one of the provided artifacts. + pkgs_to_verify: dict[str, set[str]] = {} for provider in providers: for artifact in provider.artifacts: diff --git a/tmt/steps/prepare/verify_installation.py b/tmt/steps/prepare/verify_installation.py index c322ade7cf..e10253d2d3 100644 --- a/tmt/steps/prepare/verify_installation.py +++ b/tmt/steps/prepare/verify_installation.py @@ -92,13 +92,24 @@ def go( color='green', ) - # TODO: Use ``rpm -q --whatprovides`` to resolve the actual RPM packages - # providing the requested requirements before verification. This would - # cover cases where ``require`` contains virtual provides like - # ``/usr/bin/something``. Not implemented yet as it requires live guest - # queries and is incompatible with bootc mode. + # Resolve capabilities (paths, virtual provides) to actual package names via + # rpm --whatprovides. Unresolvable entries keep their original string so they + # surface as NOT_INSTALLED in the origin check below. try: - package_origins = guest.package_manager.get_package_origin(self.data.verify.keys()) + capability_to_version = guest.package_manager.resolve_capabilities( + self.data.verify.keys() + ) + except NotImplementedError: + capability_to_version = {} + verify = { + ( + version.name if (version := capability_to_version.get(capability)) else capability + ): repos + for capability, repos in self.data.verify.items() + } + + try: + package_origins = guest.package_manager.get_package_origin(verify.keys()) except (NotImplementedError, tmt.utils.GeneralError) as err: error: Exception = ( tmt.utils.PrepareError( @@ -120,7 +131,7 @@ def go( return outcome failed_packages: list[str] = [] - for package, expected_repos in self.data.verify.items(): + for package, expected_repos in verify.items(): actual_origin = package_origins[package] if actual_origin in expected_repos: