Files
mesh-catalog/modules/pacman/cmd/pacman-tools/rollback_test.go
T
jschoubben 43fccb843c issue 1841/S10: return a node to a run's record, saying first what each package goes back to and from where
pacman_rollback plans from the bus: each package now -> record from the node's cache, else the archive's
exact version, else the archive's repository on the record's date; added packages listed and removed only
when asked; foreign packages untouched. apply is an ask the node's keeper runs as a rollback run through the
root helper's new Rollback act (RootActs, upgrades.go), with rollback.started, rollback.finished and the
after-upgrade check (TakeAsk's rollback case, transaction.go).
2026-10-22 14:44:24 +02:00

196 lines
7.7 KiB
Go

package main
// A rollback to a record (novox/hq ADR 0508 §7; acceptance 1841/AC8): the plan says each package now -> record with
// its source, the packages added and no foreign package; apply is an ask the node's keeper runs as one
// transaction, emitting rollback.started and rollback.finished and ending with the after-upgrade check.
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var recordAt = time.Date(2026, 10, 3, 4, 0, 12, 0, time.UTC)
// rollbackRecord is node-a's record of run 20261003T040012Z; rollbackNow its packages since.
var (
rollbackRecord = Record{Node: "node-a", Run: "20261003T040012Z", At: recordAt, Installed: InstalledValue{At: recordAt, Packages: []InstalledPackage{
{"bash", "5.3.15-1", true, "core"}, {"glibc", "2.42-1", false, "core"}, {"linux", "7.2.8.arch1-2", true, "core"},
{"made-up-old-library", "1.0-1", false, "extra"}, {"made-up-foreign", "1-1", true, RepoForeign},
}}}
rollbackNow = InstalledValue{At: recordAt.Add(time.Hour), Packages: []InstalledPackage{
{"bash", "5.3.20-1", true, "core"}, {"glibc", "2.42-1", false, "core"}, {"linux", "7.3.1.arch1-1", true, "core"},
{"made-up-foreign", "2-1", true, RepoForeign}, {"made-up-library", "1.0-1", false, "extra"},
}}
)
// rollbackCache is the node's package cache: bash's recorded version, and linux at its new version only.
const rollbackCache = "bash-5.3.15-1-x86_64.pkg.tar.zst\nbash-5.3.20-1-x86_64.pkg.tar.zst\nlinux-7.3.1.arch1-1-x86_64.pkg.tar.zst\n" +
"not-a-package-file\n"
func rollbackStates(t *testing.T) (*fakeStates, *Machine, *[]call) {
t.Helper()
states := &fakeStates{}
states.store(StateRecords).Put(NodeKey("node-a", rollbackRecord.Run), rollbackRecord)
states.store(StateInstalled).Put("node-a", rollbackNow)
var calls []call
m := machine(byLine(map[string]Ran{
"find " + CacheDir + " -mindepth 1 -maxdepth 1 -name *.pkg.tar.* -printf %f\n": {Stdout: rollbackCache},
}, &calls), 1000)
m.Node, m.States = "node-a", readerOf(states)
return states, m, &calls
}
func TestThePlanSaysEachPackageNowToTheRecordWithItsSourceTheAddedAndNoForeign(t *testing.T) {
_, m, calls := rollbackStates(t)
p, err := PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), "node-a", rollbackRecord.Run, false, true)
if err != nil {
t.Fatal(err)
}
want := []RollbackItem{
{"bash", "5.3.20-1", "5.3.15-1", SourceCache, CacheDir + "/bash-5.3.15-1-x86_64.pkg.tar.zst"},
{"linux", "7.3.1.arch1-1", "7.2.8.arch1-2", SourceArchive, "https://archive.archlinux.org/packages/l/linux/linux-7.2.8.arch1-2-x86_64.pkg.tar.zst"},
{"made-up-old-library", "", "1.0-1", SourceRepository, "https://archive.archlinux.org/repos/2026/10/03/extra/os/$arch"},
}
if len(p.Items) != len(want) {
t.Fatalf("items: %+v", p.Items)
}
for i := range want {
if p.Items[i] != want[i] {
t.Errorf("item %d: %+v, want %+v", i, p.Items[i], want[i])
}
}
if len(p.Added) != 1 || p.Added[0] != (PackageVersion{"made-up-library", "1.0-1"}) || p.Foreign != 1 || !p.CacheRead {
t.Errorf("added %+v, foreign %d, cache %v", p.Added, p.Foreign, p.CacheRead)
}
for _, c := range *calls {
if strings.Contains(c.String(), "made-up-foreign") {
t.Errorf("a command named the foreign package: %s", c)
}
}
// asked to remove what was added: the added package is removed too.
p, _ = PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), "node-a", rollbackRecord.Run, true, true)
if len(p.Items) != 4 || p.Items[2] != (RollbackItem{Name: "made-up-library", From: "1.0-1", Source: SourceRemoved}) {
t.Errorf("with remove_added: %+v", p.Items)
}
}
func TestAnotherNodesPlanReadsNoCacheAndTakesUnknownArchitecturesFromTheDatedRepository(t *testing.T) {
_, m, calls := rollbackStates(t)
m.Node = "node-b"
r, err := m.Rollback("node-a", rollbackRecord.Run, false, false)
if err != nil {
t.Fatal(err)
}
p := r["plan"].(RollbackPlan)
if p.CacheRead || len(*calls) != 0 {
t.Errorf("another node's cache was read: %v", *calls)
}
for _, i := range p.Items {
if i.Source != SourceRepository || !strings.HasPrefix(i.Location, "https://archive.archlinux.org/repos/2026/10/03/") {
t.Errorf("%+v", i)
}
}
}
func TestNoPlanFromPackagesNotCurrentOrWithoutARecord(t *testing.T) {
states, m, _ := rollbackStates(t)
if _, err := m.Rollback("node-a", "20250101T000000Z", false, false); err == nil || !strings.Contains(err.Error(), "no record") {
t.Errorf("no record: %v", err)
}
states.store(StateInstalled).Put(NotCurrentKey("node-a"), NotCurrent{Bytes: 300000, Since: recordAt})
if _, err := m.Rollback("node-a", rollbackRecord.Run, false, false); err == nil || !strings.Contains(err.Error(), "not current") {
t.Errorf("not current: %v", err)
}
}
func TestApplyWritesAnAskForTheNodesKeeper(t *testing.T) {
_, m, _ := rollbackStates(t)
asks := newBucket()
saved := upgradeState
upgradeState = func(name string) Bucket { return asks }
t.Cleanup(func() { upgradeState = saved })
r, err := m.Rollback("", rollbackRecord.Run, true, true)
if err != nil {
t.Fatal(err)
}
a := r["asked"].(Ask)
var got Ask
if !asks.value(NodeKey("node-a", a.ID), &got) || got.Act != ActRollback || got.Of != rollbackRecord.Run || !got.RemoveAdded {
t.Errorf("the ask: %+v", got)
}
}
func TestAnAskedRollbackIsOneTransactionWithItsEventsAndTheCheck(t *testing.T) {
k := newKeeperBed(t)
k.records.Put(NodeKey("node-a", rollbackRecord.Run), rollbackRecord)
k.installed.Put("node-a", rollbackNow)
inner := k.u.Machine.Run
k.u.Machine.Run = func(ctx context.Context, name string, args ...string) Ran {
if name == "find" {
return Ran{Stdout: rollbackCache}
}
return inner(ctx, name, args...)
}
a := Ask{ID: "20261004T120000Z-abc123", Node: "node-a", Act: ActRollback, At: k.now, Of: rollbackRecord.Run}
if err := k.takeAsk(t, a); err != nil {
t.Fatal(err)
}
var r Run
keys, _ := k.runs.Keys()
for _, key := range keys {
var x Run
k.runs.value(key, &x)
if x.Kind == RunRollback {
r = x
}
}
if r.Of != rollbackRecord.Run || r.Ask != a.ID || steps(r) != "started" || len(r.Steps[0].Plan) != 3 {
t.Fatalf("the rollback run: %+v", r)
}
if strings.Join(k.root.started, ";") != "rollback "+r.Run+" plan=bash:5.3.15-1:cache,linux:7.2.8.arch1-2:archive,made-up-old-library:1.0-1:repository" {
t.Errorf("the root helper was asked: %v", k.root.started)
}
if _, there := k.asks.values[NodeKey("node-a", a.ID)]; there {
t.Error("the ask is deleted once the run names it")
}
k.unitState, k.now = "LoadState=not-found\n", k.now.Add(time.Minute)
if err := k.u.Follow(); err != nil {
t.Fatal(err)
}
k.runs.value(NodeKey("node-a", r.Run), &r)
if steps(r) != "started finished checked-after" || r.Steps[2].Check == nil {
t.Errorf("steps: %s", steps(r))
}
if got := strings.Join(k.events.names(), " "); got != "rollback.started rollback.finished upgrade.checked" {
t.Errorf("events: %s", got)
}
// the same record again: nothing to bring back once the packages are the record's.
k.installed.Put("node-a", rollbackRecord.Installed)
k.now = k.now.Add(time.Minute)
if err := k.takeAsk(t, Ask{ID: "20261004T120200Z-def456", Node: "node-a", Act: ActRollback, At: k.now, Of: rollbackRecord.Run}); err != nil {
t.Fatal(err)
}
var again Run
k.runs.value(NodeKey("node-a", "20261004T120200Z"), &again)
if steps(again) != "refused" || !strings.Contains(again.Steps[0].Why, "nothing to bring back") {
t.Errorf("a rollback with nothing to do: %+v", again)
}
}
// takeAsk writes an ask and hands its change to the keeper, as the watch of `asks` would.
func (k *keeperBed) takeAsk(t *testing.T, a Ask) error {
t.Helper()
key := NodeKey(a.Node, a.ID)
rev, err := k.asks.Create(key, a)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(a)
return k.u.TakeAsk(stdio.StateChange{State: StateAsks, Key: key, Op: "put", Value: raw, Revision: rev})
}