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}) }