package main // Acting on the package manager (novox/hq to-be 42 Phase 1, research 026/05): an upgrade, removing // orphans, cleaning the cache. // // **A transaction is never this process's child.** A tool call is ended after twenty seconds, and an // upgrade takes minutes; a pacman killed in the middle of a transaction leaves a half-upgraded machine // and a lock. So a transaction runs as a transient unit of the service manager (`systemd-run`), started // through sudo -n: it belongs to the machine, outlives the call, and logs to the journal, from which // the tool answers what it did. import ( "fmt" "regexp" "strconv" "strings" ) // DBLock is the file pacman holds while a transaction runs. const DBLock = "/var/lib/pacman/db.lck" // unitPrefix names every transient unit the module's tools start, so one is recognised as the mesh's. const unitPrefix = "mesh-pacman-" func (m *Machine) locked() bool { _, err := m.ReadFile(DBLock) return err == nil } // transaction starts pacman with these arguments as a transient unit, waiting for it when asked. func (m *Machine) transaction(what string, wait bool, args ...string) (string, Ran, error) { if m.locked() { return "", Ran{}, fmt.Errorf("another pacman holds %s: a transaction is running, or one was killed and left its lock", DBLock) } unit := fmt.Sprintf("%s%s-%d", unitPrefix, what, m.Now().Unix()) run := []string{"--unit=" + unit, "--description=pacman " + strings.Join(args, " ") + ", started by the mesh's pacman tools", "--quiet"} if wait { run = append(run, "--wait") } run = append(run, append([]string{"pacman"}, args...)...) r, err := m.RootRan("systemd-run", run...) if err == nil && !wait && r.Status != 0 { err = failure("systemd-run", "sudo", r) } return unit, r, err } // journal is the last lines a unit logged, read through sudo -n: the operator account need not be // in a group that reads the system journal. func (m *Machine) journal(unit string, n int) []string { out, err := m.Root("journalctl", "--no-pager", "-o", "cat", "-n", strconv.Itoa(n), "-u", unit) if err != nil { return []string{"(the journal could not be read: " + err.Error() + ")"} } return lines(out) } // Upgrade starts a full system upgrade as a transient unit and answers at once, with the // distribution's news since the last upgrade; or, given a unit it started, answers how it went. func (m *Machine) Upgrade(unit string, n int) (map[string]any, error) { if unit != "" { return m.UpgradeStatus(unit, n) } news := m.News("") started, _, err := m.transaction("upgrade", false, "-Syu", "--noconfirm") if err != nil { return nil, err } return map[string]any{ "started": started, "follow": "call pacman_upgrade with this unit to read how it goes", "news": news, }, nil } var unitName = regexp.MustCompile(`^` + unitPrefix + `[a-z-]+-[0-9]+$`) // UpgradeStatus is a transaction unit's state and the tail of what it logged. func (m *Machine) UpgradeStatus(unit string, n int) (map[string]any, error) { if !unitName.MatchString(unit) { return nil, fmt.Errorf("%q is not a unit the pacman tools started", unit) } p, err := m.unitProps(unit, "LoadState", "ActiveState", "SubState", "Result", "ExecMainStatus") if err != nil { return nil, err } out := map[string]any{"unit": unit, "running": p["ActiveState"] == "active" || p["ActiveState"] == "activating", "log": m.journal(unit, n)} switch { case p["LoadState"] == "not-found": // A transient unit that finished well is let go by the service manager; one that failed stays. out["finished"], out["succeeded"] = true, true case p["ActiveState"] == "failed": out["finished"], out["succeeded"], out["exit_status"] = true, false, p["ExecMainStatus"] default: out["finished"] = !out["running"].(bool) out["succeeded"] = p["Result"] == "success" && p["ExecMainStatus"] == "0" } return out, nil } // RemoveOrphans removes the named orphans, or every one when all is said; a name that is not an // orphan is refused, so this never removes a package something needs or someone chose. Their // configuration files changed on the machine are kept by the package manager as .pacsave. func (m *Machine) RemoveOrphans(names []string, all bool) (map[string]any, error) { listed, err := m.Orphans() if err != nil { return nil, err } orphans := map[string]bool{} var every []string for _, p := range listed["orphans"].([]map[string]string) { orphans[p["name"]] = true every = append(every, p["name"]) } switch { case all && len(names) > 0: return nil, fmt.Errorf("name the orphans to remove, or say all — not both") case all: names = every case len(names) == 0: return nil, fmt.Errorf("name the orphans to remove (pacman_orphans lists them), or say all: true") } for _, n := range names { if !orphans[n] { return nil, fmt.Errorf("%s is not an orphan here, and is not removed", n) } } if len(names) == 0 { return map[string]any{"removed": []string{}, "note": "there are no orphans"}, nil } unit, r, err := m.transaction("remove-orphans", true, append([]string{"-Rs", "--noconfirm", "--"}, names...)...) if err != nil { if r.Status == 124 { return map[string]any{"unit": unit, "running": true, "note": "still running after the call's limit; it continues as its unit"}, nil } return nil, err } answer := map[string]any{"unit": unit, "log": m.journal(unit, 100)} if r.Status != 0 { answer["removed"] = []string{} answer["error"] = fmt.Sprintf("pacman failed with status %d; nothing is removed by a transaction that failed", r.Status) return answer, nil } answer["removed"] = names return answer, nil } // PkgCache is the package cache: its size, what cleaning would free, and its timer. type PkgCache struct { Directory string `json:"directory"` Files int `json:"package_files"` Bytes int64 `json:"bytes"` LeftDownloads int `json:"interrupted_download_dirs"` Keep int `json:"keep"` Candidates int `json:"candidates"` Frees string `json:"frees"` Uninstalled bool `json:"uninstalled_only"` Cleaned bool `json:"cleaned"` Timer map[string]string `json:"paccache_timer"` Said string `json:"said"` } // CacheDir is where pacman keeps what it downloaded. const CacheDir = "/var/cache/pacman/pkg" var ( dryRun = regexp.MustCompile(`finished dry run: (\d+) candidates \(disk space saved: ([^)]+)\)`) removed = regexp.MustCompile(`finished: (\d+) packages removed \(disk space saved: ([^)]+)\)`) noPrune = regexp.MustCompile(`no candidate packages found for pruning`) ) // Cache reads the cache, says what paccache would remove keeping the last keep versions of each // package (or only those of packages no longer installed), and with clean removes them. func (m *Machine) Cache(keep int, uninstalled, clean bool) (PkgCache, error) { c := PkgCache{Directory: CacheDir, Keep: keep, Uninstalled: uninstalled} out, err := m.Out("find", CacheDir, "-mindepth", "1", "-maxdepth", "1", "-printf", "%y %s %f\n") if err != nil && strings.TrimSpace(out) == "" { return c, err } for _, l := range lines(out) { f := strings.SplitN(l, " ", 3) if len(f) != 3 { continue } switch { case f[0] == "d" && strings.HasPrefix(f[2], "download-"): c.LeftDownloads++ case f[0] == "f": size, _ := strconv.ParseInt(f[1], 10, 64) c.Bytes += size if strings.Contains(f[2], ".pkg.tar") && !strings.HasSuffix(f[2], ".sig") { c.Files++ } } } args := []string{"-k", strconv.Itoa(keep)} if uninstalled { args = append(args, "-u") } if clean { said, err := m.Root("paccache", append([]string{"-r"}, args...)...) if err != nil { return c, err } c.Cleaned, c.Said = true, firstLine(lastLines(said, 1)) if x := removed.FindStringSubmatch(said); x != nil { c.Candidates, _ = strconv.Atoi(x[1]) c.Frees = x[2] } } else { r := m.Run(bg(), "paccache", append([]string{"-d"}, args...)...) if r.Err != "" || r.Status != 0 && !noPrune.MatchString(r.Stdout+r.Stderr) { if r.Err == "ENOENT" { return c, fmt.Errorf("paccache is not installed: it comes with pacman-contrib, which this module declares") } return c, failure("paccache", "paccache", r) } c.Said = firstLine(lastLines(r.Stdout+r.Stderr, 1)) if x := dryRun.FindStringSubmatch(r.Stdout + r.Stderr); x != nil { c.Candidates, _ = strconv.Atoi(x[1]) c.Frees = x[2] } } if t, err := m.unitProps("paccache.timer", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil { c.Timer = t } return c, nil } func lastLines(text string, n int) string { ls := lines(text) if len(ls) > n { ls = ls[len(ls)-n:] } return strings.Join(ls, "\n") }