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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/prepare/verify-installation/data/main.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
prepare+:
- how: verify-installation
verify:
centpkg: tmt-artifact-shared
/usr/bin/centpkg: tmt-artifact-shared

/plan/failure:
prepare+:
Expand Down
46 changes: 46 additions & 0 deletions tmt/package_managers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import abc
import configparser
import contextlib
import enum
import re
import shlex
Expand Down Expand Up @@ -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 <cap>``) 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.
Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 5 additions & 1 deletion tmt/package_managers/dnf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions tmt/steps/prepare/artifact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 18 additions & 7 deletions tmt/steps/prepare/verify_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading