diff --git a/.copr/Makefile b/.copr/Makefile new file mode 100644 index 0000000000..d4eda23355 --- /dev/null +++ b/.copr/Makefile @@ -0,0 +1,64 @@ +# Copyright David Cantrell +# SPDX-License-Identifier: GPL-3.0-or-later + +# Set the top level source directory +topdir := $(shell realpath $(dir $(lastword $(MAKEFILE_LIST)))/..) + +# Install packages before anything else +_install := $(shell dnf install -y git) +_safedir := $(shell git config --global --add safe.directory $(topdir)) + +# Pass BUILDTYPE=release to generate a release SRPM +BUILDTYPE ?= copr + +# Spec file and template +SPEC_TEMPLATE = $(shell ls -1 $(topdir)/*.spec) +SPEC = $(topdir)/$(shell basename $(SPEC_TEMPLATE)) + +# Replace placeholders in the spec file template +RPMDATE = $(shell date +'%a %b %d %Y') +#RPMAUTHOR = $(shell git log | grep ^Author: | head -n 1 | cut -d ' ' -f 2,3,4) +RPMAUTHOR = David Cantrell + +# Various things we need to generate a tarball +PKG = $(shell rpmspec -P "$(SPEC_TEMPLATE)" | grep ^Name: | awk '{ print $$2; }') +VER = $(shell rpmspec -P "$(SPEC_TEMPLATE)" | grep ^Version: | awk '{ print $$2; }') + +ifeq ($(BUILDTYPE),copr) +GITDATE = $(shell date +'%Y%m%d%H%M') +GITHASH = $(shell git rev-parse --short HEAD) +TARBALL_BASENAME = $(PKG)-$(VER)-$(GITDATE)git$(GITHASH) +TAG = HEAD +else +TAG = $(shell git describe --tags --abbrev=0) +endif + +ifeq ($(BUILDTYPE),release) +TARBALL_BASENAME = $(PKG)-$(VER) +endif + +# Where to insert the changelog entry +STARTING_POINT = $(shell expr $(shell grep -n ^%changelog "$(SPEC)" | cut -d ':' -f 1) + 1) + +srpm: + sed -i -e '1i %global source_date_epoch_from_changelog 0' "$(SPEC)" + sed -e 's|%%VERSION%%|$(VER)|g' < "$(SPEC_TEMPLATE)" > "$(SPEC)".new + mv "$(SPEC)".new "$(SPEC)" +ifeq ($(BUILDTYPE),copr) + sed -i -e '/^Release:/ s/1[^%]*/0.1.$(GITDATE)git$(GITHASH)/' "$(SPEC)" + sed -i -e 's|^Source0:.*$$|Source0: $(TARBALL_BASENAME).tar.gz|g' "$(SPEC)" + sed -i -e 's|^%autosetup.*$$|%autosetup -n $(TARBALL_BASENAME)|g' "$(SPEC)" + sed -i -e '$(STARTING_POINT)a\\' "$(SPEC)" + sed -i -e '$(STARTING_POINT)a - Build $(PKG)-$(VER)-$(GITDATE)git$(GITHASH) snapshot' "$(SPEC)" + sed -i -e '$(STARTING_POINT)a * $(RPMDATE) $(RPMAUTHOR) - $(VER)-$(GITDATE)git$(GITHASH)' "$(SPEC)" +endif + git archive \ + --format=tar \ + --output='$(topdir)/$(TARBALL_BASENAME).tar' \ + --prefix='$(TARBALL_BASENAME)/' $(TAG) $(topdir) + gzip -9f $(topdir)/$(TARBALL_BASENAME).tar + rpmbuild \ + -bs --nodeps \ + --define "_sourcedir $(topdir)" \ + --define "_srcrpmdir $(outdir)" \ + --define "_rpmdir $(outdir)" "$(SPEC)" diff --git a/dnf.spec b/dnf.spec index 67cd1859da..4155a93209 100644 --- a/dnf.spec +++ b/dnf.spec @@ -2,7 +2,7 @@ %define __cmake_in_source_build 1 # default dependencies -%global hawkey_version 0.73.1 +%global hawkey_version 0.74.0 %global libcomps_version 0.1.8 %global libmodulemd_version 2.9.3 %global rpm_version 4.14.0 @@ -190,6 +190,17 @@ Requires: python3-%{name} = %{version}-%{release} %description automatic Systemd units that can periodically download package upgrades and apply them. +%package bootc +Summary: %{pkg_summary} - additional bootc dependencies +Requires: python3-%{name} = %{version}-%{release} +Requires: ostree +Requires: ostree-libs +Requires: python3-gobject-base +Requires: util-linux-core + +%description bootc +Additional dependencies needed to perform transactions on booted bootc (bootable containers) systems. + %prep %autosetup @@ -420,6 +431,9 @@ popd %{python3_sitelib}/%{name}/automatic/ %endif +%files bootc +# bootc subpackage does not include any files + %changelog * Tue Nov 12 2024 Evan Goode - 4.22.0-1 - doc: Naming of source and debug repos diff --git a/dnf/cli/cli.py b/dnf/cli/cli.py index 2d20cf425c..c63c2538f8 100644 --- a/dnf/cli/cli.py +++ b/dnf/cli/cli.py @@ -211,22 +211,50 @@ def do_transaction(self, display=()): if self.conf.downloadonly: logger.info(_("{prog} will only download packages for the transaction.").format( prog=dnf.util.MAIN_PROG_UPPER)) + elif 'test' in self.conf.tsflags: logger.info(_("{prog} will only download packages, install gpg keys, and check the " "transaction.").format(prog=dnf.util.MAIN_PROG_UPPER)) - if dnf.util._is_bootc_host() and \ - os.path.realpath(self.conf.installroot) == "/" and \ - not self.conf.downloadonly: - _bootc_host_msg = _(""" -*** Error: system is configured to be read-only; for more -*** information run `bootc --help`. -""") - logger.info(_bootc_host_msg) - raise CliError(_("Operation aborted.")) + + is_bootc_transaction = dnf.util._BootcSystem.is_bootc_system() and \ + os.path.realpath(self.conf.installroot) == "/" and \ + not self.conf.downloadonly + + # Handle bootc transactions. `--transient` must be specified if + # /usr is not already writeable. + bootc_system = None + if is_bootc_transaction: + if self.conf.persistence == "persist": + logger.info(_("Persistent transactions aren't supported on bootc systems.")) + raise CliError(_("Operation aborted.")) + assert self.conf.persistence in ("auto", "transient") + + bootc_system = dnf.util._BootcSystem() + + if not bootc_system.is_writable(): + if self.conf.persistence == "auto": + logger.info(_("This bootc system is configured to be read-only. Pass --transient to " + "perform this transaction in a transient overlay which will reset when " + "the system reboots.")) + raise CliError(_("Operation aborted.")) + assert self.conf.persistence == "transient" + if not bootc_system.is_unlocked_transient(): + # Only tell the user about the transient overlay if + # it's not already in place + logger.info(_("A transient overlay will be created on /usr that will be discarded on reboot. " + "Keep in mind that changes to /etc and /var will still persist, and packages " + "commonly modify these directories.")) + else: + # Not a bootc transaction. + if self.conf.persistence == "transient": + raise CliError(_("Transient transactions are only supported on bootc systems.")) if self._promptWanted(): if self.conf.assumeno or not self.output.userconfirm(): raise CliError(_("Operation aborted.")) + + if bootc_system: + bootc_system.make_writable() else: logger.info(_('Nothing to do.')) return diff --git a/dnf/cli/option_parser.py b/dnf/cli/option_parser.py index 042d5fbbee..ec4696fd2c 100644 --- a/dnf/cli/option_parser.py +++ b/dnf/cli/option_parser.py @@ -317,6 +317,9 @@ def _add_general_options(self): general_grp.add_argument("--downloadonly", dest="downloadonly", action="store_true", default=False, help=_("only download packages")) + general_grp.add_argument("--transient", dest="persistence", + action="store_const", const="transient", default=None, + help=_("Use a transient overlay which will reset on reboot")) general_grp.add_argument("--comment", dest="comment", default=None, help=_("add a comment to transaction")) # Updateinfo options... diff --git a/dnf/conf/config.py b/dnf/conf/config.py index f9c8d932a5..5210ffba2a 100644 --- a/dnf/conf/config.py +++ b/dnf/conf/config.py @@ -343,7 +343,7 @@ def _configure_from_options(self, opts): 'best', 'assumeyes', 'assumeno', 'clean_requirements_on_remove', 'gpgcheck', 'showdupesfromrepos', 'plugins', 'ip_resolve', 'rpmverbosity', 'disable_excludes', 'color', - 'downloadonly', 'exclude', 'excludepkgs', 'skip_broken', + 'downloadonly', 'persistence', 'exclude', 'excludepkgs', 'skip_broken', 'tsflags', 'arch', 'basearch', 'ignorearch', 'cacheonly', 'comment'] for name in config_args: diff --git a/dnf/util.py b/dnf/util.py index 0327321caf..eb987bb8a3 100644 --- a/dnf/util.py +++ b/dnf/util.py @@ -25,6 +25,7 @@ from .pycomp import PY3, basestring from dnf.i18n import _, ucd import argparse +import ctypes import dnf import dnf.callback import dnf.const @@ -38,6 +39,7 @@ import os import pwd import shutil +import subprocess import sys import tempfile import time @@ -641,16 +643,110 @@ def _is_file_pattern_present(specs): return False -def _is_bootc_host(): - """Returns true is the system is managed as an immutable container, - false otherwise. If msg is True, a warning message is displayed - for the user. - """ - ostree_booted = '/run/ostree-booted' - usr = '/usr/' - # Check if usr is writtable and we are in a running ostree system. - # We want this code to return true only when the system is in locked state. If someone ran - # bootc overlay or ostree admin unlock we would want normal DNF path to be ran as it will be - # temporary changes (until reboot). - return os.path.isfile(ostree_booted) and not os.access(usr, os.W_OK) +class _BootcSystem: + usr = "/usr" + CLONE_NEWNS = 0x00020000 # defined in linux/include/uapi/linux/sched.h + def __init__(self): + if not self.is_bootc_system(): + raise RuntimeError(_("Not running on a bootc system.")) + + import gi + self._gi = gi + + gi.require_version("OSTree", "1.0") + from gi.repository import OSTree + + self._OSTree = OSTree + + self._sysroot = self._OSTree.Sysroot.new_default() + assert self._sysroot.load(None) + + self._booted_deployment = self._sysroot.require_booted_deployment() + assert self._booted_deployment is not None + + @staticmethod + def is_bootc_system(): + """Returns true is the system is managed as an immutable container, false + otherwise.""" + ostree_booted = "/run/ostree-booted" + return os.path.isfile(ostree_booted) + + @classmethod + def is_writable(cls): + """Returns true if and only if /usr is writable.""" + return os.access(cls.usr, os.W_OK) + + def _get_unlocked_state(self): + return self._booted_deployment.get_unlocked() + + def is_unlocked_transient(self): + """Returns true if and only if the bootc system is unlocked in a + transient state, i.e. a overlayfs is mounted as read-only on /usr. + Changes can be made to the overlayfs by remounting /usr as + read/write in a private mount namespace.""" + return self._get_unlocked_state() == self._OSTree.DeploymentUnlockedState.TRANSIENT + + @classmethod + def _set_up_mountns(cls): + # os.unshare is only available in Python >= 3.12. + + # Access symbols in libraries loaded by the Python interpreter, + # which will include libc. See https://bugs.python.org/issue34592. + libc = ctypes.CDLL(None) + if libc.unshare(cls.CLONE_NEWNS) != 0: + raise OSError("Failed to unshare mount namespace") + + mount_command = ["mount", "--options-source=disable", "-o", "remount,rw", cls.usr] + try: + completed_process = subprocess.run(mount_command, text=True) + completed_process.check_returncode() + except FileNotFoundError: + raise dnf.exceptions.Error(_("%s: command not found.") % mount_command[0]) + except subprocess.CalledProcessError: + raise dnf.exceptions.Error(_("Failed to mount %s as read/write: %s", cls.usr, completed_process.stderr)) + + @staticmethod + def _unlock(): + unlock_command = ["ostree", "admin", "unlock", "--transient"] + try: + completed_process = subprocess.run(unlock_command, text=True) + completed_process.check_returncode() + except FileNotFoundError: + raise dnf.exceptions.Error(_("%s: command not found. Is this a bootc system?") % unlock_command[0]) + except subprocess.CalledProcessError: + raise dnf.exceptions.Error(_("Failed to unlock system: %s", completed_process.stderr)) + + def make_writable(self): + """Set up a writable overlay on bootc systems.""" + + bootc_unlocked_state = self._get_unlocked_state() + + valid_bootc_unlocked_states = ( + self._OSTree.DeploymentUnlockedState.NONE, + self._OSTree.DeploymentUnlockedState.DEVELOPMENT, + self._OSTree.DeploymentUnlockedState.TRANSIENT, + self._OSTree.DeploymentUnlockedState.HOTFIX, + ) + if bootc_unlocked_state not in valid_bootc_unlocked_states: + raise ValueError(_("Unhandled bootc unlocked state: %s") % bootc_unlocked_state.value_nick) + + writable_unlocked_states = ( + self._OSTree.DeploymentUnlockedState.DEVELOPMENT, + self._OSTree.DeploymentUnlockedState.HOTFIX, + ) + if bootc_unlocked_state in writable_unlocked_states: + # System is already unlocked in development mode, and usr is + # already mounted read/write. + pass + elif bootc_unlocked_state == self._OSTree.DeploymentUnlockedState.NONE: + # System is not unlocked. Unlock it in transient mode, then set up + # a mount namespace for DNF. + self._unlock() + self._set_up_mountns() + elif bootc_unlocked_state == self._OSTree.DeploymentUnlockedState.TRANSIENT: + # System is unlocked in transient mode, so usr is mounted + # read-only. Set up a mount namespace for DNF. + self._set_up_mountns() + + assert os.access(self.usr, os.W_OK) diff --git a/doc/command_ref.rst b/doc/command_ref.rst index ebbb6d6a76..e75a08a4d2 100644 --- a/doc/command_ref.rst +++ b/doc/command_ref.rst @@ -389,6 +389,11 @@ Options ``--showduplicates`` Show duplicate packages in repositories. Applicable for the list and search commands. +.. _transient_option-label: + +``--transient`` + Applicable only on bootc (bootable containers) systems. Perform transactions using a transient overlay which will be lost on the next reboot. See also the :ref:`persistence ` configuration option. + .. _verbose_options-label: ``-v, --verbose`` @@ -707,6 +712,10 @@ transactions and act according to this information (assuming the which specifies a transaction by a package which it manipulated. When no transaction is specified, list all known transactions. + Note that transient transactions (see :ref:`--transient + `) will be listed even though they do not make + persistent changes to files under ``/usr`` or to the RPM database. + The "Action(s)" column lists each type of action taken in the transaction. The possible values are: * Install (I): a new package was installed on the system diff --git a/doc/conf_ref.rst b/doc/conf_ref.rst index b7bd4b71f4..26bf6f645a 100644 --- a/doc/conf_ref.rst +++ b/doc/conf_ref.rst @@ -442,6 +442,17 @@ configuration file by your distribution to override the DNF defaults. Directory where DNF stores its persistent data between runs. Default is ``"/var/lib/dnf"``. +.. _persistence-label: + +``persistence`` + :ref:`string ` + + Whether changes should persist across system reboots. Default is ``auto``. Passing :ref:`--transient ` will override this setting to ``transient``. Valid values are: + + * ``auto``: Changes will persist across reboots, unless the target is a running bootc system and the system is already in an unlocked state (i.e. ``/usr`` is writable). + * ``transient``: Changes will be lost on the next reboot. Only applicable on bootc systems. Beware that changes to ``/etc`` and ``/var`` will persist, depending on the configuration of your bootc system. See also https://containers.github.io/bootc/man/bootc-usr-overlay.html. + * ``persist``: Changes will persist across reboots. + .. _pluginconfpath-label: ``pluginconfpath``