# Conflicts: # modules/pacman/cmd/pacman-tools/main.go # modules/pacman/cmd/pacman-tools/transaction.go # modules/pacman/module.json
428 lines
15 KiB
Go
428 lines
15 KiB
Go
package main
|
|
|
|
// An upgrade through an ask, and a keeper started again following its run (novox/hq ADR 0508 §1, §8;
|
|
// acceptance 1841/AC1 and 1841/AC9).
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// fakeRoot is the root helper: it records the transactions it was asked to start.
|
|
type fakeRoot struct {
|
|
started []string
|
|
fail error
|
|
journal []string
|
|
mapped []Mapped
|
|
mapsErr error
|
|
}
|
|
|
|
func (f *fakeRoot) Transaction(kind, run string, ignore []string) error {
|
|
if f.fail != nil {
|
|
return f.fail
|
|
}
|
|
f.started = append(f.started, kind+" "+run+" ignore="+strings.Join(ignore, ","))
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRoot) Rollback(run string, plan []RollbackItem) error {
|
|
if f.fail != nil {
|
|
return f.fail
|
|
}
|
|
var names []string
|
|
for _, i := range plan {
|
|
names = append(names, i.Name+":"+i.To+":"+i.Source)
|
|
}
|
|
f.started = append(f.started, RunRollback+" "+run+" plan="+strings.Join(names, ","))
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRoot) Journal(unit string, n int) ([]string, error) { return f.journal, nil }
|
|
|
|
func (f *fakeRoot) Maps() ([]Mapped, error) { return f.mapped, f.mapsErr }
|
|
|
|
// keeperBed is one node's keeper over states in memory and a machine of fakes: a unit's state, the lock and
|
|
// pacman's log are set by the test as the transaction goes.
|
|
type keeperBed struct {
|
|
u *Upgrades
|
|
root *fakeRoot
|
|
events *eventLog
|
|
installed, updates, runs, records, asks *memBucket
|
|
rounds *memBucket
|
|
now time.Time
|
|
unitState string // systemctl show's answer for the run's unit
|
|
locked bool
|
|
log string
|
|
}
|
|
|
|
const upgradeLog = "[2026-10-04T12:00:01+0000] [PACMAN] Running 'pacman -Syu --noconfirm --ignore linux,linux-headers'\n" +
|
|
"[2026-10-04T12:00:02+0000] [PACMAN] starting full system upgrade\n" +
|
|
"[2026-10-04T12:00:09+0000] [ALPM] transaction started\n" +
|
|
"[2026-10-04T12:00:10+0000] [ALPM] upgraded bash (5.3.15-1 -> 5.3.20-1)\n" +
|
|
"[2026-10-04T12:00:10+0000] [ALPM] installed made-up-library (1.0-1)\n" +
|
|
"[2026-10-04T12:00:11+0000] [ALPM] transaction completed\n"
|
|
|
|
func newKeeperBed(t *testing.T) *keeperBed {
|
|
k := &keeperBed{root: &fakeRoot{}, events: &eventLog{}, installed: newBucket(), updates: newBucket(), runs: newBucket(),
|
|
records: newBucket(), asks: newBucket(), rounds: newBucket(), now: time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC), unitState: "LoadState=not-found\n"}
|
|
saved := fetch
|
|
fetch = func(string) ([]byte, error) { return nil, errors.New("offline") }
|
|
t.Cleanup(func() { fetch = saved })
|
|
if _, err := k.installed.Put("node-a", nodeAPackages); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := k.updates.Put("node-a", UpdatesValue{Reachable: true, Packages: []VersionChange{
|
|
{Name: "bash", From: "5.3.15-1", To: "5.3.20-1"},
|
|
{Name: "linux", From: "7.2.8.arch1-2", To: "7.3.1.arch1-1"},
|
|
{Name: "linux-headers", From: "7.2.8.arch1-2", To: "7.3.1.arch1-1"},
|
|
}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sources := capturedZFSSource(t)
|
|
files := func(paths ...string) Ran { return Ran{Stdout: strings.Join(paths, "\n") + "\n"} }
|
|
m := machine(fake(func(c call) Ran {
|
|
line := c.String()
|
|
switch {
|
|
case line == "systemctl list-units --state=failed --plain --no-legend --no-pager":
|
|
return Ran{}
|
|
case line == "dkms status":
|
|
return Ran{Stdout: zfsStatus}
|
|
case line == "pacman -Qq":
|
|
return Ran{Stdout: "bash\nlinux\n"}
|
|
case line == "pacman -Qlq -- linux":
|
|
return files("/usr/lib/modules/7.2.8-arch1-2/vmlinuz")
|
|
case line == "pacman -Qlq -- linux-headers":
|
|
return files("/usr/lib/modules/7.2.8-arch1-2/build/Makefile")
|
|
case strings.HasPrefix(line, "systemctl show "+unitPrefix):
|
|
return Ran{Stdout: k.unitState}
|
|
}
|
|
return Ran{Status: 99, Stderr: "unexpected command: " + line}
|
|
}, nil), 1000)
|
|
m.Now = func() time.Time { return k.now }
|
|
m.ReadFile = func(path string) ([]byte, error) {
|
|
switch {
|
|
case path == OSRelease:
|
|
return []byte("7.2.8-arch1-2\n"), nil
|
|
case path == DBLock && k.locked:
|
|
return []byte{}, nil
|
|
case path == PacmanLog:
|
|
return []byte(k.log), nil
|
|
}
|
|
if s, ok := sources[path]; ok {
|
|
return []byte(s), nil
|
|
}
|
|
return nil, errNoFile
|
|
}
|
|
k.u = k.keeper(m)
|
|
return k
|
|
}
|
|
|
|
// keeper is a keeper for node-a over the bed's states: a second call is the keeper started again.
|
|
func (k *keeperBed) keeper(m *Machine) *Upgrades {
|
|
return &Upgrades{Node: "node-a", Machine: m, Root: k.root, Events: k.events, Installed: k.installed, Updates: k.updates,
|
|
Runs: k.runs, Records: k.records, Rounds: k.rounds, Asks: k.asks}
|
|
}
|
|
|
|
// ask writes an ask for node and hands its change to the keeper, as the watch of `asks` would.
|
|
func (k *keeperBed) ask(t *testing.T, node, act string) (string, error) {
|
|
t.Helper()
|
|
a := Ask{ID: RunID(k.now) + "-abc123", Node: node, Act: act, At: k.now}
|
|
key := NodeKey(node, a.ID)
|
|
rev, err := k.asks.Create(key, a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := json.Marshal(a)
|
|
return key, k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: key, Op: "put", Value: raw, Revision: rev})
|
|
}
|
|
|
|
func (k *keeperBed) run(t *testing.T) Run {
|
|
t.Helper()
|
|
keys, _ := k.runs.Keys()
|
|
if len(keys) != 1 {
|
|
t.Fatalf("runs: %v", keys)
|
|
}
|
|
var r Run
|
|
k.runs.value(keys[0], &r)
|
|
return r
|
|
}
|
|
|
|
func steps(r Run) string {
|
|
var s []string
|
|
for _, st := range r.Steps {
|
|
s = append(s, st.Step)
|
|
}
|
|
return strings.Join(s, " ")
|
|
}
|
|
|
|
func TestAnAskedUpgradeIsARunWrittenAndEmittedAtEachStep(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
key, err := k.ask(t, "node-a", ActUpgrade)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := k.run(t)
|
|
if r.Run != "20261004T120000Z" || r.From != FromAsk || r.Ask != "20261004T120000Z-abc123" || r.Kind != RunUpgrade {
|
|
t.Errorf("the run: %+v", r)
|
|
}
|
|
if steps(r) != "recorded checked-before started" {
|
|
t.Fatalf("steps before the transaction ends: %s", steps(r))
|
|
}
|
|
if h := r.Steps[1].Held; len(h) != 1 || h[0].Package != "linux" {
|
|
t.Errorf("the gate's step names the held kernel: %+v", h)
|
|
}
|
|
if s := r.Steps[2]; s.Unit != "mesh-pacman-upgrade-20261004t120000z" || strings.Join(s.Ignored, ",") != "linux,linux-headers" {
|
|
t.Errorf("started: %+v", s)
|
|
}
|
|
if strings.Join(k.root.started, ";") != "upgrade 20261004T120000Z ignore=linux,linux-headers" {
|
|
t.Errorf("the root helper was asked: %v", k.root.started)
|
|
}
|
|
if _, there := k.asks.values[key]; there {
|
|
t.Error("the ask is deleted once the run names it")
|
|
}
|
|
var rec Record
|
|
if !k.records.value("node-a.20261004T120000Z", &rec) {
|
|
t.Error("no record was taken")
|
|
}
|
|
|
|
// The transaction ends: the lock is released and the keeper follows the run.
|
|
k.unitState, k.log, k.now = "LoadState=not-found\n", upgradeLog, k.now.Add(15*time.Second)
|
|
if err := k.u.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r = k.run(t)
|
|
if steps(r) != "recorded checked-before started finished checked-after" {
|
|
t.Fatalf("steps: %s", steps(r))
|
|
}
|
|
end := r.Steps[3]
|
|
if end.Exit == nil || *end.Exit != 0 || end.Changes == nil || len(end.Changes.Changed) != 1 || end.Changes.Changed[0].Name != "bash" ||
|
|
len(end.Changes.Added) != 1 {
|
|
t.Errorf("finished: %+v %+v", end, end.Changes)
|
|
}
|
|
if c := r.Steps[4].Check; c == nil || !c.Passed {
|
|
t.Errorf("the check: %+v", c)
|
|
}
|
|
if got := strings.Join(k.events.names(), " "); got != "upgrade.recorded upgrade.kernel-held upgrade.started upgrade.finished upgrade.checked" {
|
|
t.Errorf("events: %s", got)
|
|
}
|
|
if w := k.runs.writesTo("node-a."); len(w) != 5 || w[0] != "create node-a.20261004T120000Z" {
|
|
t.Errorf("each step written once: %v", w)
|
|
}
|
|
if err := k.u.Follow(); err != nil || len(k.runs.writesTo("node-a.")) != 5 {
|
|
t.Errorf("an ended run is not written again: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAFailedTransactionIsWrittenWithItsExitAndLastErrorAndChecked(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
if _, err := k.ask(t, "node-a", ActUpgrade); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k.unitState = "LoadState=loaded\nActiveState=failed\nResult=exit-code\nExecMainStatus=1\n"
|
|
k.root.journal = []string{"checking for file conflicts...", "error: failed to commit transaction (conflicting files)", "Errors occurred, no packages were upgraded."}
|
|
if err := k.u.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := k.run(t)
|
|
end := r.Steps[3]
|
|
if steps(r) != "recorded checked-before started failed checked-after" || *end.Exit != 1 ||
|
|
end.Error != "error: failed to commit transaction (conflicting files)" || r.Steps[4].Check.Passed {
|
|
t.Errorf("%s %+v %+v", steps(r), end, r.Steps[4].Check)
|
|
}
|
|
if got := k.events.names(); got[len(got)-2] != EventFailed || got[len(got)-1] != EventChecked {
|
|
t.Errorf("events: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestATransactionThatCannotStartFailsAndIsChecked(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
k.root.fail = errors.New("the root helper's socket is not there")
|
|
if _, err := k.ask(t, "node-a", ActUpgrade); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := k.run(t)
|
|
if steps(r) != "recorded checked-before failed checked-after" || !strings.Contains(r.Steps[2].Error, "socket is not there") {
|
|
t.Errorf("%s %+v", steps(r), r.Steps[2])
|
|
}
|
|
}
|
|
|
|
func TestAnAskIsTakenByItsNodesKeeperAlone(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
key, err := k.ask(t, "node-b", ActUpgrade)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if keys, _ := k.runs.Keys(); len(keys) != 0 || len(k.root.started) != 0 {
|
|
t.Errorf("node-a's keeper took node-b's ask: %v %v", keys, k.root.started)
|
|
}
|
|
if _, there := k.asks.values[key]; !there {
|
|
t.Error("another node's ask is left for its keeper")
|
|
}
|
|
if err := k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: "node-a.x", Op: "delete"}); err != nil {
|
|
t.Errorf("a delete is no ask: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAnAskARunAlreadyNamesIsDeletedAndNotRunAgain(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
done := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk, Ask: RunID(k.now) + "-abc123",
|
|
Steps: []Step{{Step: StepRefused, At: k.now, Why: "x"}}}
|
|
if _, err := k.runs.Create(NodeKey("node-a", done.Run), done); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
key, err := k.ask(t, "node-a", ActUpgrade)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if keys, _ := k.runs.Keys(); len(keys) != 1 || len(k.root.started) != 0 {
|
|
t.Errorf("taken twice: %v", keys)
|
|
}
|
|
if _, there := k.asks.values[key]; there {
|
|
t.Error("the ask a run names is deleted")
|
|
}
|
|
}
|
|
|
|
func TestARunDoesNotStartWhileTheDatabaseIsLockedOrAnotherRunGoesOn(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
k.locked = true
|
|
if _, err := k.ask(t, "node-a", ActUpgrade); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r := k.run(t)
|
|
if steps(r) != "refused" || !strings.Contains(r.Steps[0].Why, DBLock) || len(k.root.started) != 0 {
|
|
t.Errorf("locked: %s %+v", steps(r), r.Steps)
|
|
}
|
|
if e := k.events.sent; len(e) != 1 || e[0].Name != EventFailed || e[0].Body.(UpgradeFailed).Step != StepRefused {
|
|
t.Errorf("a refused run is said: %+v", e)
|
|
}
|
|
|
|
k = newKeeperBed(t)
|
|
going := Run{Node: "node-a", Run: "20261004T110000Z", Kind: RunUpgrade, From: FromRound, Steps: []Step{{Step: StepStarted, At: k.now}}}
|
|
if _, err := k.runs.Create(NodeKey("node-a", going.Run), going); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var r2 Run
|
|
k.runs.value("node-a.20261004T120000Z", &r2)
|
|
if steps(r2) != "refused" || !strings.Contains(r2.Steps[0].Why, "20261004T110000Z is still going") {
|
|
t.Errorf("another run going: %+v", r2)
|
|
}
|
|
}
|
|
|
|
func TestAKeeperStartedAgainFollowsItsRunToItsEnd(t *testing.T) {
|
|
k := newKeeperBed(t)
|
|
if _, err := k.ask(t, "node-a", ActUpgrade); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The keeper goes away while the transaction runs; a new one starts and finds it still running.
|
|
again := k.keeper(k.u.Machine)
|
|
k.unitState = "LoadState=loaded\nActiveState=active\nResult=success\nExecMainStatus=0\n"
|
|
before := len(k.runs.writes)
|
|
if err := again.Follow(); err != nil || len(k.runs.writes) != before {
|
|
t.Fatalf("a running transaction is left running: %v, %d writes", err, len(k.runs.writes)-before)
|
|
}
|
|
k.unitState, k.log = "LoadState=not-found\n", upgradeLog
|
|
if err := again.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" {
|
|
t.Errorf("followed to its end: %s", steps(r))
|
|
}
|
|
}
|
|
|
|
func TestAKeeperStartedAgainTakesUpARunStoppedBetweenItsSteps(t *testing.T) {
|
|
// Stopped after the gate, before the transaction started: nothing started, so the run is refused.
|
|
k := newKeeperBed(t)
|
|
stopped := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk,
|
|
Steps: []Step{{Step: StepRecorded, At: k.now}, {Step: StepCheckedBefore, At: k.now}}}
|
|
if _, err := k.runs.Create(NodeKey("node-a", stopped.Run), stopped); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := k.u.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := k.run(t); steps(r) != "recorded checked-before refused" || !strings.Contains(r.Steps[2].Why, "nothing was started") {
|
|
t.Errorf("%s %+v", steps(r), r.Steps)
|
|
}
|
|
|
|
// Stopped after the transaction started and before it was written: its log says it ran.
|
|
k = newKeeperBed(t)
|
|
if _, err := k.runs.Create(NodeKey("node-a", stopped.Run), stopped); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k.log = upgradeLog
|
|
if err := k.u.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" || r.Steps[2].Unit != "mesh-pacman-upgrade-20261004t115900z" {
|
|
t.Errorf("%s %+v", steps(r), r.Steps)
|
|
}
|
|
|
|
// Stopped after the transaction ended and before it was checked.
|
|
k = newKeeperBed(t)
|
|
zero := 0
|
|
unchecked := Run{Node: "node-a", Run: "20261004T115900Z", Kind: RunUpgrade, From: FromAsk,
|
|
Steps: []Step{{Step: StepRecorded, At: k.now}, {Step: StepCheckedBefore, At: k.now}, {Step: StepStarted, At: k.now}, {Step: StepFinished, At: k.now, Exit: &zero}}}
|
|
if _, err := k.runs.Create(NodeKey("node-a", unchecked.Run), unchecked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := k.u.Follow(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := k.run(t); steps(r) != "recorded checked-before started finished checked-after" {
|
|
t.Errorf("%s", steps(r))
|
|
}
|
|
}
|
|
|
|
func TestPacmanUpgradeWritesAnAskForTheNodeAndStartsNothing(t *testing.T) {
|
|
asks := newBucket()
|
|
savedState, savedNode, savedFetch := upgradeState, thisNode, fetch
|
|
upgradeState = func(name string) Bucket {
|
|
if name != StateAsks {
|
|
t.Fatalf("wrote %s", name)
|
|
}
|
|
return asks
|
|
}
|
|
thisNode = func() string { return "node-a" }
|
|
fetch = func(string) ([]byte, error) { return nil, errors.New("offline") }
|
|
defer func() { upgradeState, thisNode, fetch = savedState, savedNode, savedFetch }()
|
|
var calls []call
|
|
m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000)
|
|
for _, tool := range tools(m) {
|
|
if tool.Name != "pacman_upgrade" {
|
|
continue
|
|
}
|
|
out, err := tool.Run(map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := out.(map[string]any)["asked"].(Ask)
|
|
if a.Node != "node-a" || a.Act != ActUpgrade || !strings.HasPrefix(a.ID, "20261004T120000Z-") {
|
|
t.Errorf("ask: %+v", a)
|
|
}
|
|
if keys, _ := asks.Keys(); len(keys) != 1 || keys[0] != NodeKey("node-a", a.ID) {
|
|
t.Errorf("asks: %v", keys)
|
|
}
|
|
for _, c := range calls {
|
|
if c.name == "sudo" || c.name == "systemd-run" {
|
|
t.Errorf("the tool acted itself: %s", c)
|
|
}
|
|
}
|
|
if _, err := tool.Run(map[string]any{"node": "node-b"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if keys, _ := asks.Keys(); len(keys) != 2 || !strings.HasPrefix(keys[1], "node-b.") {
|
|
t.Errorf("another node's ask: %v", keys)
|
|
}
|
|
return
|
|
}
|
|
t.Fatal("no pacman_upgrade")
|
|
}
|