| diff --git a/nixos/modules/system/boot//grub/grub.nix b/nixos/modules/system/boot//grub/grub.nix | | | index 042fd63cd254..6d319a3d3ca3 100644 | | | --- a/nixos/modules/system/boot//grub/grub.nix | | | +++ b/nixos/modules/system/boot//grub/grub.nix | | | @@ -90,6 +90,9 @@ let | | | else | | | args.efiBootId; | | | timeout = if config.boot..timeout == null then -1 else config.boot..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..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..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..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 boot 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//grub/install-grub.pl b/nixos/modules/system/boot//grub/install-grub.pl | | | index f61c91f02db8..e3763b200b1c 100644 | | | --- a/nixos/modules/system/boot//grub/install-grub.pl | | | +++ b/nixos/modules/system/boot//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//systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot//systemd-boot/systemd-boot-builder.py | | | index 90241b92cd5f..474d757bb29f 100644 | | | --- a/nixos/modules/system/boot//systemd-boot/systemd-boot-builder.py | | | +++ b/nixos/modules/system/boot//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("/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 / "/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 .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 boot 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_boot(args: argparse.Namespace) -> None: | | | try: | |
| with open("/etc/machine-id") as machine_file: | |
| @@ -376,16 +609,23 @@ def install_boot(args: argparse.Namespace) -> None: | |
| (BOOT_MOUNT_POINT / NIXOS_DIR).mkdir(parents=True, exist_ok=True) | |
| (BOOT_MOUNT_POINT / "/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//systemd-boot/systemd-boot.nix b/nixos/modules/system/boot//systemd-boot/systemd-boot.nix | | | index 28ba374272f3..32dfcc0e1b67 100644 | | | --- a/nixos/modules/system/boot//systemd-boot/systemd-boot.nix | | | +++ b/nixos/modules/system/boot//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 boot 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..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..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 | |
| + boot 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"; |