Every run imported from the log ends at finished or failed and is never checked after, so the one-run-at-a-time rule counted each as still going: novox's ask of 2026-10-22 22:00 was refused on its install-time run 20220123T194900Z, and every node holds such runs. The rule now holds back a new run only for a run the mesh ran whose transaction may still go on. pacman_upgrades refused a run without its node, and the console takes node out of the arguments of an address that names the machine, so no run could be read whole: a run without a node is now that machine's.
301 lines
12 KiB
Go
301 lines
12 KiB
Go
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 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
|
|
}
|