]> git.ipfire.org Git - thirdparty/mkosi.git/commitdiff
Introduce --use-subvolumes
authorDaan De Meyer <daan.j.demeyer@gmail.com>
Fri, 5 May 2023 06:57:10 +0000 (08:57 +0200)
committerDaan De Meyer <daan.j.demeyer@gmail.com>
Fri, 5 May 2023 13:25:46 +0000 (15:25 +0200)
Let's optionally use subvolumes to speed up copies when incremental
builds or base trees are used.

mkosi.md
mkosi/__init__.py
mkosi/btrfs.py [new file with mode: 0644]
mkosi/config.py
mkosi/state.py

index a8e8c2b1a3c0045d0d5aa20faf8cec26511078c2..dc53154b32a143b01cf1a0aaae188b3ef794a8f8 100644 (file)
--- a/mkosi.md
+++ b/mkosi.md
@@ -433,7 +433,7 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0",
 
 `Bootable=`, `--bootable=`
 
-: Takes a boolean or `auto`. Enables or disable generating of a bootable
+: Takes a boolean or `auto`. Enables or disables generation of a bootable
   image. If enabled, mkosi will install systemd-boot, run kernel-install,
   generate unified kernel images for installed kernels and add an ESP
   partition when the disk image output is used. If systemd-boot is not
@@ -444,6 +444,16 @@ a boolean argument: either "1", "yes", or "true" to enable, or "0",
   executed, no unified kernel images will be generated and no ESP partition
   will be added to the image if the disk output format is used.
 
+`UseSubvolumes=`, `--use-subvolumes=`
+
+: Takes a boolean or `auto`. Enables or disables use of btrfs subvolumes for
+  directory tree outputs. If enabled, mkosi will create the root directory as
+  a btrfs subvolume and use btrfs subvolume snapshots where possible to copy
+  base or cached trees which is much faster than doing a recursive copy. If
+  explicitly enabled and `btrfs` is not installed or subvolumes cannot be
+  created, an error is raised. If `auto`, missing `btrfs` or failures to
+  create subvolumes are ignored.
+
 `KernelCommandLine=`, `--kernel-command-line=`
 
 : Use the specified kernel command line when building images. By default
index c9e0c2f672c38e412b1de419bb2de5541859526b..1009792622f538e3cc1d3cdab050ad208d831f84 100644 (file)
@@ -22,6 +22,7 @@ from pathlib import Path
 from textwrap import dedent
 from typing import Callable, ContextManager, Optional, TextIO, TypeVar, Union, cast
 
+from mkosi.btrfs import btrfs_maybe_snapshot_subvolume
 from mkosi.config import (
     ConfigFeature,
     GenericVersion,
@@ -349,16 +350,22 @@ def configure_autologin(state: MkosiState) -> None:
 
 @contextlib.contextmanager
 def mount_cache_overlay(state: MkosiState) -> Iterator[None]:
-    if not state.config.incremental:
+    if not state.config.incremental or not any(state.root.iterdir()):
         yield
         return
 
-    with mount_overlay([state.root], state.cache_overlay, state.workdir, state.root, read_only=False):
+    d = state.workspace / "cache-overlay"
+    d.mkdir(mode=0o755, exist_ok=True)
+
+    with mount_overlay([state.root], d, state.workdir, state.root, read_only=False):
         yield
 
 
 def mount_build_overlay(state: MkosiState, read_only: bool = False) -> ContextManager[Path]:
-    return mount_overlay([state.root], state.build_overlay, state.workdir, state.root, read_only)
+    d = state.workspace / "build-overlay"
+    if not d.is_symlink():
+        d.mkdir(mode=0o755, exist_ok=True)
+    return mount_overlay([state.root], state.workspace.joinpath("build-overlay"), state.workdir, state.root, read_only)
 
 
 def run_prepare_script(state: MkosiState, build: bool) -> None:
@@ -491,11 +498,16 @@ def install_base_trees(state: MkosiState) -> None:
     if not state.config.base_trees or state.config.overlay:
         return
 
+    # We only do this step once, even if we're doing cached images. If we're doing a cached image on top of
+    # base trees, the cached image is an overlay on top of the base trees, so the unpacked base trees are
+    # guaranteed to still be pristine when we run the second time to generate the final image.
+    if any(state.root.iterdir()):
+        return
+
     with complete_step("Copying in base trees…"):
         for path in state.config.base_trees:
-
             if path.is_dir():
-                copy_path(path, state.root)
+                btrfs_maybe_snapshot_subvolume(state.config, path, state.root)
             elif path.suffix == ".tar":
                 shutil.unpack_archive(path, state.root)
             elif path.suffix == ".raw":
@@ -889,11 +901,17 @@ def save_cache(state: MkosiState) -> None:
 
     with complete_step("Installing cache copies"):
         unlink_try_hard(final)
-        shutil.move(state.cache_overlay, final)
+
+        # We only use the cache-overlay directory for caching if we have a base tree, otherwise we just
+        # cache the root directory.
+        if state.workspace.joinpath("cache-overlay").exists():
+            shutil.move(state.workspace / "cache-overlay", final)
+        else:
+            shutil.move(state.root, final)
 
         if state.config.build_script:
             unlink_try_hard(build)
-            shutil.move(state.build_overlay, build)
+            shutil.move(state.workspace / "build-overlay", build)
 
 
 def dir_size(path: Union[Path, os.DirEntry[str]]) -> int:
@@ -1328,10 +1346,9 @@ def reuse_cache_tree(state: MkosiState) -> bool:
         return False
 
     with complete_step("Copying cached trees"):
-        copy_path(final, state.root)
+        btrfs_maybe_snapshot_subvolume(state.config, final, state.root)
         if state.config.build_script:
-            state.build_overlay.rmdir()
-            state.build_overlay.symlink_to(build)
+            state.workspace.joinpath("build-overlay").symlink_to(build)
 
     return True
 
diff --git a/mkosi/btrfs.py b/mkosi/btrfs.py
new file mode 100644 (file)
index 0000000..b34b447
--- /dev/null
@@ -0,0 +1,41 @@
+import shutil
+from pathlib import Path
+
+from mkosi.config import ConfigFeature, MkosiConfig
+from mkosi.install import copy_path
+from mkosi.log import die
+from mkosi.run import run
+
+
+def btrfs_maybe_make_subvolume(config: MkosiConfig, path: Path, mode: int) -> None:
+    if config.use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"):
+        die("Subvolumes requested but the btrfs command was not found")
+
+    if config.use_subvolumes != ConfigFeature.disabled:
+        result = run(["btrfs", "subvolume", "create", path], check=config.use_subvolumes == ConfigFeature.enabled).returncode
+    else:
+        result = 1
+
+    if result == 0:
+        path.chmod(mode)
+    else:
+        path.mkdir(mode)
+
+
+def btrfs_maybe_snapshot_subvolume(config: MkosiConfig, src: Path, dst: Path) -> None:
+    subvolume = (config.use_subvolumes == ConfigFeature.enabled or
+                 config.use_subvolumes == ConfigFeature.auto and shutil.which("btrfs") is not None)
+
+    if config.use_subvolumes == ConfigFeature.enabled and not shutil.which("btrfs"):
+        die("Subvolumes requested but the btrfs command was not found")
+
+    # Subvolumes always have inode 256 so we can use that to check if a directory is a subvolume.
+    if not subvolume or src.stat().st_ino != 256 or (dst.exists() and any(dst.iterdir())):
+        return copy_path(src, dst)
+
+    # btrfs can't snapshot to an existing directory so make sure the destination does not exist.
+    if dst.exists():
+        dst.rmdir()
+
+    run(["btrfs", "subvolume", "snapshot", src, dst],
+        check=config.use_subvolumes == ConfigFeature.enabled)
index 49e23187313143c951bfd44f4b347f95420b7af4..9bc982dbc449cb6054db7765a2e7bf91251c0bae 100644 (file)
@@ -605,6 +605,7 @@ class MkosiConfig:
     kernel_command_line_extra: list[str]
     acl: bool
     bootable: ConfigFeature
+    use_subvolumes: ConfigFeature
 
     # QEMU-specific options
     qemu_gui: bool
@@ -864,6 +865,11 @@ class MkosiConfigParser:
             section="Output",
             parse=config_parse_boolean,
         ),
+        MkosiConfigSetting(
+            dest="use_subvolumes",
+            section="Output",
+            parse=config_parse_feature,
+        ),
         MkosiConfigSetting(
             dest="packages",
             section="Content",
@@ -1441,6 +1447,13 @@ class MkosiConfigParser:
             nargs="?",
             action=action,
         )
+        group.add_argument(
+            "--use-subvolumes",
+            metavar="FEATURE",
+            help="Use btrfs subvolumes for faster directory operations where possible",
+            nargs="?",
+            action=action,
+        )
 
         group = parser.add_argument_group("Content options")
         group.add_argument(
index 8d6df09d45f85cc892445b20e1c604b3d08196b6..fa7c2b8554d058b273bd14f205325b089fc9fe94 100644 (file)
@@ -4,6 +4,7 @@ import dataclasses
 import importlib
 from pathlib import Path
 
+from mkosi.btrfs import btrfs_maybe_make_subvolume
 from mkosi.config import MkosiConfig
 from mkosi.distributions import DistributionInstaller
 from mkosi.log import die
@@ -38,24 +39,14 @@ class MkosiState:
             die("No installer for this distribution.")
         self.installer = instance
 
-        self.root.mkdir(exist_ok=True, mode=0o755)
-        self.build_overlay.mkdir(exist_ok=True, mode=0o755)
-        self.cache_overlay.mkdir(exist_ok=True, mode=0o755)
-        self.workdir.mkdir(exist_ok=True)
-        self.staging.mkdir(exist_ok=True)
+        btrfs_maybe_make_subvolume(self.config, self.root, mode=0o755)
+        self.workdir.mkdir()
+        self.staging.mkdir()
 
     @property
     def root(self) -> Path:
         return self.workspace / "root"
 
-    @property
-    def cache_overlay(self) -> Path:
-        return self.workspace / "cache-overlay"
-
-    @property
-    def build_overlay(self) -> Path:
-        return self.workspace / "build-overlay"
-
     @property
     def workdir(self) -> Path:
         return self.workspace / "workdir"