Skip to content

Commit bc009b0

Browse files
authored
Resolve ArtifactInfo.location overloading for verify-installation (#4798)
Add a dedicated ``repo`` field to ``ArtifactInfo`` holding the DNF repo_ids for repository providers (``None`` for download providers whose artifacts land in the shared repo). This replaces the workaround in ``_inject_verify_phase()`` that used ``provider.get_repositories()`` as a proxy and stored a comma-joined string of repo_ids, fixing the last-writer-wins bug where the same package in multiple providers would silently drop all but one valid source repo. The ``verify`` field in ``PrepareVerifyInstallationData`` is changed from ``dict[str, str]`` to ``dict[str, list[str]]`` so that a package can be expected from any of multiple repos (OR semantics). A new ``normalize_string_list_dict`` normalizer coerces single string values to one-element lists, keeping existing fmf plans working without changes. The ``verify: false`` workaround in the nvr-priority TC 1.1 test is removed as it is no longer needed. Related to #4714 Assisted-by: Claude
1 parent 2defa5b commit bc009b0

12 files changed

Lines changed: 139 additions & 62 deletions

File tree

tests/prepare/artifact/nvr-priority/data/tc11.fmf

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ summary: TC 1.1 - Repository priority overrides package version
1414
script: mkdir -p /tmp/nvr-test && cp -r tc11-high tc11-low /tmp/nvr-test/
1515
- name: Install repo files
1616
how: artifact
17-
verify: false
1817
provide:
1918
- repository-file:file:tc11-high.repo
2019
- repository-file:file:tc11-low.repo

tests/unit/artifact/test_base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55

66
import tmt.utils
77
from tmt.steps.prepare.artifact import PrepareArtifact
8-
from tmt.steps.prepare.artifact.providers import ArtifactInfo, ArtifactProvider, Version
8+
from tmt.steps.prepare.artifact.providers import (
9+
ArtifactInfo,
10+
ArtifactProvider,
11+
Version,
12+
)
913

1014

1115
class MockProvider(ArtifactProvider):
@@ -82,6 +86,7 @@ def test_persist_artifact_metadata(tmp_path, mock_provider):
8286
},
8387
"nvra": "mock-1.0-1.x86_64",
8488
"location": "http://example.com/mock-1.0-1.x86_64.rpm",
89+
"repo_id": "tmt-artifact-shared",
8590
}
8691
assert artifact == expected
8792

tests/unit/artifact/test_copr_repository.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,12 @@ def test_enumerate_artifacts(
5858
mock_guest.package_manager = mock_package_manager
5959

6060
mock_package_manager.list_packages.return_value = [
61-
RpmVersion.from_nevra('tmt-1.69.0-1.fc42.noarch'),
62-
RpmVersion.from_nevra('tmt-all-0:1.69.0-1.fc42.noarch'),
61+
RpmVersion.from_nevra(
62+
'tmt-1.69.0-1.fc42.noarch', repo_id='group_teemtee-stable-fedora-42-x86_64'
63+
),
64+
RpmVersion.from_nevra(
65+
'tmt-all-0:1.69.0-1.fc42.noarch', repo_id='group_teemtee-stable-fedora-42-x86_64'
66+
),
6367
]
6468

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

tests/unit/artifact/test_file.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ def test_download_artifact(tmp_path, artifact_provider):
7474
"version": "5.1.8",
7575
"release": "6.el9",
7676
"arch": "x86_64",
77+
"repo_id": None,
7778
},
7879
),
7980
(
@@ -84,6 +85,7 @@ def test_download_artifact(tmp_path, artifact_provider):
8485
"version": "1.61.0.dev17+gf29b2e83e",
8586
"release": "1.fc41",
8687
"arch": "x86_64",
88+
"repo_id": None,
8789
},
8890
),
8991
],

tmt/package_managers/_rpm.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"""
44

55
import re
6-
from typing import Any
6+
from typing import Any, Optional
77

88
from tmt._compat.typing import Self
99
from tmt.container import container
@@ -20,6 +20,9 @@ class RpmVersion(Version):
2020
Represents an RPM package version.
2121
"""
2222

23+
#: The repository section ID this package was listed from, if known.
24+
repo_id: Optional[str] = None
25+
2326
@classmethod
2427
def from_rpm_meta(cls, rpm_meta: dict[str, Any]) -> Self:
2528
"""
@@ -69,7 +72,7 @@ def from_filename(cls, filename: str) -> Self:
6972
return cls(name=name, version=version, release=release, arch=arch, epoch=0)
7073

7174
@classmethod
72-
def from_nevra(cls, nevra: str) -> Self:
75+
def from_nevra(cls, nevra: str, repo_id: Optional[str] = None) -> Self:
7376
"""
7477
Version constructed from a NEVRA string as returned by ``dnf repoquery``.
7578
@@ -90,4 +93,5 @@ def from_nevra(cls, nevra: str) -> Self:
9093
version=match.group('version'),
9194
release=match.group('release'),
9295
arch=match.group('arch'),
96+
repo_id=repo_id,
9397
)

tmt/package_managers/dnf.py

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -185,12 +185,16 @@ def install_repository(self, repository: Repository) -> ShellScript:
185185
)
186186

187187
def list_packages(self, repository: Repository) -> ShellScript:
188-
repo_ids = " ".join(f"--enablerepo={repo_id}" for repo_id in repository.repo_ids)
189-
return ShellScript(
190-
f"""
191-
{self.command.to_script()} repoquery --disablerepo='*' {repo_ids}
192-
"""
193-
)
188+
return (
189+
self.command
190+
+ Command(
191+
'repoquery',
192+
'--disablerepo=*',
193+
*[f'--enablerepo={repo_id}' for repo_id in repository.repo_ids],
194+
'--queryformat',
195+
r'%{repoid};%{name}-%{epoch}:%{version}-%{release}.%{arch}\n',
196+
)
197+
).to_script()
194198

195199
def get_package_origin(self, packages: Iterable[str]) -> ShellScript:
196200
return (
@@ -253,8 +257,14 @@ def list_packages(self, repository: Repository) -> list[Version]:
253257
if stdout is None:
254258
raise GeneralError("Repository query provided no output")
255259

256-
stripped_lines = (line.strip() for line in stdout.strip().splitlines())
257-
return [RpmVersion.from_nevra(line) for line in stripped_lines if line]
260+
result: list[Version] = []
261+
for line in stdout.strip().splitlines():
262+
line = line.strip()
263+
if not line:
264+
continue
265+
repo_id, nevra = line.split(';', maxsplit=1)
266+
result.append(RpmVersion.from_nevra(nevra, repo_id=repo_id))
267+
return result
258268

259269
def check_presence(self, *installables: Installable) -> dict[Installable, bool]:
260270
try:

tmt/steps/prepare/artifact/__init__.py

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from tmt.steps.prepare import PreparePlugin, PrepareStepData
1313
from tmt.steps.prepare.artifact.providers import (
1414
_PROVIDER_REGISTRY,
15+
SHARED_REPO_NAME,
1516
ArtifactProvider,
1617
Repository,
1718
)
@@ -198,7 +199,6 @@ class PrepareArtifact(PreparePlugin[PrepareArtifactData]):
198199

199200
# Shared repository configuration
200201
SHARED_REPO_DIR_NAME: ClassVar[str] = 'artifact-shared-repo'
201-
SHARED_REPO_NAME: ClassVar[str] = 'tmt-artifact-shared'
202202
ARTIFACTS_METADATA_FILENAME: ClassVar[str] = 'artifacts.yaml'
203203

204204
#: Name of the auto-injected verify-installation phase.
@@ -228,7 +228,7 @@ def go(
228228
artifact_dir=shared_repo_dir,
229229
guest=guest,
230230
logger=logger,
231-
repo_name=self.SHARED_REPO_NAME,
231+
repo_name=SHARED_REPO_NAME,
232232
priority=self.data.default_repository_priority,
233233
)
234234

@@ -355,29 +355,12 @@ def _inject_verify_phase(self, providers: list[ArtifactProvider], guest: Guest)
355355
for pkg in install_phase.data.package: # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
356356
pkg_names.add(str(pkg)) # pyright: ignore[reportUnknownArgumentType]
357357

358-
# Build package → origin repo mapping from all providers.
359-
# TODO: ``artifact.location`` is overloaded: a download URL for download
360-
# providers and a repo name for repository providers. We use
361-
# ``provider.get_repositories()`` as a proxy until ``ArtifactInfo`` gains
362-
# a dedicated ``repo`` field. See https://github.com/teemtee/tmt/issues/4714.
363-
pkg_to_repo: dict[str, str] = {}
358+
# Build package → set of valid repo_ids, filtering to only required packages.
359+
pkgs_to_verify: dict[str, set[str]] = {}
364360
for provider in providers:
365-
repositories = provider.get_repositories()
366-
if repositories:
367-
# Repository provider: collect all repo_ids from all repositories.
368-
# A .repo file may declare multiple sections; any of them is a valid origin.
369-
all_repo_ids = ','.join(
370-
repo_id for repo in repositories for repo_id in repo.repo_ids
371-
)
372-
for artifact in provider.artifacts:
373-
pkg_to_repo[artifact.version.name] = all_repo_ids
374-
else:
375-
# Download provider: packages land in the shared repo.
376-
for artifact in provider.artifacts:
377-
pkg_to_repo[artifact.version.name] = self.SHARED_REPO_NAME
378-
379-
# Only verify packages that are both required and from a known artifact.
380-
pkgs_to_verify = {pkg: repo for pkg, repo in pkg_to_repo.items() if pkg in pkg_names}
361+
for artifact in provider.artifacts:
362+
if artifact.version.name in pkg_names:
363+
pkgs_to_verify.setdefault(artifact.version.name, set()).add(artifact.repo_id)
381364

382365
if not pkgs_to_verify:
383366
self.verbose('No packages to be installed were found in the provided artifacts.')
@@ -397,8 +380,10 @@ def _inject_verify_phase(self, providers: list[ArtifactProvider], guest: Guest)
397380
)
398381

399382
if existing_verify is not None:
400-
# Merge into existing verify phase.
401-
existing_verify.data.verify.update(pkgs_to_verify) # pyright: ignore[reportUnknownMemberType]
383+
# Merge into existing verify phase, extending repo lists rather than replacing them.
384+
for verify_pkg, verify_repos in pkgs_to_verify.items():
385+
existing = existing_verify.data.verify.setdefault(verify_pkg, []) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType]
386+
existing.extend(repo_id for repo_id in verify_repos if repo_id not in existing) # pyright: ignore[reportUnknownMemberType]
402387
else:
403388
# Create and add a new verify phase.
404389
verify_data = PrepareVerifyInstallationData(
@@ -407,7 +392,7 @@ def _inject_verify_phase(self, providers: list[ArtifactProvider], guest: Guest)
407392
summary=self.VERIFY_PHASE_SUMMARY,
408393
order=tmt.steps.PHASE_ORDER_PREPARE_VERIFY_INSTALLATION,
409394
where=list(self.data.where),
410-
verify=pkgs_to_verify,
395+
verify={pkg: sorted(repo_ids) for pkg, repo_ids in pkgs_to_verify.items()},
411396
)
412397
verify_phase = PreparePlugin.delegate(self.step, data=verify_data) # pyright: ignore[reportUnknownVariableType]
413398
self.step.add_phase(verify_phase) # pyright: ignore[reportUnknownArgumentType]

tmt/steps/prepare/artifact/providers/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
r'^(?P<name>.+)-(?:(?P<epoch>\d+):)?(?P<version>.+)-(?P<release>.+)\.(?P<arch>.+)$'
2424
)
2525

26+
#: Name of the shared repository that download providers contribute RPMs into.
27+
SHARED_REPO_NAME: str = 'tmt-artifact-shared'
28+
2629

2730
class DownloadError(tmt.utils.GeneralError):
2831
"""
@@ -45,6 +48,9 @@ class ArtifactInfo:
4548
version: Version
4649
location: str
4750
provider: "ArtifactProvider"
51+
#: Repository ID this artifact is available from. Used during verification
52+
#: to confirm the artifact was installed from the expected repository.
53+
repo_id: str = SHARED_REPO_NAME
4854

4955
@property
5056
def id(self) -> str:
@@ -254,11 +260,24 @@ def enumerate_artifacts(self, guest: Guest) -> None:
254260
)
255261
continue
256262
for rpm_version in packages:
263+
from tmt.package_managers._rpm import RpmVersion
264+
265+
if not isinstance(rpm_version, RpmVersion):
266+
raise tmt.utils.GeneralError(
267+
f"Unexpected package type '{type(rpm_version).__name__}' "
268+
f"from repository '{repository.name}'."
269+
)
270+
if rpm_version.repo_id is None:
271+
raise tmt.utils.GeneralError(
272+
f"Package '{rpm_version}' from repository '{repository.name}' "
273+
f"has no repo_id."
274+
)
257275
self._artifacts.append(
258276
ArtifactInfo(
259277
version=rpm_version,
260278
provider=self,
261279
location=repository.name,
280+
repo_id=rpm_version.repo_id,
262281
)
263282
)
264283
self.logger.debug(
@@ -303,6 +322,7 @@ def artifact_metadata(self) -> list[dict[str, Any]]:
303322
'version': vars(artifact.version),
304323
'nvra': artifact.version.nvra,
305324
'location': artifact.location,
325+
'repo_id': artifact.repo_id,
306326
}
307327
for artifact in self.artifacts
308328
]

tmt/steps/prepare/artifact/providers/copr_build.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,9 @@ def make_rpm_artifact(self, rpm_meta: dict[str, str]) -> ArtifactInfo:
220220
base_url = self.result_url.rstrip("/")
221221

222222
return ArtifactInfo(
223-
version=version_info, location=urljoin(base_url + "/", filename), provider=self
223+
version=version_info,
224+
location=urljoin(base_url + "/", filename),
225+
provider=self,
224226
)
225227

226228
@cached_property

tmt/steps/prepare/artifact/providers/koji.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,9 @@ def make_rpm_artifact(self, rpm_meta: dict[str, Any]) -> ArtifactInfo:
227227
)
228228

229229
return ArtifactInfo(
230-
version=version_info, location=urljoin(self._top_url, path), provider=self
230+
version=version_info,
231+
location=urljoin(self._top_url, path),
232+
provider=self,
231233
)
232234

233235

@@ -269,7 +271,9 @@ def make_rpm_artifact(self, task_id: int, filename: str) -> ArtifactInfo: # typ
269271
url = f"{work_path}/{task_path}/{filename}"
270272

271273
return ArtifactInfo(
272-
version=RpmVersion.from_filename(filename), location=url, provider=self
274+
version=RpmVersion.from_filename(filename),
275+
location=url,
276+
provider=self,
273277
)
274278

275279
@cached_property

0 commit comments

Comments
 (0)