nixos/systemd-boot-builder: re-instate boot counting

Co-Authored-By: Julien Malka <julien@malka.sh>
Co-Authored-By: AkechiShiro <14914796+AkechiShiro@users.noreply.github.com>
This commit is contained in:
r-vdp
2026-06-02 12:20:12 +03:00
co-authored by Julien Malka AkechiShiro
parent ac2410be5d
commit 69ce6b2391
3 changed files with 495 additions and 178 deletions
@@ -11,7 +11,8 @@ import sys
import tempfile
import warnings
import json
from typing import NamedTuple, Any, Sequence
import glob
from typing import NamedTuple, Any, Sequence, Type
from dataclasses import dataclass
from pathlib import Path
@@ -36,6 +37,8 @@ GRACEFUL = "@graceful@"
COPY_EXTRA_FILES = "@copyExtraFiles@"
CHECK_MOUNTPOINTS = "@checkMountpoints@"
STORE_DIR = "@storeDir@"
BOOT_COUNTING_TRIES = "@bootCountingTries@"
BOOT_COUNTING = "@bootCounting@" == "True"
@dataclass
@@ -53,6 +56,135 @@ class BootSpec:
initrdSecrets: str | None = None # noqa: N815
@dataclass
class Entry:
profile: str | None
generation_number: int
specialisation: str | None
@classmethod
def from_path(cls: Type["Entry"], path: Path) -> "Entry":
filename = path.name
# Matching nixos-$profile-generation-*.conf
rex_profile = re.compile(r"^nixos-(.*)-generation-.*\.conf$")
# Matching nixos*-generation-$number*.conf
rex_generation = re.compile(r"^nixos.*-generation-([0-9]+).*\.conf$")
# Matching nixos*-generation-$number-specialisation-$specialisation_name*.conf
rex_specialisation = re.compile(
r"^nixos.*-generation-([0-9]+)-specialisation-([a-zA-Z0-9]+).*\.conf$"
)
profile = (
rex_profile.sub(r"\1", filename) if rex_profile.match(filename) else None
)
specialisation = (
rex_specialisation.sub(r"\2", filename)
if rex_specialisation.match(filename)
else None
)
try:
generation_number = int(rex_generation.sub(r"\1", filename))
except ValueError:
raise
return cls(profile, generation_number, specialisation)
@dataclass
class DiskEntry:
entry: Entry
default: bool
counters: str | None
title: str | None
description: str | None
kernel: Path
initrd: Path
devicetree: Path | None
kernel_params: str | None
machine_id: str | None
sort_key: str
@classmethod
def from_path(cls: Type["DiskEntry"], path: Path) -> "DiskEntry":
entry = Entry.from_path(path)
data = path.read_text().splitlines()
if "" in data:
data.remove("")
entry_map = dict(lines.split(" ", 1) for lines in data)
assert "linux" in entry_map
assert "initrd" in entry_map
filename = path.name
# Matching nixos*-generation-*$counters.conf
rex_counters = re.compile(r"^nixos.*-generation-.*(\+\d(-\d)?)\.conf$")
counters = (
rex_counters.sub(r"\1", filename) if rex_counters.match(filename) else None
)
maybe_devicetree_path = entry_map.get("devicetree")
disk_entry = cls(
entry=entry,
default=(entry_map.get("sort-key") == "default"),
counters=counters,
title=entry_map.get("title"),
description=entry_map.get("version"),
kernel=Path(entry_map["linux"]),
initrd=Path(entry_map["initrd"]),
devicetree=Path(maybe_devicetree_path) if maybe_devicetree_path else None,
machine_id=entry_map.get("machine-id"),
sort_key=entry_map.get("sort_key", "nixos"),
kernel_params=entry_map.get("options"),
)
return disk_entry
def write(self, sorted_first: str) -> None:
# TODO
# Compute a sort-key sorted before sorted_first
# This will compute something like: nixos -> nixor-default to make sure we come before other nixos entries,
# while allowing users users can pre-pend their own entries before.
default_sort_key = (
sorted_first[:-1] + chr(ord(sorted_first[-1]) - 1) + "-default"
)
tmp_path = self.path.with_suffix(".tmp")
with tmp_path.open("w") as f:
# We use "sort-key" to sort the default generation first.
# The "default" string is sorted before "non-default" (alphabetically)
boot_entry = [
f"title {self.title}" if self.title is not None else None,
f"version {self.description}" if self.description is not None else None,
f"linux {self.kernel}",
f"initrd {self.initrd}",
f"options {self.kernel_params}"
if self.kernel_params is not None
else None,
f"machine-id {self.machine_id}"
if self.machine_id is not None
else None,
f"devicetree /{self.devicetree}"
if self.devicetree is not None
else None,
f"sort-key {default_sort_key if self.default else self.sort_key}",
]
f.write("\n".join(filter(None, boot_entry)))
f.flush()
os.fsync(f.fileno())
tmp_path.rename(self.path)
@property
def path(self) -> Path:
pieces = [
"nixos",
self.entry.profile or None,
"generation",
str(self.entry.generation_number),
f"specialisation-{self.entry.specialisation}"
if self.entry.specialisation
else None,
]
prefix = "-".join(p for p in pieces if p)
return Path(
f"{BOOT_MOUNT_POINT}/loader/entries/{prefix}{self.counters if self.counters else ''}.conf"
)
libc = ctypes.CDLL("libc.so.6")
FILE = None | int
@@ -72,7 +204,9 @@ class SystemIdentifier(NamedTuple):
def copy_if_not_exists(source: Path, dest: Path) -> None:
if not dest.exists():
tmpfd, tmppath = tempfile.mkstemp(dir=dest.parent, prefix=dest.name, suffix='.tmp.')
tmpfd, tmppath = tempfile.mkstemp(
dir=dest.parent, prefix=dest.name, suffix=".tmp."
)
shutil.copyfile(source, tmppath)
os.fsync(tmpfd)
shutil.move(tmppath, dest)
@@ -97,38 +231,14 @@ def system_dir(
return d
BOOT_ENTRY = """title {title}
sort-key {sort_key}
version Generation {generation} {description}
linux {kernel}
initrd {initrd}
options {kernel_params}
"""
def generation_conf_filename(
profile: str | None, generation: int, specialisation: str | None
) -> str:
pieces = [
"nixos",
profile or None,
"generation",
str(generation),
f"specialisation-{specialisation}" if specialisation else None,
]
return "-".join(p for p in pieces if p) + ".conf"
def write_loader_conf(
profile: str | None, generation: int, specialisation: str | None
) -> None:
def write_loader_conf(profile: str | None) -> None:
tmp = LOADER_CONF.with_suffix(".tmp")
with tmp.open("x") as f:
f.write(f"timeout {TIMEOUT}\n")
f.write(
"default %s\n"
% generation_conf_filename(profile, generation, specialisation)
)
if profile:
f.write("default nixos-%s-generation-*\n" % profile)
else:
f.write("default nixos-generation-*\n")
if not EDITOR:
f.write("editor 0\n")
if REBOOT_FOR_BITLOCKER:
@@ -139,6 +249,23 @@ def write_loader_conf(
os.rename(tmp, LOADER_CONF)
def scan_entries() -> list[DiskEntry]:
"""
Scan all entries in $ESP/loader/entries/*
Does not support Type 2 entries as we do not support them for now.
Returns a generator of Entry.
"""
entries = []
for path in Path(f"{EFI_SYS_MOUNT_POINT}/loader/entries/").glob(
"nixos*-generation-[1-9]*.conf"
):
try:
entries.append(DiskEntry.from_path(path))
except ValueError:
continue
return entries
def get_bootspec(profile: str | None, generation: int) -> BootSpec:
system_directory = system_dir(profile, generation, None)
boot_json_path = (system_directory / "boot.json").resolve()
@@ -210,6 +337,8 @@ def write_entry(
specialisation: str | None,
machine_id: str | None,
bootspec: BootSpec,
entries: list[DiskEntry],
sorted_first: str,
current: bool,
) -> None:
if specialisation:
@@ -244,37 +373,35 @@ def write_entry(
"or renamed a file in `boot.initrd.secrets`",
file=sys.stderr,
)
entry_file = (
BOOT_MOUNT_POINT
/ "loader/entries"
/ generation_conf_filename(profile, generation, specialisation)
)
tmp_path = entry_file.with_suffix(".tmp")
kernel_params = "init=%s " % bootspec.init
kernel_params = kernel_params + " ".join(bootspec.kernelParams)
build_time = int(system_dir(profile, generation, specialisation).stat().st_ctime)
build_date = datetime.datetime.fromtimestamp(build_time).strftime("%F")
with tmp_path.open("w") as f:
f.write(
BOOT_ENTRY.format(
title=title,
sort_key=bootspec.sortKey,
generation=generation,
kernel=f"/{kernel}",
initrd=f"/{initrd}",
kernel_params=kernel_params,
description=f"{bootspec.label}, built on {build_date}",
)
)
if machine_id is not None:
f.write("machine-id %s\n" % machine_id)
if devicetree is not None:
f.write(f"devicetree /{devicetree}\n")
f.flush()
os.fsync(f.fileno())
tmp_path.rename(entry_file)
counters = f"+{BOOT_COUNTING_TRIES}" if BOOT_COUNTING else ""
entry = Entry(profile, generation, specialisation)
# We check if the entry we are writing is already on disk
# and we update its "default entry" status
for entry_on_disk in entries:
if entry == entry_on_disk.entry:
entry_on_disk.default = current
entry_on_disk.write(sorted_first)
return
DiskEntry(
entry=entry,
title=title,
kernel=kernel,
initrd=initrd,
devicetree=devicetree,
counters=counters,
kernel_params=kernel_params,
machine_id=machine_id,
description=f"Generation {generation} {bootspec.label}, built on {build_date}",
sort_key=bootspec.sortKey,
default=current,
).write(sorted_first)
def get_generations(profile: str | None = None) -> list[SystemIdentifier]:
@@ -301,11 +428,9 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]:
return configurations[-configurationLimit:]
def remove_old_entries(gens: list[SystemIdentifier]) -> None:
rex_profile = re.compile(r"^nixos-(.*)-generation-.*\.conf$")
rex_generation = re.compile(
r"^nixos.*-generation-([0-9]+)(-specialisation-.*)?\.conf$"
)
def remove_old_entries(
gens: list[SystemIdentifier], disk_entries: list[DiskEntry]
) -> None:
known_paths = []
for gen in gens:
bootspec = get_bootspec(gen.profile, gen.generation)
@@ -313,20 +438,16 @@ def remove_old_entries(gens: list[SystemIdentifier]) -> None:
known_paths.append(copy_from_file(bootspec.initrd, True).name)
if bootspec.devicetree is not None:
known_paths.append(copy_from_file(bootspec.devicetree, True).name)
for path in (BOOT_MOUNT_POINT / "loader/entries").glob(
"nixos*-generation-[1-9]*.conf", case_sensitive=False
):
if rex_profile.match(path.name):
prof = rex_profile.sub(r"\1", path.name)
else:
prof = None
try:
gen_number = int(rex_generation.sub(r"\1", path.name))
except ValueError:
continue
if (prof, gen_number, None) not in gens:
path.unlink()
for path in (BOOT_MOUNT_POINT / NIXOS_DIR).iterdir():
for disk_entry in disk_entries:
if (
disk_entry.entry.profile,
disk_entry.entry.generation_number,
None,
) not in gens:
os.unlink(disk_entry.path)
for path_name in glob.iglob(f"{EFI_SYS_MOUNT_POINT}/efi/nixos/*"):
path = Path(path_name)
if path.name not in known_paths and not path.is_dir():
path.unlink()
@@ -444,13 +565,37 @@ def install_bootloader(args: argparse.Namespace) -> None:
for profile in get_profiles():
gens += get_generations(profile)
remove_old_entries(gens)
entries = scan_entries()
remove_old_entries(gens, entries)
# Compute the sort-key that will be sorted first.
sorted_first = ""
for gen in gens:
try:
bootspec = get_bootspec(gen.profile, gen.generation)
if bootspec.sortKey < sorted_first or sorted_first == "":
sorted_first = bootspec.sortKey
except OSError as e:
# See https://github.com/NixOS/nixpkgs/issues/114552
if e.errno == errno.EINVAL:
profile = (
f"profile '{gen.profile}'" if gen.profile else "default profile"
)
print(
"ignoring {} in the list of boot entries because of the following error:\n{}".format(
profile, e
),
file=sys.stderr,
)
else:
raise e
for gen in gens:
try:
bootspec = get_bootspec(gen.profile, gen.generation)
is_default = Path(bootspec.init).parent == Path(args.default_config)
write_entry(*gen, machine_id, bootspec, current=is_default)
write_entry(
*gen, machine_id, bootspec, entries, sorted_first, current=is_default
)
for specialisation in bootspec.specialisations.keys():
write_entry(
gen.profile,
@@ -458,10 +603,16 @@ def install_bootloader(args: argparse.Namespace) -> None:
specialisation,
machine_id,
bootspec,
current=is_default,
entries,
sorted_first,
current=(
is_default
and bootspec.specialisations[specialisation].sortKey
== bootspec.sortKey
),
)
if is_default:
write_loader_conf(*gen)
write_loader_conf(gen.profile)
except OSError as e:
# See https://github.com/NixOS/nixpkgs/issues/114552
if e.errno == errno.EINVAL:
@@ -97,6 +97,9 @@ let
'') cfg.extraEntries
)}
'';
bootCountingTries = cfg.bootCounting.tries;
bootCounting = if cfg.bootCounting.enable then "True" else "False";
};
};
@@ -417,6 +420,15 @@ in
'';
};
bootCounting = {
enable = mkEnableOption "automatic boot assessment";
tries = mkOption {
default = 3;
type = types.int;
description = "number of tries each entry should start with";
};
};
rebootForBitlocker = mkOption {
default = false;
+253 -99
View File
@@ -14,6 +14,8 @@ let
boot.loader.efi.canTouchEfiVariables = true;
environment.systemPackages = [ pkgs.efibootmgr ];
system.switch.enable = true;
# Needed for machine-id to be persisted between reboots
environment.etc."machine-id".text = "00000000000000000000000000000000";
};
commonXbootldr =
@@ -90,8 +92,111 @@ let
# Set NIX_DISK_IMAGE so that the qemu script finds the right disk image.
os.environ['NIX_DISK_IMAGE'] = tmp_disk_image.name
'';
# Check that we are booting the default entry and not the generation with largest version number
defaultEntry =
{
lib,
pkgs,
withBootCounting ? false,
...
}:
runTest {
name = "systemd-boot-default-entry" + lib.optionalString withBootCounting "-with-boot-counting";
meta.maintainers = with pkgs.lib.maintainers; [ julienmalka ];
nodes = {
machine =
{ nodes, ... }:
{
imports = [ common ];
system.extraDependencies = [ nodes.other_machine.system.build.toplevel ];
boot.loader.systemd-boot.bootCounting.enable = withBootCounting;
};
other_machine = {
imports = [ common ];
boot.loader.systemd-boot.bootCounting.enable = withBootCounting;
environment.systemPackages = [ pkgs.hello ];
};
};
testScript =
{ nodes, ... }:
let
orig = nodes.machine.system.build.toplevel;
other = nodes.other_machine.system.build.toplevel;
in
''
orig = "${orig}"
other = "${other}"
def check_current_system(system_path):
machine.succeed(f'test $(readlink -f /run/current-system) = "{system_path}"')
check_current_system(orig)
# Switch to other configuration
machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${other}")
machine.succeed(f"{other}/bin/switch-to-configuration boot")
# Rollback, default entry is now generation 1
machine.succeed("nix-env -p /nix/var/nix/profiles/system --rollback")
machine.succeed(f"{orig}/bin/switch-to-configuration boot")
machine.shutdown()
machine.start()
machine.wait_for_unit("multi-user.target")
# Check that we booted generation 1 (default)
# even though generation 2 comes first in alphabetical order
check_current_system(orig)
'';
};
garbage-collect-entry =
{
withBootCounting ? false,
...
}:
runTest (
{ lib, ... }:
{
name =
"systemd-boot-garbage-collect-entry" + lib.optionalString withBootCounting "-with-boot-counting";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes = {
inherit common;
machine =
{ nodes, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.bootCounting.enable = withBootCounting;
# These are configs for different nodes, but we'll use them here in `machine`
system.extraDependencies = [
nodes.common.system.build.toplevel
];
};
};
testScript =
{ nodes, ... }:
let
baseSystem = nodes.common.system.build.toplevel;
in
''
machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${baseSystem}")
machine.succeed("nix-env -p /nix/var/nix/profiles/system --delete-generations 1")
# At this point generation 1 has already been marked as good so we reintroduce counters artificially
${lib.optionalString withBootCounting ''
machine.succeed("mv /boot/loader/entries/nixos-generation-1.conf /boot/loader/entries/nixos-generation-1+3.conf")
''}
machine.succeed("${baseSystem}/bin/switch-to-configuration boot")
machine.fail("test -e /boot/loader/entries/nixos-generation-1*")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2.conf")
'';
}
);
in
{
inherit defaultEntry;
basic = runTest (
{ lib, ... }:
{
@@ -108,7 +213,10 @@ in
machine.wait_for_unit("multi-user.target")
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("grep 'sort-key nixos' /boot/loader/entries/nixos-generation-1.conf")
out = machine.succeed("cat /boot/loader/entries/nixos-generation-1.conf")
print(out)
# our sort-key will uses r to sort before nixos
machine.succeed("grep 'sort-key nixor-default' /boot/loader/entries/nixos-generation-1.conf")
# Ensure we actually booted using systemd-boot
# Magic number is the vendor UUID used by systemd-boot.
@@ -224,33 +332,31 @@ in
};
};
testScript =
{ nodes, ... }:
''
machine.start()
machine.wait_for_unit("multi-user.target")
testScript = ''
machine.start()
machine.wait_for_unit("multi-user.target")
machine.succeed(
"test -e /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
machine.succeed(
"grep -q 'title NixOS (something)' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
machine.succeed(
"grep 'sort-key something' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
''
+ pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isAarch64 ''
machine.succeed(
r"grep 'devicetree /EFI/nixos/[a-z0-9]\{32\}.*dummy' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
'';
machine.succeed(
"test -e /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
machine.succeed(
"grep -q 'title NixOS (something)' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
machine.succeed(
"grep 'sort-key something' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
''
+ pkgs.lib.optionalString pkgs.stdenv.hostPlatform.isAarch64 ''
machine.succeed(
r"grep 'devicetree /EFI/nixos/[a-z0-9]\{32\}.*dummy' /boot/loader/entries/nixos-generation-1-specialisation-something.conf"
)
'';
}
);
# Boot without having created an EFI entry--instead using default "/EFI/BOOT/BOOTX64.EFI"
fallback = runTest (
{ pkgs, lib, ... }:
{ lib, ... }:
{
name = "systemd-boot-fallback";
meta.maintainers = with lib.maintainers; [
@@ -259,7 +365,7 @@ in
];
nodes.machine =
{ pkgs, lib, ... }:
{ lib, ... }:
{
imports = [ common ];
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
@@ -333,12 +439,10 @@ in
name = "systemd-boot-memtest86";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes.machine =
{ pkgs, lib, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.memtest86.enable = true;
};
nodes.machine = {
imports = [ common ];
boot.loader.systemd-boot.memtest86.enable = true;
};
testScript = ''
machine.succeed("test -e /boot/loader/entries/memtest86.conf")
@@ -353,12 +457,10 @@ in
name = "systemd-boot-netbootxyz";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes.machine =
{ pkgs, lib, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.netbootxyz.enable = true;
};
nodes.machine = {
imports = [ common ];
boot.loader.systemd-boot.netbootxyz.enable = true;
};
testScript = ''
machine.succeed("test -e /boot/loader/entries/netbootxyz.conf")
@@ -443,13 +545,11 @@ in
name = "systemd-boot-memtest-sortkey";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes.machine =
{ pkgs, lib, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.memtest86.enable = true;
boot.loader.systemd-boot.memtest86.sortKey = "apple";
};
nodes.machine = {
imports = [ common ];
boot.loader.systemd-boot.memtest86.enable = true;
boot.loader.systemd-boot.memtest86.sortKey = "apple";
};
testScript = ''
machine.succeed("test -e /boot/loader/entries/memtest86.conf")
@@ -466,12 +566,10 @@ in
name = "systemd-boot-entry-filename-xbootldr";
meta.maintainers = with lib.maintainers; [ sdht0 ];
nodes.machine =
{ pkgs, lib, ... }:
{
imports = [ commonXbootldr ];
boot.loader.systemd-boot.memtest86.enable = true;
};
nodes.machine = {
imports = [ commonXbootldr ];
boot.loader.systemd-boot.memtest86.enable = true;
};
testScript =
{ nodes, ... }:
@@ -494,16 +592,14 @@ in
name = "systemd-boot-extra-entries";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes.machine =
{ pkgs, lib, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.extraEntries = {
"banana.conf" = ''
title banana
'';
};
nodes.machine = {
imports = [ common ];
boot.loader.systemd-boot.extraEntries = {
"banana.conf" = ''
title banana
'';
};
};
testScript = ''
machine.succeed("test -e /boot/loader/entries/banana.conf")
@@ -519,7 +615,7 @@ in
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes.machine =
{ pkgs, lib, ... }:
{ pkgs, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.extraFiles = {
@@ -558,12 +654,10 @@ in
];
};
with_netbootxyz =
{ pkgs, ... }:
{
imports = [ common ];
boot.loader.systemd-boot.netbootxyz.enable = true;
};
with_netbootxyz = {
imports = [ common ];
boot.loader.systemd-boot.netbootxyz.enable = true;
};
};
testScript =
@@ -602,41 +696,6 @@ in
}
);
garbage-collect-entry = runTest (
{ lib, ... }:
{
name = "systemd-boot-garbage-collect-entry";
meta.maintainers = with lib.maintainers; [ julienmalka ];
nodes = {
inherit common;
machine =
{ pkgs, nodes, ... }:
{
imports = [ common ];
# These are configs for different nodes, but we'll use them here in `machine`
system.extraDependencies = [
nodes.common.system.build.toplevel
];
};
};
testScript =
{ nodes, ... }:
let
baseSystem = nodes.common.system.build.toplevel;
in
''
machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${baseSystem}")
machine.succeed("nix-env -p /nix/var/nix/profiles/system --delete-generations 1")
machine.succeed("${baseSystem}/bin/switch-to-configuration boot")
machine.fail("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2.conf")
'';
}
);
no-bootspec = runTest (
{ lib, ... }:
{
@@ -654,4 +713,99 @@ in
'';
}
);
bootCounting =
let
baseConfig = {
imports = [ common ];
boot.loader.systemd-boot.bootCounting.enable = true;
boot.loader.systemd-boot.bootCounting.trials = 2;
};
in
{ pkgs, ... }:
runTest {
name = "systemd-boot-counting";
meta.maintainers = with pkgs.lib.maintainers; [ julienmalka ];
nodes = {
machine =
{ nodes, ... }:
{
imports = [ baseConfig ];
system.extraDependencies = [ nodes.bad_machine.system.build.toplevel ];
};
bad_machine = {
imports = [ baseConfig ];
systemd.services."failing" = {
script = "exit 1";
requiredBy = [ "boot-complete.target" ];
before = [ "boot-complete.target" ];
serviceConfig.Type = "oneshot";
};
};
};
testScript =
{ nodes, ... }:
let
orig = nodes.machine.system.build.toplevel;
bad = nodes.bad_machine.system.build.toplevel;
in
''
orig = "${orig}"
bad = "${bad}"
def check_current_system(system_path):
machine.succeed(f'test $(readlink -f /run/current-system) = "{system_path}"')
# Ensure we booted using an entry with counters enabled
machine.succeed(
"test -e /sys/firmware/efi/efivars/LoaderBootCountPath-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
)
# systemd-bless-boot should have already removed the "+2" suffix from the boot entry
machine.wait_for_unit("systemd-bless-boot.service")
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
check_current_system(orig)
# Switch to bad configuration
machine.succeed("nix-env -p /nix/var/nix/profiles/system --set ${bad}")
machine.succeed(f"{bad}/bin/switch-to-configuration boot")
# Ensure new bootloader entry has initialized counter
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2+2.conf")
machine.shutdown()
machine.start()
machine.wait_for_unit("multi-user.target")
check_current_system(bad)
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2+1-1.conf")
machine.shutdown()
machine.start()
machine.wait_for_unit("multi-user.target")
check_current_system(bad)
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2+0-2.conf")
machine.shutdown()
# Should boot back into original configuration
machine.start()
check_current_system(orig)
machine.wait_for_unit("multi-user.target")
machine.succeed("test -e /boot/loader/entries/nixos-generation-1.conf")
machine.succeed("test -e /boot/loader/entries/nixos-generation-2+0-2.conf")
machine.shutdown()
'';
};
defaultEntryWithBootCounting =
{ lib, pkgs }:
defaultEntry {
inherit lib pkgs;
withBootCounting = true;
};
garbageCollectEntryWithBootCounting = garbage-collect-entry { withBootCounting = true; };
}