Last active
July 28, 2026 02:25
-
-
Save ivan/6064b523d6a1461fae305cc82f289010 to your computer and use it in GitHub Desktop.
(Claude Fable 5 + ChatGPT 5.6 Sol (Pro)) Untested nixpkgs patch to avoid filling up /boot with too many generations
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"; |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
add to configuration: