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