Commit ec8d30cc authored by nikstur's avatar nikstur
Browse files

nixos/image: add repart builder

parent a662dc8b
Loading
Loading
Loading
Loading
+113 −0
Original line number Diff line number Diff line
#!/usr/bin/env python

"""Amend systemd-repart definiton files.

In order to avoid Import-From-Derivation (IFD) when building images with
systemd-repart, the definition files created by Nix need to be amended with the
store paths from the closure.

This is achieved by adding CopyFiles= instructions to the definition files.

The arbitrary files configured via `contents` are also added to the definition
files using the same mechanism.
"""

import json
import sys
import shutil
import os
import tempfile
from pathlib import Path


def add_contents_to_definition(
    definition: Path, contents: dict[str, dict[str, str]] | None
) -> None:
    """Add CopyFiles= instructions to a definition for all files in contents."""
    if not contents:
        return

    copy_files_lines: list[str] = []
    for target, options in contents.items():
        source = options["source"]

        copy_files_lines.append(f"CopyFiles={source}:{target}\n")

    with open(definition, "a") as f:
        f.writelines(copy_files_lines)


def add_closure_to_definition(
    definition: Path, closure: Path | None, strip_nix_store_prefix: bool | None
) -> None:
    """Add CopyFiles= instructions to a definition for all paths in the closure.

    If strip_nix_store_prefix is True, `/nix/store` is stripped from the target path.
    """
    if not closure:
        return

    copy_files_lines: list[str] = []
    with open(closure, "r") as f:
        for line in f:
            if not isinstance(line, str):
                continue

            source = Path(line.strip())
            target = str(source.relative_to("/nix/store/"))
            target = f":{target}" if strip_nix_store_prefix else ""

            copy_files_lines.append(f"CopyFiles={source}{target}\n")

    with open(definition, "a") as f:
        f.writelines(copy_files_lines)


def main() -> None:
    """Amend the provided repart definitions by adding CopyFiles= instructions.

    For each file specified in the `contents` field of a partition in the
    partiton config file, a `CopyFiles=` instruction is added to the
    corresponding definition file.

    The same is done for every store path of the `closure` field.

    Print the path to a directory that contains the amended repart
    definitions to stdout.
    """
    partition_config_file = sys.argv[1]
    if not partition_config_file:
        print("No partition config file was supplied.")
        sys.exit(1)

    repart_definitions = sys.argv[2]
    if not repart_definitions:
        print("No repart definitions were supplied.")
        sys.exit(1)

    with open(partition_config_file, "rb") as f:
        partition_config = json.load(f)

    if not partition_config:
        print("Partition config is empty.")
        sys.exit(1)

    temp = tempfile.mkdtemp()
    shutil.copytree(repart_definitions, temp, dirs_exist_ok=True)

    for name, config in partition_config.items():
        definition = Path(f"{temp}/{name}.conf")
        os.chmod(definition, 0o644)

        contents = config.get("contents")
        add_contents_to_definition(definition, contents)

        closure = config.get("closure")
        strip_nix_store_prefix = config.get("stripStorePaths")
        add_closure_to_definition(definition, closure, strip_nix_store_prefix)

    print(temp)


if __name__ == "__main__":
    main()
+204 −0
Original line number Diff line number Diff line
# This module exposes options to build a disk image with a GUID Partition Table
# (GPT). It uses systemd-repart to build the image.

{ config, pkgs, lib, utils, ... }:

let
  cfg = config.image.repart;

  partitionOptions = {
    options = {
      storePaths = lib.mkOption {
        type = with lib.types; listOf path;
        default = [ ];
        description = lib.mdDoc "The store paths to include in the partition.";
      };

      stripNixStorePrefix = lib.mkOption {
        type = lib.types.bool;
        default = false;
        description = lib.mdDoc ''
          Whether to strip `/nix/store/` from the store paths. This is useful
          when you want to build a partition that only contains store paths and
          is mounted under `/nix/store`.
        '';
      };

      contents = lib.mkOption {
        type = with lib.types; attrsOf (submodule {
          options = {
            source = lib.mkOption {
              type = types.path;
              description = lib.mdDoc "Path of the source file.";
            };
          };
        });
        default = { };
        example = lib.literalExpression '' {
          "/EFI/BOOT/BOOTX64.EFI".source =
            "''${pkgs.systemd}/lib/systemd/boot/efi/systemd-bootx64.efi";

          "/loader/entries/nixos.conf".source = systemdBootEntry;
        }
        '';
        description = lib.mdDoc "The contents to end up in the filesystem image.";
      };

      repartConfig = lib.mkOption {
        type = with lib.types; attrsOf (oneOf [ str int bool ]);
        example = {
          Type = "home";
          SizeMinBytes = "512M";
          SizeMaxBytes = "2G";
        };
        description = lib.mdDoc ''
          Specify the repart options for a partiton as a structural setting.
          See <https://www.freedesktop.org/software/systemd/man/repart.d.html>
          for all available options.
        '';
      };
    };
  };
in
{
  options.image.repart = {

    name = lib.mkOption {
      type = lib.types.str;
      description = lib.mdDoc "The name of the image.";
    };

    seed = lib.mkOption {
      type = with lib.types; nullOr str;
      # Generated with `uuidgen`. Random but fixed to improve reproducibility.
      default = "0867da16-f251-457d-a9e8-c31f9a3c220b";
      description = lib.mdDoc ''
        A UUID to use as a seed. You can set this to `null` to explicitly
        randomize the partition UUIDs.
      '';
    };

    split = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = lib.mdDoc ''
        Enables generation of split artifacts from partitions. If enabled, for
        each partition with SplitName= set, a separate output file containing
        just the contents of that partition is generated.
      '';
    };

    partitions = lib.mkOption {
      type = with lib.types; attrsOf (submodule partitionOptions);
      default = { };
      example = lib.literalExpression '' {
        "10-esp" = {
          contents = {
            "/EFI/BOOT/BOOTX64.EFI".source =
              "''${pkgs.systemd}/lib/systemd/boot/efi/systemd-bootx64.efi";
          }
          repartConfig = {
            Type = "esp";
            Format = "fat";
          };
        };
        "20-root" = {
          storePaths = [ config.system.build.toplevel ];
          repartConfig = {
            Type = "root";
            Format = "ext4";
            Minimize = "guess";
          };
        };
      };
      '';
      description = lib.mdDoc ''
        Specify partitions as a set of the names of the partitions with their
        configuration as the key.
      '';
    };

  };

  config = {

    system.build.image =
      let
        fileSystemToolMapping = with pkgs; {
          "vfat" = [ dosfstools mtools ];
          "ext4" = [ e2fsprogs.bin ];
          "squashfs" = [ squashfsTools ];
          "erofs" = [ erofs-utils ];
          "btrfs" = [ btrfs-progs ];
          "xfs" = [ xfsprogs ];
        };

        fileSystems = lib.filter
          (f: f != null)
          (lib.mapAttrsToList (_n: v: v.repartConfig.Format or null) cfg.partitions);

        fileSystemTools = builtins.concatMap (f: fileSystemToolMapping."${f}") fileSystems;


        makeClosure = paths: pkgs.closureInfo { rootPaths = paths; };

        # Add the closure of the provided Nix store paths to cfg.partitions so
        # that amend-repart-definitions.py can read it.
        addClosure = _name: partitionConfig: partitionConfig // (
          lib.optionalAttrs
            (partitionConfig.storePaths or [ ] != [ ])
            { closure = "${makeClosure partitionConfig.storePaths}/store-paths"; }
        );


        finalPartitions = lib.mapAttrs addClosure cfg.partitions;


        amendRepartDefinitions = pkgs.runCommand "amend-repart-definitions.py"
          {
            nativeBuildInputs = with pkgs; [ black ruff mypy ];
            buildInputs = [ pkgs.python3 ];
          } ''
          install ${./amend-repart-definitions.py} $out
          patchShebangs --host $out

          black --check --diff $out
          ruff --line-length 88 $out
          mypy --strict $out
        '';

        format = pkgs.formats.ini { };

        definitionsDirectory = utils.systemdUtils.lib.definitions
          "repart.d"
          format
          (lib.mapAttrs (_n: v: { Partition = v.repartConfig; }) finalPartitions);

        partitions = pkgs.writeText "partitions.json" (builtins.toJSON finalPartitions);
      in
      pkgs.runCommand cfg.name
        {
          nativeBuildInputs = with pkgs; [
            fakeroot
            systemd
          ] ++ fileSystemTools;
        } ''
        amendedRepartDefinitions=$(${amendRepartDefinitions} ${partitions} ${definitionsDirectory})

        mkdir -p $out
        cd $out

        fakeroot systemd-repart \
          --dry-run=no \
          --empty=create \
          --size=auto \
          --seed="${cfg.seed}" \
          --definitions="$amendedRepartDefinitions" \
          --split="${lib.boolToString cfg.split}" \
          image.raw
      '';

    meta.maintainers = with lib.maintainers; [ nikstur ];

  };
}