Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save ivan/6064b523d6a1461fae305cc82f289010 to your computer and use it in GitHub Desktop.

Select an option

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
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";
@ivan

ivan commented Jul 27, 2026

Copy link
Copy Markdown
Author

add to configuration:

  # Try to avoid filling up /boot to 100% with too many generations
  boot = {
    loader.systemd-boot.dynamicConfigurationLimit.enable = true;
    loader.grub.dynamicConfigurationLimit.enable = true;
  };

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment