(Claude Fable 5 + ChatGPT 5.6 Sol (Pro)) Untested nixpkgs patch to avoid filling up /boot with too many generations A NixOS developer has proposed an untested patch for nixpkgs that dynamically limits the number of boot menu generations based on available space on the /boot filesystem, preventing it from filling up. The patch adds a `dynamicConfigurationLimit` option with configurable margin and minimum generations, and only takes effect when kernels are copied to the boot partition. | 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"; |