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