Skip to content

Commit b32fa85

Browse files
authored
Add a new feature plugin to verify package installation (#4660)
Introduce `prepare/verify-installation` plugin to verify that a package installation come from a specific pre-declared repository Closes #4644
1 parent 187781f commit b32fa85

File tree

14 files changed

+459
-2
lines changed

14 files changed

+459
-2
lines changed

docs/releases/1.70.0/4660.fmf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
description: |
2+
New :ref:`/plugins/prepare/verify-installation` plugin to verify that
3+
packages were installed from expected repositories.

spec/plans/prepare.fmf

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ description: |
3232
75
3333
Installation of packages :tmt:story:`recommended</spec/tests/recommend>` by tests.
3434

35+
79
36+
Verification of package source repositories after installation.
37+
3538
.. note::
3639

3740
Individual plugins may define their own special ``order`` values,

tests/core/about/test_about.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def test_plugin_ls_human_output(run_tmt: 'RunTmt') -> None:
8282
"step.discover": ["fmf", "shell"],
8383
"step.execute": ["tmt", "upgrade"],
8484
"step.finish": ["ansible", "shell"],
85-
"step.prepare": ["ansible", "artifact", "feature", "install", "shell"],
85+
"step.prepare": ["ansible", "artifact", "feature", "install", "shell", "verify-installation"],
8686
"step.provision": [
8787
"artemis",
8888
"beaker",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/plan:
2+
provision:
3+
how: container
4+
execute:
5+
how: tmt
6+
discover:
7+
how: fmf
8+
test: /test
9+
prepare:
10+
- how: feature
11+
epel: enabled
12+
13+
/plan/success:
14+
prepare+:
15+
- how: verify-installation
16+
verify:
17+
make: baseos
18+
diffutils: "<unknown>"
19+
centpkg: epel
20+
21+
/plan/failure:
22+
prepare+:
23+
- how: verify-installation
24+
verify:
25+
make: SOME_NON_EXISTENT_REPO
26+
diffutils: "<unknown>"
27+
centpkg: epel
28+
random-non-existent-package: some-repo
29+
30+
/test:
31+
test: /bin/true
32+
require:
33+
- make
34+
- diffutils
35+
- centpkg
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
1
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/plan:
2+
provision:
3+
how: container
4+
execute:
5+
how: tmt
6+
discover:
7+
how: fmf
8+
test: /test
9+
prepare:
10+
- how: artifact
11+
provide:
12+
- koji.build:KOJI_BUILD_ID
13+
14+
/plan/success:
15+
prepare+:
16+
- how: verify-installation
17+
verify:
18+
make: tmt-artifact-shared
19+
make-devel: tmt-artifact-shared
20+
diffutils: fedora
21+
22+
/plan/failure:
23+
prepare+:
24+
- how: verify-installation
25+
verify:
26+
make: tmt-artifact-shared
27+
make-devel: SOME_NON_EXISTENT_REPO
28+
diffutils: fedora
29+
random-non-existent-package: some-repo
30+
31+
/test:
32+
test: /bin/true
33+
require:
34+
- make
35+
- make-devel
36+
- diffutils
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
summary: Check verify-installation prepare plugin
2+
test: ./test.sh
3+
4+
# TODO: Enable for other provision methods when appropriate
5+
tag+:
6+
- provision-only
7+
- provision-container
8+
9+
adjust:
10+
- when: distro != centos and distro != fedora
11+
enabled: false
12+
because: Test targets dnf4 behaviour on CentOS Stream 10 and dnf5 behaviour on Fedora
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/bin/bash
2+
. /usr/share/beakerlib/beakerlib.sh || exit 1
3+
. ../../images.sh || exit 1
4+
5+
rlJournalStart
6+
rlPhaseStartSetup
7+
rlRun "PROVISION_HOW=${PROVISION_HOW:-container}"
8+
rlRun "run=\$(mktemp -d)" 0 "Create run directory"
9+
10+
if rlIsFedora; then
11+
. ../artifact/lib/common.sh || exit 1
12+
13+
setup_distro_environment
14+
rlRun "image=\$TEST_IMAGE_PREFIX/\$image_name"
15+
16+
rlRun "data_dir=\$(mktemp -d)" 0 "Create temp data directory"
17+
18+
# Fetch the latest koji build ID for make dynamically
19+
get_koji_build_id "make" "f\${fedora_release}"
20+
21+
# Copy plan data and substitute the dynamic build ID
22+
rlRun "cp -r data-fedora/. \$data_dir/" 0 "Copy test data"
23+
rlRun "sed -i 's/KOJI_BUILD_ID/${KOJI_BUILD_ID}/g' \$data_dir/main.fmf" 0 "Substitute koji build ID"
24+
rlRun "pushd \$data_dir"
25+
else
26+
build_container_image "centos/stream10/upstream:latest"
27+
rlRun "image=\$TEST_IMAGE_PREFIX/centos/stream10/upstream:latest"
28+
rlRun "pushd data-centos"
29+
fi
30+
rlPhaseEnd
31+
32+
rlPhaseStartTest "Test successful verification"
33+
rlRun -s "tmt run -i \$run/success --scratch -vvv --all \
34+
plan --name /plan/success \
35+
provision -h \$PROVISION_HOW --image \$image" \
36+
0 "Run verification test with correct repos"
37+
38+
# make and diffutils are available in both Fedora and CentOS
39+
rlAssertGrep "pass .* / make" $rlRun_LOG
40+
rlAssertGrep "pass .* / diffutils" $rlRun_LOG
41+
42+
if rlIsFedora; then
43+
rlAssertGrep "pass .* / make-devel" $rlRun_LOG
44+
else
45+
rlAssertGrep "pass .* / centpkg" $rlRun_LOG
46+
fi
47+
48+
rlAssertGrep "All packages verified successfully." $rlRun_LOG
49+
rlAssertNotGrep "Package source verification failed for:" $rlRun_LOG
50+
rlAssertGrep "1 test passed" $rlRun_LOG
51+
rlPhaseEnd
52+
53+
rlPhaseStartTest "Test verification failure"
54+
rlRun -s "tmt run -i \$run/failure --scratch -vvv --all \
55+
plan --name /plan/failure \
56+
provision -h \$PROVISION_HOW --image \$image" \
57+
2 "Verification should fail with wrong repo"
58+
59+
# diffutils passes on both distros in the failure plan
60+
rlAssertGrep "pass .* / diffutils" $rlRun_LOG
61+
62+
rlAssertGrep "4 packages" $rlRun_LOG
63+
if rlIsFedora; then
64+
rlAssertGrep "pass .* / make" $rlRun_LOG
65+
rlAssertGrep "fail .* / make-devel" $rlRun_LOG
66+
rlAssertGrep "actual 'tmt-artifact-shared'" $rlRun_LOG
67+
else
68+
rlAssertGrep "pass .* / centpkg" $rlRun_LOG
69+
rlAssertGrep "fail .* / make" $rlRun_LOG
70+
rlAssertGrep "actual 'baseos'" $rlRun_LOG
71+
fi
72+
73+
rlAssertGrep "expected repo 'SOME_NON_EXISTENT_REPO'" $rlRun_LOG
74+
rlAssertGrep "fail .* / random-non-existent-package" $rlRun_LOG
75+
rlAssertGrep "random-non-existent-package.*not installed" $rlRun_LOG
76+
rlAssertGrep "Package source verification failed for:" $rlRun_LOG
77+
rlAssertNotGrep "All packages verified successfully." $rlRun_LOG
78+
rlPhaseEnd
79+
80+
rlPhaseStartCleanup
81+
if rlIsFedora; then
82+
rlRun "rm -rf \$run \$data_dir" 0 "Removing run and data directories"
83+
else
84+
rlRun "rm -rf \$run" 0 "Removing run directory"
85+
fi
86+
rlRun "popd"
87+
rlPhaseEnd
88+
rlJournalEnd

tmt/package_managers/__init__.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import abc
2+
import enum
23
import re
34
import shlex
4-
from collections.abc import Iterator
5+
from collections.abc import Iterable, Iterator
56
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Optional, TypeVar, Union
67

78
import tmt.log
@@ -10,6 +11,20 @@
1011
from tmt.container import container, simple_field
1112
from tmt.utils import Command, CommandOutput, GeneralError, Path, PrepareError, ShellScript
1213

14+
15+
class SpecialPackageOrigin(str, enum.Enum):
16+
"""
17+
Sentinel values used in place of an actual repository name to convey
18+
special package states returned by :py:meth:`PackageManager.get_package_origin`.
19+
"""
20+
21+
#: Package is not installed on the guest.
22+
NOT_INSTALLED = '<not-installed>'
23+
#: Package is installed but its source repository cannot be determined
24+
#: (e.g. pre-installed in a container image).
25+
UNKNOWN = '<unknown>'
26+
27+
1328
if TYPE_CHECKING:
1429
from tmt._compat.typing import TypeAlias
1530

@@ -21,6 +36,9 @@
2136

2237
#: A type of package manager names.
2338
GuestPackageManager: TypeAlias = str
39+
40+
#: A package origin: either an actual repository name or a :class:`SpecialPackageOrigin`.
41+
PackageOrigin: TypeAlias = Union[str, SpecialPackageOrigin]
2442
else:
2543
Repository: Any = None # type: ignore[assignment]
2644

@@ -222,6 +240,27 @@ def list_packages(self, repository: "Repository") -> ShellScript:
222240
"""
223241
raise NotImplementedError
224242

243+
def get_package_origin(self, packages: Iterable[str]) -> ShellScript:
244+
"""
245+
List source repositories for each installed package.
246+
247+
The script must emit one line per package in the format::
248+
249+
<name> <origin>
250+
251+
Empty lines are allowed and will be ignored by the caller. If
252+
the origin field is omitted the package is treated as having an
253+
unknown source repository (equivalent to
254+
:py:attr:`SpecialPackageOrigin.UNKNOWN`). Packages whose name
255+
does not appear in the output at all are treated as not installed
256+
(equivalent to :py:attr:`SpecialPackageOrigin.NOT_INSTALLED`).
257+
258+
:param packages: Package names to query.
259+
:returns: A shell script to list source repositories for the given packages.
260+
:raises NotImplementedError: If the package manager does not support this query.
261+
"""
262+
raise NotImplementedError
263+
225264
def create_repository(self, directory: Path) -> ShellScript:
226265
"""
227266
Create repository metadata for package files in the given directory.
@@ -338,6 +377,32 @@ def list_packages(self, repository: "Repository") -> list[str]:
338377

339378
return stdout.strip().splitlines()
340379

380+
def get_package_origin(self, packages: Iterable[str]) -> 'dict[str, PackageOrigin]':
381+
"""
382+
Get the repository each package was installed from.
383+
384+
:param packages: Package names to query.
385+
:returns: A mapping of package names to source repository names.
386+
Packages not installed are mapped to
387+
:py:attr:`SpecialPackageOrigin.NOT_INSTALLED`. Packages whose
388+
source repository is unknown are mapped to
389+
:py:attr:`SpecialPackageOrigin.UNKNOWN`.
390+
"""
391+
result: dict[str, PackageOrigin] = dict.fromkeys(
392+
packages, SpecialPackageOrigin.NOT_INSTALLED
393+
)
394+
script = self.engine.get_package_origin(result.keys())
395+
output = self.guest.execute(script)
396+
for line in (output.stdout or '').strip().splitlines():
397+
# Empty lines are allowed by the engine contract.
398+
if not line.strip():
399+
continue
400+
parts = line.split(maxsplit=1)
401+
package = parts[0]
402+
# Omitted origin field → unknown source repository.
403+
result[package] = parts[1] if len(parts) == 2 else SpecialPackageOrigin.UNKNOWN
404+
return result
405+
341406
def create_repository(self, directory: Path) -> CommandOutput:
342407
"""
343408
Wrapper of :py:meth:`PackageManagerEngine.create_repository`.

0 commit comments

Comments
 (0)