1# This module creates a bootable SD card image containing the given NixOS
2# configuration. The generated image is MBR partitioned, with a FAT
3# /boot/firmware partition, and ext4 root partition. The generated image
4# is sized to fit its contents, and a boot script automatically resizes
5# the root partition to fit the device on the first boot.
6#
7# The firmware partition is built with expectation to hold the Raspberry
8# Pi firmware and bootloader, and be removed and replaced with a firmware
9# build for the target SoC for other board families.
10#
11# The derivation for the SD image will be placed in
12# config.system.build.sdImage
13
14{ config, lib, pkgs, ... }:
15
16with lib;
17
18let
19 rootfsImage = pkgs.callPackage ../../../lib/make-ext4-fs.nix ({
20 inherit (config.sdImage) storePaths;
21 compressImage = config.sdImage.compressImage;
22 populateImageCommands = config.sdImage.populateRootCommands;
23 volumeLabel = "NIXOS_SD";
24 } // optionalAttrs (config.sdImage.rootPartitionUUID != null) {
25 uuid = config.sdImage.rootPartitionUUID;
26 });
27in
28{
29 imports = [
30 (mkRemovedOptionModule [ "sdImage" "bootPartitionID" ] "The FAT partition for SD image now only holds the Raspberry Pi firmware files. Use firmwarePartitionID to configure that partition's ID.")
31 (mkRemovedOptionModule [ "sdImage" "bootSize" ] "The boot files for SD image have been moved to the main ext4 partition. The FAT partition now only holds the Raspberry Pi firmware files. Changing its size may not be required.")
32 ../../profiles/all-hardware.nix
33 ];
34
35 options.sdImage = {
36 imageName = mkOption {
37 default = "${config.sdImage.imageBaseName}-${config.system.nixos.label}-${pkgs.stdenv.hostPlatform.system}.img";
38 description = ''
39 Name of the generated image file.
40 '';
41 };
42
43 imageBaseName = mkOption {
44 default = "nixos-sd-image";
45 description = ''
46 Prefix of the name of the generated image file.
47 '';
48 };
49
50 storePaths = mkOption {
51 type = with types; listOf package;
52 example = literalExpression "[ pkgs.stdenv ]";
53 description = ''
54 Derivations to be included in the Nix store in the generated SD image.
55 '';
56 };
57
58 firmwarePartitionOffset = mkOption {
59 type = types.int;
60 default = 8;
61 description = ''
62 Gap in front of the /boot/firmware partition, in mebibytes (1024×1024
63 bytes).
64 Can be increased to make more space for boards requiring to dd u-boot
65 SPL before actual partitions.
66
67 Unless you are building your own images pre-configured with an
68 installed U-Boot, you can instead opt to delete the existing `FIRMWARE`
69 partition, which is used **only** for the Raspberry Pi family of
70 hardware.
71 '';
72 };
73
74 firmwarePartitionID = mkOption {
75 type = types.str;
76 default = "0x2178694e";
77 description = ''
78 Volume ID for the /boot/firmware partition on the SD card. This value
79 must be a 32-bit hexadecimal number.
80 '';
81 };
82
83 firmwarePartitionName = mkOption {
84 type = types.str;
85 default = "FIRMWARE";
86 description = ''
87 Name of the filesystem which holds the boot firmware.
88 '';
89 };
90
91 rootPartitionUUID = mkOption {
92 type = types.nullOr types.str;
93 default = null;
94 example = "14e19a7b-0ae0-484d-9d54-43bd6fdc20c7";
95 description = ''
96 UUID for the filesystem on the main NixOS partition on the SD card.
97 '';
98 };
99
100 firmwareSize = mkOption {
101 type = types.int;
102 # As of 2019-08-18 the Raspberry pi firmware + u-boot takes ~18MiB
103 default = 30;
104 description = ''
105 Size of the /boot/firmware partition, in megabytes.
106 '';
107 };
108
109 populateFirmwareCommands = mkOption {
110 example = literalExpression "'' cp \${pkgs.myBootLoader}/u-boot.bin firmware/ ''";
111 description = ''
112 Shell commands to populate the ./firmware directory.
113 All files in that directory are copied to the
114 /boot/firmware partition on the SD image.
115 '';
116 };
117
118 populateRootCommands = mkOption {
119 example = literalExpression "''\${config.boot.loader.generic-extlinux-compatible.populateCmd} -c \${config.system.build.toplevel} -d ./files/boot''";
120 description = ''
121 Shell commands to populate the ./files directory.
122 All files in that directory are copied to the
123 root (/) partition on the SD image. Use this to
124 populate the ./files/boot (/boot) directory.
125 '';
126 };
127
128 postBuildCommands = mkOption {
129 example = literalExpression "'' dd if=\${pkgs.myBootLoader}/SPL of=$img bs=1024 seek=1 conv=notrunc ''";
130 default = "";
131 description = ''
132 Shell commands to run after the image is built.
133 Can be used for boards requiring to dd u-boot SPL before actual partitions.
134 '';
135 };
136
137 compressImage = mkOption {
138 type = types.bool;
139 default = true;
140 description = ''
141 Whether the SD image should be compressed using
142 {command}`zstd`.
143 '';
144 };
145
146 expandOnBoot = mkOption {
147 type = types.bool;
148 default = true;
149 description = ''
150 Whether to configure the sd image to expand it's partition on boot.
151 '';
152 };
153 };
154
155 config = {
156 fileSystems = {
157 "/boot/firmware" = {
158 device = "/dev/disk/by-label/${config.sdImage.firmwarePartitionName}";
159 fsType = "vfat";
160 # Alternatively, this could be removed from the configuration.
161 # The filesystem is not needed at runtime, it could be treated
162 # as an opaque blob instead of a discrete FAT32 filesystem.
163 options = [ "nofail" "noauto" ];
164 };
165 "/" = {
166 device = "/dev/disk/by-label/NIXOS_SD";
167 fsType = "ext4";
168 };
169 };
170
171 sdImage.storePaths = [ config.system.build.toplevel ];
172
173 system.build.sdImage = pkgs.callPackage ({ stdenv, dosfstools, e2fsprogs,
174 mtools, libfaketime, util-linux, zstd }: stdenv.mkDerivation {
175 name = config.sdImage.imageName;
176
177 nativeBuildInputs = [ dosfstools e2fsprogs libfaketime mtools util-linux ]
178 ++ lib.optional config.sdImage.compressImage zstd;
179
180 inherit (config.sdImage) imageName compressImage;
181
182 buildCommand = ''
183 mkdir -p $out/nix-support $out/sd-image
184 export img=$out/sd-image/${config.sdImage.imageName}
185
186 echo "${pkgs.stdenv.buildPlatform.system}" > $out/nix-support/system
187 if test -n "$compressImage"; then
188 echo "file sd-image $img.zst" >> $out/nix-support/hydra-build-products
189 else
190 echo "file sd-image $img" >> $out/nix-support/hydra-build-products
191 fi
192
193 root_fs=${rootfsImage}
194 ${lib.optionalString config.sdImage.compressImage ''
195 root_fs=./root-fs.img
196 echo "Decompressing rootfs image"
197 zstd -d --no-progress "${rootfsImage}" -o $root_fs
198 ''}
199
200 # Gap in front of the first partition, in MiB
201 gap=${toString config.sdImage.firmwarePartitionOffset}
202
203 # Create the image file sized to fit /boot/firmware and /, plus slack for the gap.
204 rootSizeBlocks=$(du -B 512 --apparent-size $root_fs | awk '{ print $1 }')
205 firmwareSizeBlocks=$((${toString config.sdImage.firmwareSize} * 1024 * 1024 / 512))
206 imageSize=$((rootSizeBlocks * 512 + firmwareSizeBlocks * 512 + gap * 1024 * 1024))
207 truncate -s $imageSize $img
208
209 # type=b is 'W95 FAT32', type=83 is 'Linux'.
210 # The "bootable" partition is where u-boot will look file for the bootloader
211 # information (dtbs, extlinux.conf file).
212 sfdisk $img <<EOF
213 label: dos
214 label-id: ${config.sdImage.firmwarePartitionID}
215
216 start=''${gap}M, size=$firmwareSizeBlocks, type=b
217 start=$((gap + ${toString config.sdImage.firmwareSize}))M, type=83, bootable
218 EOF
219
220 # Copy the rootfs into the SD image
221 eval $(partx $img -o START,SECTORS --nr 2 --pairs)
222 dd conv=notrunc if=$root_fs of=$img seek=$START count=$SECTORS
223
224 # Create a FAT32 /boot/firmware partition of suitable size into firmware_part.img
225 eval $(partx $img -o START,SECTORS --nr 1 --pairs)
226 truncate -s $((SECTORS * 512)) firmware_part.img
227
228 mkfs.vfat --invariant -i ${config.sdImage.firmwarePartitionID} -n ${config.sdImage.firmwarePartitionName} firmware_part.img
229
230 # Populate the files intended for /boot/firmware
231 mkdir firmware
232 ${config.sdImage.populateFirmwareCommands}
233
234 find firmware -exec touch --date=2000-01-01 {} +
235 # Copy the populated /boot/firmware into the SD image
236 cd firmware
237 # Force a fixed order in mcopy for better determinism, and avoid file globbing
238 for d in $(find . -type d -mindepth 1 | sort); do
239 faketime "2000-01-01 00:00:00" mmd -i ../firmware_part.img "::/$d"
240 done
241 for f in $(find . -type f | sort); do
242 mcopy -pvm -i ../firmware_part.img "$f" "::/$f"
243 done
244 cd ..
245
246 # Verify the FAT partition before copying it.
247 fsck.vfat -vn firmware_part.img
248 dd conv=notrunc if=firmware_part.img of=$img seek=$START count=$SECTORS
249
250 ${config.sdImage.postBuildCommands}
251
252 if test -n "$compressImage"; then
253 zstd -T$NIX_BUILD_CORES --rm $img
254 fi
255 '';
256 }) {};
257
258 boot.postBootCommands = lib.mkIf config.sdImage.expandOnBoot ''
259 # On the first boot do some maintenance tasks
260 if [ -f /nix-path-registration ]; then
261 set -euo pipefail
262 set -x
263 # Figure out device names for the boot device and root filesystem.
264 rootPart=$(${pkgs.util-linux}/bin/findmnt -n -o SOURCE /)
265 bootDevice=$(lsblk -npo PKNAME $rootPart)
266 partNum=$(lsblk -npo MAJ:MIN $rootPart | ${pkgs.gawk}/bin/awk -F: '{print $2}')
267
268 # Resize the root partition and the filesystem to fit the disk
269 echo ",+," | sfdisk -N$partNum --no-reread $bootDevice
270 ${pkgs.parted}/bin/partprobe
271 ${pkgs.e2fsprogs}/bin/resize2fs $rootPart
272
273 # Register the contents of the initial Nix store
274 ${config.nix.package.out}/bin/nix-store --load-db < /nix-path-registration
275
276 # nixos-rebuild also requires a "system" profile and an /etc/NIXOS tag.
277 touch /etc/NIXOS
278 ${config.nix.package.out}/bin/nix-env -p /nix/var/nix/profiles/system --set /run/current-system
279
280 # Prevents this from running on later boots.
281 rm -f /nix-path-registration
282 fi
283 '';
284 };
285}