# (Claude Fable 5 + ChatGPT 5.6 Sol (Pro)) Untested nixpkgs patch to avoid filling up /boot with too many generations

> Source: <https://gist.github.com/ivan/6064b523d6a1461fae305cc82f289010>
> Published: 2026-07-27 04:07:56+00:00

| diff --git a/nixos/modules/system/boot/loader/grub/grub.nix b/nixos/modules/system/boot/loader/grub/grub.nix | |
| index 042fd63cd254..6d319a3d3ca3 100644 | |
| --- a/nixos/modules/system/boot/loader/grub/grub.nix | |
| +++ b/nixos/modules/system/boot/loader/grub/grub.nix | |
| @@ -90,6 +90,9 @@ let | |
| else | |
| args.efiBootloaderId; | |
| timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout; | |
| + dynamicLimit = cfg.dynamicConfigurationLimit.enable; | |
| + dynamicLimitMarginMB = cfg.dynamicConfigurationLimit.marginMB; | |
| + dynamicLimitMinGenerations = cfg.dynamicConfigurationLimit.minGenerations; | |
| theme = f cfg.theme; | |
| inherit efiSysMountPoint; | |
| inherit (args) devices; | |
| @@ -641,6 +644,64 @@ in | |
| ''; | |
| }; | |
| + dynamicConfigurationLimit = { | |
| + enable = mkOption { | |
| + default = false; | |
| + type = types.bool; | |
| + description = '' | |
| + Size the boot menu to the boot filesystem instead of a fixed | |
| + count. At menu-build time, the newest generations whose | |
| + kernels, initrds and Xen images fit within the filesystem | |
| + capacity — minus space already used by unmanaged files and minus | |
| + {option}`boot.loader.grub.dynamicConfigurationLimit.marginMB` | |
| + — are kept; older ones are dropped from the boot menu with a | |
| + warning, and their files are removed from the kernels | |
| + directory before any copying so the space is actually | |
| + available. Files shared between generations are only counted | |
| + once. The configuration being activated is always kept. | |
| + | |
| + Only takes effect when kernels are copied to the boot | |
| + partition (see {option}`boot.loader.grub.copyKernels`, which | |
| + is forced on when `/boot` is a separate filesystem); without | |
| + copying, GRUB reads `/nix/store` directly and the boot | |
| + partition has no space pressure. Composes with | |
| + {option}`boot.loader.grub.configurationLimit`, which still | |
| + applies first as a hard cap. Dropped generations remain in | |
| + the system profile: they are still only deleted by garbage | |
| + collection, and reappear in the menu if space allows. | |
| + ''; | |
| + }; | |
| + | |
| + marginMB = mkOption { | |
| + default = 50; | |
| + example = 256; | |
| + type = types.ints.positive; | |
| + description = '' | |
| + Safety margin in MB left unallocated on the boot filesystem. | |
| + Covers estimate error (allocation overhead beyond block | |
| + rounding, directory growth), newly generated secret initrds or | |
| + growth beyond their installed size, files newly written or | |
| + enlarged by `extraPrepareConfig`, and bootloader updates. Raise | |
| + this if those writes can be large. | |
| + ''; | |
| + }; | |
| + | |
| + minGenerations = mkOption { | |
| + default = 2; | |
| + type = types.ints.positive; | |
| + description = '' | |
| + Keep at least this many of the newest generations even when | |
| + the estimate says they exceed the budget. The copy may still | |
| + succeed because the estimate is conservative; if the files | |
| + genuinely cannot fit, menu generation fails exactly as it | |
| + does without this option. The default of 2 keeps the | |
| + activated generation plus one rollback when possible. This | |
| + floor is global across the | |
| + default and named system profiles; it is not applied per profile. | |
| + ''; | |
| + }; | |
| + }; | |
| + | |
| copyKernels = mkOption { | |
| default = false; | |
| type = types.bool; | |
| diff --git a/nixos/modules/system/boot/loader/grub/install-grub.pl b/nixos/modules/system/boot/loader/grub/install-grub.pl | |
| index f61c91f02db8..e3763b200b1c 100644 | |
| --- a/nixos/modules/system/boot/loader/grub/install-grub.pl | |
| +++ b/nixos/modules/system/boot/loader/grub/install-grub.pl | |
| @@ -441,11 +441,22 @@ $conf .= "\n"; | |
| my %copied; | |
| make_path("$bootPath/kernels", { mode => 0755 }) if $copyKernels; | |
| +# kernelsDirName($path): the file name a store path gets in $bootPath/kernels. | |
| +# $path: an absolute /nix/store path. | |
| +# Returns the store-relative path with slashes flattened to dashes; this is | |
| +# the single source of truth for kernels-dir naming, shared by the copier | |
| +# and the dynamic-limit footprint accounting. | |
| +sub kernelsDirName { | |
| + my ($path) = @_; | |
| + $path =~ /\/nix\/store\/(.*)/ or die "not a store path: $path\n"; | |
| + my $name = $1; $name =~ s/\//-/g; | |
| + return $name; | |
| +} | |
| + | |
| sub copyToKernelsDir { | |
| my ($path) = @_; | |
| return $grubStore->path . substr($path, length("/nix/store")) unless $copyKernels; | |
| - $path =~ /\/nix\/store\/(.*)/ or die; | |
| - my $name = $1; $name =~ s/\//-/g; | |
| + my $name = kernelsDirName($path); | |
| my $dst = "$bootPath/kernels/$name"; | |
| # Don't copy the file if $dst already exists. This means that we | |
| # have to create $dst atomically to prevent partially copied | |
| @@ -561,6 +572,206 @@ sub addGeneration { | |
| } | |
| } | |
| +# --- Dynamic configuration limit -------------------------------------------- | |
| +# When enabled, size the boot menu to the filesystem holding the kernels dir | |
| +# instead of a fixed count. Only meaningful when kernels are copied to | |
| +# $bootPath (copyKernels, possibly forced above because /boot is a separate | |
| +# filesystem); without copying, GRUB reads the store and /boot has no space | |
| +# pressure. All of the following is inert when $dynamicLimit is false. | |
| + | |
| +my $dynamicLimit = get("dynamicLimit") eq "true" && $copyKernels; | |
| +my $dynamicLimitMarginBytes = int(get("dynamicLimitMarginMB")) * 1000 * 1000; | |
| +my $dynamicLimitMinGenerations = int(get("dynamicLimitMinGenerations")); | |
| +my %keptLink; | |
| +my %regeneratedSecrets; | |
| + | |
| +# roundUp($size, $granule): bytes a file occupies on the filesystem. | |
| +# $size: byte count, >= 0. | |
| +# $granule: allocation unit in bytes (filesystem block/cluster size), > 0. | |
| +# Returns the smallest multiple of $granule that is >= $size. | |
| +sub roundUp { | |
| + my ($size, $granule) = @_; | |
| + die "roundUp: bad arguments\n" unless $granule > 0 && $size >= 0; | |
| + return int(($size + $granule - 1) / $granule) * $granule; | |
| +} | |
| + | |
| +# generationFootprint($path): files a generation needs in $bootPath/kernels. | |
| +# $path: toplevel directory of the generation (store path or profile link). | |
| +# Returns a hashref of kernels-dir file name -> source size in bytes, | |
| +# covering kernel, initrd and xen.gz of the generation and of each of its | |
| +# specialisations. Existing generated initrd-secrets files are charged | |
| +# at their installed size. Selection also reserves enough scratch space | |
| +# for the largest such file, because addEntry() creates its replacement | |
| +# beside the old file before atomically renaming it. | |
| +sub generationFootprint { | |
| + my ($path) = @_; | |
| + my %footprint; | |
| + foreach my $system ($path, sort (glob "$path/specialisation/*")) { | |
| + foreach my $file ("kernel", "initrd", "xen.gz") { | |
| + next unless -e "$system/$file"; | |
| + my $source = Cwd::abs_path("$system/$file"); | |
| + $footprint{kernelsDirName($source)} = -s $source; | |
| + } | |
| + if (-e -x "$system/append-initrd-secrets") { | |
| + my $name = basename(Cwd::abs_path($system)) . "-secrets"; | |
| + my $installed = "$bootPath/kernels/$name"; | |
| + $regeneratedSecrets{$name} = 1; | |
| + $footprint{$name} = -s $installed if -f $installed; | |
| + } | |
| + } | |
| + return \%footprint; | |
| +} | |
| + | |
| +# bootFsBudget(): how many bytes the kernels dir may occupy. | |
| +# Returns ($budget, $granule), both in bytes: the capacity of the | |
| +# filesystem at $bootPath, minus space used by files outside our | |
| +# management (grub itself, other OSes, extra files), minus the configured | |
| +# margin. Existing files in $bootPath/kernels are excluded from "used" | |
| +# because the pre-prune below reclaims whichever of them the kept | |
| +# generations do not reference. | |
| +sub bootFsBudget { | |
| + my ($status, @out) = runCommand("stat", "-f", "-c", "%S %b %a", $bootPath); | |
| + die "stat -f $bootPath failed\n" if $status != 0 || !@out; | |
| + my ($granule, $blocks, $avail) = split ' ', $out[0]; | |
| + die "cannot parse stat -f output: $out[0]\n" unless defined $avail && $granule > 0; | |
| + my $total = $granule * $blocks; | |
| + my $free = $granule * $avail; | |
| + my $managed = 0; | |
| + foreach my $fn (glob "$bootPath/kernels/*") { | |
| + $managed += roundUp(-s $fn, $granule) if -f $fn; | |
| + } | |
| + my $unmanaged = $total - $free - $managed; | |
| + $unmanaged = 0 if $unmanaged < 0; | |
| + return ($total - $unmanaged - $dynamicLimitMarginBytes, $granule); | |
| +} | |
| + | |
| +# profileCandidates($profile): statically capped, bootable generation links. | |
| +# $profile: path to a system profile without a generation suffix. | |
| +# Returns the links addProfile() can actually turn into menu entries. | |
| +# Non-positive limits match addProfile(): no profile generations are added. | |
| +sub profileCandidates { | |
| + my ($profile) = @_; | |
| + return () if $configurationLimit <= 0; | |
| + my @links = sort { nrFromGen($b) <=> nrFromGen($a) } (glob "$profile-*-link"); | |
| + splice(@links, $configurationLimit) if @links > $configurationLimit; | |
| + my @bootable; | |
| + foreach my $link (@links) { | |
| + my @required = ("nixos-version", "kernel", "initrd", "init", "kernel-params"); | |
| + if (grep { ! -e "$link/$_" } @required) { | |
| + warn "skipping corrupt system profile entry ‘$link’\n"; | |
| + next; | |
| + } | |
| + push @bootable, $link; | |
| + } | |
| + return @bootable; | |
| +} | |
| + | |
| +# secretScratch($footprint, $granule): temporary space needed to regenerate | |
| +# initrd-secrets files without deleting the currently bootable copies first. | |
| +# $footprint: generationFootprint() result. | |
| +# $granule: filesystem allocation unit in bytes. | |
| +# Returns the largest allocated size among regenerated files in the footprint. | |
| +sub secretScratch { | |
| + my ($footprint, $granule) = @_; | |
| + my $scratch = 0; | |
| + foreach my $name (keys %$footprint) { | |
| + next unless $regeneratedSecrets{$name}; | |
| + my $size = roundUp($footprint->{$name}, $granule); | |
| + $scratch = $size if $size > $scratch; | |
| + } | |
| + return $scratch; | |
| +} | |
| + | |
| +# selectGenerationsThatFit(): fill %keptLink and free the space that | |
| +# excluded generations release. | |
| +# Consumes no arguments; reads the profile links and $defaultConfig. | |
| +# The footprint of $defaultConfig is charged unconditionally first, so | |
| +# the entry being activated is always in the menu and either fits or | |
| +# fails loudly at copy time, exactly as today. Candidates are all | |
| +# profile links after the static $configurationLimit cap, walked newest | |
| +# first by symlink mtime (the same clock the menu displays); files | |
| +# shared between generations are charged once; the first generation | |
| +# over budget ends the run so the menu has no holes, except that the | |
| +# newest $dynamicLimitMinGenerations are always kept. Finally, files | |
| +# in $bootPath/kernels not referenced by any kept generation are | |
| +# deleted here, BEFORE any copying, because the post-copy obsolete-file | |
| +# cleanup at the end of this script runs too late to make room. | |
| +sub selectGenerationsThatFit { | |
| + my $defaultPath = Cwd::abs_path($defaultConfig); | |
| + die "cannot resolve activated configuration $defaultConfig\n" unless defined $defaultPath; | |
| + foreach my $required ("kernel", "initrd", "init", "kernel-params") { | |
| + die "activated configuration $defaultConfig is missing $required\n" | |
| + unless -e "$defaultConfig/$required"; | |
| + } | |
| + my ($budget, $granule) = bootFsBudget(); | |
| + my @candidates; | |
| + foreach my $profile ("/nix/var/nix/profiles/system", | |
| + grep { basename($_) =~ /^\w+$/ } (glob "/nix/var/nix/profiles/system-profiles/*")) { | |
| + push @candidates, profileCandidates($profile); | |
| + } | |
| + my @ordered = sort { lstat($b)->mtime <=> lstat($a)->mtime | |
| + || nrFromGen($b) <=> nrFromGen($a) } @candidates; | |
| + my %counted; | |
| + my $used = 0; | |
| + my $secretScratch = 0; | |
| + my $marginal = sub { | |
| + my ($footprint) = @_; | |
| + my $sum = 0; | |
| + foreach my $name (keys %$footprint) { | |
| + $sum += roundUp($footprint->{$name}, $granule) unless $counted{$name}; | |
| + } | |
| + my $candidateScratch = secretScratch($footprint, $granule); | |
| + $sum += $candidateScratch - $secretScratch if $candidateScratch > $secretScratch; | |
| + return ($sum, $candidateScratch); | |
| + }; | |
| + my $commit = sub { | |
| + my ($footprint, $candidateScratch) = @_; | |
| + $counted{$_} = 1 foreach keys %$footprint; | |
| + $secretScratch = $candidateScratch if $candidateScratch > $secretScratch; | |
| + }; | |
| + my $defaultFootprint = generationFootprint($defaultConfig); | |
| + my ($defaultCost, $defaultScratch) = $marginal->($defaultFootprint); | |
| + $used += $defaultCost; | |
| + $commit->($defaultFootprint, $defaultScratch); | |
| + my $fits = 1; | |
| + foreach my $link (@ordered) { | |
| + my $footprint = generationFootprint($link); | |
| + my ($cost, $candidateScratch) = $marginal->($footprint); | |
| + $fits = 0 if $used + $cost > $budget; | |
| + my $linkPath = Cwd::abs_path($link); | |
| + my $pinned = defined($linkPath) && $linkPath eq $defaultPath; | |
| + if ($pinned || scalar(keys %keptLink) < $dynamicLimitMinGenerations || $fits) { | |
| + $keptLink{$link} = 1; | |
| + $used += $cost; | |
| + $commit->($footprint, $candidateScratch); | |
| + } | |
| + } | |
| + if (scalar(keys %keptLink) < scalar(@ordered)) { | |
| + my @skipped = grep { !$keptLink{$_} } @ordered; | |
| + printf STDERR "GRUB: keeping %d of %d generations, ~%d MB of a %d MB budget on %s " | |
| + . "(margin %d MB); dropping %s from the boot menu. They remain in the " | |
| + . "system profile until garbage collected and reappear if space allows.\n", | |
| + scalar(keys %keptLink), scalar(@ordered), $used / 1e6, | |
| + ($budget > 0 ? $budget : 0) / 1e6, $bootPath, $dynamicLimitMarginBytes / 1e6, | |
| + join(", ", map { basename($_) } @skipped); | |
| + } | |
| + foreach my $fn (glob "$bootPath/kernels/*") { | |
| + next unless -f $fn; | |
| + my $name = basename($fn); | |
| + next if $counted{$name}; | |
| + print STDERR "removing $fn to make room before copying\n"; | |
| + # A failed unlink means the budget premise is off, but aborting | |
| + # here strands /boot mid-prune with the old grub.cfg; continuing | |
| + # gives the run its best chance to reach the atomic cfg switch, | |
| + # and a genuine shortfall still fails loudly at copy time. | |
| + unlink $fn or print STDERR "warning: could not remove $fn: $!\n"; | |
| + } | |
| +} | |
| + | |
| +selectGenerationsThatFit() if $dynamicLimit; | |
| + | |
| +# ----------------------------------------------------------------------------- | |
| + | |
| # Add default entries. | |
| $conf .= "$extraEntries\n" if $extraEntriesBeforeNixOS; | |
| @@ -588,6 +799,7 @@ sub addProfile { | |
| my $curEntry = 0; | |
| foreach my $link (@links) { | |
| last if $curEntry++ >= $configurationLimit; | |
| + next if $dynamicLimit && !$keptLink{$link}; | |
| if (! -e "$link/nixos-version") { | |
| warn "skipping corrupt system profile entry ‘$link’\n"; | |
| next; | |
| diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |
| index 90241b92cd5f..474d757bb29f 100644 | |
| --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |
| +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |
| @@ -28,6 +28,9 @@ DISTRO_NAME = "@distroName@" | |
| NIX = "@nix@" | |
| SYSTEMD = "@systemd@" | |
| CONFIGURATION_LIMIT = int("@configurationLimit@") | |
| +DYNAMIC_LIMIT = "@dynamicLimit@" == "1" | |
| +DYNAMIC_LIMIT_MARGIN_BYTES = int("@dynamicLimitMarginMB@") * 1000 * 1000 | |
| +DYNAMIC_LIMIT_MIN_GENERATIONS = int("@dynamicLimitMinGenerations@") | |
| REBOOT_FOR_BITLOCKER = bool("@rebootForBitlocker@") | |
| CAN_TOUCH_EFI_VARIABLES = "@canTouchEfiVariables@" | |
| GRACEFUL = "@graceful@" | |
| @@ -228,7 +231,52 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None | |
| tmp_path.rename(entry_file) | |
| -def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |
| +def limit_generations( | |
| + configurations: list[SystemIdentifier], | |
| + default_config: Path | None, | |
| +) -> list[SystemIdentifier]: | |
| + """ | |
| + Apply the static limit without discarding the activated generation. | |
| + | |
| + configurations: generations for one profile, ordered oldest first. | |
| + default_config: activated toplevel, or None when no generation needs | |
| + pinning. | |
| + Returns: the same slice stock systemd-boot would select. When | |
| + default_config is not None, an activated generation outside | |
| + a positive newest-generation slice replaces that slice's | |
| + oldest member instead of being discarded. | |
| + """ | |
| + if CONFIGURATION_LIMIT == 0: | |
| + return configurations | |
| + limited = configurations[-CONFIGURATION_LIMIT:] | |
| + if default_config is None or not limited: | |
| + return limited | |
| + default_path = default_config.resolve() | |
| + active = next( | |
| + ( | |
| + gen | |
| + for gen in reversed(configurations) | |
| + if generation_dir(gen.profile, gen.generation).resolve() == default_path | |
| + ), | |
| + None, | |
| + ) | |
| + if active is None or active in limited: | |
| + return limited | |
| + return [active, *limited[1:]] | |
| + | |
| + | |
| +def get_generations( | |
| + profile: str | None = None, | |
| + default_config: Path | None = None, | |
| +) -> list[SystemIdentifier]: | |
| + """ | |
| + Read a system profile's generations and apply the static cap. | |
| + | |
| + profile: named system profile, or None for the default system profile. | |
| + default_config: activated toplevel that must survive a positive cap, or | |
| + None to preserve stock slicing exactly. | |
| + Returns: capped generation identifiers ordered oldest first. | |
| + """ | |
| gen_list = run( | |
| [ | |
| f"{NIX}/bin/nix-env", | |
| @@ -242,7 +290,6 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |
| gen_lines = gen_list.split("\n") | |
| gen_lines.pop() | |
| - configurationLimit = CONFIGURATION_LIMIT | |
| configurations = [ | |
| SystemIdentifier( | |
| profile=profile, | |
| @@ -251,7 +298,7 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |
| ) | |
| for line in gen_lines | |
| ] | |
| - return configurations[-configurationLimit:] | |
| + return limit_generations(configurations, default_config) | |
| def remove_old_entries(gens: list[SystemIdentifier]) -> None: | |
| @@ -297,6 +344,192 @@ def get_profiles() -> list[str]: | |
| else: | |
| return [] | |
| +def round_up(size: int, granule: int) -> int: | |
| + """ | |
| + Round up to a whole number of allocation units. | |
| + | |
| + size: a byte count, >= 0. | |
| + granule: the allocation unit in bytes (the FAT cluster size here), > 0. | |
| + Returns: the smallest multiple of granule that is >= size, i.e. the | |
| + bytes the file actually occupies on the filesystem. | |
| + """ | |
| + if granule <= 0 or size < 0: | |
| + raise ValueError(f"round_up: invalid size {size} or granule {granule}") | |
| + return ((size + granule - 1) // granule) * granule | |
| + | |
| + | |
| +def newest_first(gens: list[SystemIdentifier]) -> list[SystemIdentifier]: | |
| + """ | |
| + Sort generations newest first. | |
| + | |
| + gens: generations, possibly from several profiles, in any order. | |
| + Returns: a new list ordered by descending creation time of the profile | |
| + symlink (generation numbers are not comparable across | |
| + profiles), ties broken by descending generation number. | |
| + """ | |
| + def key(gen: SystemIdentifier) -> tuple[float, int]: | |
| + link = generation_dir(gen.profile, gen.generation) | |
| + return (os.lstat(link).st_ctime, gen.generation) | |
| + return sorted(gens, key=key, reverse=True) | |
| + | |
| + | |
| +def copied_file_size(source: Path) -> tuple[Path, int]: | |
| + """ | |
| + Determine the destination and installed size of a copied boot payload. | |
| + | |
| + source: pristine store file copied by write_entry(). | |
| + Returns: destination relative to BOOT_MOUNT_POINT and the larger of the | |
| + source size and an existing destination size. The latter | |
| + includes initrd data already appended by boot.initrd.secrets. | |
| + """ | |
| + destination = copy_from_file(source, True) | |
| + source_size = source.resolve().stat().st_size | |
| + installed = BOOT_MOUNT_POINT / destination | |
| + installed_size = installed.stat().st_size if installed.is_file() else 0 | |
| + return destination, max(source_size, installed_size) | |
| + | |
| + | |
| +def generation_footprint(gen: SystemIdentifier) -> dict[Path, int]: | |
| + """ | |
| + The files a generation needs on the boot filesystem. | |
| + | |
| + gen: the generation; its specialisations are included. | |
| + Returns: destination path (relative to BOOT_MOUNT_POINT) -> byte size | |
| + of the source that write_entry() would copy there. Existing | |
| + copied payloads and entry files are measured too, so initrd | |
| + data already appended by `boot.initrd.secrets` and entries | |
| + larger than one cluster are included. Further growth during | |
| + this install must fit within the margin. | |
| + """ | |
| + bootspec = get_bootspec(gen.profile, gen.generation) | |
| + variants: list[tuple[str | None, BootSpec]] = [(None, bootspec)] | |
| + variants += bootspec.specialisations.items() | |
| + footprint: dict[Path, int] = {} | |
| + for specialisation, spec in variants: | |
| + for source in (spec.kernel, spec.initrd, spec.devicetree): | |
| + if source is not None: | |
| + destination, size = copied_file_size(source) | |
| + footprint[destination] = size | |
| + entry = Path("loader/entries") / generation_conf_filename(gen.profile, gen.generation, specialisation) | |
| + installed_entry = BOOT_MOUNT_POINT / entry | |
| + footprint[entry] = max(1, installed_entry.stat().st_size if installed_entry.is_file() else 0) | |
| + return footprint | |
| + | |
| + | |
| +def tracked_extra_files() -> set[Path]: | |
| + """ | |
| + Return destinations owned by systemd-boot's extra-file marker tree. | |
| + | |
| + Returns: absolute paths below BOOT_MOUNT_POINT corresponding to marker | |
| + files below NIXOS_DIR/.extra-files. | |
| + """ | |
| + marker_root = BOOT_MOUNT_POINT / NIXOS_DIR / ".extra-files" | |
| + if not marker_root.is_dir(): | |
| + return set() | |
| + return { | |
| + BOOT_MOUNT_POINT / marker.relative_to(marker_root) | |
| + for marker in marker_root.rglob("*") | |
| + if marker.is_file() | |
| + } | |
| + | |
| + | |
| +def boot_fs_budget() -> tuple[int, int]: | |
| + """ | |
| + How much of the boot filesystem the NixOS-managed files may occupy. | |
| + | |
| + Returns: (budget, cluster), both in bytes. budget is the filesystem | |
| + capacity minus the space used by files we do not manage | |
| + (systemd-boot itself, other OSes, and marker-tracked | |
| + extraFiles) minus the configured safety margin. Generation | |
| + payloads and entries are excluded from "used" because | |
| + remove_old_entries() reclaims whichever of them the kept | |
| + generations do not reference. | |
| + """ | |
| + st = os.statvfs(BOOT_MOUNT_POINT) | |
| + cluster = st.f_frsize | |
| + total = st.f_frsize * st.f_blocks | |
| + free = st.f_frsize * st.f_bavail | |
| + managed = 0 | |
| + tracked_extras = tracked_extra_files() | |
| + nixos_dir = BOOT_MOUNT_POINT / NIXOS_DIR | |
| + if nixos_dir.is_dir(): | |
| + for path in nixos_dir.iterdir(): | |
| + if not path.is_dir() and path not in tracked_extras: | |
| + managed += round_up(path.stat().st_size, cluster) | |
| + entries_dir = BOOT_MOUNT_POINT / "loader/entries" | |
| + if entries_dir.is_dir(): | |
| + for path in entries_dir.glob("nixos*-generation-[1-9]*.conf", case_sensitive=False): | |
| + if path not in tracked_extras: | |
| + managed += round_up(path.stat().st_size, cluster) | |
| + unmanaged = max(0, (total - free) - managed) | |
| + return total - unmanaged - DYNAMIC_LIMIT_MARGIN_BYTES, cluster | |
| + | |
| + | |
| +def select_generations_that_fit(gens: list[SystemIdentifier], default_config: Path) -> list[SystemIdentifier]: | |
| + """ | |
| + Drop the oldest generations from the boot menu when they cannot fit. | |
| + | |
| + gens: candidate generations, already capped by the static | |
| + configurationLimit if one is set. | |
| + default_config: toplevel directory of the configuration being | |
| + activated; its generation is always kept when it is | |
| + registered in a profile (limit_generations() already | |
| + rescued it from the static cap), as are the | |
| + DYNAMIC_LIMIT_MIN_GENERATIONS newest ones. An | |
| + unregistered configuration cannot be pinned: a warning | |
| + is printed and selection proceeds without a pin. | |
| + Returns: the subset of gens to install, in gens' original order. The | |
| + kept set is a contiguous newest-first run plus the pinned | |
| + generation: files shared between generations are charged | |
| + once, and the first generation that does not fit ends the | |
| + run so the boot menu never has holes. | |
| + """ | |
| + budget, cluster = boot_fs_budget() | |
| + default_path = default_config.resolve() | |
| + pinned = { | |
| + gen | |
| + for gen in gens | |
| + if generation_dir(gen.profile, gen.generation).resolve() == default_path | |
| + } | |
| + if not gens: | |
| + return gens | |
| + if not pinned: | |
| + # Activating a toplevel never registered in any profile (direct | |
| + # switch-to-configuration on a build result). Stock tolerates it: | |
| + # no entry is written for it and loader.conf keeps its previous | |
| + # default. Selection proceeds without a pin — nothing in /boot | |
| + # belongs to the unregistered configuration, so pruning on behalf | |
| + # of the kept generations cannot break it — rather than aborting | |
| + # the whole bootloader update. | |
| + print(f"systemd-boot: warning: no profile generation matches {default_path}; " | |
| + "the dynamic limit cannot pin the activated configuration and the " | |
| + "boot menu will not contain an entry for it", file=sys.stderr) | |
| + ordered = newest_first(gens) | |
| + kept: set[SystemIdentifier] = set() | |
| + counted: set[Path] = set() | |
| + used = 0 | |
| + fits = True | |
| + for gen in ordered: | |
| + footprint = generation_footprint(gen) | |
| + marginal = sum(round_up(size, cluster) for path, size in footprint.items() if path not in counted) | |
| + fits = fits and used + marginal <= budget | |
| + if gen in pinned or len(kept) < DYNAMIC_LIMIT_MIN_GENERATIONS or fits: | |
| + kept.add(gen) | |
| + counted |= footprint.keys() | |
| + used += marginal | |
| + if not kept or not pinned <= kept: | |
| + raise RuntimeError(f"dynamic limit selection violated its invariant: kept={kept} pinned={pinned}") | |
| + if len(kept) < len(gens): | |
| + skipped = ", ".join(str(gen.generation) for gen in ordered if gen not in kept) | |
| + print(f"systemd-boot: keeping {len(kept)} of {len(gens)} generations, " | |
| + f"~{used // 10**6} MB of a {max(budget, 0) // 10** 6} MB budget on {BOOT_MOUNT_POINT} " | |
| + f"(margin {DYNAMIC_LIMIT_MARGIN_BYTES // 10**6} MB); dropping older " | |
| + f"generation(s) {skipped} from the boot menu. They remain in the " | |
| + "system profile until garbage collected and reappear if space allows.", | |
| + file=sys.stderr) | |
| + return [gen for gen in gens if gen in kept] | |
| + | |
| + | |
| def install_bootloader(args: argparse.Namespace) -> None: | |
| try: | |
| with open("/etc/machine-id") as machine_file: | |
| @@ -376,16 +609,23 @@ def install_bootloader(args: argparse.Namespace) -> None: | |
| (BOOT_MOUNT_POINT / NIXOS_DIR).mkdir(parents=True, exist_ok=True) | |
| (BOOT_MOUNT_POINT / "loader/entries").mkdir(parents=True, exist_ok=True) | |
| - gens = get_generations() | |
| + default_config = Path(args.default_config).resolve() | |
| + generation_pin = default_config if DYNAMIC_LIMIT else None | |
| + gens = get_generations(default_config=generation_pin) | |
| for profile in get_profiles(): | |
| - gens += get_generations(profile) | |
| + gens += get_generations(profile, generation_pin) | |
| + if DYNAMIC_LIMIT: | |
| + gens = select_generations_that_fit(gens, default_config) | |
| + | |
| + # Pruning runs before any copying, so files owned only by generations | |
| + # that select_generations_that_fit() dropped are reclaimed first. | |
| remove_old_entries(gens) | |
| for gen in gens: | |
| try: | |
| bootspec = get_bootspec(gen.profile, gen.generation) | |
| - is_default = Path(bootspec.init).parent == Path(args.default_config) | |
| + is_default = Path(bootspec.init).parent.resolve() == default_config | |
| write_entry(*gen, machine_id, bootspec, current=is_default) | |
| for specialisation in bootspec.specialisations.keys(): | |
| write_entry(gen.profile, gen.generation, specialisation, machine_id, bootspec, current=is_default) | |
| diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |
| index 28ba374272f3..32dfcc0e1b67 100644 | |
| --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |
| +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |
| @@ -55,6 +55,10 @@ let | |
| configurationLimit = if cfg.configurationLimit == null then 0 else cfg.configurationLimit; | |
| + dynamicLimit = if cfg.dynamicConfigurationLimit.enable then "1" else "0"; | |
| + dynamicLimitMarginMB = cfg.dynamicConfigurationLimit.marginMB; | |
| + dynamicLimitMinGenerations = cfg.dynamicConfigurationLimit.minGenerations; | |
| + | |
| inherit (cfg) | |
| consoleMode | |
| graceful | |
| @@ -243,6 +247,61 @@ in | |
| ''; | |
| }; | |
| + dynamicConfigurationLimit = { | |
| + enable = mkOption { | |
| + default = false; | |
| + type = types.bool; | |
| + description = '' | |
| + Size the boot menu to the boot filesystem instead of a fixed | |
| + count. At bootloader install time, the newest generations whose | |
| + kernels, initrds and devicetrees fit within the filesystem | |
| + capacity — minus space used by currently installed unmanaged | |
| + files (systemd-boot itself, other OSes, extraFiles) and minus | |
| + {option}`boot.loader.systemd-boot.dynamicConfigurationLimit.marginMB` | |
| + — are kept; older ones are dropped from the boot menu with a | |
| + warning. Files shared between generations are only counted | |
| + once. The generation being activated is always kept. | |
| + | |
| + Composes with {option}`boot.loader.systemd-boot.configurationLimit`, | |
| + which still applies first as a hard cap; when necessary, the | |
| + activated generation replaces the oldest member of that cap. | |
| + Dropped generations remain in the system profile: they are still | |
| + only deleted by garbage collection, and reappear in the menu if | |
| + space allows. | |
| + ''; | |
| + }; | |
| + | |
| + marginMB = mkOption { | |
| + default = 50; | |
| + example = 256; | |
| + type = types.ints.positive; | |
| + description = '' | |
| + Safety margin in MB left unallocated on the boot filesystem. | |
| + Covers estimate error (FAT overhead beyond cluster rounding, | |
| + directory growth), growth beyond an already installed initrd | |
| + produced by `boot.initrd.secrets`, newly added or enlarged | |
| + `extraFiles` or `extraEntries`, `extraInstallCommands`, and | |
| + bootloader binary updates. Raise this if those writes can be | |
| + large. | |
| + ''; | |
| + }; | |
| + | |
| + minGenerations = mkOption { | |
| + default = 2; | |
| + type = types.ints.positive; | |
| + description = '' | |
| + Keep at least this many of the newest generations even when the | |
| + estimate says they exceed the budget. The copy may still | |
| + succeed because the estimate is conservative; if the files | |
| + genuinely cannot fit, installation fails with ENOSPC exactly as | |
| + it does without this module. The default of 2 keeps the | |
| + activated generation plus one rollback when possible. This | |
| + floor is global across the | |
| + default and named system profiles; it is not applied per profile. | |
| + ''; | |
| + }; | |
| + }; | |
| + | |
| installDeviceTree = mkOption { | |
| default = with config.hardware.deviceTree; enable && name != null; | |
| defaultText = "with config.hardware.deviceTree; enable && name != null"; |
