···11+#!/usr/bin/env python
22+33+"""Amend systemd-repart definiton files.
44+55+In order to avoid Import-From-Derivation (IFD) when building images with
66+systemd-repart, the definition files created by Nix need to be amended with the
77+store paths from the closure.
88+99+This is achieved by adding CopyFiles= instructions to the definition files.
1010+1111+The arbitrary files configured via `contents` are also added to the definition
1212+files using the same mechanism.
1313+"""
1414+1515+import json
1616+import sys
1717+import shutil
1818+import os
1919+import tempfile
2020+from pathlib import Path
2121+2222+2323+def add_contents_to_definition(
2424+ definition: Path, contents: dict[str, dict[str, str]] | None
2525+) -> None:
2626+ """Add CopyFiles= instructions to a definition for all files in contents."""
2727+ if not contents:
2828+ return
2929+3030+ copy_files_lines: list[str] = []
3131+ for target, options in contents.items():
3232+ source = options["source"]
3333+3434+ copy_files_lines.append(f"CopyFiles={source}:{target}\n")
3535+3636+ with open(definition, "a") as f:
3737+ f.writelines(copy_files_lines)
3838+3939+4040+def add_closure_to_definition(
4141+ definition: Path, closure: Path | None, strip_nix_store_prefix: bool | None
4242+) -> None:
4343+ """Add CopyFiles= instructions to a definition for all paths in the closure.
4444+4545+ If strip_nix_store_prefix is True, `/nix/store` is stripped from the target path.
4646+ """
4747+ if not closure:
4848+ return
4949+5050+ copy_files_lines: list[str] = []
5151+ with open(closure, "r") as f:
5252+ for line in f:
5353+ if not isinstance(line, str):
5454+ continue
5555+5656+ source = Path(line.strip())
5757+ target = str(source.relative_to("/nix/store/"))
5858+ target = f":{target}" if strip_nix_store_prefix else ""
5959+6060+ copy_files_lines.append(f"CopyFiles={source}{target}\n")
6161+6262+ with open(definition, "a") as f:
6363+ f.writelines(copy_files_lines)
6464+6565+6666+def main() -> None:
6767+ """Amend the provided repart definitions by adding CopyFiles= instructions.
6868+6969+ For each file specified in the `contents` field of a partition in the
7070+ partiton config file, a `CopyFiles=` instruction is added to the
7171+ corresponding definition file.
7272+7373+ The same is done for every store path of the `closure` field.
7474+7575+ Print the path to a directory that contains the amended repart
7676+ definitions to stdout.
7777+ """
7878+ partition_config_file = sys.argv[1]
7979+ if not partition_config_file:
8080+ print("No partition config file was supplied.")
8181+ sys.exit(1)
8282+8383+ repart_definitions = sys.argv[2]
8484+ if not repart_definitions:
8585+ print("No repart definitions were supplied.")
8686+ sys.exit(1)
8787+8888+ with open(partition_config_file, "rb") as f:
8989+ partition_config = json.load(f)
9090+9191+ if not partition_config:
9292+ print("Partition config is empty.")
9393+ sys.exit(1)
9494+9595+ temp = tempfile.mkdtemp()
9696+ shutil.copytree(repart_definitions, temp, dirs_exist_ok=True)
9797+9898+ for name, config in partition_config.items():
9999+ definition = Path(f"{temp}/{name}.conf")
100100+ os.chmod(definition, 0o644)
101101+102102+ contents = config.get("contents")
103103+ add_contents_to_definition(definition, contents)
104104+105105+ closure = config.get("closure")
106106+ strip_nix_store_prefix = config.get("stripStorePaths")
107107+ add_closure_to_definition(definition, closure, strip_nix_store_prefix)
108108+109109+ print(temp)
110110+111111+112112+if __name__ == "__main__":
113113+ main()
+204
nixos/modules/image/repart.nix
···11+# This module exposes options to build a disk image with a GUID Partition Table
22+# (GPT). It uses systemd-repart to build the image.
33+44+{ config, pkgs, lib, utils, ... }:
55+66+let
77+ cfg = config.image.repart;
88+99+ partitionOptions = {
1010+ options = {
1111+ storePaths = lib.mkOption {
1212+ type = with lib.types; listOf path;
1313+ default = [ ];
1414+ description = lib.mdDoc "The store paths to include in the partition.";
1515+ };
1616+1717+ stripNixStorePrefix = lib.mkOption {
1818+ type = lib.types.bool;
1919+ default = false;
2020+ description = lib.mdDoc ''
2121+ Whether to strip `/nix/store/` from the store paths. This is useful
2222+ when you want to build a partition that only contains store paths and
2323+ is mounted under `/nix/store`.
2424+ '';
2525+ };
2626+2727+ contents = lib.mkOption {
2828+ type = with lib.types; attrsOf (submodule {
2929+ options = {
3030+ source = lib.mkOption {
3131+ type = types.path;
3232+ description = lib.mdDoc "Path of the source file.";
3333+ };
3434+ };
3535+ });
3636+ default = { };
3737+ example = lib.literalExpression '' {
3838+ "/EFI/BOOT/BOOTX64.EFI".source =
3939+ "''${pkgs.systemd}/lib/systemd/boot/efi/systemd-bootx64.efi";
4040+4141+ "/loader/entries/nixos.conf".source = systemdBootEntry;
4242+ }
4343+ '';
4444+ description = lib.mdDoc "The contents to end up in the filesystem image.";
4545+ };
4646+4747+ repartConfig = lib.mkOption {
4848+ type = with lib.types; attrsOf (oneOf [ str int bool ]);
4949+ example = {
5050+ Type = "home";
5151+ SizeMinBytes = "512M";
5252+ SizeMaxBytes = "2G";
5353+ };
5454+ description = lib.mdDoc ''
5555+ Specify the repart options for a partiton as a structural setting.
5656+ See <https://www.freedesktop.org/software/systemd/man/repart.d.html>
5757+ for all available options.
5858+ '';
5959+ };
6060+ };
6161+ };
6262+in
6363+{
6464+ options.image.repart = {
6565+6666+ name = lib.mkOption {
6767+ type = lib.types.str;
6868+ description = lib.mdDoc "The name of the image.";
6969+ };
7070+7171+ seed = lib.mkOption {
7272+ type = with lib.types; nullOr str;
7373+ # Generated with `uuidgen`. Random but fixed to improve reproducibility.
7474+ default = "0867da16-f251-457d-a9e8-c31f9a3c220b";
7575+ description = lib.mdDoc ''
7676+ A UUID to use as a seed. You can set this to `null` to explicitly
7777+ randomize the partition UUIDs.
7878+ '';
7979+ };
8080+8181+ split = lib.mkOption {
8282+ type = lib.types.bool;
8383+ default = false;
8484+ description = lib.mdDoc ''
8585+ Enables generation of split artifacts from partitions. If enabled, for
8686+ each partition with SplitName= set, a separate output file containing
8787+ just the contents of that partition is generated.
8888+ '';
8989+ };
9090+9191+ partitions = lib.mkOption {
9292+ type = with lib.types; attrsOf (submodule partitionOptions);
9393+ default = { };
9494+ example = lib.literalExpression '' {
9595+ "10-esp" = {
9696+ contents = {
9797+ "/EFI/BOOT/BOOTX64.EFI".source =
9898+ "''${pkgs.systemd}/lib/systemd/boot/efi/systemd-bootx64.efi";
9999+ }
100100+ repartConfig = {
101101+ Type = "esp";
102102+ Format = "fat";
103103+ };
104104+ };
105105+ "20-root" = {
106106+ storePaths = [ config.system.build.toplevel ];
107107+ repartConfig = {
108108+ Type = "root";
109109+ Format = "ext4";
110110+ Minimize = "guess";
111111+ };
112112+ };
113113+ };
114114+ '';
115115+ description = lib.mdDoc ''
116116+ Specify partitions as a set of the names of the partitions with their
117117+ configuration as the key.
118118+ '';
119119+ };
120120+121121+ };
122122+123123+ config = {
124124+125125+ system.build.image =
126126+ let
127127+ fileSystemToolMapping = with pkgs; {
128128+ "vfat" = [ dosfstools mtools ];
129129+ "ext4" = [ e2fsprogs.bin ];
130130+ "squashfs" = [ squashfsTools ];
131131+ "erofs" = [ erofs-utils ];
132132+ "btrfs" = [ btrfs-progs ];
133133+ "xfs" = [ xfsprogs ];
134134+ };
135135+136136+ fileSystems = lib.filter
137137+ (f: f != null)
138138+ (lib.mapAttrsToList (_n: v: v.repartConfig.Format or null) cfg.partitions);
139139+140140+ fileSystemTools = builtins.concatMap (f: fileSystemToolMapping."${f}") fileSystems;
141141+142142+143143+ makeClosure = paths: pkgs.closureInfo { rootPaths = paths; };
144144+145145+ # Add the closure of the provided Nix store paths to cfg.partitions so
146146+ # that amend-repart-definitions.py can read it.
147147+ addClosure = _name: partitionConfig: partitionConfig // (
148148+ lib.optionalAttrs
149149+ (partitionConfig.storePaths or [ ] != [ ])
150150+ { closure = "${makeClosure partitionConfig.storePaths}/store-paths"; }
151151+ );
152152+153153+154154+ finalPartitions = lib.mapAttrs addClosure cfg.partitions;
155155+156156+157157+ amendRepartDefinitions = pkgs.runCommand "amend-repart-definitions.py"
158158+ {
159159+ nativeBuildInputs = with pkgs; [ black ruff mypy ];
160160+ buildInputs = [ pkgs.python3 ];
161161+ } ''
162162+ install ${./amend-repart-definitions.py} $out
163163+ patchShebangs --host $out
164164+165165+ black --check --diff $out
166166+ ruff --line-length 88 $out
167167+ mypy --strict $out
168168+ '';
169169+170170+ format = pkgs.formats.ini { };
171171+172172+ definitionsDirectory = utils.systemdUtils.lib.definitions
173173+ "repart.d"
174174+ format
175175+ (lib.mapAttrs (_n: v: { Partition = v.repartConfig; }) finalPartitions);
176176+177177+ partitions = pkgs.writeText "partitions.json" (builtins.toJSON finalPartitions);
178178+ in
179179+ pkgs.runCommand cfg.name
180180+ {
181181+ nativeBuildInputs = with pkgs; [
182182+ fakeroot
183183+ systemd
184184+ ] ++ fileSystemTools;
185185+ } ''
186186+ amendedRepartDefinitions=$(${amendRepartDefinitions} ${partitions} ${definitionsDirectory})
187187+188188+ mkdir -p $out
189189+ cd $out
190190+191191+ fakeroot systemd-repart \
192192+ --dry-run=no \
193193+ --empty=create \
194194+ --size=auto \
195195+ --seed="${cfg.seed}" \
196196+ --definitions="$amendedRepartDefinitions" \
197197+ --split="${lib.boolToString cfg.split}" \
198198+ image.raw
199199+ '';
200200+201201+ meta.maintainers = with lib.maintainers; [ nikstur ];
202202+203203+ };
204204+}