From 43fccb843c7405c431559deea5018f9b571d3a11 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 22 Oct 2026 14:44:24 +0200 Subject: [PATCH] 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). --- modules/pacman/cmd/pacman-tools/keeper.go | 4 + modules/pacman/cmd/pacman-tools/main.go | 36 +++ modules/pacman/cmd/pacman-tools/rollback.go | 300 ++++++++++++++++++ .../pacman/cmd/pacman-tools/rollback_test.go | 195 ++++++++++++ .../pacman/cmd/pacman-tools/transaction.go | 2 + .../cmd/pacman-tools/transaction_test.go | 12 + modules/pacman/cmd/pacman-tools/upgrades.go | 4 + modules/pacman/module.json | 1 + 8 files changed, 554 insertions(+) create mode 100644 modules/pacman/cmd/pacman-tools/rollback.go create mode 100644 modules/pacman/cmd/pacman-tools/rollback_test.go diff --git a/modules/pacman/cmd/pacman-tools/keeper.go b/modules/pacman/cmd/pacman-tools/keeper.go index ece3640d..7385e88a 100644 --- a/modules/pacman/cmd/pacman-tools/keeper.go +++ b/modules/pacman/cmd/pacman-tools/keeper.go @@ -195,6 +195,10 @@ func (noRoot) Journal(unit string, n int) ([]string, error) { return nil, errors.New("the root helper's client is not built in yet: no journal can be read") } +func (noRoot) Rollback(run string, plan []RollbackItem) error { + return errors.New("the root helper's client is not built in yet: no rollback can start") +} + func (noRoot) Maps() ([]Mapped, error) { return nil, errors.New("the root helper's client is not built in yet: no process's maps can be read") } diff --git a/modules/pacman/cmd/pacman-tools/main.go b/modules/pacman/cmd/pacman-tools/main.go index 8c732a25..eaa88b6b 100644 --- a/modules/pacman/cmd/pacman-tools/main.go +++ b/modules/pacman/cmd/pacman-tools/main.go @@ -265,6 +265,42 @@ func tools(m *Machine) []stdio.Tool { return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n) }, }, + { + Name: "pacman_rollback", + Description: "Return a node to the package record a run took (ADR 0508 §7). Without apply it says what that would change: each package " + + "from its version now to the record's and where that version comes from (the node's package cache, the Arch Linux Archive's copy " + + "of that exact version, or the archive's repository on the record's date), the packages installed since (removed only with " + + "remove_added), and no foreign package. With apply it asks the node's keeper to carry it out as a run of its own, followed by " + + "the after-upgrade check; pacman_upgrades follows it. This node's by default.", + Input: schema(map[string]any{ + "node": nodeArg, + "run": map[string]any{"type": "string", "description": "the run whose record to return to (pacman_upgrades lists them)"}, + "remove_added": map[string]any{"type": "boolean", "description": "also remove the packages installed since the record"}, + "apply": map[string]any{"type": "boolean", "description": "carry it out"}, + }, "run"), + Run: func(args map[string]any) (any, error) { + node, err := nodeOf(args) + if err != nil { + return nil, err + } + run, err := text(args, "run", true) + if err != nil { + return nil, err + } + if !nodeName.MatchString(run) { + return nil, fmt.Errorf("run %q is not a run's id", run) + } + remove, err := flag(args, "remove_added") + if err != nil { + return nil, err + } + apply, err := flag(args, "apply") + if err != nil { + return nil, err + } + return m.Rollback(node, run, remove, apply) + }, + }, { Name: "pacman_orphans", Description: "Packages installed as dependencies that nothing requires any more (pacman -Qdt), with versions.", diff --git a/modules/pacman/cmd/pacman-tools/rollback.go b/modules/pacman/cmd/pacman-tools/rollback.go new file mode 100644 index 00000000..690e0bb5 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rollback.go @@ -0,0 +1,300 @@ +package main + +// A rollback (novox/hq ADR 0508 §7; acceptance 1841/AC8): a node is returned to the record a run took, and says +// first what that would change. The plan compares the record's packages with the node's `installed` now: each +// package whose version differs, or that is gone since, is brought back to the record's version, from the node's +// package cache when it holds that file, else from the Arch Linux Archive's copy of that exact version, else from +// the archive's repository as it stood on the record's date. The packages installed since are listed, and are +// removed only when asked. A foreign package, in the record or now, is never in the plan (ADR 0507 §7). +// +// The archive keeps every version its repositories published, so a version is taken from it by its file's name, +// which needs the package's architecture: the architecture of any of the package's files in the cache, else of +// its installed version (the local database). A package whose architecture is not known on this machine is +// taken from the dated repository, where pacman finds it by name. Nothing is fetched to make the plan; a file +// the archive does not hold fails the transaction, which the run then says. +// +// With apply, pacman_rollback writes an ask; the node's keeper takes it (TakeAsk), makes the plan again on the +// node itself (its cache is the one that counts), and runs it as a run of kind rollback: one transaction started +// by the root helper with the plan in the request (RootActs.Rollback), `rollback.started` and +// `rollback.finished`, then the after-upgrade check against the record (Follow, checkAfter). + +import ( + "encoding/json" + "errors" + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// ArchiveURL is the Arch Linux Archive. +const ArchiveURL = "https://archive.archlinux.org" + +// SourceRemoved marks a plan item that removes a package installed since the record (to "" and no location): +// only when the rollback was asked to remove what the upgrade added. +const SourceRemoved = "removed" + +// RollbackPlan is what a rollback to a run's record would change. +type RollbackPlan struct { + Node string `json:"node"` + Of string `json:"of"` + // RecordAt is when the record was taken; the dated repository is that day's. + RecordAt time.Time `json:"record_at"` + // Items brings each package back, or removes one installed since when RemoveAdded. + Items []RollbackItem `json:"items"` + // Added is the packages installed since the record, not foreign. + Added []PackageVersion `json:"added"` + RemoveAdded bool `json:"remove_added"` + // Foreign is how many foreign packages the plan leaves untouched. + Foreign int `json:"foreign_untouched"` + // CacheRead says whether the node's package cache was read: only on the node itself. + CacheRead bool `json:"cache_read"` +} + +// cacheFile is `--.pkg.tar.`; a name may hold dashes, a version and an arch none. +var cacheFile = regexp.MustCompile(`^(.+)-([^-]+-[^-]+)-([^-]+)\.pkg\.tar\.(zst|xz)$`) + +// packageCache is the node's cache: by package name, by version, the file's path; and each name's arch. +type packageCache struct { + files map[string]map[string]string + arch map[string]string +} + +// readCache lists the cache's package files. +func (m *Machine) readCache() (packageCache, error) { + c := packageCache{files: map[string]map[string]string{}, arch: map[string]string{}} + out, err := m.Out("find", CacheDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.pkg.tar.*", "-printf", "%f\n") + if err != nil { + return c, err + } + for _, l := range lines(out) { + f := cacheFile.FindStringSubmatch(strings.TrimSpace(l)) + if f == nil || !packageName.MatchString(f[1]) || !packageVersion.MatchString(f[2]) { + continue + } + if c.files[f[1]] == nil { + c.files[f[1]] = map[string]string{} + } + if _, ok := c.files[f[1]][f[2]]; !ok || f[4] == "zst" { + c.files[f[1]][f[2]] = path.Join(CacheDir, f[0]) + } + c.arch[f[1]] = f[3] + } + return c, nil +} + +// installedArch is an installed package's architecture from the local database, or "". +func (m *Machine) installedArch(name, version string) string { + desc, err := m.ReadFile(path.Join(PacmanDBDir, "local", name+"-"+version, "desc")) + if err != nil { + return "" + } + ls := strings.Split(string(desc), "\n") + for i, l := range ls { + if strings.TrimSpace(l) == "%ARCH%" && i+1 < len(ls) { + if a := strings.TrimSpace(ls[i+1]); repoName.MatchString(a) { + return a + } + } + } + return "" +} + +// source is where a package's record version comes from. +func source(name, version, repo string, arch string, cache *packageCache, recordAt time.Time) (string, string) { + if cache != nil { + if p, ok := cache.files[name][version]; ok { + return SourceCache, p + } + } + if arch != "" { + file := name + "-" + version + "-" + arch + ".pkg.tar.zst" + return SourceArchive, ArchiveURL + "/packages/" + name[:1] + "/" + name + "/" + file + } + day := recordAt.UTC().Format("2006/01/02") + return SourceRepository, ArchiveURL + "/repos/" + day + "/" + repo + "/os/$arch" +} + +// keyGetter reads one key: a Bucket the keeper writes, or a StateReader a verb reads. +type keyGetter interface { + Get(key string) (*stdio.StateEntry, error) +} + +// PlanRollback is the plan for returning node to the record of run, from the states: `records..` and +// `installed.`, which must be current. local says this is the node itself, whose cache and local database +// are read. +func PlanRollback(m *Machine, installed, records keyGetter, node, run string, removeAdded, local bool) (RollbackPlan, error) { + p := RollbackPlan{Node: node, Of: run, Items: []RollbackItem{}, Added: []PackageVersion{}, RemoveAdded: removeAdded} + if e, err := installed.Get(NotCurrentKey(node)); err != nil { + return p, err + } else if e != nil { + return p, fmt.Errorf("the packages of %s on the bus are not current: a plan from them would not be what is installed", node) + } + var rec Record + if e, err := records.Get(NodeKey(node, run)); err != nil { + return p, err + } else if e == nil { + return p, fmt.Errorf("%s has no record of run %s: it was never taken, or is older than record-days", node, run) + } else if err := json.Unmarshal(e.Value, &rec); err != nil { + return p, fmt.Errorf("the record of run %s is not readable: %w", run, err) + } + var now InstalledValue + if e, err := installed.Get(node); err != nil { + return p, err + } else if e == nil { + return p, fmt.Errorf("no packages of %s are on the bus", node) + } else if err := json.Unmarshal(e.Value, &now); err != nil { + return p, fmt.Errorf("installed.%s is not readable: %w", node, err) + } + p.RecordAt = rec.At + var cache *packageCache + if local { + c, err := m.readCache() + if err != nil { + return p, fmt.Errorf("reading the package cache: %w", err) + } + cache, p.CacheRead = &c, true + } + foreign := map[string]bool{} + nowBy := map[string]InstalledPackage{} + for _, ip := range now.Packages { + nowBy[ip.Name] = ip + if ip.Repo == RepoForeign { + foreign[ip.Name] = true + } + } + recBy := map[string]InstalledPackage{} + for _, ip := range rec.Installed.Packages { + recBy[ip.Name] = ip + if ip.Repo == RepoForeign { + foreign[ip.Name] = true + } + } + p.Foreign = len(foreign) + for _, want := range rec.Installed.Packages { + if foreign[want.Name] { + continue + } + have, installedNow := nowBy[want.Name] + if installedNow && have.Version == want.Version { + continue + } + arch := "" + if cache != nil { + arch = cache.arch[want.Name] + } + if arch == "" && local && installedNow { + arch = m.installedArch(have.Name, have.Version) + } + src, loc := source(want.Name, want.Version, want.Repo, arch, cache, rec.At) + p.Items = append(p.Items, RollbackItem{Name: want.Name, From: have.Version, To: want.Version, Source: src, Location: loc}) + } + for _, have := range now.Packages { + if _, inRecord := recBy[have.Name]; inRecord || foreign[have.Name] { + continue + } + p.Added = append(p.Added, PackageVersion{have.Name, have.Version}) + if removeAdded { + p.Items = append(p.Items, RollbackItem{Name: have.Name, From: have.Version, Source: SourceRemoved}) + } + } + sort.Slice(p.Items, func(i, j int) bool { return p.Items[i].Name < p.Items[j].Name }) + return p, nil +} + +// AskRollback writes an ask for a rollback of node to the record of run, for that node's keeper to take. +func AskRollback(asks Bucket, node, run string, removeAdded bool, now time.Time) (Ask, error) { + a := Ask{ID: AskID(now), Node: node, Act: ActRollback, At: now.UTC(), Of: run, RemoveAdded: removeAdded} + if _, err := asks.Create(NodeKey(node, a.ID), a); err != nil { + return Ask{}, fmt.Errorf("writing the ask: %w", err) + } + return a, nil +} + +// StartRollback runs a rollback's steps up to its transaction's start: the plan made again on the node, and the +// transaction started by the root helper with it. The end and the check follow as for an upgrade (Follow). +func (u *Upgrades) StartRollback(of string, removeAdded bool, ask string) (Run, error) { + now := u.Machine.Now().UTC() + r := &openRun{Run: Run{Node: u.Node, Run: RunID(now), Kind: RunRollback, From: FromAsk, Ask: ask, Of: of}} + runs, err := u.ownRuns() + if err != nil { + return r.Run, err + } + for _, o := range runs { + if o.Run.Run == r.Run.Run { + return r.Run, fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node) + } + if !ended(o.Run) && o.From != FromLog { + return r.Run, u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run))) + } + } + if u.Machine.locked() { + return r.Run, u.refuse(r, "another pacman holds "+DBLock+": a transaction is running, or one was killed and left its lock") + } + plan, err := PlanRollback(u.Machine, u.Installed, u.Records, u.Node, of, removeAdded, true) + if err != nil { + return r.Run, u.refuse(r, err.Error()) + } + if len(plan.Items) == 0 { + return r.Run, u.refuse(r, "the node's packages are those of the record already: there is nothing to bring back") + } + unit := TransactionUnit(RunRollback, r.Run.Run) + if err := u.Root.Rollback(r.Run.Run, plan.Items); err != nil { + return r.Run, u.end(r, Step{Step: StepFailed, At: u.Machine.Now().UTC(), Error: "the transaction could not be started: " + err.Error()}) + } + if err := u.step(r, Step{Step: StepStarted, At: now, Unit: unit, Plan: plan.Items}); err != nil { + return r.Run, err + } + return r.Run, u.emit(EventRollbackStarted, RollbackStarted{Node: u.Node, Run: r.Run.Run, Of: of, Unit: unit}) +} + +// takeRollback carries out an ask for a rollback; the ask is deleted once a run names it. +func (u *Upgrades) takeRollback(key string, a Ask) error { + if a.Of == "" { + return errors.Join(fmt.Errorf("ask %s names no run to return to", key), u.Asks.Delete(key)) + } + r, err := u.StartRollback(a.Of, a.RemoveAdded, a.ID) + if len(r.Steps) == 0 { + return err + } + if derr := u.Asks.Delete(key); derr != nil { + err = errors.Join(err, fmt.Errorf("deleting the ask %s: %w", key, derr)) + } + return err +} + +// Rollback is pacman_rollback: the plan, read from the bus (and on the node itself its cache), and with apply +// the ask that has the node's keeper carry it out. +func (m *Machine) Rollback(node, run string, removeAdded, apply bool) (map[string]any, error) { + if node == "" { + node = m.Node + } + if node == "" { + return nil, errors.New("node is required: this bundle does not know its own") + } + if err := m.states(); err != nil { + return nil, err + } + plan, err := PlanRollback(m, m.States(StateInstalled), m.States(StateRecords), node, run, removeAdded, node == m.Node) + if err != nil { + return nil, err + } + out := map[string]any{"plan": plan} + if !apply { + return out, nil + } + if len(plan.Items) == 0 { + return nil, fmt.Errorf("the packages of %s are those of the record already: there is nothing to bring back", node) + } + a, err := AskRollback(upgradeState(StateAsks), node, run, removeAdded, m.Now()) + if err != nil { + return nil, err + } + out["asked"] = a + out["follow"] = "pacman_upgrades with node " + node + " lists the rollback run that names this ask; its keeper makes the plan again from its own cache" + return out, nil +} diff --git a/modules/pacman/cmd/pacman-tools/rollback_test.go b/modules/pacman/cmd/pacman-tools/rollback_test.go new file mode 100644 index 00000000..ff7ac532 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/rollback_test.go @@ -0,0 +1,195 @@ +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}) +} diff --git a/modules/pacman/cmd/pacman-tools/transaction.go b/modules/pacman/cmd/pacman-tools/transaction.go index ae94fee8..ca92d40c 100644 --- a/modules/pacman/cmd/pacman-tools/transaction.go +++ b/modules/pacman/cmd/pacman-tools/transaction.go @@ -380,6 +380,8 @@ func (u *Upgrades) TakeAsk(c stdio.StateChange) error { err = errors.Join(err, fmt.Errorf("deleting the ask %s: %w", c.Key, derr)) } return err + case ActRollback: + return u.takeRollback(c.Key, a) default: return fmt.Errorf("ask %s asks %q, which this keeper does not carry out yet", c.Key, a.Act) } diff --git a/modules/pacman/cmd/pacman-tools/transaction_test.go b/modules/pacman/cmd/pacman-tools/transaction_test.go index ad2cc04a..52086306 100644 --- a/modules/pacman/cmd/pacman-tools/transaction_test.go +++ b/modules/pacman/cmd/pacman-tools/transaction_test.go @@ -30,6 +30,18 @@ func (f *fakeRoot) Transaction(kind, run string, ignore []string) error { 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 } diff --git a/modules/pacman/cmd/pacman-tools/upgrades.go b/modules/pacman/cmd/pacman-tools/upgrades.go index 567dacc5..0e4d8045 100644 --- a/modules/pacman/cmd/pacman-tools/upgrades.go +++ b/modules/pacman/cmd/pacman-tools/upgrades.go @@ -39,6 +39,10 @@ type RootActs interface { // Transaction starts the run's transaction as a unit of the service manager, named TransactionUnit(kind, // run), leaving out the packages ignore names, and answers once it started. Transaction(kind, run string, ignore []string) error + // Rollback starts a rollback's transaction as a unit of the service manager, named TransactionUnit(RunRollback, + // run), carrying out the plan in the request (ADR 0510: the plan travels with the verb), and answers once it + // started. + Rollback(run string, plan []RollbackItem) error // Journal is the last n lines a transaction unit logged. Journal(unit string, n int) ([]string, error) // Maps is every process still mapping a replaced file, by its unit or container and its executable. diff --git a/modules/pacman/module.json b/modules/pacman/module.json index 2c9a6250..d5a9f371 100644 --- a/modules/pacman/module.json +++ b/modules/pacman/module.json @@ -73,6 +73,7 @@ "pacman_upgrade", "pacman_upgrade_health", "pacman_upgrades", + "pacman_rollback", "pacman_orphans", "pacman_remove_orphans", "pacman_cache",