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}