285 lines
11 KiB
Go
285 lines
11 KiB
Go
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)
|
|
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
|
|
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+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) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
|
|
t.Fatalf("grub-mkconfig was asked %q", raw)
|
|
}
|
|
}
|
|
|
|
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
|
|
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
|
|
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
|
|
for name, c := range map[string]struct{ cmdline, want string }{
|
|
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
|
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
|
|
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
|
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
|
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
|
|
} {
|
|
b := newBootBed(t)
|
|
b.put(t, "boot/grub/grub.cfg", grubMenu)
|
|
b.put(t, "proc/cmdline", c.cmdline)
|
|
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
|
|
b.put(t, "boot/"+k, "k")
|
|
}
|
|
if out, err := b.run(t); err != nil {
|
|
t.Fatalf("%s: %v %s", name, err, out)
|
|
}
|
|
raw, _ := os.ReadFile(b.mkconfig)
|
|
if !strings.HasPrefix(string(raw), c.want+" -o ") {
|
|
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
|
|
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
|
|
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
|
|
m := manifest(t)
|
|
at := map[string]int{}
|
|
for i, r := range m.Resources {
|
|
at[r["id"].(string)] = i
|
|
}
|
|
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
|
|
r := m.resource(t, id)
|
|
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
|
|
t.Errorf("%s: %v", id, r)
|
|
}
|
|
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
|
|
t.Errorf("%s is declared before the boot-entries unit and hook", id)
|
|
}
|
|
}
|
|
}
|