Merge branch 'feat/upgrade-safety' into p2
This commit is contained in:
@@ -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/<release>/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, ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user