Skip to content
Draft
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/artifact/providers/copr-repository/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ rlJournalStart
rlPhaseEnd

rlPhaseStartTest "Test copr-repository provider with command-line override"
rlRun "tmt run -i $run --scratch -vv --all \
rlRun "tmt run -i $run --scratch -vvv --all \
provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \
prepare --how artifact --provide copr.repository:mariobl/pyspread" 0 "Run with copr-repository provider"
rlPhaseEnd
Expand Down
2 changes: 1 addition & 1 deletion tests/prepare/artifact/providers/koji-build/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ rlJournalStart
rlPhaseEnd

rlPhaseStartTest "Test koji.build provider with command-line override"
rlRun "tmt run -i $run --scratch -vv --all \
rlRun "tmt run -i $run --scratch -vvv --all \
provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \
prepare --how artifact --provide koji.build:$KOJI_BUILD_ID" 0 "Run with koji.build provider"
rlPhaseEnd
Expand Down
2 changes: 1 addition & 1 deletion tests/prepare/artifact/providers/multi/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ rlJournalStart
rlPhaseEnd

rlPhaseStartTest "Test multiple providers with command-line override"
rlRun "tmt run -i $run --scratch -vv --all \
rlRun "tmt run -i $run --scratch -vvv --all \
provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \
prepare --how artifact \
--provide koji.build:$KOJI_BUILD_ID \
Expand Down
2 changes: 1 addition & 1 deletion tests/prepare/artifact/providers/repository-file/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ rlJournalStart
rlPhaseEnd

rlPhaseStartTest "Test repository-file provider with command-line override"
rlRun "tmt run -i $run --scratch -vv --all \
rlRun "tmt run -i $run --scratch -vvv --all \
provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name \
prepare --how artifact --provide repository-file:https://download.docker.com/linux/fedora/docker-ce.repo" 0 "Run with repository-file provider"
rlPhaseEnd
Expand Down
2 changes: 1 addition & 1 deletion tests/prepare/artifact/providers/repository-url/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ rlJournalStart
rlPhaseEnd

rlPhaseStartTest "Test repository-url provider"
rlRun "tmt run -i $run --scratch -vv --all \
rlRun "tmt run -i $run --scratch -vvv --all \
provision -h $PROVISION_HOW --image $TEST_IMAGE_PREFIX/$image_name" 0 "Run with repository-url provider"
rlPhaseEnd

Expand Down
38 changes: 38 additions & 0 deletions tmt/steps/prepare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,44 @@ def _emit_phase(
missing='skip',
)

# Auto-inject a verify-installation phase when:
# - at least one artifact phase has verify=True, and
# - no explicit verify-installation phase was configured by the user.
from tmt.steps.prepare.artifact import PrepareArtifact
from tmt.steps.prepare.verify_installation import (
PrepareVerifyInstallation,
PrepareVerifyInstallationData,
)

artifact_phases_with_verify = [
phase
for phase in self._phases
if isinstance(phase, PrepareArtifact) and phase.data.verify
]
has_verify_phase = any(
isinstance(phase, PrepareVerifyInstallation) for phase in self._phases
)

if artifact_phases_with_verify and not has_verify_phase:
# Mirror the where= scope of the artifact phases: if any artifact
# phase runs on all guests (empty where), verify should too;
# otherwise restrict to the union of their guest/role targets.
if any(not phase.data.where for phase in artifact_phases_with_verify):
verify_where: list[str] = []
else:
verify_where = list(
{dest for phase in artifact_phases_with_verify for dest in phase.data.where}
)
verify_data = PrepareVerifyInstallationData(
name='verify-artifact-packages',
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Define name as a constant. This improves maintainability by centralizing the string literal, making it easier to update and ensuring consistency if referenced elsewhere.

how='verify-installation',
summary='Verify packages were installed from artifact repositories',
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Define summary as a constant. This improves maintainability by centralizing the string literal, making it easier to update and ensuring consistency if referenced elsewhere.

order=tmt.steps.PHASE_ORDER_PREPARE_VERIFY_INSTALLATION,
auto=True,
where=verify_where,
)
self._phases.append(PreparePlugin.delegate(self, data=verify_data))
Comment on lines +391 to +409
Copy link
Member

Choose a reason for hiding this comment

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

Uph, please not like this. Would rather it be created inside the artifact plugin instead, where we would also control how it is created. The questionable thing is if can be created while in the prepare.go phase, but that needs to be solved regardless because we do not know the packages and their origins until PrepareArtifact (probably all of them) complete.

Copy link
Member

Choose a reason for hiding this comment

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

For reference, see Discover dist-git in how it inserts the phase dynamically

Copy link
Contributor

Choose a reason for hiding this comment

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

It does, but it's a discover phase adding to prepare step that haven't started yet. prepare phase adding to prepare step, after the queue has been established and started is not supported. Right now, I would do it in the prepare step, just as test requirements are sorted out there. It's not ideal, but it's available today, and can be improved independently later.

Copy link
Member

Choose a reason for hiding this comment

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

Uph. At the minimum, encapsulate it in a function, and document to get rid of it as soon as possible.

Copy link
Member

Choose a reason for hiding this comment

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

On second thought, I still don't like it. What does it take to inject the phase in the queue properly and improperly?

Copy link
Contributor

Choose a reason for hiding this comment

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

Changing how queues are populated in steps, adding API to support dynamic additions while the queue is already running, which most likely requires reordering of the queued tasks as tasks were enqueued according to phases' order keys. I don't mind working on that, already some needed work landed, it just won't be ready today.

Copy link
Contributor

Choose a reason for hiding this comment

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

And a unit test, to make sure it works because this would be rather a rare use case. If there's a bug in how queue works, we'd see tmt explode everywhere; if there's a bug in how tasks are enqueued from another tasks of the same step, only a handful of tests would fail, so this calls for having a test as well.

Copy link
Member

Choose a reason for hiding this comment

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

We did create the interface in preparation for it at least

tmt/tmt/steps/__init__.py

Lines 1322 to 1330 in b32fa85

def add_phase(self, phase: Phase) -> None:
"""
Add a phase dynamically to the current step.
:param phase: The phase to add.
"""
if not isinstance(phase, self._plugin_base_class):
raise GeneralError(f"The phase '{phase}' is not part of step '{self}'.")
self._phases.append(phase)


# Prepare guests (including workdir sync)
guest_copies: list[Guest] = []

Expand Down
23 changes: 20 additions & 3 deletions tmt/steps/prepare/artifact/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from typing import ClassVar, Optional
from typing import ClassVar, Final, Optional

import tmt.base.core
import tmt.steps
Expand All @@ -15,6 +15,12 @@
)
from tmt.utils import Environment, Path

#: Name of the shared repository created by the artifact plugin.
ARTIFACT_SHARED_REPO_NAME: Final[str] = 'tmt-artifact-shared'

#: Filename of the artifact metadata file written by the artifact plugin.
ARTIFACT_METADATA_FILENAME: Final[str] = 'artifacts.yaml'


@container
class PrepareArtifactData(PrepareStepData):
Expand All @@ -37,6 +43,17 @@ class PrepareArtifactData(PrepareStepData):
""",
)

verify: bool = field(
default=True,
option='--verify/--no-verify',
is_flag=True,
help="""
Automatically verify that packages from require/recommend that are
present in the artifact metadata were installed from the artifact
repository. Enabled by default.
""",
)


def get_artifact_provider(provider_id: str) -> type[ArtifactProvider]:
provider_type = provider_id.split(':', maxsplit=1)[0]
Expand Down Expand Up @@ -169,8 +186,8 @@ class PrepareArtifact(PreparePlugin[PrepareArtifactData]):

# Shared repository configuration
SHARED_REPO_DIR_NAME: ClassVar[str] = 'artifact-shared-repo'
SHARED_REPO_NAME: ClassVar[str] = 'tmt-artifact-shared'
ARTIFACTS_METADATA_FILENAME: ClassVar[str] = 'artifacts.yaml'
SHARED_REPO_NAME: ClassVar[str] = ARTIFACT_SHARED_REPO_NAME
ARTIFACTS_METADATA_FILENAME: ClassVar[str] = ARTIFACT_METADATA_FILENAME

def go(
self,
Expand Down
98 changes: 94 additions & 4 deletions tmt/steps/prepare/verify_installation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import fmf.utils

import tmt.base.core
import tmt.steps
import tmt.utils
from tmt.container import container, field
Expand All @@ -26,6 +27,18 @@ class PrepareVerifyInstallationData(PrepareStepData):
normalize=tmt.utils.normalize_string_dict,
)

auto: bool = field(
default=False,
option='--auto/--no-auto',
is_flag=True,
help="""
Automatically verify packages listed in ``require`` or ``recommend``
that are present in the artifact metadata (``artifacts.yaml``) were
installed from the artifact repository. Entries in ``verify`` take
precedence over auto-detected ones.
""",
)


@tmt.steps.provides_method('verify-installation')
class PrepareVerifyInstallation(PreparePlugin[PrepareVerifyInstallationData]):
Expand Down Expand Up @@ -58,6 +71,76 @@ class PrepareVerifyInstallation(PreparePlugin[PrepareVerifyInstallationData]):

_data_class = PrepareVerifyInstallationData

def _build_auto_verify(self, guest: Guest) -> dict[str, str]:
"""
Build a verify mapping from artifact metadata and test requirements.

Reads ``artifacts.yaml`` from the plan workdir, collects all
package names provided by artifact providers, and intersects them
with packages listed in ``require``/``recommend`` of tests enabled
on this guest. Returns a mapping of intersecting package names to
the artifact shared repository name.
"""
Comment on lines +74 to +83
Copy link
Member

Choose a reason for hiding this comment

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

Well, the second issue with not being able to inject the phase is having this magic method. Didn't like the branching in discover, and this just adds another flavor of it. This wouldn't even work if you have one artifact provider with verify and another without since it cannot discriminate between them, unless there is even more introspection between these phases

from tmt.steps.prepare.artifact import (
ARTIFACT_METADATA_FILENAME,
ARTIFACT_SHARED_REPO_NAME,
)

artifacts_file = self.plan_workdir / ARTIFACT_METADATA_FILENAME

if not artifacts_file.exists():
self.debug('No artifacts.yaml found, skipping auto-verification.')
return {}

try:
metadata = tmt.utils.yaml_to_dict(artifacts_file.read_text())
except tmt.utils.GeneralError as err:
self.warn(f"Failed to read artifacts.yaml: {err}")
return {}

# Collect all package names provided by artifact providers.
artifact_package_names: set[str] = {
artifact['version']['name']
for provider in metadata.get('providers', [])
for artifact in provider.get('artifacts', [])
}

if not artifact_package_names:
self.debug('No artifact packages found in artifacts.yaml.')
return {}

# Collect require/recommend package names for tests enabled on this guest.
# Only DependencySimple entries are package names; fmf/file deps are skipped.
require_recommend_names: set[str] = set()
for test_origin in self.step.plan.discover.tests(enabled=True):
test = test_origin.test
if not test.enabled_on_guest(guest):
continue
for dep in (*test.require, *test.recommend):
if isinstance(dep, tmt.base.core.DependencySimple):
require_recommend_names.add(str(dep))

intersection = artifact_package_names & require_recommend_names
if not intersection:
self.debug('No overlap between artifact packages and test requirements.')
return {}

self.debug(
f"Auto-verifying {fmf.utils.listed(sorted(intersection), 'package')} "
f"against '{ARTIFACT_SHARED_REPO_NAME}'."
)

unverified = require_recommend_names - artifact_package_names
if unverified:
self.warn(
f"{fmf.utils.listed(sorted(unverified), 'package')} "
f"from require/recommend not found in artifacts.yaml, "
f"consider adding a custom verify-installation entry: "
f"{', '.join(sorted(unverified))}"
)

return dict.fromkeys(intersection, ARTIFACT_SHARED_REPO_NAME)

def go(
self,
*,
Expand All @@ -71,17 +154,24 @@ def go(
if self.is_dry_run:
return outcome

if not self.data.verify:
# Build the effective verify mapping: auto-detected entries are
# overridden by any manually specified ones.
effective_verify: dict[str, str] = {}
if self.data.auto:
effective_verify.update(self._build_auto_verify(guest))
effective_verify.update(self.data.verify)

if not effective_verify:
self.verbose('No packages to verify.')
return outcome

self.info(
fmf.utils.listed(list(self.data.verify.keys()), 'package'),
fmf.utils.listed(list(effective_verify.keys()), 'package'),
color='green',
)

try:
package_origins = guest.package_manager.get_package_origin(self.data.verify.keys())
package_origins = guest.package_manager.get_package_origin(effective_verify.keys())
except (NotImplementedError, tmt.utils.GeneralError) as err:
error: Exception = (
tmt.utils.PrepareError(
Expand All @@ -103,7 +193,7 @@ def go(
return outcome

failed_packages: list[str] = []
for package, expected_repo in self.data.verify.items():
for package, expected_repo in effective_verify.items():
actual_origin = package_origins[package]

if actual_origin == expected_repo:
Expand Down
Loading