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).
This commit is contained in:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 `<name>-<version>-<arch>.pkg.tar.<ext>`; 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.<node>.<run>` and
|
||||
// `installed.<node>`, 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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"pacman_upgrade",
|
||||
"pacman_upgrade_health",
|
||||
"pacman_upgrades",
|
||||
"pacman_rollback",
|
||||
"pacman_orphans",
|
||||
"pacman_remove_orphans",
|
||||
"pacman_cache",
|
||||
|
||||
Reference in New Issue
Block a user