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
8 changes: 8 additions & 0 deletions tests/unit/artifact/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from tmt.steps.prepare.artifact import get_artifact_provider
from tmt.steps.prepare.artifact.providers import koji as koji_module
from tmt.steps.prepare.artifact.providers.brew import BrewArtifactProvider
from tmt.steps.prepare.artifact.providers.copr_build import CoprBuildArtifactProvider
from tmt.steps.prepare.artifact.providers.koji import KojiArtifactProvider


Expand Down Expand Up @@ -50,6 +51,13 @@ def mock_initialize_session(self, api_url=None, top_url=None):
yield mock_koji


@pytest.fixture
def mock_copr():
mock_session = MagicMock()
with patch.object(CoprBuildArtifactProvider, '_initialize_session', return_value=mock_session):
yield mock_session


@pytest.fixture
def artifact_provider(root_logger, _load_plugins):
def get_provider(provider_id: str, repository_priority: int = 50):
Expand Down
44 changes: 41 additions & 3 deletions tests/unit/artifact/test_copr_repository.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch

import pytest

Expand Down Expand Up @@ -28,13 +28,51 @@ def test_invalid_repository_id(raw_id, error, artifact_provider):
artifact_provider(raw_id)


def test_fetch_contents_enables_repository(mock_package_manager, artifact_provider, tmppath):
def test_fetch_contents_enables_repository(
mock_copr, mock_package_manager, artifact_provider, tmppath
):
mock_repo = MagicMock()
mock_guest = MagicMock()
mock_guest.package_manager = mock_package_manager

provider = artifact_provider("copr.repository:@teemtee/stable")

result = provider.fetch_contents(mock_guest, tmppath / "artifacts")
with patch(
'tmt.steps.prepare.artifact.providers.copr_repository.Repository.from_file_path',
return_value=mock_repo,
) as mock_from_file:
result = provider.fetch_contents(mock_guest, tmppath / "artifacts")

mock_package_manager.enable_copr.assert_called_once_with('@teemtee/stable')
mock_guest.pull.assert_called_once()
mock_from_file.assert_called_once()
assert result == []
assert provider.repository is mock_repo


def test_enumerate_artifacts(mock_copr, mock_package_manager, artifact_provider, tmppath):
mock_repo = MagicMock()
mock_repo.repo_ids = ['group_teemtee-stable-fedora-42-x86_64']
mock_guest = MagicMock()
mock_guest.package_manager = mock_package_manager

mock_package_manager.list_packages.return_value = [
'tmt-1.69.0-1.fc42.noarch',
'tmt-all-0:1.69.0-1.fc42.noarch',
]

provider = artifact_provider("copr.repository:@teemtee/stable")

with patch(
'tmt.steps.prepare.artifact.providers.copr_repository.Repository.from_file_path',
return_value=mock_repo,
):
provider.fetch_contents(mock_guest, tmppath / "artifacts")

provider.enumerate_artifacts(mock_guest)

assert len(provider.artifacts) == 2
assert provider.artifacts[0].version.name == 'tmt'
assert provider.artifacts[0].version.version == '1.69.0'
assert provider.artifacts[1].version.name == 'tmt-all'
assert provider.artifacts[1].version.epoch == 0
4 changes: 4 additions & 0 deletions tmt/steps/prepare/artifact/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,10 @@ def go(
guest.package_manager.install_repository(repo)
logger.debug(f"Installed repository '{repo.name}'.")

# Enumerate artifacts from installed repositories.
for provider in providers:
provider.enumerate_artifacts(guest)

# Persist artifact metadata to YAML
self._save_artifacts_metadata(providers)

Expand Down
79 changes: 71 additions & 8 deletions tmt/steps/prepare/artifact/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
"""

import configparser
import re
from abc import ABC, abstractmethod
from collections.abc import Iterator, Sequence
from functools import cached_property
from re import Pattern
from shlex import quote
from typing import Any, Optional
Expand All @@ -19,6 +19,10 @@
from tmt.plugins import PluginRegistry
from tmt.utils import GeneralError, Path, ShellScript

NEVRA_PATTERN = re.compile(
r'^(?P<name>.+)-(?:(?P<epoch>\d+):)?(?P<version>.+)-(?P<release>.+)\.(?P<arch>.+)$'
)


class DownloadError(tmt.utils.GeneralError):
"""
Expand Down Expand Up @@ -86,6 +90,32 @@ def from_rpm_meta(cls, rpm_meta: dict[str, Any]) -> Self:
epoch=rpm_meta.get("epoch", 0),
)

@classmethod
def from_nevra(
cls, nevra: str
) -> Self: # TODO: move this to `tmt.package_managers.PackageManager.list_packages`
"""
Version constructed from a NEVRA string as returned by ``dnf repoquery``.

Example usage:

.. code-block:: python

version_info = RpmVersion.from_nevra("curl-0:8.11.1-7.fc42.x86_64")
version_info = RpmVersion.from_nevra("curl-8.11.1-7.fc42.x86_64")
"""
match = NEVRA_PATTERN.match(nevra)
if not match:
raise ValueError(f"Cannot parse NEVRA '{nevra}'.")
epoch_str = match.group('epoch')
return cls(
name=match.group('name'),
epoch=int(epoch_str) if epoch_str is not None else 0,
version=match.group('version'),
release=match.group('release'),
arch=match.group('arch'),
)

@classmethod
def from_filename(cls, filename: str) -> Self:
"""
Expand Down Expand Up @@ -169,6 +199,10 @@ class ArtifactProvider(ABC):
#: Lower values have higher priority in package managers.
repository_priority: int

# Artifacts enumerated from this provider's repositories after they have
# been installed on the guest.
_artifacts: list[ArtifactInfo]

def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger):
self.repository_priority = repository_priority
self.logger = logger
Expand All @@ -177,6 +211,7 @@ def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger
self.sanitized_id = tmt.utils.sanitize_name(raw_id, allow_slash=False)

self.id = self._extract_provider_id(raw_id)
self._artifacts = []

@classmethod
@abstractmethod
Expand All @@ -191,20 +226,15 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:

raise NotImplementedError

@cached_property
@abstractmethod
@property
def artifacts(self) -> Sequence[ArtifactInfo]:
"""
Collect all artifacts available from this provider.

The method is left for derived classes to implement with respect
to the actual artifact provider they implement. The list of
artifacts will be cached, and is treated as read-only.

:returns: a list of provided artifacts.
"""

raise NotImplementedError
return self._artifacts

@abstractmethod
def _download_artifact(
Expand Down Expand Up @@ -300,6 +330,39 @@ def get_repositories(self) -> list['Repository']:
"""
return []

def enumerate_artifacts(
self, guest: Guest
) -> None: # TODO: refactor this once the NEVRA parsing is centralized.
"""
Enumerate artifacts available from this provider after its repositories
have been installed on the guest.
"""
Comment on lines +333 to +339
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a special handling when the artifacts are from tmt-shared-repo, otherwise all providers that write to it would overwrite each other.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies - do not clearly understand the concern here. Can you please elaborte?

for repository in self.get_repositories():
try:
nevras = guest.package_manager.list_packages(repository)
except tmt.utils.RunError:
self.logger.warning(
f"Failed to enumerate packages from repository '{repository.name}'."
)
continue
count = 0
for nevra in nevras:
nevra = nevra.strip()
if not nevra:
continue
try:
self._artifacts.append(
ArtifactInfo(
version=RpmVersion.from_nevra(nevra),
provider=self,
location=repository.name,
)
)
count += 1
except ValueError:
self.logger.warning(f"Could not parse NEVRA '{nevra}'.")
self.logger.debug(f"Enumerated {count} packages from repository '{repository.name}'.")

# B027: "... is an empty method in an abstract base class, but has
# no abstract decorator" - expected, it's a default implementation
# provided for subclasses. It is acceptable to do nothing.
Expand Down
59 changes: 47 additions & 12 deletions tmt/steps/prepare/artifact/providers/copr_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,48 @@
"""

import re
from collections.abc import Sequence
from functools import cached_property
from re import Pattern
from typing import Optional

import tmt.log
import tmt.utils
from tmt.container import container
from tmt.guest import Guest
from tmt.steps.prepare.artifact.providers import (
ArtifactInfo,
ArtifactProvider,
ArtifactProviderId,
Repository,
UnsupportedOperationError,
provides_artifact_provider,
)
from tmt.utils import Path

COPR_REPO_PATTERN = re.compile(r'^(?P<group>@)?(?P<name>[^/]+)/(?P<project>[^/]+)$')
COPR_REPOSITORY_PATTERN = re.compile(r'^(?:@[^/]+/[^/]+|[^@/]+/[^/]+)$')


@container(frozen=True)
class CoprRepo:
is_group: bool
name: str
project: str


def parse_copr_repo(copr_repo: str) -> CoprRepo:
"""
Parse a COPR repository identifier into its components.
"""
matched = COPR_REPO_PATTERN.match(copr_repo)
if not matched:
raise tmt.utils.PrepareError(f"Invalid copr repository '{copr_repo}'.")
return CoprRepo(
is_group=bool(matched.group('group')),
name=matched.group('name'),
project=matched.group('project'),
)


@provides_artifact_provider('copr.repository')
class CoprRepositoryProvider(ArtifactProvider):
"""
Expand All @@ -41,10 +64,12 @@ class CoprRepositoryProvider(ArtifactProvider):
"""

copr_repo: str # Parsed Copr repository name (e.g. 'packit/packit-dev')
repository: Optional[Repository]

def __init__(self, raw_id: str, repository_priority: int, logger: tmt.log.Logger):
super().__init__(raw_id, repository_priority, logger)
self.copr_repo = self.id
self.repository = None

@classmethod
def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:
Expand All @@ -64,11 +89,10 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:

return value

@cached_property
def artifacts(self) -> Sequence[ArtifactInfo]:
# Copr repository provider does not enumerate individual artifacts.
# The repository is enabled and packages are available through the package manager.
return []
def get_repositories(self) -> list[Repository]:
if self.repository is None:
return []
return [self.repository]

def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None:
"""This provider only enables repositories; it does not download individual RPMs."""
Expand All @@ -83,11 +107,22 @@ def fetch_contents(
exclude_patterns: Optional[list[Pattern[str]]] = None,
) -> list[Path]:
"""
Enable the Copr repository on the guest.

:return: Empty list as no files are downloaded.
:raises tmt.utils.PrepareError: If the package manager does not support
enabling Copr repositories.
Enable the Copr repository on the guest and retrieve the resulting
``.repo`` file content.
"""
guest.package_manager.enable_copr(self.copr_repo)

repo = parse_copr_repo(self.copr_repo)
owner = f'group_{repo.name}' if repo.is_group else repo.name
repo_filename = f"_copr:copr.fedorainfracloud.org:{owner}:{repo.project}.repo"

# pull from guest to build Repository object with populated repo_ids
guest.pull(
source=Path(f"/etc/yum.repos.d/{repo_filename}"),
destination=download_path,
)

self.repository = Repository.from_file_path(
download_path / repo_filename, self.logger, name=self.copr_repo
)
return []
9 changes: 0 additions & 9 deletions tmt/steps/prepare/artifact/providers/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
Artifact provider for discovering RPMs from repository files.
"""

from collections.abc import Sequence
from functools import cached_property
from re import Pattern
from typing import Optional
from urllib.parse import urlparse
Expand Down Expand Up @@ -57,13 +55,6 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:
raise ValueError("Missing repository URL.")
return value

@cached_property
def artifacts(self) -> Sequence[ArtifactInfo]:
# Repository provider does not enumerate individual artifacts.
# The repository is installed and packages are available through the package manager.
# There is no need to download individual artifact files.
return []

def _download_artifact(self, artifact: ArtifactInfo, guest: Guest, destination: Path) -> None:
"""This provider only discovers repos; it does not download individual RPMs."""
raise AssertionError(
Expand Down
9 changes: 0 additions & 9 deletions tmt/steps/prepare/artifact/providers/repository_url.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@
Artifact provider for creating DNF repositories from baseurl.
"""

from collections.abc import Sequence
from functools import cached_property
from re import Pattern
from typing import Optional

Expand Down Expand Up @@ -57,13 +55,6 @@ def _extract_provider_id(cls, raw_id: str) -> ArtifactProviderId:
raise ValueError("Missing repository baseurl.")
return value

@cached_property
def artifacts(self) -> Sequence[ArtifactInfo]:
# Repository provider does not enumerate individual artifacts.
# The repository is installed and packages are available through the package manager.
# There is no need to download individual artifact files.
return []

def _download_artifact(
self, artifact: ArtifactInfo, guest: Guest, destination: tmt.utils.Path
) -> None:
Expand Down
Loading