diff --git a/modules/kernel/cmd/kernel-tools/bootentries_test.go b/modules/kernel/cmd/kernel-tools/bootentries_test.go new file mode 100644 index 00000000..ada33169 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/bootentries_test.go @@ -0,0 +1,238 @@ +package main + +// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and +// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB +// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine +// booted with. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n" + +const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n" + +const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n" + +// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel. +const grubMenu = `menuentry 'Arch Linux' --class arch { + set root='hd0,gpt2' + linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet + initrd /boot/initramfs-linux.img +} +menuentry 'Arch Linux, with fallback initramfs' --class arch { + linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet + initrd /boot/initramfs-linux-fallback.img +} +` + +// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition. +const kernelCopyingHook = `[Trigger] +Operation = Install +Operation = Upgrade +Type = Path +Target = usr/lib/modules/*/vmlinuz + +[Action] +Description = Copying the kernel to the default name +When = PostTransaction +Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux' +` + +// otherHook is a hook of the distribution's shape that has nothing to do with kernel images. +const otherHook = `[Trigger] +Operation = Upgrade +Type = Package +Target = pacman-mirrorlist + +[Action] +Description = Updating the mirror list +When = PostTransaction +Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist +` + +type bootBed struct { + script, root, state, bin, mkconfig string + need string +} + +func newBootBed(t *testing.T) bootBed { + t.Helper() + dir := t.TempDir() + b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"), + bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")} + content := manifest(t).resource(t, "boot-entries").text(t) + content = strings.ReplaceAll(content, "${dir:state}", b.state) + if strings.Contains(content, "${") { + t.Fatalf("a substitution the test does not make is left in the script") + } + for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} { + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + } + write(t, b.script, content, 0o755) + write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644) + write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"$@\" >>"+b.mkconfig+"\n", 0o755) + return b +} + +func (b bootBed) put(t *testing.T, p, content string) { + t.Helper() + full := filepath.Join(b.root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + write(t, full, content, 0o644) +} + +func (b bootBed) systemdBoot(t *testing.T) { + b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + b.put(t, "boot/loader/loader.conf", loaderConf) + b.put(t, "boot/loader/entries/arch.conf", defaultEntry) + b.put(t, "boot/vmlinuz-linux", "k") +} + +func (b bootBed) run(t *testing.T) (string, error) { + t.Helper() + args := []string{b.script, "run", b.root, b.state} + if b.need != "" { + args = append(args, b.need) + } + cmd := exec.Command("sh", args...) + cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH")) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func (b bootBed) read(t *testing.T, p string) string { + t.Helper() + raw, _ := os.ReadFile(filepath.Join(b.root, p)) + return string(raw) +} + +func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.put(t, "boot/intel-ucode.img", "u") + out, err := b.run(t) + if err != nil || !strings.Contains(out, "wrote") { + t.Fatalf("%v %s", err, out) + } + want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" + + "options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n" + if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want { + t.Fatalf("entry:\n%s\nwant:\n%s", got, want) + } + // The default entry and the loader's own file are as they were. + if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf { + t.Fatalf("the default entry or loader.conf was touched") + } + if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") { + t.Fatalf("again: %v %s", err, out) + } +} + +func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + if out, err := b.run(t); err != nil { + t.Fatalf("%v %s", err, out) + } + if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") { + t.Fatalf("entry: %s", got) + } +} + +func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) { + b := newBootBed(t) + b.put(t, "boot/grub/grub.cfg", grubMenu) + b.put(t, "boot/vmlinuz-linux", "k") + if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") { + t.Fatalf("same: %v %s", err, out) + } + if _, err := os.Stat(b.mkconfig); err == nil { + t.Fatalf("the menu was generated again with the same kernels") + } + b.put(t, "boot/vmlinuz-linux-lts", "k") + if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") { + t.Fatalf("changed: %v %s", err, out) + } + raw, _ := os.ReadFile(b.mkconfig) + if string(raw) != "-o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" { + t.Fatalf("grub-mkconfig was asked %q", raw) + } +} + +func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) { + b := newBootBed(t) + out, err := b.run(t) + if err == nil || !strings.Contains(out, "no boot loader found") { + t.Fatalf("%v %s", err, out) + } +} + +func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.need = "999999999999" + out, err := b.run(t) + if err == nil || !strings.Contains(out, "free room there before the kernel is installed") { + t.Fatalf("%v %s", err, out) + } + if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" { + t.Fatalf("an entry was written") + } + // Once the fallback kernel is in place the room is not asked for again. + b.put(t, "boot/vmlinuz-linux-lts", "k") + if out, err := b.run(t); err != nil { + t.Fatalf("%v %s", err, out) + } +} + +func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) { + b := newBootBed(t) + b.systemdBoot(t) + b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook) + b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook) + b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n") + out, err := b.run(t) + if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") { + t.Fatalf("%v %s", err, out) + } + if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" { + t.Fatalf("the copying hook is still in the hook directory") + } + if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook { + t.Fatalf("moved hook: %q", raw) + } + if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" { + t.Fatalf("a hook that copies no kernel was moved") + } +} + +func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) { + m := manifest(t) + unit := m.resource(t, "boot-unit").text(t) + for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict", + "ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} { + if !strings.Contains(unit, line+"\n") { + t.Errorf("the unit lacks %q", line) + } + } + hook := m.resource(t, "boot-hook") + text := hook.text(t) + if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") || + !strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") { + t.Fatalf("the hook: %v\n%s", hook["path"], text) + } + apply := m.resource(t, "boot-apply") + if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" { + t.Fatalf("apply: %v", apply) + } +} diff --git a/modules/kernel/cmd/kernel-tools/manifest_test.go b/modules/kernel/cmd/kernel-tools/manifest_test.go index b68dacf5..59ab018a 100644 --- a/modules/kernel/cmd/kernel-tools/manifest_test.go +++ b/modules/kernel/cmd/kernel-tools/manifest_test.go @@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) { for _, r := range m.Resources { ids = append(ids, r["id"].(string)) } - if strings.Join(ids, " ") != "state huge-pages unit apply" { + if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply" { t.Fatalf("order: %v", ids) } script := m.resource(t, "huge-pages") diff --git a/modules/kernel/files/90-mesh-kernel-boot-entries.hook b/modules/kernel/files/90-mesh-kernel-boot-entries.hook new file mode 100644 index 00000000..d671caaf --- /dev/null +++ b/modules/kernel/files/90-mesh-kernel-boot-entries.hook @@ -0,0 +1,13 @@ +# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the +# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module. +[Trigger] +Operation = Install +Operation = Upgrade +Operation = Remove +Type = Path +Target = usr/lib/modules/*/vmlinuz + +[Action] +Description = Placing the fallback kernel on the boot menu +When = PostTransaction +Exec = ${dir:state}/boot-entries run diff --git a/modules/kernel/files/boot-entries.sh b/modules/kernel/files/boot-entries.sh new file mode 100644 index 00000000..9bcf957a --- /dev/null +++ b/modules/kernel/files/boot-entries.sh @@ -0,0 +1,111 @@ +#!/bin/sh +# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10, +# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the +# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed, +# upgraded or removed. Replaced on every push; change the module, never this file. +# +# boot-entries run [ [ []]] +# +# The three are arguments only so the module's tests can run this against a stand-in tree. +# +# It reads from the machine which loader booted it, never from a setting: +# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written +# from the command line the machine booted with. +# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only +# when the set of kernels in /boot differs from the menu's. +# neither it fails loudly. The default entry is never touched. +# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this +# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place. +set -eu + +root=/ +state='${dir:state}' +[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||') +[ $# -ge 3 ] && state=$3 +boot=$root/boot +hooks=$root/etc/pacman.d/hooks +efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f +ours=90-mesh-kernel-boot-entries.hook +# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit +# before a package writes half of them. +need_kib=98304 +[ $# -ge 4 ] && need_kib=$4 + +[ $# -ge 1 ] && [ "$1" = run ] || { + echo "usage: boot-entries run [ [ []]]" >&2 + exit 2 +} + +# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot. +copies_kernels() { + grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" && + grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1" +} + +if [ -d "$hooks" ]; then + for hook in "$hooks"/*.hook; do + [ -f "$hook" ] || continue + [ "$(basename "$hook")" = "$ours" ] && continue + copies_kernels "$hook" || continue + mkdir -p "$state/taken-over" + mv -f "$hook" "$state/taken-over/$(basename "$hook")" + echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name" + done +fi + +if [ -e "$efivar" ]; then + loader=systemd-boot +elif [ -f "$boot/grub/grub.cfg" ]; then + loader=grub +else + echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2 + exit 1 +fi + +# Room first, only while the fallback kernel is not in /boot yet. +if [ ! -e "$boot/vmlinuz-linux-lts" ]; then + free=$(df -Pk "$boot" | awk 'NR==2 {print $4}') + [ -n "$free" ] || free=0 + if [ "$free" -lt "$need_kib" ]; then + echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2 + exit 1 + fi +fi + +case "$loader" in +systemd-boot) + entries=$boot/loader/entries + cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//') + [ -n "$cmdline" ] || { + echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2 + exit 1 + } + want=$( + echo "title Arch Linux (linux-lts, fallback)" + echo "linux /vmlinuz-linux-lts" + for ucode in intel-ucode.img amd-ucode.img; do + [ -e "$boot/$ucode" ] && echo "initrd /$ucode" + done + echo "initrd /initramfs-linux-lts.img" + echo "options $cmdline" + ) + mkdir -p "$entries" + if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then + echo "systemd-boot: mesh-linux-lts.conf, as it was" + else + printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf" + echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was" + fi + ;; +grub) + cfg=$boot/grub/grub.cfg + menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u) + have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true) + if [ "$menu" = "$have" ]; then + echo "grub: the menu lists the kernels in /boot, left as it is" + else + grub-mkconfig -o "$cfg" + echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again; the default entry is as it was" + fi + ;; +esac diff --git a/modules/kernel/files/mesh-kernel-boot-entries.service b/modules/kernel/files/mesh-kernel-boot-entries.service new file mode 100644 index 00000000..56cfa319 --- /dev/null +++ b/modules/kernel/files/mesh-kernel-boot-entries.service @@ -0,0 +1,22 @@ +[Unit] +Description=The fallback kernel's place on this machine's boot menu +# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and +# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says +# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this +# unit, so the entry is in place before they are installed. +After=local-fs.target +Wants=local-fs.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=${dir:state}/boot-entries run +# It writes the boot partition, the pacman hook directory and its own state, and nothing else. +ProtectSystem=strict +ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks +ProtectHome=yes +PrivateNetwork=yes +NoNewPrivileges=yes + +[Install] +WantedBy=multi-user.target diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 67d32156..ea26522d 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -53,6 +53,42 @@ "health": { "kind": "unit" } + }, + { + "id": "boot-entries", + "type": "file", + "path": "${dir:state}/boot-entries", + "mode": "0755", + "trusted": true, + "template": "files/boot-entries.sh" + }, + { + "id": "boot-unit", + "type": "file", + "path": "/etc/systemd/system/mesh-kernel-boot-entries.service", + "mode": "0644", + "template": "files/mesh-kernel-boot-entries.service" + }, + { + "id": "boot-hook", + "type": "file", + "path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", + "mode": "0644", + "template": "files/90-mesh-kernel-boot-entries.hook" + }, + { + "id": "boot-apply", + "type": "service", + "unit": "mesh-kernel-boot-entries.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "boot-unit", + "boot-entries" + ], + "health": { + "kind": "unit" + } } ], "build": {