Source code for meta_package_manager.managers.microdnf

# Copyright Kevin Deldycke <[email protected]> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

from __future__ import annotations

import re

from extra_platforms import LINUX_LIKE

from ..capabilities import search_capabilities
from ..manager import PackageManager

TYPE_CHECKING = False
if TYPE_CHECKING:
    from collections.abc import Iterator

    from ..package import Package


[docs] class MicroDNF(PackageManager): """A minimal `dnf` written in C on top of libdnf, for containers. The minimal container images of the RHEL family install it instead of `dnf`: [AlmaLinux 10's](https://github.com/AlmaLinux/container-images/blob/09f5ebe70432a7a932b5cfa6e5e5725654aa6b6d/Containerfiles/10/Containerfile.minimal#L4-L19) lists `microdnf` and no `dnf`. On a host that has both, `microdnf` and [`dnf`](dnf.md) read the same RPM database, so their listings overlap. ```{note} On Fedora, and on RHEL releases after 10, the dnf5 package takes over the `microdnf` name as a symlink to `dnf5` ([dnf5.spec](https://github.com/rpm-software-management/dnf5/blob/cfdbb345396b2cfed7afe69cf70b3aba95ce7ee4/dnf5.spec#L1161-L1163)). No `microdnf` RPM package is installed there, so this manager reports no version and leaves that binary to [`dnf5`](dnf5.md). ``` ```{caution} `microdnf` keeps one system-wide cache under `/var/cache/yum`, and only root can write to it. As a regular user, every query fails while `/var/cache/yum/metadata` is missing, which is the state `clean all` leaves. `search` and `outdated` also fail once the repository metadata expires, with `failed to obtain lock 'metadata'`. `mpm --microdnf sync` runs `makecache` with `sudo` to rebuild the cache. ``` `microdnf` has no autoremove and no self-check, so `mpm` offers no orphan operations and no `doctor` for it. Its `leaves` command is no substitute: it lists every installed package that nothing else requires, including the packages installed on request. """ maintenance_note = ( "Superseded by [dnf5](https://github.com/rpm-software-management/dnf5) on " "Fedora, and on RHEL releases after 10. The C implementation stays " "maintained for RHEL 10 and older, whose minimal images ship it; mpm wraps " "`dnf5` as a separate manager." ) name = "microdnf" repository_url = "https://github.com/rpm-software-management/microdnf" logo = "fedora" platforms = LINUX_LIKE default_sudo = True requirement = ">=3.8.0" """`3.8.0` is the first release with the `makecache` command that `sync` runs ([rpm-software-management/microdnf@e2d56ee](https://github.com/rpm-software-management/microdnf/commit/e2d56eefd58149a2d80b914b87171a35ee51146a)). Everything else is older: `--assumeno`, which `outdated` needs, shipped in `3.7.0`, and `repoquery` in `3.4.0`. """ version_cli = "rpm" """`microdnf` has no version option. It ignores `--version`, prints its usage and `No command specified` to stderr, and exits `1`. So the probe asks RPM for the version of the `microdnf` package instead. `rpm --query` matches package names, never the capabilities a package provides. That is why the probe finds nothing where dnf5 provides a `microdnf` symlink. """ version_cli_options = ("--query", "--queryformat", "%{VERSION}", "microdnf") version_regexes = (r"^(?P<version>\d\S*)$",) """Anchored at both ends: a host without the package answers with the sentence `package microdnf is not installed`, which must match nothing. ```{code-block} shell-session $ rpm --query --queryformat %{VERSION} microdnf 3.10.1 ``` """ _NEVRA_REGEXP = re.compile( r"^(?P<name>\S+)-(?P<evr>(?:\d+:)?[^-\s]+-[^-\s]+)\.(?P<arch>[^.\s]+)$", re.MULTILINE, ) """Split a `name-[epoch:]version-release.arch` line. RPM forbids dashes in the version and the release, so the last two dash-separated fields before the `.arch` suffix are always those two, whatever dashes the name carries. `microdnf` prints the epoch only when it is not zero. The version keeps it, so a pinned install can pass it back. Every listing prints its rows sorted by name, then by version, so the last row of a name is its newest build. """ _SECTION_REGEXP = re.compile(r"^(?P<section>[A-Z][a-z]+):\s*$") """Match a section heading of the transaction table, like `Upgrading:`.""" _ROW_REGEXP = re.compile(r"^ (?P<nevra>\S+)") """Match a package row of the transaction table, indented by one space.""" _REPLACING_REGEXP = re.compile(r"^\s+replacing\s+(?P<nevra>\S+)") """Match the child row naming the installed package the row above replaces. Its indentation varies: the last child of the table gets one more space. """ @property def installed(self) -> Iterator[Package]: """Fetch installed packages. ```{code-block} shell-session $ microdnf repoquery --installed NetworkManager-1:1.56.0-2.el10_2.aarch64 NetworkManager-libnm-1:1.56.0-2.el10_2.aarch64 NetworkManager-tui-1:1.56.0-2.el10_2.aarch64 almalinux-gpg-keys-10.2-21.el10.aarch64 almalinux-release-10.2-21.el10.aarch64 almalinux-repos-10.2-21.el10.aarch64 alternatives-1.30-2.el10.aarch64 attr-2.5.2-5.el10.aarch64 ``` """ output = self.run_cli("repoquery", "--installed", must_succeed=True) for match in self._NEVRA_REGEXP.finditer(output): yield self.package( id=match["name"], installed_version=match["evr"], arch=match["arch"], ) @property def outdated(self) -> Iterator[Package]: """Fetch outdated packages. `microdnf` has no command that lists pending updates. So `mpm` reads the transaction `upgrade` would run, which `--assumeno` refuses before anything changes. The command exits `0` either way, with `Operation aborted.` after the table, or `Nothing to do.` alone. An upgraded package is a row under `Upgrading:`, followed by a `replacing` row that names the installed version. An install-only package, like the kernel, is a row under `Installing:` instead: RPM installs its new version beside the old one. New dependencies show up in that section too, so a second query reads which of those names are installed, and skips the others. ```{code-block} shell-session $ microdnf --assumeno upgrade Package Repository Size Installing: kernel-6.12.0-211.53.1.el10_2.aarch64 baseos 1.7 MB kernel-core-6.12.0-211.53.1.el10_2.aarch64 baseos 19.9 MB kernel-modules-6.12.0-211.53.1.el10_2.aarch64 baseos 28.3 MB kernel-modules-core-6.12.0-211.53.1.el10_2.aarch64 baseos 26.6 MB Upgrading: rsync-3.5.0-3.el10_2.aarch64 baseos 474.3 kB replacing rsync-3.4.4-1.el10_2.aarch64 tar-2:1.35-13.el10_2.aarch64 baseos 884.5 kB replacing tar-2:1.35-11.el10.aarch64 Transaction Summary: Installing: 4 packages Reinstalling: 0 packages Upgrading: 2 packages Obsoleting: 0 packages Removing: 0 packages Downgrading: 0 packages Operation aborted. ``` ```{code-block} shell-session $ microdnf repoquery --installed kernel kernel-core kernel-modules kernel-modules-core kernel-6.12.0-211.47.1.el10_2.aarch64 kernel-core-6.12.0-211.47.1.el10_2.aarch64 kernel-modules-6.12.0-211.47.1.el10_2.aarch64 kernel-modules-core-6.12.0-211.47.1.el10_2.aarch64 ``` """ output = self.run_cli("--assumeno", "upgrade", must_succeed=True) section = None last_row = None rows: list[tuple[str | None, re.Match[str]]] = [] installed_evr: dict[tuple[str, str], str] = {} for line in output.splitlines(): if heading := self._SECTION_REGEXP.match(line): section = heading["section"] elif replacing := self._REPLACING_REGEXP.match(line): old = self._NEVRA_REGEXP.match(replacing["nevra"]) # A row also replaces the packages it obsoletes, which carry # other names and are not an installed version of it. if last_row and old and old["name"] == last_row["name"]: installed_evr[last_row["name"], last_row["arch"]] = old["evr"] elif row := self._ROW_REGEXP.match(line): last_row = self._NEVRA_REGEXP.match(row["nevra"]) if last_row and section in ("Installing", "Upgrading"): rows.append((section, last_row)) unpaired = dict.fromkeys( new["name"] for _, new in rows if (new["name"], new["arch"]) not in installed_evr ) if unpaired: listing = self.run_cli( "repoquery", "--installed", *unpaired, must_succeed=True ) for match in self._NEVRA_REGEXP.finditer(listing): installed_evr[match["name"], match["arch"]] = match["evr"] for row_section, new in rows: installed_version = installed_evr.get((new["name"], new["arch"])) if row_section == "Installing" and installed_version is None: continue yield self.package( id=new["name"], installed_version=installed_version, latest_version=new["evr"], arch=new["arch"], )
[docs] @search_capabilities(extended_support=False, exact_support=False) def search(self, query: str, extended: bool, exact: bool) -> Iterator[Package]: """Fetch matching packages. ```{caution} `repoquery` matches package names only, never summaries. The match ignores case and also accepts a `name-version` form, so {meth}`meta_package_manager.manager.PackageManager.refiltered_search` refines the exact results. ``` A repository can keep several builds of a package, and `repoquery` prints a row for each. Only the newest build is reported. ```{code-block} shell-session $ microdnf repoquery *xxd* xxd-2:9.1.083-9.el10_2.2.aarch64 xxd-2:9.1.083-9.el10_2.3.aarch64 xxd-2:9.1.083-9.el10_2.4.aarch64 xxd-2:9.1.083-9.el10_2.7.aarch64 xxd-2:9.1.083-9.el10_2.12.aarch64 xxd-2:9.1.083-9.el10_2.20.aarch64 ``` ```{code-block} shell-session $ microdnf repoquery xxd xxd-2:9.1.083-9.el10_2.2.aarch64 xxd-2:9.1.083-9.el10_2.3.aarch64 xxd-2:9.1.083-9.el10_2.4.aarch64 xxd-2:9.1.083-9.el10_2.7.aarch64 xxd-2:9.1.083-9.el10_2.12.aarch64 xxd-2:9.1.083-9.el10_2.20.aarch64 ``` """ output = self.run_cli( "repoquery", query if exact else f"*{query}*", must_succeed=True ) newest = { match["name"]: match["evr"] for match in self._NEVRA_REGEXP.finditer(output) } for package_id, evr in newest.items(): yield self.package(id=package_id, latest_version=evr)
[docs] def install(self, package_id: str, version: str | None = None) -> str: """Install one package, optionally pinned to a version. `microdnf` resolves `name-version` the way it resolves a bare name, so a pin joins the two with a dash. The epoch and the release are optional: a version without a release installs the newest build of that version. ```{code-block} shell-session $ sudo microdnf --assumeyes install tree ``` ```{code-block} shell-session $ sudo microdnf --assumeyes install xxd-2:9.1.083-9.el10_2.3 ``` """ spec = package_id if version is None else f"{package_id}-{version}" return self.run_cli("--assumeyes", "install", spec, sudo=True)
[docs] def upgrade_all_cli(self) -> tuple[str, ...]: """Generates the CLI to upgrade all outdated packages. ```{code-block} shell-session $ sudo microdnf --assumeyes upgrade ``` """ return self.build_cli("--assumeyes", "upgrade", sudo=True)
[docs] def upgrade_one_cli( self, package_id: str, version: str | None = None, ) -> tuple[str, ...]: """Generates the CLI to upgrade one package, optionally to a version. The version joins the package ID the way it does for `install`. ```{code-block} shell-session $ sudo microdnf --assumeyes upgrade tree ``` ```{code-block} shell-session $ sudo microdnf --assumeyes upgrade xxd-9.1.083-9.el10_2.12 ``` """ spec = package_id if version is None else f"{package_id}-{version}" return self.build_cli("--assumeyes", "upgrade", spec, sudo=True)
[docs] def remove(self, package_id: str) -> str: """Remove one package and one only. ```{code-block} shell-session $ sudo microdnf --assumeyes remove tree ``` """ return self.run_cli("--assumeyes", "remove", package_id, sudo=True)
[docs] def sync(self) -> None: """Download fresh metadata for every enabled repository. ```{code-block} shell-session $ sudo microdnf makecache ``` """ self.run_cli("makecache", sudo=True)
[docs] def cleanup_cache(self) -> None: """Clear the cached packages and repository metadata. ```{code-block} shell-session $ sudo microdnf clean all ``` """ self.run_cli("clean", "all", sudo=True)