Fix silent package upgrades in package install#4937
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors _construct_install_script in tmt/package_managers/dnf.py to avoid best=True upgrades when options.check_first is enabled by checking package presence with rpm before running the DNF install command. Feedback recommends guarding against empty installables and using the Command class instead of ShellScript to handle variable arguments safely and prevent execution errors.
|
Unit Tests need to be fixed. |
7f789d9 to
7e4f2d0
Compare
f05316e to
0a2afcc
Compare
0a2afcc to
27b8cc4
Compare
The set-level presence check (`rpm -q --whatprovides A B C || dnf install A B C`) fails as a unit if any single package is absent, sending the entire set to DNF. On RHEL and CentOS Stream where best=True is the default, already-installed packages in that set become upgrade candidates, silently landing guests at versions different from what `requires` declared. Replace the set-level guard with a per-package bash accumulator loop that identifies exactly which packages are missing, then passes only those to a single DNF invocation.
Replace the bash accumulator loop with a cleaner approach: call check_presence() first, install only missing packages, revert the engine to a plain unconditional install script. Update tests.
Use a CHECK_FIRST flag in INSTALL_TEMPLATE when set, the rendered script gates apt reinstall behind `dpkg-query --show` so reinstalling a missing package fails fast with a clear error rather than a confusing apt message. Also fix the non-filesystem-path reinstall path to escape installables consistently with install.
DnfEngine.check_presence now emits one command per package of the form `rpm -q --whatprovides PKG >&2 || echo PKG`, which echoes the bare package name to stdout when missing (instead of verbose rpm error messages). Bootc.check_presence still used the old zip-based parser that matched patterns like `no package provides X`, so missing packages were never recognised and falsely reported as present. Update Bootc.check_presence to treat each stdout line as the name of a missing package directly, consistent with how Dnf.check_presence works after the same refactor.
|
/packit retest-failed |
01bc8c2 to
1798c59
Compare
LecrisUT
left a comment
There was a problem hiding this comment.
Only some minor comments left, otherwise lgtm
| return self.guest.run( | ||
| self.engine.reinstall(*installables, options=options).to_shell_command() | ||
| self.engine.reinstall(*to_reinstall, options=options).to_shell_command() | ||
| ) |
There was a problem hiding this comment.
It is the same as the base, no longer needed to be defined
There was a problem hiding this comment.
The filtering logic is identical to the base, but the dispatch has to differ. GuestMock.execute runs commands inside the mock shell, while mock reinstall needs to run on the host via guest.run. Dropping the override would cause the base to call guest.execute(ShellScript('mock --pm-cmd reinstall ...') which fails because mock is not.
|
Need to fix test failures CI issues due to |
Skip presence check when check_first is False, matching base class behavior.
|
/packit retest-failed |
5e22fc6 to
d9ec3a3
Compare
d9ec3a3 to
fea9193
Compare
LecrisUT
left a comment
There was a problem hiding this comment.
Some relevant failing tests, and still a lot to follow up after this, but otherwise looks fine as first step
| {% endif %} | ||
|
|
||
| {% if OPTIONS.check_first %} | ||
| {% if CHECK_FIRST %} |
There was a problem hiding this comment.
This should need a default if not defined?
There was a problem hiding this comment.
it's been fixed, so it can be resolved
5aa7322 to
60a97d3
Compare
|
/packit build |
|
Is this meant to be tied to an issue? |
|
Could use a release note, please. |
| for match in pattern.finditer(output): | ||
| yield match.group(1) | ||
|
|
||
| def _check_first_filter( |
There was a problem hiding this comment.
since check_first = True by default, and this will change the behavior of all install operations. Is there a way for a user to set check_first = False easily on the commandline and through testing-farm?
There was a problem hiding this comment.
The only effective change should be in the grouping of the check (which was previously in the engine script), and would be difficult to make that configurable (if not counterproductive). The check_first was never exposed to the user and it is instead indirectly set, e.g.
tmt/tmt/package_managers/dnf.py
Line 373 in b1751ea
But exposing this new interface, I think would be ok and in line with the discussion previously.
There was a problem hiding this comment.
I am just concerned someone somewhere is unwittingly relying on the old behavior, and when it changes, it'll be an issue. Having it configurable right away should alleviate that.
There was a problem hiding this comment.
Addressed in b3c52e1. Users can opt out via --no-check-first CLI flag, check-first: false in the plan config, or by setting TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST=0 as an environment variable.
I've added the issue. Same goes for #4938 |
| option='--check-first / --no-check-first', | ||
| is_flag=True, | ||
| show_default=True, | ||
| envvar='TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST', |
There was a problem hiding this comment.
These are created and handled automatically. Please no
| help=""" | ||
| Check whether packages are already installed before attempting | ||
| to install them. Can also be set via the | ||
| ``TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST`` environment variable. |
There was a problem hiding this comment.
We do not document that these for each and other variable. It should be handled manually
| ) | ||
|
|
||
| check_first: bool = field( | ||
| default=configure_bool_constant(True, 'TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST'), |
There was a problem hiding this comment.
| default=configure_bool_constant(True, 'TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST'), | |
| default=True, |
| The :ref:`/plugins/prepare/install` plugin no longer silently upgrades | ||
| already-installed packages. Only missing packages are passed to install, |
There was a problem hiding this comment.
We do not guarantee that. Would reverse these 2 sentences with the first one being a "should no longer silently upgrade pre-installed packages".
| and only present packages are passed to reinstall. The new ``check-first`` | ||
| option (default: enabled) can be disabled via the ``--no-check-first`` | ||
| CLI flag, the ``check-first: false`` plan config option, or the | ||
| ``TMT_PLUGIN_PREPARE_INSTALL_CHECK_FIRST`` environment variable. |
There was a problem hiding this comment.
Do we have to document every such way to interact with it. Would just keep it at added check-first to the /plugins/prepare/install plugin and call it a day.
On RHEL and CentOS Stream, DNF ships with
best=Trueby default. When tmt installed a set of packages and any one was missing, it sent the entire set to DNF via a shell-level presence check:rpm -q --whatprovides pkg-a pkg-b pkg-c || dnf install -y pkg-a pkg-b pkg-cIf
pkg-cwas missing, the||fired and DNF received all three packages. Withbest=True, DNF silently upgradedpkg-aandpkg-beven though they were already installed. An unintended side effect that could break test environments.Fix
Replace the shell-level
check || installone-liner with a Python 2-stage process inPackageManager.install():check_presence()— determine which packages are actually missing (per-package, Python level)install()— pass only the missing subset to the package managerThis is now the single, consistent implementation for all package managers. No engine needs to handle
check_firstin its install script.Previously, installing a set
[A (installed, old), B (missing)]would inadvertently upgrade A (viabest=True).Now only B is passed to the package manager : A is untouched. This is intentional:
check_firstmeans "skip if present", not "upgrade if outdated".Callers that need a guaranteed upgrade can pass
check_first=False.@bajertom edit: fixes #4909