Files
mesh-catalog/modules/pacman/cmd/pacman-tools/acts.go
T
jochen d9336d11d0 pacman: the package manager's configuration, mirrors and cache as a module
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).
2026-10-04 12:50:20 +02:00

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")
}