diff --git a/tests/prepare/artifact/nvr-priority/data/tc11.fmf b/tests/prepare/artifact/nvr-priority/data/tc11.fmf index 21bc43a81f..94576dbfef 100644 --- a/tests/prepare/artifact/nvr-priority/data/tc11.fmf +++ b/tests/prepare/artifact/nvr-priority/data/tc11.fmf @@ -14,6 +14,7 @@ summary: TC 1.1 - Repository priority overrides package version script: mkdir -p /tmp/nvr-test && cp -r tc11-high tc11-low /tmp/nvr-test/ - name: Install repo files how: artifact + verify: false provide: - repository-file:file:tc11-high.repo - repository-file:file:tc11-low.repo diff --git a/tests/prepare/artifact/verify-installation/data/.fmf/version b/tests/prepare/artifact/verify-installation/data/.fmf/version new file mode 100644 index 0000000000..d00491fd7e --- /dev/null +++ b/tests/prepare/artifact/verify-installation/data/.fmf/version @@ -0,0 +1 @@ +1 diff --git a/tests/prepare/artifact/verify-installation/data/main.fmf b/tests/prepare/artifact/verify-installation/data/main.fmf new file mode 100644 index 0000000000..eeb7a63599 --- /dev/null +++ b/tests/prepare/artifact/verify-installation/data/main.fmf @@ -0,0 +1,15 @@ +/plan: + summary: Plan for verify-installation injection tests + discover: + how: fmf + provision: + how: container + execute: + how: tmt + +/test: + test: /bin/true + duration: 5m + summary: Verify verify-installation injection behaviour + require: + - make diff --git a/tests/prepare/artifact/verify-installation/main.fmf b/tests/prepare/artifact/verify-installation/main.fmf new file mode 100644 index 0000000000..59e6965d4c --- /dev/null +++ b/tests/prepare/artifact/verify-installation/main.fmf @@ -0,0 +1,4 @@ +summary: Test verify-installation injection +description: | + Test that the verify-installation phase is injected + when verify=true (default) and suppressed when verify=false. diff --git a/tests/prepare/artifact/verify-installation/test.sh b/tests/prepare/artifact/verify-installation/test.sh new file mode 100755 index 0000000000..a6f41ead3d --- /dev/null +++ b/tests/prepare/artifact/verify-installation/test.sh @@ -0,0 +1,44 @@ +#!/bin/bash +. /usr/share/beakerlib/beakerlib.sh || exit 1 +. ../../../images.sh || exit 1 +. ../lib/common.sh || exit 1 + +rlJournalStart + rlPhaseStartSetup + rlRun "PROVISION_HOW=${PROVISION_HOW:-container}" + rlRun "pushd data" + rlRun "run=\$(mktemp -d)" 0 "Create run directory" + + setup_distro_environment + + # Get koji build ID for make + get_koji_build_id "make" "$koji_tag" + rlPhaseEnd + + rlPhaseStartTest "Test verify-installation phase injection (verify=true)" + rlRun -s "tmt run -i $run --scratch -vvv --all \ + plan --name /plan \ + provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \ + prepare --how artifact --provide koji.build:${KOJI_BUILD_ID}" \ + 0 "Verify should pass with verify=true (default)" + + rlAssertGrep "verify-artifact-packages" $rlRun_LOG + rlAssertGrep "pass verify-artifact-packages / make" $rlRun_LOG + rlAssertGrep "All packages verified successfully" $rlRun_LOG + rlPhaseEnd + + rlPhaseStartTest "Test verify=false suppresses verify-installation phase" + rlRun -s "tmt run -i $run --scratch -vvv --all \ + plan --name /plan \ + provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \ + prepare --how artifact --provide koji.build:${KOJI_BUILD_ID} --no-verify" \ + 0 "No-verify should succeed without verify phase" + + rlAssertNotGrep "verify-artifact-packages" $rlRun_LOG + rlPhaseEnd + + rlPhaseStartCleanup + rlRun "rm -rf $run" + rlRun "popd" + rlPhaseEnd +rlJournalEnd diff --git a/tests/prepare/verify-installation/data/main.fmf b/tests/prepare/verify-installation/data/main.fmf index e3a8568943..179bc64bdf 100644 --- a/tests/prepare/verify-installation/data/main.fmf +++ b/tests/prepare/verify-installation/data/main.fmf @@ -8,6 +8,7 @@ test: /test prepare: - how: artifact + verify: false provide: - koji.build:KOJI_BUILD_ID diff --git a/tmt/schemas/prepare/artifact.yaml b/tmt/schemas/prepare/artifact.yaml index b7e3df412a..b25d413a9a 100644 --- a/tmt/schemas/prepare/artifact.yaml +++ b/tmt/schemas/prepare/artifact.yaml @@ -21,5 +21,8 @@ properties: default-repository-priority: type: integer + verify: + type: boolean + required: - how diff --git a/tmt/steps/prepare/artifact/__init__.py b/tmt/steps/prepare/artifact/__init__.py index 8bc2d86d42..253c0f056e 100644 --- a/tmt/steps/prepare/artifact/__init__.py +++ b/tmt/steps/prepare/artifact/__init__.py @@ -1,5 +1,7 @@ from typing import ClassVar, Optional +import fmf.utils + import tmt.base.core import tmt.steps import tmt.utils @@ -13,6 +15,15 @@ ArtifactProvider, Repository, ) + +# ``@provides_method`` causes pyright to lose the class type, which is the +# root cause of all ``pyright: ignore`` waivers referencing these two classes. +# This will be fixed by https://github.com/teemtee/tmt/issues/4766. +from tmt.steps.prepare.install import PrepareInstall # pyright: ignore[reportUnknownVariableType] +from tmt.steps.prepare.verify_installation import ( + PrepareVerifyInstallation, # pyright: ignore[reportUnknownVariableType] + PrepareVerifyInstallationData, +) from tmt.utils import Environment, Path @@ -37,6 +48,18 @@ class PrepareArtifactData(PrepareStepData): """, ) + verify: bool = field( + default=True, + option='--verify/--no-verify', + is_flag=True, + help=""" + Verify that packages from tmt-injected ``prepare/install`` phases + (test ``require``/``recommend`` keys, their dist-git equivalents, and essential requires) + were installed from the correct provider artifact repository. + User-defined ``prepare/install`` phases are not covered. + """, + ) + def get_artifact_provider(provider_id: str) -> type[ArtifactProvider]: provider_type = provider_id.split(':', maxsplit=1)[0] @@ -60,14 +83,20 @@ class PrepareArtifact(PreparePlugin[PrepareArtifactData]): a preferred repository on the guest. The goal is to make sure these exact artifacts are being used - when requested in one of the - :tmt:story:`test require `, - :tmt:story:`test recommend `, or - :ref:`prepare install `. Exact NVR + when requested via + :tmt:story:`test require ` or + :tmt:story:`test recommend ` keys. Exact NVR *should not* be used in those requests, instead this plugin will take care of disambiguating the requested package based on the provided artifacts. + When ``verify`` is enabled (the default), the plugin injects a + verification phase that checks packages installed from tmt-managed + install phases (``require``, ``recommend``, ``essential-requires``, + and their dist-git equivalents) actually came from the configured + artifact repositories. User-defined ``prepare/install`` phases are + not covered by this verification. + Currently, the following artifact providers are supported: **Koji** @@ -172,6 +201,14 @@ class PrepareArtifact(PreparePlugin[PrepareArtifactData]): SHARED_REPO_NAME: ClassVar[str] = 'tmt-artifact-shared' ARTIFACTS_METADATA_FILENAME: ClassVar[str] = 'artifacts.yaml' + #: Name of the auto-injected verify-installation phase. + VERIFY_PHASE_NAME: ClassVar[str] = 'verify-artifact-packages' + + #: Summary of the auto-injected verify-installation phase. + VERIFY_PHASE_SUMMARY: ClassVar[str] = ( + 'Verify test requirement packages were installed from the correct artifact repositories' + ) + def go( self, *, @@ -272,6 +309,10 @@ def go( # Persist artifact metadata to YAML self._save_artifacts_metadata(providers) + # Verify phase injection + if self.data.verify: + self._inject_verify_phase(providers, guest) + # Report configuration summary logger.info( f"Configured artifact preparation with {len(self.data.provide)} provider(s) " @@ -280,6 +321,97 @@ def go( return outcome + def _inject_verify_phase(self, providers: list[ArtifactProvider], guest: Guest) -> None: + """ + Inject a verify-installation phase for packages from these providers. + + If a verify phase already exists for the same where= group, merge + the packages into it. Otherwise, create and add a new phase. + """ + # Collect packages from the install phases injected by tmt on behalf of + # test/essential requirements. User-defined prepare/install phases are + # intentionally excluded. + # + # Phase name sources: + # 'essential-requires' — Prepare._go() in tmt/steps/prepare/__init__.py + # 'requires' — Prepare._go() in tmt/steps/prepare/__init__.py + # 'recommends' — Prepare._go() in tmt/steps/prepare/__init__.py + # 'requires (dist-git)' — tmt/steps/prepare/distgit.py + # 'recommends (dist-git)' — tmt/steps/prepare/distgit.py + _tmt_install_phase_names = { + 'essential-requires', + 'requires', + 'recommends', + 'requires (dist-git)', + 'recommends (dist-git)', + } + pkg_names: set[str] = set() + _install_phases = self.step.phases(classes=PrepareInstall) # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType] + for install_phase in _install_phases: # pyright: ignore[reportUnknownVariableType] + if install_phase.data.name not in _tmt_install_phase_names: # pyright: ignore[reportUnknownMemberType] + continue + if not install_phase.enabled_on_guest(guest): # pyright: ignore[reportUnknownMemberType] + continue + for pkg in install_phase.data.package: # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] + pkg_names.add(str(pkg)) # pyright: ignore[reportUnknownArgumentType] + + # Build package → origin repo mapping from all providers. + # TODO: ``artifact.location`` is overloaded: a download URL for download + # providers and a repo name for repository providers. We use + # ``provider.get_repositories()`` as a proxy until ``ArtifactInfo`` gains + # a dedicated ``repo`` field. See https://github.com/teemtee/tmt/issues/4714. + pkg_to_repo: dict[str, str] = {} + for provider in providers: + repositories = provider.get_repositories() + if repositories: + # Repository provider: collect all repo_ids from all repositories. + # A .repo file may declare multiple sections; any of them is a valid origin. + all_repo_ids = ','.join( + repo_id for repo in repositories for repo_id in repo.repo_ids + ) + for artifact in provider.artifacts: + pkg_to_repo[artifact.version.name] = all_repo_ids + else: + # Download provider: packages land in the shared repo. + for artifact in provider.artifacts: + pkg_to_repo[artifact.version.name] = self.SHARED_REPO_NAME + + # Only verify packages that are both required and from a known artifact. + pkgs_to_verify = {pkg: repo for pkg, repo in pkg_to_repo.items() if pkg in pkg_names} + + if not pkgs_to_verify: + self.verbose('No packages to be installed were found in the provided artifacts.') + return + + self.debug(f"Verifying {fmf.utils.listed(sorted(pkgs_to_verify), 'package')}.") + + # Look for an existing verify phase for this where= group. + existing_verify: Optional[PrepareVerifyInstallation] = next( # pyright: ignore[reportUnknownVariableType] + ( + phase + for phase in self.step.phases(PrepareVerifyInstallation) # pyright: ignore[reportUnknownArgumentType,reportUnknownVariableType] + if phase.data.name == self.VERIFY_PHASE_NAME # pyright: ignore[reportUnknownMemberType] + and set(phase.data.where) == set(self.data.where) # pyright: ignore[reportUnknownArgumentType,reportUnknownMemberType] + ), + None, + ) + + if existing_verify is not None: + # Merge into existing verify phase. + existing_verify.data.verify.update(pkgs_to_verify) # pyright: ignore[reportUnknownMemberType] + else: + # Create and add a new verify phase. + verify_data = PrepareVerifyInstallationData( + name=self.VERIFY_PHASE_NAME, + how='verify-installation', + summary=self.VERIFY_PHASE_SUMMARY, + order=tmt.steps.PHASE_ORDER_PREPARE_VERIFY_INSTALLATION, + where=list(self.data.where), + verify=pkgs_to_verify, + ) + verify_phase = PreparePlugin.delegate(self.step, data=verify_data) # pyright: ignore[reportUnknownVariableType] + self.step.add_phase(verify_phase) # pyright: ignore[reportUnknownArgumentType] + def essential_requires(self) -> list[tmt.base.core.Dependency]: # createrepo is needed to create repository metadata from downloaded artifacts return [ diff --git a/tmt/steps/prepare/verify_installation.py b/tmt/steps/prepare/verify_installation.py index 1aeb98d5d4..eeb5aa492d 100644 --- a/tmt/steps/prepare/verify_installation.py +++ b/tmt/steps/prepare/verify_installation.py @@ -20,6 +20,13 @@ class PrepareVerifyInstallationData(PrepareStepData): help='Order in which the phase should be handled.', ) + # TODO: The value type should be ``list[str]`` to allow specifying multiple + # acceptable source repositories for a single package (e.g. the same NVR + # can exist in both ``tmt-artifact-shared`` and added ``repository`` without clashing. + # When that change is made the comparison in ``go()`` must be updated from + # ``actual_origin == expected_repo`` to ``actual_origin in expected_repos``, + # and the semantics must be documented: a package passes verification if its + # actual source repo matches ANY of the listed repos (OR semantics). verify: dict[str, str] = field( default_factory=dict, help="Mapping of package names to expected source repository names.", @@ -80,6 +87,11 @@ 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. try: package_origins = guest.package_manager.get_package_origin(self.data.verify.keys()) except (NotImplementedError, tmt.utils.GeneralError) as err: @@ -106,7 +118,7 @@ def go( for package, expected_repo in self.data.verify.items(): actual_origin = package_origins[package] - if actual_origin == expected_repo: + if actual_origin in expected_repo: outcome.results.append( PhaseResult( name=f'{self.name} / {package}',