Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge bootc branch to master #2180

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .copr/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Copyright David Cantrell <[email protected]>
# 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 <[email protected]>

# 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
0TAG = $(shell git tag -l | sort -V | tail -n 1)
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)"
48 changes: 39 additions & 9 deletions dnf/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,28 +205,58 @@ 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 \
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 and subsequent transactions 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
Expand Down
3 changes: 3 additions & 0 deletions dnf/cli/option_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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...
Expand Down
2 changes: 1 addition & 1 deletion dnf/conf/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
120 changes: 108 additions & 12 deletions dnf/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,7 @@
import os
import pwd
import shutil
import subprocess
import sys
import tempfile
import time
Expand Down Expand Up @@ -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)
9 changes: 9 additions & 0 deletions doc/command_ref.rst
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,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 <persistence-label>` configuration option.

.. _verbose_options-label:

``-v, --verbose``
Expand Down Expand Up @@ -705,6 +710,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
<transient_option-label>`) 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
Expand Down
11 changes: 11 additions & 0 deletions doc/conf_ref.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <string-label>`

Whether changes should persist across system reboots. Default is ``auto``. Passing :ref:`--transient <transient_option-label>` 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``
Expand Down
Loading