The keeper process runs under its own account with a bus account of its own, keeps the six states and emits the nine events of ADR 0507 and ADR 0508; its config (upgrade-order, record-days, round-start-minutes) lands in its config file and restarts it. shapes.go fixes every key's value and event body as the interface the keeper and the verbs share. The weekly timer and its service are dropped, which the node-engine removes.
242 lines
8.9 KiB
Go
242 lines
8.9 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)
|
|
}
|
|
|
|
// UpgradeArgs is the full system upgrade, in one place: pacman_upgrade starts pacman with these arguments as
|
|
// a transient unit (issue 1574). The weekly timer that ran the same is gone: a round of runs replaces it
|
|
// (ADR 0508 §5).
|
|
var UpgradeArgs = []string{"-Syu", "--noconfirm"}
|
|
|
|
// 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, UpgradeArgs...)
|
|
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")
|
|
}
|