From b015576a132e3d39adb88c85f7beb1f0d8c3f736 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 14:31:53 +0200 Subject: [PATCH] Hold a kernel an installed DKMS module's own bound excludes, with its headers, so an upgrade never leaves a machine whose storage driver cannot load (issue 1841/S4) --- modules/pacman/cmd/pacman-tools/dkms.go | 166 +++++++++++++++++++ modules/pacman/cmd/pacman-tools/dkms_test.go | 115 +++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 modules/pacman/cmd/pacman-tools/dkms.go create mode 100644 modules/pacman/cmd/pacman-tools/dkms_test.go diff --git a/modules/pacman/cmd/pacman-tools/dkms.go b/modules/pacman/cmd/pacman-tools/dkms.go new file mode 100644 index 00000000..202bd655 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/dkms.go @@ -0,0 +1,166 @@ +package main + +// The DKMS gate (novox/hq ADR 0508 §3): before the transaction, every kernel package the upgrade would change is +// compared with the kernel bound each installed DKMS module declares itself: `Linux-Maximum` in its source's +// META, or `BUILD_EXCLUSIVE_KERNEL` (a pattern of kernel releases) in its dkms.conf. A kernel past a bound is +// left out of the run with its headers, and the rest is upgraded. A kernel package is known by its files, not +// its name: one whose files hold usr/lib/modules//vmlinuz. + +import ( + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" +) + +var ( + vmlinuzPath = regexp.MustCompile(`^/?usr/lib/modules/([^/]+)/vmlinuz$`) + linuxMaximum = regexp.MustCompile(`(?m)^Linux-Maximum:\s*(\S+)\s*$`) + buildExclusive = regexp.MustCompile(`(?m)^BUILD_EXCLUSIVE_KERNEL=(?:"([^"]*)"|'([^']*)'|(\S+))\s*$`) + leadingNumber = regexp.MustCompile(`^[0-9]+(\.[0-9]+)*`) +) + +// kernelRelease is the release an installed kernel package's image is for, and whether the package is a kernel. +func (m *Machine) kernelRelease(pkg string) (string, bool, error) { + out, err := m.Out("pacman", "-Qlq", "--", pkg) + if err != nil { + return "", false, fmt.Errorf("the files of %s: %w", pkg, err) + } + for _, l := range lines(out) { + if g := vmlinuzPath.FindStringSubmatch(strings.TrimSpace(l)); g != nil { + return g[1], true, nil + } + } + return "", false, nil +} + +// NewRelease is the release a kernel package's new version will install, derived from it as the installed +// release is from the installed version: `7.2.8.arch1-2` installs `7.2.8-arch1-2`, `6.18.54-2` (the LTS +// kernel) installs `6.18.54-2-lts`. +func NewRelease(installedVersion, installedRelease, newVersion string) string { + base := func(v string) string { return strings.Replace(v, ".arch", "-arch", 1) } + suffix := "" + if b := base(installedVersion); strings.HasPrefix(installedRelease, b) { + suffix = strings.TrimPrefix(installedRelease, b) + } + return base(newVersion) + suffix +} + +// pastMaximum says whether a release is past a Linux-Maximum bound, compared on as many numbers as the bound +// gives: 7.3.1 is past 7.2, 7.2.9 is not. +func pastMaximum(release, maximum string) bool { + bound := strings.Split(leadingNumber.FindString(maximum), ".") + have := strings.Split(leadingNumber.FindString(release), ".") + if bound[0] == "" || have[0] == "" { + return false + } + for i, b := range bound { + bn, _ := strconv.Atoi(b) + hn := 0 + if i < len(have) { + hn, _ = strconv.Atoi(have[i]) + } + if hn != bn { + return hn > bn + } + } + return false +} + +// dkmsBound is the bound one DKMS module declares, read from its source. +type dkmsBound struct { + Maximum string + Exclusive string +} + +func (m *Machine) dkmsBound(mod DKMSModule) dkmsBound { + dir := "/usr/src/" + mod.Module + "-" + mod.Version + "/" + var b dkmsBound + if meta, err := m.ReadFile(dir + "META"); err == nil { + if g := linuxMaximum.FindSubmatch(meta); g != nil { + b.Maximum = string(g[1]) + } + } + if conf, err := m.ReadFile(dir + "dkms.conf"); err == nil { + if g := buildExclusive.FindSubmatch(conf); g != nil { + b.Exclusive = string(g[1]) + string(g[2]) + string(g[3]) + } + } + return b +} + +// heldBy says which bound of a module holds a release, or nothing. +func heldBy(b dkmsBound, release string) (string, error) { + if b.Maximum != "" && pastMaximum(release, b.Maximum) { + return "Linux-Maximum: " + b.Maximum, nil + } + if b.Exclusive != "" { + re, err := regexp.Compile(b.Exclusive) + if err != nil { + return "", fmt.Errorf("BUILD_EXCLUSIVE_KERNEL %q is not a pattern this reads: %w", b.Exclusive, err) + } + if !re.MatchString(release) { + return "BUILD_EXCLUSIVE_KERNEL=" + b.Exclusive, nil + } + } + return "", nil +} + +// Gate is the DKMS gate for the node's next run: the kernels it holds, the packages left out of the transaction +// for them (each held kernel and its headers), or why the run cannot start. +func (u *Upgrades) Gate() (held []HeldKernel, ignore []string, why string) { + e, err := u.Updates.Get(u.Node) + if err != nil { + return nil, nil, "what an upgrade would change could not be read from the bus: " + err.Error() + } + if e == nil { + return nil, nil, "what an upgrade would change is not on the bus yet, so no kernel it would bring can be checked" + } + var updates UpdatesValue + if err := json.Unmarshal(e.Value, &updates); err != nil { + return nil, nil, "what an upgrade would change is not readable: " + err.Error() + } + if !updates.Reachable { + return nil, nil, "the node could not reach its mirrors, so what an upgrade would change is not known" + } + var modules []DKMSModule + var bounds []dkmsBound + held, ignore = []HeldKernel{}, []string{} + for _, p := range updates.Packages { + if p.Name != "linux" && !strings.HasPrefix(p.Name, "linux-") { + continue + } + release, kernel, err := u.Machine.kernelRelease(p.Name) + if err != nil { + return nil, nil, "whether " + p.Name + " is a kernel could not be read: " + err.Error() + } + if !kernel { + continue + } + if modules == nil { + if modules, err = u.Machine.DKMSModules(); err != nil { + return nil, nil, "the DKMS modules could not be read: " + err.Error() + } + for _, mod := range modules { + b := u.Machine.dkmsBound(mod) + bounds = append(bounds, b) + } + } + next := NewRelease(p.From, release, p.To) + for i, mod := range modules { + bound, err := heldBy(bounds[i], next) + if err != nil { + return nil, nil, mod.Module + " " + mod.Version + ": " + err.Error() + } + if bound == "" { + continue + } + held = append(held, HeldKernel{Package: p.Name, Version: p.To, Module: mod.Module + "/" + mod.Version, Bound: bound}) + if !contains(ignore, p.Name) { + ignore = append(ignore, p.Name, p.Name+"-headers") + } + } + } + return held, ignore, "" +} diff --git a/modules/pacman/cmd/pacman-tools/dkms_test.go b/modules/pacman/cmd/pacman-tools/dkms_test.go new file mode 100644 index 00000000..1c396138 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/dkms_test.go @@ -0,0 +1,115 @@ +package main + +// The DKMS gate (novox/hq ADR 0508 §3; acceptance 1841/AC4). The META is captured from a machine whose ZFS +// module declares Linux-Maximum 7.2; the BUILD_EXCLUSIVE_KERNEL case is a made-up module's pattern. + +import ( + "os" + "strings" + "testing" +) + +func TestTheNewKernelsReleaseIsDerivedAsTheInstalledOnesIs(t *testing.T) { + for _, c := range [][4]string{ + {"7.2.8.arch1-2", "7.2.8-arch1-2", "7.3.1.arch1-1", "7.3.1-arch1-1"}, + {"6.18.54-2", "6.18.54-2-lts", "6.18.60-1", "6.18.60-1-lts"}, + } { + if got := NewRelease(c[0], c[1], c[2]); got != c[3] { + t.Errorf("%v: %s", c, got) + } + } + for release, past := range map[string]bool{"7.3.1-arch1-1": true, "7.2.9-arch1-1": false, "7.2-arch1-1": false, "8.0.1": true, "6.18.60-1-lts": false} { + if pastMaximum(release, "7.2") != past { + t.Errorf("%s past 7.2: want %v", release, past) + } + } +} + +// gateMachine is a machine with the linux and LTS kernels and these DKMS modules' sources. +func gateMachine(t *testing.T, dkmsStatus string, sources map[string]string) *Machine { + files := func(paths ...string) Ran { return Ran{Stdout: strings.Join(paths, "\n") + "\n"} } + m := machine(byLine(map[string]Ran{ + "pacman -Qlq -- linux": files("/usr/lib/modules/7.2.8-arch1-2/", "/usr/lib/modules/7.2.8-arch1-2/vmlinuz"), + "pacman -Qlq -- linux-headers": files("/usr/lib/modules/7.2.8-arch1-2/build/Makefile"), + "pacman -Qlq -- linux-lts": files("/usr/lib/modules/6.18.54-2-lts/vmlinuz"), + "pacman -Qlq -- linux-firmware": files("/usr/lib/firmware/amdgpu/"), + "dkms status": {Stdout: dkmsStatus}, + }, nil), 1000) + m.ReadFile = func(path string) ([]byte, error) { + if s, ok := sources[path]; ok { + return []byte(s), nil + } + return nil, errNoFile + } + return m +} + +func capturedZFSSource(t *testing.T) map[string]string { + out := map[string]string{} + for _, f := range []string{"META", "dkms.conf"} { + raw, err := os.ReadFile("testdata/dkms-zfs-linux-maximum-7.2/" + f) + if err != nil { + t.Fatal(err) + } + out["/usr/src/zfs-2.4.4/"+f] = string(raw) + } + return out +} + +func gateUpgrades(t *testing.T, m *Machine, linuxTo string, reachable bool) *Upgrades { + updates := newBucket() + if _, err := updates.Put("node-a", UpdatesValue{Reachable: reachable, Packages: []VersionChange{ + {Name: "bash", From: "5.3.15-1", To: "5.3.20-1"}, + {Name: "linux", From: "7.2.8.arch1-2", To: linuxTo}, + {Name: "linux-firmware", From: "20261001-1", To: "20261015-1"}, + {Name: "linux-headers", From: "7.2.8.arch1-2", To: linuxTo}, + {Name: "linux-lts", From: "6.18.54-2", To: "6.18.60-1"}, + }}); err != nil { + t.Fatal(err) + } + return &Upgrades{Node: "node-a", Machine: m, Updates: updates} +} + +const zfsStatus = "zfs/2.4.4, 7.2.8-arch1-2, x86_64: installed\nzfs/2.4.4, 6.18.54-2-lts, x86_64: installed\n" + +func TestAKernelPastAModulesLinuxMaximumIsHeldWithItsHeaders(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.3.1.arch1-1", true) + held, ignore, why := u.Gate() + if why != "" { + t.Fatal(why) + } + if len(held) != 1 || held[0] != (HeldKernel{Package: "linux", Version: "7.3.1.arch1-1", Module: "zfs/2.4.4", Bound: "Linux-Maximum: 7.2"}) { + t.Errorf("held: %+v", held) + } + if strings.Join(ignore, " ") != "linux linux-headers" { + t.Errorf("left out: %v (the LTS kernel stays within the bound and is upgraded)", ignore) + } +} + +func TestAKernelWithinTheBoundIsNotHeld(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.2.9.arch1-1", true) + if held, ignore, why := u.Gate(); why != "" || len(held) != 0 || len(ignore) != 0 { + t.Errorf("%+v %v %q", held, ignore, why) + } +} + +func TestABuildExclusivePatternHoldsTheKernelsItDoesNotMatch(t *testing.T) { + src := map[string]string{"/usr/src/made-up-driver-1.0/dkms.conf": "PACKAGE_NAME=\"made-up-driver\"\nBUILD_EXCLUSIVE_KERNEL=\"^(6\\.|7\\.[0-2]\\.)\"\n"} + u := gateUpgrades(t, gateMachine(t, "made-up-driver/1.0, 7.2.8-arch1-2, x86_64: installed\n", src), "7.3.1.arch1-1", true) + held, ignore, why := u.Gate() + if why != "" || len(held) != 1 || held[0].Package != "linux" || held[0].Bound != `BUILD_EXCLUSIVE_KERNEL=^(6\.|7\.[0-2]\.)` || + strings.Join(ignore, " ") != "linux linux-headers" { + t.Errorf("%+v %v %q", held, ignore, why) + } +} + +func TestNoRunWhenWhatTheUpgradeWouldChangeIsNotKnown(t *testing.T) { + u := gateUpgrades(t, gateMachine(t, zfsStatus, capturedZFSSource(t)), "7.3.1.arch1-1", false) + if _, _, why := u.Gate(); !strings.Contains(why, "could not reach its mirrors") { + t.Errorf("unreachable: %q", why) + } + u.Updates = newBucket() + if _, _, why := u.Gate(); !strings.Contains(why, "not on the bus") { + t.Errorf("no updates key: %q", why) + } +}