diff --git a/modules/kernel/README.md b/modules/kernel/README.md index 6acb6a37..2f04aac7 100644 --- a/modules/kernel/README.md +++ b/modules/kernel/README.md @@ -1,8 +1,9 @@ # kernel -The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one: -**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot -entries) is not built yet. +The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things: +**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and +its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are +not built yet. ## Why (novox/hq issue 746) @@ -35,16 +36,34 @@ under the new mode. A failed rotation is said in the unit's journal and does not rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing` or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit. +## The fallback kernel (novox/hq ADR 0508, issue 1841) + +Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the +end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is +built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is. + +| | what | notes | +|---|---|---| +| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly | +| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched | +| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names; the default is left as it is | +| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) | +| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks | +| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network | +| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed | +| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook | + ## Tools | tool | | answers | |---|---|---| +| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB | | `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) | ## Tests `go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the -manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh +manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once, rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel diff --git a/modules/kernel/cmd/kernel-tools/boot.go b/modules/kernel/cmd/kernel-tools/boot.go new file mode 100644 index 00000000..10a895a1 --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/boot.go @@ -0,0 +1,89 @@ +package main + +// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and +// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only. + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "syscall" + "unicode/utf16" +) + +const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + +// BootState is what kernel_boot answers. +type BootState struct { + Loader string `json:"loader"` + Entries []string `json:"entries"` + Kernels []string `json:"kernels"` + Booted string `json:"booted"` + FallbackPresent bool `json:"fallback_present"` + BootFreeKiB int64 `json:"boot_free_kib"` +} + +var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`) +var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`) +var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`) + +// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means +// systemd-boot, a grub.cfg without it GRUB. +func (m *Machine) Boot() (*BootState, error) { + b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}} + files, _ := filepath.Glob(m.path("boot/vmlinuz-*")) + for _, f := range files { + b.Kernels = append(b.Kernels, filepath.Base(f)) + } + sort.Strings(b.Kernels) + hasKernel := func(name string) bool { + for _, k := range b.Kernels { + if k == name { + return true + } + } + return false + } + var s syscall.Statfs_t + if err := syscall.Statfs(m.path("boot"), &s); err == nil { + b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024 + } + if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil { + b.Loader = "systemd-boot" + if len(raw) > 4 { + var u []uint16 + for i := 4; i+1 < len(raw); i += 2 { + u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8) + } + b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00") + } + files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf")) + for _, f := range files { + b.Entries = append(b.Entries, filepath.Base(f)) + } + sort.Strings(b.Entries) + if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil { + b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts") + } + return b, nil + } + if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil { + b.Loader = "grub" + for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) { + b.Entries = append(b.Entries, t[1]) + } + for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) { + if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") { + b.FallbackPresent = true + } + } + if cmd, err := m.read("proc/cmdline"); err == nil { + if i := bootImage.FindStringSubmatch(cmd); i != nil { + b.Booted = i[1] + } + } + } + return b, nil +} diff --git a/modules/kernel/cmd/kernel-tools/boot_test.go b/modules/kernel/cmd/kernel-tools/boot_test.go new file mode 100644 index 00000000..9c8d964f --- /dev/null +++ b/modules/kernel/cmd/kernel-tools/boot_test.go @@ -0,0 +1,90 @@ +package main + +// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine +// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841). + +import ( + "os" + "path/filepath" + "testing" +) + +const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f" + +func put(t *testing.T, root, p, content string) { + t.Helper() + full := filepath.Join(root, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + write(t, full, content, 0o644) +} + +func bootOf(t *testing.T, root string) *BootState { + t.Helper() + got, err := (&Machine{Root: root}).Boot() + if err != nil { + t.Fatal(err) + } + return got +} + +func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) { + root := t.TempDir() + put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + put(t, root, "boot/loader/entries/arch.conf", defaultEntry) + put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n") + put(t, root, "boot/vmlinuz-linux", "k") + put(t, root, "boot/vmlinuz-linux-lts", "k") + got := bootOf(t, root) + if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent { + t.Fatalf("%+v", got) + } + if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" { + t.Fatalf("entries: %v", got.Entries) + } + if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" { + t.Fatalf("kernels: %v", got.Kernels) + } + if got.BootFreeKiB <= 0 { + t.Fatalf("free: %d", got.BootFreeKiB) + } +} + +func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) { + root := t.TempDir() + put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00") + put(t, root, "boot/loader/entries/arch.conf", defaultEntry) + put(t, root, "boot/vmlinuz-linux", "k") + // The entry file alone is not the fallback: the kernel it names must be in /boot. + put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n") + if got := bootOf(t, root); got.FallbackPresent { + t.Fatalf("a fallback entry without its kernel: %+v", got) + } +} + +func TestKernelBootReadsAGrubMachine(t *testing.T) { + root := t.TempDir() + put(t, root, "boot/grub/grub.cfg", grubMenu) + put(t, root, "boot/vmlinuz-linux", "k") + put(t, root, "proc/cmdline", bootedCmdline) + got := bootOf(t, root) + if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" { + t.Fatalf("%+v", got) + } + if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" { + t.Fatalf("entries: %v", got.Entries) + } + put(t, root, "boot/vmlinuz-linux-lts", "k") + put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n") + if got := bootOf(t, root); !got.FallbackPresent { + t.Fatalf("%+v", got) + } +} + +func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) { + got := bootOf(t, t.TempDir()) + if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 { + t.Fatalf("%+v", got) + } +} diff --git a/modules/kernel/cmd/kernel-tools/main.go b/modules/kernel/cmd/kernel-tools/main.go index ec15c916..93cdece8 100644 --- a/modules/kernel/cmd/kernel-tools/main.go +++ b/modules/kernel/cmd/kernel-tools/main.go @@ -46,5 +46,15 @@ func tools(m *Machine) []stdio.Tool { return m.HugePages(n) }, }, + { + Name: "kernel_boot", + Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " + + "(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " + + "the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " + + "the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " + + "Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)", + Input: schema(map[string]any{}), + Run: func(args map[string]any) (any, error) { return m.Boot() }, + }, } } diff --git a/modules/kernel/module.json b/modules/kernel/module.json index 50cddebe..2a63563e 100644 --- a/modules/kernel/module.json +++ b/modules/kernel/module.json @@ -13,7 +13,8 @@ } }, "tools": [ - "kernel_huge_pages" + "kernel_huge_pages", + "kernel_boot" ], "resources": [ {