1use strict;
2use warnings;
3use Class::Struct;
4use XML::LibXML;
5use File::Basename;
6use File::Path;
7use File::stat;
8use File::Copy;
9use File::Slurp;
10use File::Temp;
11require List::Compare;
12use POSIX;
13use Cwd;
14
15# system.build.toplevel path
16my $defaultConfig = $ARGV[1] or die;
17
18# Grub config XML generated by grubConfig function in grub.nix
19my $dom = XML::LibXML->load_xml(location => $ARGV[0]);
20
21sub get { my ($name) = @_; return $dom->findvalue("/expr/attrs/attr[\@name = '$name']/*/\@value"); }
22
23sub readFile {
24 my ($fn) = @_; local $/ = undef;
25 open FILE, "<$fn" or return undef; my $s = <FILE>; close FILE;
26 local $/ = "\n"; chomp $s; return $s;
27}
28
29sub writeFile {
30 my ($fn, $s) = @_;
31 open FILE, ">$fn" or die "cannot create $fn: $!\n";
32 print FILE $s or die;
33 close FILE or die;
34}
35
36sub runCommand {
37 my ($cmd) = @_;
38 open FILE, "$cmd 2>/dev/null |" or die "Failed to execute: $cmd\n";
39 my @ret = <FILE>;
40 close FILE;
41 return ($?, @ret);
42}
43
44my $grub = get("grub");
45my $grubVersion = int(get("version"));
46my $grubTarget = get("grubTarget");
47my $extraConfig = get("extraConfig");
48my $extraPrepareConfig = get("extraPrepareConfig");
49my $extraPerEntryConfig = get("extraPerEntryConfig");
50my $extraEntries = get("extraEntries");
51my $extraEntriesBeforeNixOS = get("extraEntriesBeforeNixOS") eq "true";
52my $extraInitrd = get("extraInitrd");
53my $splashImage = get("splashImage");
54my $splashMode = get("splashMode");
55my $backgroundColor = get("backgroundColor");
56my $configurationLimit = int(get("configurationLimit"));
57my $copyKernels = get("copyKernels") eq "true";
58my $timeout = int(get("timeout"));
59my $defaultEntry = get("default");
60my $fsIdentifier = get("fsIdentifier");
61my $grubEfi = get("grubEfi");
62my $grubTargetEfi = get("grubTargetEfi");
63my $bootPath = get("bootPath");
64my $storePath = get("storePath");
65my $canTouchEfiVariables = get("canTouchEfiVariables");
66my $efiInstallAsRemovable = get("efiInstallAsRemovable");
67my $efiSysMountPoint = get("efiSysMountPoint");
68my $gfxmodeEfi = get("gfxmodeEfi");
69my $gfxmodeBios = get("gfxmodeBios");
70my $bootloaderId = get("bootloaderId");
71my $forceInstall = get("forceInstall");
72my $font = get("font");
73$ENV{'PATH'} = get("path");
74
75die "unsupported GRUB version\n" if $grubVersion != 1 && $grubVersion != 2;
76
77print STDERR "updating GRUB $grubVersion menu...\n";
78
79mkpath("$bootPath/grub", 0, 0700);
80
81# Discover whether the bootPath is on the same filesystem as / and
82# /nix/store. If not, then all kernels and initrds must be copied to
83# the bootPath.
84if (stat($bootPath)->dev != stat("/nix/store")->dev) {
85 $copyKernels = 1;
86}
87
88# Discover information about the location of the bootPath
89struct(Fs => {
90 device => '$',
91 type => '$',
92 mount => '$',
93});
94sub PathInMount {
95 my ($path, $mount) = @_;
96 my @splitMount = split /\//, $mount;
97 my @splitPath = split /\//, $path;
98 if ($#splitPath < $#splitMount) {
99 return 0;
100 }
101 for (my $i = 0; $i <= $#splitMount; $i++) {
102 if ($splitMount[$i] ne $splitPath[$i]) {
103 return 0;
104 }
105 }
106 return 1;
107}
108
109# Figure out what filesystem is used for the directory with init/initrd/kernel files
110sub GetFs {
111 my ($dir) = @_;
112 my $bestFs = Fs->new(device => "", type => "", mount => "");
113 foreach my $fs (read_file("/proc/self/mountinfo")) {
114 chomp $fs;
115 my @fields = split / /, $fs;
116 my $mountPoint = $fields[4];
117 next unless -d $mountPoint;
118 my @mountOptions = split /,/, $fields[5];
119
120 # Skip the optional fields.
121 my $n = 6; $n++ while $fields[$n] ne "-"; $n++;
122 my $fsType = $fields[$n];
123 my $device = $fields[$n + 1];
124 my @superOptions = split /,/, $fields[$n + 2];
125
126 # Skip the bind-mount on /nix/store.
127 next if $mountPoint eq "/nix/store" && (grep { $_ eq "rw" } @superOptions);
128 # Skip mount point generated by systemd-efi-boot-generator?
129 next if $fsType eq "autofs";
130
131 # Ensure this matches the intended directory
132 next unless PathInMount($dir, $mountPoint);
133
134 # Is it better than our current match?
135 if (length($mountPoint) > length($bestFs->mount)) {
136 $bestFs = Fs->new(device => $device, type => $fsType, mount => $mountPoint);
137 }
138 }
139 return $bestFs;
140}
141struct (Grub => {
142 path => '$',
143 search => '$',
144});
145my $driveid = 1;
146sub GrubFs {
147 my ($dir) = @_;
148 my $fs = GetFs($dir);
149 my $path = substr($dir, length($fs->mount));
150 if (substr($path, 0, 1) ne "/") {
151 $path = "/$path";
152 }
153 my $search = "";
154
155 if ($grubVersion > 1) {
156 # ZFS is completely separate logic as zpools are always identified by a label
157 # or custom UUID
158 if ($fs->type eq 'zfs') {
159 my $sid = index($fs->device, '/');
160
161 if ($sid < 0) {
162 $search = '--label ' . $fs->device;
163 $path = '/@' . $path;
164 } else {
165 $search = '--label ' . substr($fs->device, 0, $sid);
166 $path = '/' . substr($fs->device, $sid) . '/@' . $path;
167 }
168 } else {
169 my %types = ('uuid' => '--fs-uuid', 'label' => '--label');
170
171 if ($fsIdentifier eq 'provided') {
172 # If the provided dev is identifying the partition using a label or uuid,
173 # we should get the label / uuid and do a proper search
174 my @matches = $fs->device =~ m/\/dev\/disk\/by-(label|uuid)\/(.*)/;
175 if ($#matches > 1) {
176 die "Too many matched devices"
177 } elsif ($#matches == 1) {
178 $search = "$types{$matches[0]} $matches[1]"
179 }
180 } else {
181 # Determine the identifying type
182 $search = $types{$fsIdentifier} . ' ';
183
184 # Based on the type pull in the identifier from the system
185 my ($status, @devInfo) = runCommand("@utillinux@/bin/blkid -o export @{[$fs->device]}");
186 if ($status != 0) {
187 die "Failed to get blkid info (returned $status) for @{[$fs->mount]} on @{[$fs->device]}";
188 }
189 my @matches = join("", @devInfo) =~ m/@{[uc $fsIdentifier]}=([^\n]*)/;
190 if ($#matches != 0) {
191 die "Couldn't find a $types{$fsIdentifier} for @{[$fs->device]}\n"
192 }
193 $search .= $matches[0];
194 }
195
196 # BTRFS is a special case in that we need to fix the referrenced path based on subvolumes
197 if ($fs->type eq 'btrfs') {
198 my ($status, @id_info) = runCommand("@btrfsprogs@/bin/btrfs subvol show @{[$fs->mount]}");
199 if ($status != 0) {
200 die "Failed to retrieve subvolume info for @{[$fs->mount]}\n";
201 }
202 my @ids = join("\n", @id_info) =~ m/^(?!\/\n).*Subvolume ID:[ \t\n]*([0-9]+)/s;
203 if ($#ids > 0) {
204 die "Btrfs subvol name for @{[$fs->device]} listed multiple times in mount\n"
205 } elsif ($#ids == 0) {
206 my ($status, @path_info) = runCommand("@btrfsprogs@/bin/btrfs subvol list @{[$fs->mount]}");
207 if ($status != 0) {
208 die "Failed to find @{[$fs->mount]} subvolume id from btrfs\n";
209 }
210 my @paths = join("", @path_info) =~ m/ID $ids[0] [^\n]* path ([^\n]*)/;
211 if ($#paths > 0) {
212 die "Btrfs returned multiple paths for a single subvolume id, mountpoint @{[$fs->mount]}\n";
213 } elsif ($#paths != 0) {
214 die "Btrfs did not return a path for the subvolume at @{[$fs->mount]}\n";
215 }
216 $path = "/$paths[0]$path";
217 }
218 }
219 }
220 if (not $search eq "") {
221 $search = "search --set=drive$driveid " . $search;
222 $path = "(\$drive$driveid)$path";
223 $driveid += 1;
224 }
225 }
226 return Grub->new(path => $path, search => $search);
227}
228my $grubBoot = GrubFs($bootPath);
229my $grubStore;
230if ($copyKernels == 0) {
231 $grubStore = GrubFs($storePath);
232}
233my $extraInitrdPath;
234if ($extraInitrd) {
235 if (! -f $extraInitrd) {
236 print STDERR "Warning: the specified extraInitrd " . $extraInitrd . " doesn't exist. Your system won't boot without it.\n";
237 }
238 $extraInitrdPath = GrubFs($extraInitrd);
239}
240
241# Generate the header.
242my $conf .= "# Automatically generated. DO NOT EDIT THIS FILE!\n";
243
244if ($grubVersion == 1) {
245 $conf .= "
246 default $defaultEntry
247 timeout $timeout
248 ";
249 if ($splashImage) {
250 copy $splashImage, "$bootPath/background.xpm.gz" or die "cannot copy $splashImage to $bootPath\n";
251 $conf .= "splashimage " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/background.xpm.gz\n";
252 }
253}
254
255else {
256 if ($copyKernels == 0) {
257 $conf .= "
258 " . $grubStore->search;
259 }
260 # FIXME: should use grub-mkconfig.
261 $conf .= "
262 " . $grubBoot->search . "
263 if [ -s \$prefix/grubenv ]; then
264 load_env
265 fi
266
267 # ‘grub-reboot’ sets a one-time saved entry, which we process here and
268 # then delete.
269 if [ \"\${next_entry}\" ]; then
270 set default=\"\${next_entry}\"
271 set next_entry=
272 save_env next_entry
273 set timeout=1
274 else
275 set default=$defaultEntry
276 set timeout=$timeout
277 fi
278
279 # Setup the graphics stack for bios and efi systems
280 if [ \"\${grub_platform}\" = \"efi\" ]; then
281 insmod efi_gop
282 insmod efi_uga
283 else
284 insmod vbe
285 fi
286 ";
287
288 if ($font) {
289 copy $font, "$bootPath/converted-font.pf2" or die "cannot copy $font to $bootPath\n";
290 $conf .= "
291 insmod font
292 if loadfont " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/converted-font.pf2; then
293 insmod gfxterm
294 if [ \"\${grub_platform}\" = \"efi\" ]; then
295 set gfxmode=$gfxmodeEfi
296 set gfxpayload=keep
297 else
298 set gfxmode=$gfxmodeBios
299 set gfxpayload=text
300 fi
301 terminal_output gfxterm
302 fi
303 ";
304 }
305 if ($splashImage) {
306 # Keeps the image's extension.
307 my ($filename, $dirs, $suffix) = fileparse($splashImage, qr"\..[^.]*$");
308 # The module for jpg is jpeg.
309 if ($suffix eq ".jpg") {
310 $suffix = ".jpeg";
311 }
312 if ($backgroundColor) {
313 $conf .= "
314 background_color '$backgroundColor'
315 ";
316 }
317 copy $splashImage, "$bootPath/background$suffix" or die "cannot copy $splashImage to $bootPath\n";
318 $conf .= "
319 insmod " . substr($suffix, 1) . "
320 if background_image --mode '$splashMode' " . ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/background$suffix; then
321 set color_normal=white/black
322 set color_highlight=black/white
323 else
324 set menu_color_normal=cyan/blue
325 set menu_color_highlight=white/blue
326 fi
327 ";
328 }
329}
330
331$conf .= "$extraConfig\n";
332
333
334# Generate the menu entries.
335$conf .= "\n";
336
337my %copied;
338mkpath("$bootPath/kernels", 0, 0755) if $copyKernels;
339
340sub copyToKernelsDir {
341 my ($path) = @_;
342 return $grubStore->path . substr($path, length("/nix/store")) unless $copyKernels;
343 $path =~ /\/nix\/store\/(.*)/ or die;
344 my $name = $1; $name =~ s/\//-/g;
345 my $dst = "$bootPath/kernels/$name";
346 # Don't copy the file if $dst already exists. This means that we
347 # have to create $dst atomically to prevent partially copied
348 # kernels or initrd if this script is ever interrupted.
349 if (! -e $dst) {
350 my $tmp = "$dst.tmp";
351 copy $path, $tmp or die "cannot copy $path to $tmp\n";
352 rename $tmp, $dst or die "cannot rename $tmp to $dst\n";
353 }
354 $copied{$dst} = 1;
355 return ($grubBoot->path eq "/" ? "" : $grubBoot->path) . "/kernels/$name";
356}
357
358sub addEntry {
359 my ($name, $path) = @_;
360 return unless -e "$path/kernel" && -e "$path/initrd";
361
362 my $kernel = copyToKernelsDir(Cwd::abs_path("$path/kernel"));
363 my $initrd = copyToKernelsDir(Cwd::abs_path("$path/initrd"));
364 if ($extraInitrd) {
365 $initrd .= " " .$extraInitrdPath->path;
366 }
367 my $xen = -e "$path/xen.gz" ? copyToKernelsDir(Cwd::abs_path("$path/xen.gz")) : undef;
368
369 # FIXME: $confName
370
371 my $kernelParams =
372 "systemConfig=" . Cwd::abs_path($path) . " " .
373 "init=" . Cwd::abs_path("$path/init") . " " .
374 readFile("$path/kernel-params");
375 my $xenParams = $xen && -e "$path/xen-params" ? readFile("$path/xen-params") : "";
376
377 if ($grubVersion == 1) {
378 $conf .= "title $name\n";
379 $conf .= " $extraPerEntryConfig\n" if $extraPerEntryConfig;
380 $conf .= " kernel $xen $xenParams\n" if $xen;
381 $conf .= " " . ($xen ? "module" : "kernel") . " $kernel $kernelParams\n";
382 $conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n\n";
383 } else {
384 $conf .= "menuentry \"$name\" {\n";
385 $conf .= $grubBoot->search . "\n";
386 if ($copyKernels == 0) {
387 $conf .= $grubStore->search . "\n";
388 }
389 if ($extraInitrd) {
390 $conf .= $extraInitrdPath->search . "\n";
391 }
392 $conf .= " $extraPerEntryConfig\n" if $extraPerEntryConfig;
393 $conf .= " multiboot $xen $xenParams\n" if $xen;
394 $conf .= " " . ($xen ? "module" : "linux") . " $kernel $kernelParams\n";
395 $conf .= " " . ($xen ? "module" : "initrd") . " $initrd\n";
396 $conf .= "}\n\n";
397 }
398}
399
400
401# Add default entries.
402$conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS;
403
404addEntry("NixOS - Default", $defaultConfig);
405
406$conf .= "$extraEntries\n" unless $extraEntriesBeforeNixOS;
407
408my $grubBootPath = $grubBoot->path;
409# extraEntries could refer to @bootRoot@, which we have to substitute
410$conf =~ s/\@bootRoot\@/$grubBootPath/g;
411
412# Emit submenus for all system profiles.
413sub addProfile {
414 my ($profile, $description) = @_;
415
416 # Add entries for all generations of this profile.
417 $conf .= "submenu \"$description\" {\n" if $grubVersion == 2;
418
419 sub nrFromGen { my ($x) = @_; $x =~ /\/\w+-(\d+)-link/; return $1; }
420
421 my @links = sort
422 { nrFromGen($b) <=> nrFromGen($a) }
423 (glob "$profile-*-link");
424
425 my $curEntry = 0;
426 foreach my $link (@links) {
427 last if $curEntry++ >= $configurationLimit;
428 if (! -e "$link/nixos-version") {
429 warn "skipping corrupt system profile entry ‘$link’\n";
430 next;
431 }
432 my $date = strftime("%F", localtime(lstat($link)->mtime));
433 my $version =
434 -e "$link/nixos-version"
435 ? readFile("$link/nixos-version")
436 : basename((glob(dirname(Cwd::abs_path("$link/kernel")) . "/lib/modules/*"))[0]);
437 addEntry("NixOS - Configuration " . nrFromGen($link) . " ($date - $version)", $link);
438 }
439
440 $conf .= "}\n" if $grubVersion == 2;
441}
442
443addProfile "/nix/var/nix/profiles/system", "NixOS - All configurations";
444
445if ($grubVersion == 2) {
446 for my $profile (glob "/nix/var/nix/profiles/system-profiles/*") {
447 my $name = basename($profile);
448 next unless $name =~ /^\w+$/;
449 addProfile $profile, "NixOS - Profile '$name'";
450 }
451}
452
453# Run extraPrepareConfig in sh
454if ($extraPrepareConfig ne "") {
455 system((get("shell"), "-c", $extraPrepareConfig));
456}
457
458# write the GRUB config.
459my $confFile = $grubVersion == 1 ? "$bootPath/grub/menu.lst" : "$bootPath/grub/grub.cfg";
460my $tmpFile = $confFile . ".tmp";
461writeFile($tmpFile, $conf);
462
463
464# check whether to install GRUB EFI or not
465sub getEfiTarget {
466 if ($grubVersion == 1) {
467 return "no"
468 } elsif (($grub ne "") && ($grubEfi ne "")) {
469 # EFI can only be installed when target is set;
470 # A target is also required then for non-EFI grub
471 if (($grubTarget eq "") || ($grubTargetEfi eq "")) { die }
472 else { return "both" }
473 } elsif (($grub ne "") && ($grubEfi eq "")) {
474 # TODO: It would be safer to disallow non-EFI grub installation if no taget is given.
475 # If no target is given, then grub auto-detects the target which can lead to errors.
476 # E.g. it seems as if grub would auto-detect a EFI target based on the availability
477 # of a EFI partition.
478 # However, it seems as auto-detection is currently relied on for non-x86_64 and non-i386
479 # architectures in NixOS. That would have to be fixed in the nixos modules first.
480 return "no"
481 } elsif (($grub eq "") && ($grubEfi ne "")) {
482 # EFI can only be installed when target is set;
483 if ($grubTargetEfi eq "") { die }
484 else {return "only" }
485 } else {
486 # prevent an installation if neither grub nor grubEfi is given
487 return "neither"
488 }
489}
490
491my $efiTarget = getEfiTarget();
492
493# Append entries detected by os-prober
494if (get("useOSProber") eq "true") {
495 my $targetpackage = ($efiTarget eq "no") ? $grub : $grubEfi;
496 system(get("shell"), "-c", "pkgdatadir=$targetpackage/share/grub $targetpackage/etc/grub.d/30_os-prober >> $tmpFile");
497}
498
499# Atomically switch to the new config
500rename $tmpFile, $confFile or die "cannot rename $tmpFile to $confFile\n";
501
502
503# Remove obsolete files from $bootPath/kernels.
504foreach my $fn (glob "$bootPath/kernels/*") {
505 next if defined $copied{$fn};
506 print STDERR "removing obsolete file $fn\n";
507 unlink $fn;
508}
509
510
511#
512# Install GRUB if the parameters changed from the last time we installed it.
513#
514
515struct(GrubState => {
516 name => '$',
517 version => '$',
518 efi => '$',
519 devices => '$',
520 efiMountPoint => '$',
521});
522sub readGrubState {
523 my $defaultGrubState = GrubState->new(name => "", version => "", efi => "", devices => "", efiMountPoint => "" );
524 open FILE, "<$bootPath/grub/state" or return $defaultGrubState;
525 local $/ = "\n";
526 my $name = <FILE>;
527 chomp($name);
528 my $version = <FILE>;
529 chomp($version);
530 my $efi = <FILE>;
531 chomp($efi);
532 my $devices = <FILE>;
533 chomp($devices);
534 my $efiMountPoint = <FILE>;
535 chomp($efiMountPoint);
536 close FILE;
537 my $grubState = GrubState->new(name => $name, version => $version, efi => $efi, devices => $devices, efiMountPoint => $efiMountPoint );
538 return $grubState
539}
540
541sub getDeviceTargets {
542 my @devices = ();
543 foreach my $dev ($dom->findnodes('/expr/attrs/attr[@name = "devices"]/list/string/@value')) {
544 $dev = $dev->findvalue(".") or die;
545 push(@devices, $dev);
546 }
547 return @devices;
548}
549my @deviceTargets = getDeviceTargets();
550my $prevGrubState = readGrubState();
551my @prevDeviceTargets = split/,/, $prevGrubState->devices;
552
553my $devicesDiffer = scalar (List::Compare->new( '-u', '-a', \@deviceTargets, \@prevDeviceTargets)->get_symmetric_difference());
554my $nameDiffer = get("fullName") ne $prevGrubState->name;
555my $versionDiffer = get("fullVersion") ne $prevGrubState->version;
556my $efiDiffer = $efiTarget ne $prevGrubState->efi;
557my $efiMountPointDiffer = $efiSysMountPoint ne $prevGrubState->efiMountPoint;
558if (($ENV{'NIXOS_INSTALL_GRUB'} // "") eq "1") {
559 warn "NIXOS_INSTALL_GRUB env var deprecated, use NIXOS_INSTALL_BOOTLOADER";
560 $ENV{'NIXOS_INSTALL_BOOTLOADER'} = "1";
561}
562my $requireNewInstall = $devicesDiffer || $nameDiffer || $versionDiffer || $efiDiffer || $efiMountPointDiffer || (($ENV{'NIXOS_INSTALL_BOOTLOADER'} // "") eq "1");
563
564# install a symlink so that grub can detect the boot drive
565my $tmpDir = File::Temp::tempdir(CLEANUP => 1) or die "Failed to create temporary space";
566symlink "$bootPath", "$tmpDir/boot" or die "Failed to symlink $tmpDir/boot";
567
568# install non-EFI GRUB
569if (($requireNewInstall != 0) && ($efiTarget eq "no" || $efiTarget eq "both")) {
570 foreach my $dev (@deviceTargets) {
571 next if $dev eq "nodev";
572 print STDERR "installing the GRUB $grubVersion boot loader on $dev...\n";
573 my @command = ("$grub/sbin/grub-install", "--recheck", "--root-directory=$tmpDir", Cwd::abs_path($dev));
574 if ($forceInstall eq "true") {
575 push @command, "--force";
576 }
577 if ($grubTarget ne "") {
578 push @command, "--target=$grubTarget";
579 }
580 (system @command) == 0 or die "$0: installation of GRUB on $dev failed\n";
581 }
582}
583
584
585# install EFI GRUB
586if (($requireNewInstall != 0) && ($efiTarget eq "only" || $efiTarget eq "both")) {
587 print STDERR "installing the GRUB $grubVersion EFI boot loader into $efiSysMountPoint...\n";
588 my @command = ("$grubEfi/sbin/grub-install", "--recheck", "--target=$grubTargetEfi", "--boot-directory=$bootPath", "--efi-directory=$efiSysMountPoint");
589 if ($forceInstall eq "true") {
590 push @command, "--force";
591 }
592 if ($canTouchEfiVariables eq "true") {
593 push @command, "--bootloader-id=$bootloaderId";
594 } else {
595 push @command, "--no-nvram";
596 push @command, "--removable" if $efiInstallAsRemovable eq "true";
597 }
598
599 (system @command) == 0 or die "$0: installation of GRUB EFI into $efiSysMountPoint failed\n";
600}
601
602
603# update GRUB state file
604if ($requireNewInstall != 0) {
605 open FILE, ">$bootPath/grub/state" or die "cannot create $bootPath/grub/state: $!\n";
606 print FILE get("fullName"), "\n" or die;
607 print FILE get("fullVersion"), "\n" or die;
608 print FILE $efiTarget, "\n" or die;
609 print FILE join( ",", @deviceTargets ), "\n" or die;
610 print FILE $efiSysMountPoint, "\n" or die;
611 close FILE or die;
612}