Skip to content
Merged
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
1 change: 1 addition & 0 deletions tests/prepare/artifact/nvr-priority/data/tc11.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
1
15 changes: 15 additions & 0 deletions tests/prepare/artifact/verify-installation/data/main.fmf
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions tests/prepare/artifact/verify-installation/main.fmf
Original file line number Diff line number Diff line change
@@ -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.
44 changes: 44 additions & 0 deletions tests/prepare/artifact/verify-installation/test.sh
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions tests/prepare/verify-installation/data/main.fmf
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
test: /test
prepare:
- how: artifact
verify: false
provide:
- koji.build:KOJI_BUILD_ID

Expand Down
3 changes: 3 additions & 0 deletions tmt/schemas/prepare/artifact.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ properties:
default-repository-priority:
type: integer

verify:
type: boolean

required:
- how
140 changes: 136 additions & 4 deletions tmt/steps/prepare/artifact/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from typing import ClassVar, Optional

import fmf.utils

import tmt.base.core
import tmt.steps
import tmt.utils
Expand All @@ -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]
Comment thread
happz marked this conversation as resolved.
from tmt.steps.prepare.verify_installation import (
PrepareVerifyInstallation, # pyright: ignore[reportUnknownVariableType]
PrepareVerifyInstallationData,
)
from tmt.utils import Environment, Path


Expand All @@ -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]
Expand All @@ -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 </spec/tests/require>`,
:tmt:story:`test recommend </spec/tests/recommend>`, or
:ref:`prepare install </plugins/prepare/install>`. Exact NVR
when requested via
:tmt:story:`test require </spec/tests/require>` or
:tmt:story:`test recommend </spec/tests/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**
Expand Down Expand Up @@ -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'
)
Comment thread
happz marked this conversation as resolved.

def go(
self,
*,
Expand Down Expand Up @@ -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) "
Expand All @@ -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)',
}
Comment thread
happz marked this conversation as resolved.
pkg_names: set[str] = set()
Comment thread
LecrisUT marked this conversation as resolved.
_install_phases = self.step.phases(classes=PrepareInstall) # pyright: ignore[reportUnknownVariableType,reportUnknownArgumentType]
for install_phase in _install_phases: # pyright: ignore[reportUnknownVariableType]
Comment thread
happz marked this conversation as resolved.
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
Comment thread
psss marked this conversation as resolved.

# 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 [
Expand Down
14 changes: 13 additions & 1 deletion tmt/steps/prepare/verify_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Comment thread
vaibhavdaren marked this conversation as resolved.
outcome.results.append(
PhaseResult(
name=f'{self.name} / {package}',
Expand Down
Loading