Mirrors were generated once and never again and caches never cleaned. The module holds node-package-manager (hq ADR 0207), declares pacman itself, owns /etc/pacman.conf whole — [options] cannot take an appended block — with the union of the enabled repositories and improved options, proven by pacman-conf in its test, and enables reflector.timer (its config owned) and paccache.timer. Fifteen tools from a Go bundle; transactions run as transient units so a call's timeout never kills pacman mid-transaction (to-be 42).
237 lines
8.6 KiB
Go
237 lines
8.6 KiB
Go
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")
|
|
}
|