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