diff --git a/dnf/cli/cli.py b/dnf/cli/cli.py index d3844df344..17eb6ddbf4 100644 --- a/dnf/cli/cli.py +++ b/dnf/cli/cli.py @@ -205,28 +205,50 @@ def do_transaction(self, display=()): else: self.output.reportDownloadSize(install_pkgs, install_only) + bootc_unlock_requested = False + if trans or self._moduleContainer.isChanged() or \ (self._history and (self._history.group or self._history.env)): # confirm with user 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 \ + + is_bootc_transaction = 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.")) + not self.conf.downloadonly + + # Handle bootc transactions. `--transient` must be specified if + # /usr is not already writeable. + 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") + if not dnf.util._is_bootc_unlocked(): + if self.conf.persistence == "auto": + logger.info(_("This bootc system is configured to be read-only. Pass --transient to " + "perform this and subsequent transactions in a transient overlay which " + "will reset when the system reboots.")) + raise CliError(_("Operation aborted.")) + assert self.conf.persistence == "transient" + 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.")) + bootc_unlock_requested = True + elif 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_unlock_requested: + dnf.util._bootc_unlock() 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..2e270890ca 100644 --- a/dnf/util.py +++ b/dnf/util.py @@ -38,6 +38,7 @@ import os import pwd import shutil +import subprocess import sys import tempfile import time @@ -642,15 +643,32 @@ def _is_file_pattern_present(specs): 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) + """Returns true is the system is managed as an immutable container, false + otherwise.""" + ostree_booted = "/run/ostree-booted" + return os.path.isfile(ostree_booted) + + +def _is_bootc_unlocked(): + """Check whether /usr is writeable, e.g. if we are in a normal mutable + system or if we are in a bootc after `bootc usr-overlay` or `ostree admin + unlock` was run.""" + usr = "/usr" + return os.access(usr, os.W_OK) + +def _bootc_unlock(): + """Set up a writeable overlay on bootc systems.""" + + if _is_bootc_unlocked(): + return + + unlock_command = ["bootc", "usr-overlay"] + + try: + completed_process = subprocess.run(unlock_command, text=True) + completed_process.check_returncode() + except FileNotFoundError: + raise dnf.exceptions.Error(_("bootc command not found. Is this a bootc system?")) + except subprocess.CalledProcessError: + raise dnf.exceptions.Error(_("Failed to unlock system: %s", completed_process.stderr))