From 61a60c9cc6d2c2c12b93e33b55286621409b8e71 Mon Sep 17 00:00:00 2001
From: Luca Boccassi <luca.boccassi@gmail.com>
Date: Fri, 6 Dec 2024 00:28:13 +0000
Subject: [PATCH] Add mkosi-addon and kernel-install plugin

Add new mkosi-addon and kernel-install plugin to build local
customizations into an EFI addon.

This allows us to move closer to the desired goal of having
universal UKIs, built by vendors, used together with locally
built enhancements.
---
 .gitignore                             |   1 +
 README.md                              |  15 +--
 bin/mkosi-addon                        |   1 +
 kernel-install/51-mkosi-addon.install  | 111 ++++++++++++++++++
 mkosi-addon                            |   1 +
 mkosi/__init__.py                      |  97 ++++++++++++----
 mkosi/addon.py                         | 151 +++++++++++++++++++++++++
 mkosi/config.py                        |  14 ++-
 mkosi/initrd.py                        |  22 +---
 mkosi/resources/man/mkosi-addon.1.md   |  49 ++++++++
 mkosi/resources/man/mkosi.1.md         |   4 +-
 mkosi/resources/man/mkosi.news.7.md    |   2 +-
 mkosi/resources/mkosi-addon/mkosi.conf |  19 ++++
 mkosi/util.py                          |  26 +++++
 pyproject.toml                         |   2 +
 15 files changed, 456 insertions(+), 59 deletions(-)
 create mode 120000 bin/mkosi-addon
 create mode 100755 kernel-install/51-mkosi-addon.install
 create mode 120000 mkosi-addon
 create mode 100644 mkosi/addon.py
 create mode 100644 mkosi/resources/man/mkosi-addon.1.md
 create mode 100644 mkosi/resources/mkosi-addon/mkosi.conf

diff --git a/.gitignore b/.gitignore
index b6e54df8a..eef237602 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
 *.cache-pre-inst
 .cache
 .mkosi.1
+.mkosi-addon.1
 .mkosi-initrd.1
 .mkosi-sandbox.1
 .mypy_cache/
diff --git a/README.md b/README.md
index 3ff31bf3d..82019db2a 100644
--- a/README.md
+++ b/README.md
@@ -81,15 +81,16 @@ mkosi binary and is not to be considered a public API.
 
 ## kernel-install plugin
 
-mkosi can also be used as a kernel-install plugin to build initrds. To
-enable this feature, install `kernel-install/50-mkosi.install`
-into `/usr/lib/kernel/install.d`. Extra distro configuration for the
-initrd can be configured in `/usr/lib/mkosi-initrd`. Users can add their
-own customizations in `/etc/mkosi-initrd`.
+mkosi can also be used as a kernel-install plugin to build initrds and addons.
+To enable these features, install `kernel-install/50-mkosi.install` and
+`kernel-install/51-mkosi-addon.install` into `/usr/lib/kernel/install.d`.
+Extra distro configuration for the initrd can be configured in
+`/usr/lib/mkosi-initrd`. Users can add their own customizations in
+`/etc/mkosi-initrd` and `/etc/mkosi-addon`.
 
 Once installed, the mkosi plugin can be enabled by writing
-`initrd_generator=mkosi-initrd` to `/usr/lib/kernel/install.conf` or to
-`/etc/kernel/install.conf`.
+`initrd_generator=mkosi-initrd` and `layout=uki` to `/usr/lib/kernel/install.conf`
+or to `/etc/kernel/install.conf`.
 
 # Hacking on mkosi
 
diff --git a/bin/mkosi-addon b/bin/mkosi-addon
new file mode 120000
index 000000000..b5f44fa8e
--- /dev/null
+++ b/bin/mkosi-addon
@@ -0,0 +1 @@
+mkosi
\ No newline at end of file
diff --git a/kernel-install/51-mkosi-addon.install b/kernel-install/51-mkosi-addon.install
new file mode 100755
index 000000000..8a2036a5c
--- /dev/null
+++ b/kernel-install/51-mkosi-addon.install
@@ -0,0 +1,111 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+import argparse
+import dataclasses
+import logging
+import os
+import sys
+import tempfile
+from pathlib import Path
+from typing import Optional
+
+from mkosi import identify_cpu
+from mkosi.archive import make_cpio
+from mkosi.config import OutputFormat
+from mkosi.log import die, log_setup
+from mkosi.run import run, uncaught_exception_handler
+from mkosi.sandbox import __version__, umask
+from mkosi.types import PathString
+
+
+@dataclasses.dataclass(frozen=True)
+class Context:
+    command: str
+    kernel_version: str
+    entry_dir: Path
+    kernel_image: Path
+    staging_area: Path
+    layout: str
+    image_type: str
+    verbose: bool
+
+
+def we_are_wanted(context: Context) -> bool:
+    return context.layout == "uki"
+
+
+def mandatory_variable(name: str) -> str:
+    try:
+        return os.environ[name]
+    except KeyError:
+        die(f"${name} must be set in the environment")
+
+
+@uncaught_exception_handler()
+def main() -> None:
+    log_setup()
+
+    parser = argparse.ArgumentParser(
+        description="kernel-install plugin to build local addon for initrd/cmdline/ucode",
+        allow_abbrev=False,
+        usage="51-mkosi-addon.install COMMAND KERNEL_VERSION ENTRY_DIR KERNEL_IMAGE…",
+    )
+
+    parser.add_argument(
+        "command",
+        metavar="COMMAND",
+        help="The action to perform. Only 'add' is supported.",
+    )
+    parser.add_argument(
+        "kernel_version",
+        metavar="KERNEL_VERSION",
+        help="Kernel version string",
+    )
+    parser.add_argument(
+        "entry_dir",
+        metavar="ENTRY_DIR",
+        type=Path,
+        nargs="?",
+        help="Type#1 entry directory (ignored)",
+    )
+    parser.add_argument(
+        "kernel_image",
+        metavar="KERNEL_IMAGE",
+        type=Path,
+        nargs="?",
+        help="Kernel image",
+    )
+    parser.add_argument(
+        "--version",
+        action="version",
+        version=f"mkosi {__version__}",
+    )
+
+    context = Context(
+        **vars(parser.parse_args()),
+        staging_area=Path(mandatory_variable("KERNEL_INSTALL_STAGING_AREA")),
+        layout=mandatory_variable("KERNEL_INSTALL_LAYOUT"),
+        image_type=mandatory_variable("KERNEL_INSTALL_IMAGE_TYPE"),
+        verbose=int(os.getenv("KERNEL_INSTALL_VERBOSE", 0)) > 0,
+    )
+
+    if context.command != "add" or not context.layout == "uki":
+        return
+
+    cmdline: list[PathString] = [
+        "mkosi-addon",
+        "--output", "mkosi-local.addon.efi",
+        "--output-dir", context.staging_area / "uki.efi.extra.d",
+    ]  # fmt: skip
+
+    if context.verbose:
+        cmdline += ["--debug"]
+
+    logging.info(f"Building mkosi-local.addon.efi")
+
+    run(cmdline, stdin=sys.stdin, stdout=sys.stdout)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/mkosi-addon b/mkosi-addon
new file mode 120000
index 000000000..c8442ca02
--- /dev/null
+++ b/mkosi-addon
@@ -0,0 +1 @@
+mkosi/resources/mkosi-addon
\ No newline at end of file
diff --git a/mkosi/__init__.py b/mkosi/__init__.py
index 565d0d009..1edfd29d1 100644
--- a/mkosi/__init__.py
+++ b/mkosi/__init__.py
@@ -192,7 +192,7 @@ def remove_files(context: Context) -> None:
 
 
 def install_distribution(context: Context) -> None:
-    if context.config.base_trees:
+    if context.config.base_trees or context.config.output_format == OutputFormat.addon:
         if not context.config.packages:
             return
 
@@ -283,7 +283,10 @@ def remove_packages(context: Context) -> None:
 
 
 def check_root_populated(context: Context) -> None:
-    if context.config.output_format.is_extension_image():
+    if (
+        context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     """Check that the root was populated by looking for a os-release file."""
@@ -303,7 +306,11 @@ def configure_os_release(context: Context) -> None:
     if not (context.config.image_id or context.config.image_version or context.config.hostname):
         return
 
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     for candidate in ["usr/lib/os-release", "usr/lib/initrd-release", "etc/os-release"]:
@@ -2084,7 +2091,7 @@ def install_kernel(context: Context, partitions: Sequence[Partition]) -> None:
     # single-file images have the benefit that they can be signed like normal EFI binaries, and can
     # encode everything necessary to boot a specific root device, including the root hash.
 
-    if context.config.output_format in (OutputFormat.uki, OutputFormat.esp):
+    if context.config.output_format in (OutputFormat.uki, OutputFormat.esp, OutputFormat.addon):
         return
 
     if context.config.bootable == ConfigFeature.disabled:
@@ -2157,7 +2164,7 @@ def make_uki(
         extract_pe_section(context, output, ".initrd", context.staging / context.config.output_split_initrd)
 
 
-def make_initrd_addon(context: Context, stub: Path, output: Path) -> None:
+def make_initrd_addon(context: Context, stub: Path, microcodes: list[Path], output: Path) -> None:
     make_cpio(context.root, context.workspace / "initrd", sandbox=context.sandbox)
     maybe_compress(
         context,
@@ -2170,11 +2177,25 @@ def make_initrd_addon(context: Context, stub: Path, output: Path) -> None:
         "--ro-bind", context.workspace / "initrd", workdir(context.workspace / "initrd")
     ]  # fmt: skip
 
+    if microcodes:
+        # new .ucode section support?
+        check_ukify(
+            context.config,
+            version="256~devel",
+            reason="build addon with .ucode section support",
+            hint=("Use ToolsTree=default to download most required tools including ukify automatically"),
+        )
+
+        for microcode in microcodes:
+            arguments += ["--microcode", workdir(microcode)]
+            options += ["--ro-bind", microcode, workdir(microcode)]
+
     with complete_step(f"Generating initrd PE addon {output}"):
         run_ukify(
             context,
             stub,
             output,
+            cmdline=context.config.kernel_command_line,
             arguments=arguments,
             options=options,
         )
@@ -2613,7 +2634,7 @@ def check_tools(config: Config, verb: Verb) -> None:
             check_tool(config, "depmod", reason="generate kernel module dependencies")
 
         if want_efi(config):
-            if config.unified_kernel_image_profiles:
+            if config.unified_kernel_image_profiles or config.output_format == OutputFormat.addon:
                 check_ukify(
                     config,
                     version="257~devel",
@@ -2748,7 +2769,11 @@ def configure_ssh(context: Context) -> None:
 
 
 def configure_initrd(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_or_portable_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_or_portable_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     if (
@@ -2769,7 +2794,11 @@ def configure_initrd(context: Context) -> None:
 
 
 def configure_clock(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     with umask(~0o644):
@@ -2816,7 +2845,11 @@ def run_depmod(context: Context, *, cache: bool = False) -> None:
 
 
 def run_sysusers(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     if not context.config.find_binary("systemd-sysusers"):
@@ -2831,7 +2864,11 @@ def run_sysusers(context: Context) -> None:
 
 
 def run_tmpfiles(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     if not context.config.find_binary("systemd-tmpfiles"):
@@ -2872,7 +2909,11 @@ def run_tmpfiles(context: Context) -> None:
 
 
 def run_preset(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     if not context.config.find_binary("systemctl"):
@@ -2891,7 +2932,11 @@ def run_preset(context: Context) -> None:
 
 
 def run_hwdb(context: Context) -> None:
-    if context.config.overlay or context.config.output_format.is_extension_image():
+    if (
+        context.config.overlay
+        or context.config.output_format.is_extension_image()
+        or context.config.output_format == OutputFormat.addon
+    ):
         return
 
     if not context.config.find_binary("systemd-hwdb"):
@@ -3083,15 +3128,20 @@ def reuse_cache(context: Context) -> bool:
 def save_esp_components(
     context: Context,
 ) -> tuple[Optional[Path], Optional[str], Optional[Path], list[Path]]:
-    if context.config.output_format == OutputFormat.initrd_addon:
+    if not context.config.architecture.to_efi():
+        die(f"Architecture {context.config.architecture} does not support UEFI")
+
+    if context.config.output_format not in (OutputFormat.uki, OutputFormat.esp, OutputFormat.addon):
+        return None, None, None, []
+
+    microcode = build_microcode_initrd(context)
+
+    if context.config.output_format == OutputFormat.addon:
         stub = systemd_addon_stub_binary(context)
         if not stub.exists():
             die(f"sd-stub not found at /{stub.relative_to(context.root)} in the image")
 
-        return shutil.copy2(stub, context.workspace), None, None, []
-
-    if context.config.output_format not in (OutputFormat.uki, OutputFormat.esp):
-        return None, None, None, []
+        return shutil.copy2(stub, context.workspace), None, None, microcode
 
     try:
         kver, kimg = next(gen_kernel_images(context))
@@ -3100,15 +3150,11 @@ def save_esp_components(
 
     kimg = shutil.copy2(context.root / kimg, context.workspace)
 
-    if not context.config.architecture.to_efi():
-        die(f"Architecture {context.config.architecture} does not support UEFI")
-
     stub = systemd_stub_binary(context)
     if not stub.exists():
         die(f"sd-stub not found at /{stub.relative_to(context.root)} in the image")
 
     stub = shutil.copy2(stub, context.workspace)
-    microcode = build_microcode_initrd(context)
 
     return stub, kver, kimg, microcode
 
@@ -3761,15 +3807,15 @@ def build_image(context: Context) -> None:
         assert stub and kver and kimg
         make_uki(context, stub, kver, kimg, microcode, context.staging / context.config.output_split_uki)
         make_esp(context, context.staging / context.config.output_split_uki)
-    elif context.config.output_format == OutputFormat.initrd_addon:
+    elif context.config.output_format == OutputFormat.addon:
         assert stub
-        make_initrd_addon(context, stub, context.staging / context.config.output_with_format)
+        make_initrd_addon(context, stub, microcode, context.staging / context.config.output_with_format)
     elif context.config.output_format.is_extension_or_portable_image():
         make_extension_or_portable_image(context, context.staging / context.config.output_with_format)
     elif context.config.output_format == OutputFormat.directory:
         context.root.rename(context.staging / context.config.output_with_format)
 
-    if context.config.output_format not in (OutputFormat.uki, OutputFormat.esp):
+    if context.config.output_format not in (OutputFormat.uki, OutputFormat.esp, OutputFormat.addon):
         maybe_compress(
             context,
             context.config.compress_output,
@@ -3857,7 +3903,7 @@ def run_sandbox(args: Args, config: Config) -> None:
 
 def run_shell(args: Args, config: Config) -> None:
     opname = "acquire shell in" if args.verb == Verb.shell else "boot"
-    if config.output_format in (OutputFormat.tar, OutputFormat.cpio):
+    if config.output_format in (OutputFormat.tar, OutputFormat.cpio, OutputFormat.addon):
         die(f"Sorry, can't {opname} a {config.output_format} archive.")
     if config.output_format.use_outer_compression() and config.compress_output:
         die(f"Sorry, can't {opname} a compressed image.")
@@ -4630,6 +4676,7 @@ def run_verb(args: Args, images: Sequence[Config], *, resources: Path) -> None:
     if args.verb == Verb.documentation:
         if args.cmdline:
             manual = {
+                "addon": "mkosi-addon",
                 "initrd": "mkosi-initrd",
                 "sandbox": "mkosi-sandbox",
                 "news": "mkosi.news",
diff --git a/mkosi/addon.py b/mkosi/addon.py
new file mode 100644
index 000000000..c5c4527cf
--- /dev/null
+++ b/mkosi/addon.py
@@ -0,0 +1,151 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+import argparse
+import contextlib
+import os
+import platform
+import sys
+import tempfile
+from pathlib import Path
+from typing import cast
+
+import mkosi.resources
+from mkosi.config import DocFormat, OutputFormat
+from mkosi.documentation import show_docs
+from mkosi.log import log_notice, log_setup
+from mkosi.run import run, uncaught_exception_handler
+from mkosi.sandbox import __version__, umask
+from mkosi.tree import copy_tree
+from mkosi.types import PathString
+from mkosi.util import initrd_copy_host_config, resource_path
+
+
+@uncaught_exception_handler()
+def main() -> None:
+    log_setup()
+
+    parser = argparse.ArgumentParser(
+        prog="mkosi-addon",
+        description="Build initrd/cmdline/ucode addon for the current system using mkosi",
+        allow_abbrev=False,
+        usage="mkosi-addon [options...]",
+    )
+
+    parser.add_argument(
+        "--kernel-version",
+        metavar="KERNEL_VERSION",
+        help="Kernel version string",
+        default=platform.uname().release,
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        metavar="NAME",
+        help="Output name",
+        default="mkosi-local.addon.efi",
+    )
+    parser.add_argument(
+        "-O",
+        "--output-dir",
+        metavar="DIR",
+        help="Output directory",
+        default="",
+    )
+    parser.add_argument(
+        "--debug",
+        help="Turn on debugging output",
+        action="store_true",
+        default=False,
+    )
+    parser.add_argument(
+        "--debug-shell",
+        help="Spawn debug shell if a sandboxed command fails",
+        action="store_true",
+        default=False,
+    )
+    parser.add_argument(
+        "-D",
+        "--show-documentation",
+        help="Show the man page",
+        action="store_true",
+        default=False,
+    )
+    parser.add_argument(
+        "--version",
+        action="version",
+        version=f"mkosi {__version__}",
+    )
+
+    args = parser.parse_args()
+
+    if args.show_documentation:
+        with resource_path(mkosi.resources) as r:
+            show_docs("mkosi-addon", DocFormat.all(), resources=r)
+        return
+
+    # No local configuration? Then nothing to do
+    if not Path("/etc/mkosi-addon").exists() and not Path("/run/mkosi-addon").exists():
+        return
+
+    with tempfile.TemporaryDirectory() as staging_dir:
+        cmdline: list[PathString] = [
+            "mkosi",
+            "--force",
+            "--directory", "",
+            f"--format={str(OutputFormat.addon)}",
+            "--output", args.output,
+            "--output-directory", staging_dir,
+            "--build-sources", "",
+            "--include=mkosi-addon",
+        ]  # fmt: skip
+
+        cmdline += [
+            "--extra-tree=/usr/lib/systemd/boot/efi:/usr/lib/systemd/boot/efi",
+            "--remove-files=/usr/lib/systemd/boot/efi/",
+            "--sandbox-tree",
+            f"/usr/lib/modules/{args.kernel_version}:/usr/lib/modules/{args.kernel_version}",
+            "--sandbox-tree=/usr/lib/firmware:/usr/lib/firmware",
+        ]  # fmt: skip
+
+        if args.debug:
+            cmdline += ["--debug"]
+        if args.debug_shell:
+            cmdline += ["--debug-shell"]
+
+        if os.getuid() == 0:
+            cmdline += [
+                "--workspace-dir=/var/tmp",
+                "--package-cache-dir=/var",
+                "--cache-only=metadata",
+                "--output-mode=600",
+            ]
+
+        for d in (
+            "/usr/lib/mkosi-addon",
+            "/usr/local/lib/mkosi-addon",
+            "/run/mkosi-addon",
+            "/etc/mkosi-addon",
+        ):
+            if Path(d).exists():
+                cmdline += ["--include", d]
+
+        cmdline += initrd_copy_host_config(staging_dir)
+
+        run(cmdline, stdin=sys.stdin, stdout=sys.stdout)
+
+        if args.output_dir:
+            with umask(~0o700) if os.getuid() == 0 else cast(umask, contextlib.nullcontext()):
+                Path(args.output_dir).mkdir(parents=True, exist_ok=True)
+        else:
+            args.output_dir = Path.cwd()
+
+        log_notice(f"Copying {staging_dir}/{args.output} to {args.output_dir}/{args.output}")
+        # mkosi symlinks the expected output image, so dereference it
+        copy_tree(
+            Path(f"{staging_dir}/{args.output}").resolve(),
+            Path(f"{args.output_dir}/{args.output}"),
+        )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/mkosi/config.py b/mkosi/config.py
index ffbb78fe5..f885e50c5 100644
--- a/mkosi/config.py
+++ b/mkosi/config.py
@@ -191,7 +191,7 @@ class OutputFormat(StrEnum):
     tar = enum.auto()
     uki = enum.auto()
     oci = enum.auto()
-    initrd_addon = enum.auto()
+    addon = enum.auto()
 
     def extension(self) -> str:
         return {
@@ -203,7 +203,7 @@ def extension(self) -> str:
             OutputFormat.sysext:       ".raw",
             OutputFormat.tar:          ".tar",
             OutputFormat.uki:          ".efi",
-            OutputFormat.initrd_addon: ".efi",
+            OutputFormat.addon:        ".efi",
         }.get(self, "")  # fmt: skip
 
     def use_outer_compression(self) -> bool:
@@ -217,7 +217,7 @@ def use_outer_compression(self) -> bool:
         )
 
     def is_extension_image(self) -> bool:
-        return self in (OutputFormat.sysext, OutputFormat.confext, OutputFormat.initrd_addon)
+        return self in (OutputFormat.sysext, OutputFormat.confext, OutputFormat.addon)
 
     def is_extension_or_portable_image(self) -> bool:
         return self.is_extension_image() or self == OutputFormat.portable
@@ -843,7 +843,13 @@ def config_parse_mode(value: Optional[str], old: Optional[int]) -> Optional[int]
 
 
 def config_default_compression(namespace: argparse.Namespace) -> Compression:
-    if namespace.output_format in (OutputFormat.tar, OutputFormat.cpio, OutputFormat.uki, OutputFormat.esp):
+    if namespace.output_format in (
+        OutputFormat.tar,
+        OutputFormat.cpio,
+        OutputFormat.uki,
+        OutputFormat.esp,
+        OutputFormat.addon,
+    ):
         return Compression.zstd
     elif namespace.output_format == OutputFormat.oci:
         return Compression.gz
diff --git a/mkosi/initrd.py b/mkosi/initrd.py
index 6673605a0..189bf4b05 100644
--- a/mkosi/initrd.py
+++ b/mkosi/initrd.py
@@ -18,7 +18,7 @@
 from mkosi.sandbox import __version__, umask
 from mkosi.tree import copy_tree
 from mkosi.types import PathString
-from mkosi.util import resource_path
+from mkosi.util import initrd_copy_host_config, resource_path
 
 
 @uncaught_exception_handler()
@@ -180,25 +180,7 @@ def main() -> None:
 
         cmdline += ["--sandbox-tree", sandbox_tree]
 
-        # Generate crypttab with all the x-initrd.attach entries
-        if Path("/etc/crypttab").exists():
-            crypttab = [
-                line
-                for line in Path("/etc/crypttab").read_text().splitlines()
-                if (
-                    len(entry := line.split()) >= 4
-                    and not entry[0].startswith("#")
-                    and "x-initrd.attach" in entry[3]
-                )
-            ]
-            if crypttab:
-                with (Path(staging_dir) / "crypttab").open("w") as f:
-                    f.write("# Automatically generated by mkosi-initrd\n")
-                    f.write("\n".join(crypttab))
-                cmdline += ["--extra-tree", f"{staging_dir}/crypttab:/etc/crypttab"]
-
-        if Path("/etc/kernel/cmdline").exists():
-            cmdline += ["--kernel-command-line", Path("/etc/kernel/cmdline").read_text()]
+        cmdline += initrd_copy_host_config(staging_dir)
 
         # Resolve dnf binary to determine which version the host uses by default
         # (to avoid preferring dnf5 if the host uses dnf4)
diff --git a/mkosi/resources/man/mkosi-addon.1.md b/mkosi/resources/man/mkosi-addon.1.md
new file mode 100644
index 000000000..891ab5b73
--- /dev/null
+++ b/mkosi/resources/man/mkosi-addon.1.md
@@ -0,0 +1,49 @@
+% mkosi-addon(1)
+%
+%
+
+# NAME
+
+mkosi-addon — Build addons for unified kernel images for the current system
+using mkosi
+
+# SYNOPSIS
+
+`mkosi-addon [options…]`
+
+# DESCRIPTION
+
+`mkosi-addon` is wrapper on top of `mkosi` to simplify the generation of
+addons containing customizations for a Unified Kernel Images specific for the
+current running system. Will include entries in `/etc/crypttab` marked with
+`x-initrd.attach`, `/etc/kernel/cmdline`, kernel modules, firmwares and microcode
+for the running hardware.
+
+# OPTIONS
+
+`--kernel-version=`
+:   Kernel version where to look for the kernel modules to include. Defaults to
+    the kernel version of the running system (`uname -r`).
+
+`--output=`, `-o`
+:   Name to use for the generated output addon. Defaults to
+    `mkosi-local.addon.efi`.
+
+`--output-dir=`, `-O`
+:   Path to a directory where to place all generated artifacts. Defaults to the
+    current working directory.
+
+`--debug=`
+:   Enable additional debugging output.
+
+`--debug-shell=`
+:   Spawn debug shell in sandbox if a sandboxed command fails.
+
+`--version`
+:   Show package version.
+
+`--help`, `-h`
+:   Show brief usage information.
+
+# SEE ALSO
+`mkosi(1)`
diff --git a/mkosi/resources/man/mkosi.1.md b/mkosi/resources/man/mkosi.1.md
index 3db114147..8f0b76a0f 100644
--- a/mkosi/resources/man/mkosi.1.md
+++ b/mkosi/resources/man/mkosi.1.md
@@ -519,7 +519,7 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
     the `.initrd` PE section), `esp` (`uki` but wrapped in a disk image
     with only an ESP partition), `oci` (a directory compatible with the
     OCI image specification), `sysext`, `confext`, `portable`,
-    `initrd-addon` or `none` (the OS image is solely intended as a build
+    `addon` or `none` (the OS image is solely intended as a build
     image to produce another artifact).
 
     If the `disk` output format is used, the disk image is generated using
@@ -565,7 +565,7 @@ boolean argument: either `1`, `yes`, or `true` to enable, or `0`, `no`,
     compression means the image cannot be started directly but needs to be
     decompressed first. This also means that the `shell`, `boot`, `vm` verbs
     are not available when this option is used. Implied for `tar`, `cpio`, `uki`,
-    `esp`, and `oci`.
+    `esp`, `oci`, and `addon`.
 
 `CompressLevel=`, `--compress-level=`
 :   Configure the compression level to use. Takes an integer. The possible
diff --git a/mkosi/resources/man/mkosi.news.7.md b/mkosi/resources/man/mkosi.news.7.md
index 0dd486aaa..bbbde0119 100644
--- a/mkosi/resources/man/mkosi.news.7.md
+++ b/mkosi/resources/man/mkosi.news.7.md
@@ -110,7 +110,7 @@
 - The `coredumpctl` and `journalctl` verbs will now always operate on
   the image, even if `ForwardJournal=` is configured.
 - Bumped default Fedora release to `41`.
-- Added `initrd-addon` output format to build initrd addons.
+- Added `addon` output format to build initrd addons.
 - Renamed `[Host]` section to `[Runtime]` section.
 - Renamed various settings from `[Host]`.
 - Binaries coming from `ExtraSearchPaths=` are now executed with the
diff --git a/mkosi/resources/mkosi-addon/mkosi.conf b/mkosi/resources/mkosi-addon/mkosi.conf
new file mode 100644
index 000000000..3eac2090a
--- /dev/null
+++ b/mkosi/resources/mkosi-addon/mkosi.conf
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: LGPL-2.1-or-later
+
+[Distribution]
+Distribution=custom
+
+[Output]
+Output=addon
+Format=addon
+ManifestFormat=
+SplitArtifacts=
+
+[Content]
+Bootable=no
+RemoveFiles=
+        # Including kernel images in the initrd is generally not useful.
+        # This also stops mkosi from extracting the kernel image out of the image as a separate output.
+        /usr/lib/modules/*/vmlinuz*
+        /usr/lib/modules/*/vmlinux*
+        /usr/lib/modules/*/System.map
diff --git a/mkosi/util.py b/mkosi/util.py
index 248d8e0cc..ebe82f9ba 100644
--- a/mkosi/util.py
+++ b/mkosi/util.py
@@ -257,3 +257,29 @@ def current_home_dir() -> Optional[Path]:
 
 def unique(seq: Sequence[T]) -> list[T]:
     return list(dict.fromkeys(seq))
+
+
+def initrd_copy_host_config(staging_dir: str) -> list[str]:
+    cmdline = []
+
+    # Generate crypttab with all the x-initrd.attach entries
+    if Path("/etc/crypttab").exists():
+        crypttab = [
+            line
+            for line in Path("/etc/crypttab").read_text().splitlines()
+            if (
+                len(entry := line.split()) >= 4
+                and not entry[0].startswith("#")
+                and "x-initrd.attach" in entry[3]
+            )
+        ]
+        if crypttab:
+            with (Path(staging_dir) / "crypttab").open("w") as f:
+                f.write("# Automatically generated by mkosi-initrd\n")
+                f.write("\n".join(crypttab))
+            cmdline += ["--extra-tree", f"{staging_dir}/crypttab:/etc/crypttab"]
+
+    if Path("/etc/kernel/cmdline").exists():
+        cmdline += ["--kernel-command-line", Path("/etc/kernel/cmdline").read_text()]
+
+    return cmdline
diff --git a/pyproject.toml b/pyproject.toml
index 7426a7a78..0011fb89f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ bootable = [
 mkosi = "mkosi.__main__:main"
 mkosi-initrd = "mkosi.initrd:main"
 mkosi-sandbox = "mkosi.sandbox:main"
+mkosi-addon = "mkosi.addon:main"
 
 [tool.setuptools]
 packages = [
@@ -35,6 +36,7 @@ packages = [
 "mkosi.resources" = [
     "completion.*",
     "man/*",
+    "mkosi-addon/**/*",
     "mkosi-initrd/**/*",
     "mkosi-tools/**/*",
     "mkosi-vm/**/*",