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 holdsTheDatabase(o.Run) { 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 }