From d9336d11d025b7003b2d3ce3c55472660147469e Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 12:37:40 +0200 Subject: [PATCH] pacman: the package manager's configuration, mirrors and cache as a module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- modules/pacman/README.md | 64 +++ modules/pacman/cmd/pacman-tools/acts.go | 236 ++++++++++ modules/pacman/cmd/pacman-tools/history.go | 125 ++++++ modules/pacman/cmd/pacman-tools/machine.go | 289 +++++++++++++ .../pacman/cmd/pacman-tools/machine_test.go | 107 +++++ modules/pacman/cmd/pacman-tools/main.go | 278 ++++++++++++ .../pacman/cmd/pacman-tools/manifest_test.go | 103 +++++ modules/pacman/cmd/pacman-tools/mirrors.go | 86 ++++ modules/pacman/cmd/pacman-tools/news.go | 110 +++++ .../pacman/cmd/pacman-tools/pacman_test.go | 376 ++++++++++++++++ modules/pacman/cmd/pacman-tools/query.go | 404 ++++++++++++++++++ modules/pacman/cmd/pacman-tools/shape_test.go | 80 ++++ modules/pacman/go.mod | 5 + modules/pacman/go.sum | 2 + modules/pacman/module.json | 91 ++++ 15 files changed, 2356 insertions(+) create mode 100644 modules/pacman/README.md create mode 100644 modules/pacman/cmd/pacman-tools/acts.go create mode 100644 modules/pacman/cmd/pacman-tools/history.go create mode 100644 modules/pacman/cmd/pacman-tools/machine.go create mode 100644 modules/pacman/cmd/pacman-tools/machine_test.go create mode 100644 modules/pacman/cmd/pacman-tools/main.go create mode 100644 modules/pacman/cmd/pacman-tools/manifest_test.go create mode 100644 modules/pacman/cmd/pacman-tools/mirrors.go create mode 100644 modules/pacman/cmd/pacman-tools/news.go create mode 100644 modules/pacman/cmd/pacman-tools/pacman_test.go create mode 100644 modules/pacman/cmd/pacman-tools/query.go create mode 100644 modules/pacman/cmd/pacman-tools/shape_test.go create mode 100644 modules/pacman/go.mod create mode 100644 modules/pacman/go.sum create mode 100644 modules/pacman/module.json diff --git a/modules/pacman/README.md b/modules/pacman/README.md new file mode 100644 index 0000000..eb5ebaa --- /dev/null +++ b/modules/pacman/README.md @@ -0,0 +1,64 @@ +# pacman + +The package manager as a module (novox/hq to-be 42 Phase 1, ADR 0207, research 027). It holds the +`node-package-manager` seat, which has no verbs yet. Every module that declares a package depends on +that seat (ADR 0207). + +## What it owns + +- The `pacman` package. A component's own package belongs to the module that holds its seat + (ADR 0207). +- **`/etc/pacman.conf`, whole.** A block cannot be added to `[options]` by appending: anything added + at the end of the file lands in the last repository's section. So the module owns the file: + - The repositories are the union of what the four machines had enabled on 2026-10-04: `core`, + `extra` and `multilib`. All four had all three. + - The options are the distribution's defaults, plus `Color`, `ParallelDownloads = 5`, + `VerbosePkgLists`, and `DownloadUser = alpm` (pacman 7; the `alpm` user exists on all four). + - The manifest test runs `pacman-conf` on the rendered file and checks the repository list and the + options as pacman reads them. It skips that check where `pacman-conf` is absent. + - The host keeps the machine's previous file once, the first time it writes over it (ADR 0102). +- **Mirrors.** The `reflector` package, `/etc/xdg/reflector/reflector.conf` written whole (https, + Belgium, the Netherlands, Luxembourg, Germany, France, the 20 most recently synced, sorted by + rate, saved to `/etc/pacman.d/mirrorlist`), and `reflector.timer` running and enabled. The mirror + list stays reflector's to write, not the mesh's. +- **Cache cleaning.** The `pacman-contrib` package and `paccache.timer` running and enabled. Each + week it keeps the last three versions of each package. + +## What it improves + +- Mirrors were generated once and never again: in 2022, 2023 and 2024, and on one machine by its + hosting provider's installer. The list is now refreshed weekly. The timer's first run is at the + next weekly boundary; `pacman_mirrors` with `refresh: true` runs it at once. +- Package caches were never cleaned. One workstation held 48 GB, of which paccache would free 33 GB. +- Every machine has the same options. Only one had parallel downloads. + +## What it leaves found + +- `/etc/pacman.d/mirrorlist`, which reflector rewrites, and the stale `mirrorlist.pacnew`, + `.bak`, `.original` and similar copies beside it. +- `/etc/pacman.d/hooks`, the keyring, and the AUR helper. Packages from outside the repositories + are research 027 question 1. + +## Tools + +| tool | | answers | +|---|---|---| +| `pacman_search` | r | `pacman -Ss`: repository, name, version, groups, installed and at which version, description | +| `pacman_info` | r | `-Qi`, or `-Si` when not installed, with lists as lists | +| `pacman_installed` | r | every package with version, explicit or dependency, foreign; filters and totals | +| `pacman_owns` | r | which package owns a path, or `owned: false` | +| `pacman_files` | r | what a package placed, bounded | +| `pacman_updates` | r | `checkupdates`: what a full upgrade would change, never setting up a partial upgrade | +| `pacman_upgrade` | a | starts `pacman -Syu --noconfirm` (sudo -n) as a transient unit that outlives the call; answers the unit and the news since the last upgrade; given the unit, how it went | +| `pacman_orphans` | r | `pacman -Qdt` | +| `pacman_remove_orphans` | a | `pacman -Rs` on named orphans, or all of them, as a unit of its own; a name that is not an orphan is refused | +| `pacman_cache` | r/a | size, interrupted downloads, what paccache would free keeping N; `clean: true` removes them | +| `pacman_history` | r | `/var/log/pacman.log`: installs, upgrades, downgrades, reinstalls and removals since a day, and the last full upgrade | +| `pacman_mirrors` | r/a | the list, its generator and age, reflector's options, timer and last run; `refresh: true` starts reflector | +| `pacman_foreign` | r | `pacman -Qm` | +| `pacman_news` | r | the distribution's news since the last full upgrade (or a day), over https; no network is `reachable: false` | +| `pacman_config` | r | `pacman-conf`: options, repositories, and whether `/etc/pacman.conf` is the module's | + +A transaction never runs as the tool's own child. An upgrade takes longer than the 20 s a call may +take, and a pacman killed mid-transaction leaves a half-upgraded machine and a lock. So a transaction +runs as a transient unit (`systemd-run`, named `mesh-pacman-…`), and its log is read from the journal. diff --git a/modules/pacman/cmd/pacman-tools/acts.go b/modules/pacman/cmd/pacman-tools/acts.go new file mode 100644 index 0000000..a0b4e3c --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/acts.go @@ -0,0 +1,236 @@ +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") +} diff --git a/modules/pacman/cmd/pacman-tools/history.go b/modules/pacman/cmd/pacman-tools/history.go new file mode 100644 index 0000000..38c093f --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/history.go @@ -0,0 +1,125 @@ +package main + +// The package manager's own record, /var/log/pacman.log (novox/hq research 026/05: "installs and +// upgrades from the log"): every install, upgrade, downgrade, reinstall and removal since a date, +// and when the machine was last fully upgraded. + +import ( + "fmt" + "regexp" + "strings" + "time" +) + +// PacmanLog is where pacman writes what it did. +const PacmanLog = "/var/log/pacman.log" + +// Event is one package changed by a transaction. +type Event struct { + Time string `json:"time"` + Action string `json:"action"` + Package string `json:"package"` + Version string `json:"version"` + From string `json:"from,omitempty"` +} + +var ( + logLine = regexp.MustCompile(`^\[([^\]]+)\] \[ALPM\] (installed|upgraded|downgraded|reinstalled|removed) (\S+) \((.*)\)$`) + fullUpdate = regexp.MustCompile(`^\[([^\]]+)\] \[PACMAN\] starting full system upgrade`) +) + +// Actions are what a history may be narrowed to. +var Actions = []string{"installed", "upgraded", "downgraded", "reinstalled", "removed"} + +func logTime(s string) (time.Time, bool) { + for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} { + if t, err := time.Parse(layout, s); err == nil { + return t, true + } + } + return time.Time{}, false +} + +// ParseLog reads pacman.log's package events since a time, and the last full upgrade it records. +func ParseLog(text string, since time.Time) (events []Event, lastUpgrade time.Time) { + for _, l := range strings.Split(text, "\n") { + if u := fullUpdate.FindStringSubmatch(l); u != nil { + if t, ok := logTime(u[1]); ok { + lastUpgrade = t + } + continue + } + e := logLine.FindStringSubmatch(l) + if e == nil { + continue + } + t, ok := logTime(e[1]) + if !ok || t.Before(since) { + continue + } + ev := Event{Time: t.Format(time.RFC3339), Action: e[2], Package: e[3], Version: e[4]} + if from, to, ok := strings.Cut(e[4], " -> "); ok { + ev.From, ev.Version = from, to + } + events = append(events, ev) + } + return events, lastUpgrade +} + +// LastUpgrade is when the machine last started a full upgrade, from pacman's log. +func (m *Machine) LastUpgrade() (time.Time, error) { + text, err := m.ReadFile(PacmanLog) + if err != nil { + return time.Time{}, err + } + _, last := ParseLog(string(text), m.Now()) + return last, nil +} + +// History is the package events since a day (YYYY-MM-DD; thirty days ago by default), narrowed to +// an action and a name, the newest last and at most limit of them. +func (m *Machine) History(since, action, match string, limit int) (map[string]any, error) { + from := m.Now().AddDate(0, 0, -30) + if since != "" { + t, err := time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + return nil, fmt.Errorf("since %q is not a day as YYYY-MM-DD", since) + } + from = t + } + if action != "" && !contains(Actions, action) { + return nil, fmt.Errorf("action %q is one of %s", action, strings.Join(Actions, ", ")) + } + text, err := m.ReadFile(PacmanLog) + if err != nil { + return nil, fmt.Errorf("reading %s: %w", PacmanLog, err) + } + all, last := ParseLog(string(text), from) + events := []Event{} + counts := map[string]int{} + for _, e := range all { + if action != "" && e.Action != action || match != "" && !strings.Contains(e.Package, match) { + continue + } + events = append(events, e) + counts[e.Action]++ + } + truncated := false + if len(events) > limit { + events, truncated = events[len(events)-limit:], true + } + out := map[string]any{"since": from.Format(time.RFC3339), "count": len(events), "by_action": counts, "events": events, "truncated": truncated} + if !last.IsZero() { + out["last_full_upgrade"] = last.Format(time.RFC3339) + } + return out, nil +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/modules/pacman/cmd/pacman-tools/machine.go b/modules/pacman/cmd/pacman-tools/machine.go new file mode 100644 index 0000000..691a19d --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/machine.go @@ -0,0 +1,289 @@ +package main + +// The commands this bundle runs on its machine, and who runs them. +// +// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4), +// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words — +// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only +// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the +// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the +// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an +// empty answer. +// +// The runner is injected, so every tool is tested over a fake one without the machine. + +import ( + "bytes" + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "strings" + "time" +) + +// Ran is what one command did: its output, its exit status, and why it never ran to an answer. +type Ran struct { + Stdout string + Stderr string + Status int + // Err is "ENOENT" when the program is not there, or that it was ended for taking too long. + Err string +} + +// Runner runs one command, so the tools can be tested without the machine. +type Runner func(ctx context.Context, name string, args ...string) Ran + +// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a +// command that hangs is answered as such rather than as a call the runtime gave up on. +const CallTimeout = 20 * time.Second + +// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the +// process; well above anything a tool answers. +const outputLimit = 16 << 20 + +type bounded struct { + bytes.Buffer + cut bool +} + +func (b *bounded) Write(p []byte) (int, error) { + if room := outputLimit - b.Len(); room < len(p) { + if room > 0 { + b.Buffer.Write(p[:room]) + } + b.cut = true + return len(p), nil + } + return b.Buffer.Write(p) +} + +// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language. +func ExecRunner(ctx context.Context, name string, args ...string) Ran { + ctx, cancel := context.WithTimeout(ctx, CallTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + var out, errb bounded + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + r := Ran{Stdout: out.String(), Stderr: errb.String()} + if ctx.Err() == context.DeadlineExceeded { + r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds())) + return r + } + var exit *exec.ExitError + switch { + case err == nil: + case errors.As(err, &exit): + r.Status = exit.ExitCode() + case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist): + r.Status, r.Err = 127, "ENOENT" + default: + r.Status, r.Err = 126, err.Error() + } + return r +} + +// Escalated is the command as it is run: as given when this process is root, else through sudo +// without a prompt. +func Escalated(uid int, name string, args ...string) (string, []string) { + if uid == 0 { + return name, args + } + return "sudo", append([]string{"-n", name}, args...) +} + +// Machine is this machine as the tools see it: a runner, who this process is, and its files. +type Machine struct { + Run Runner + UID int + User string + Account string + ReadFile func(path string) ([]byte, error) + Now func() time.Time + Sleep func(time.Duration) +} + +// ThisMachine is the machine the runtime launched this bundle on. +func ThisMachine() *Machine { + user := os.Getenv("USER") + if user == "" { + user = os.Getenv("LOGNAME") + } + account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT")) + if account == "" { + account = user + } + return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep} +} + +// Out runs a command that only reads, and fails with what went wrong named. +func (m *Machine) Out(name string, args ...string) (string, error) { + r := m.Run(context.Background(), name, args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, name, r) +} + +// Root runs a command that needs root, escalated when this process is not. +func (m *Machine) Root(name string, args ...string) (string, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + return r.Stdout, failure(name, program, r) +} + +// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer. +func (m *Machine) RootRan(name string, args ...string) (Ran, error) { + program, argv := Escalated(m.UID, name, args...) + r := m.Run(context.Background(), program, argv...) + if r.Err != "" || (program == "sudo" && sudoRefused(r)) { + return r, failure(name, program, r) + } + return r, nil +} + +func sudoRefused(r Ran) bool { + return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:") +} + +// failure names what failed by how it failed: the program missing is a spawn error, sudo missing +// or refusing speaks for itself, and the rest is the command's own first line. +func failure(cmd, program string, r Ran) error { + said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout) + if r.Err == "ENOENT" { + if program == "sudo" { + return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd) + } + return fmt.Errorf("%s is not installed on this machine", cmd) + } + if r.Err != "" { + return fmt.Errorf("%s did not answer: %s", cmd, r.Err) + } + if program == "sudo" && sudoRefused(r) { + if strings.Contains(said, "command not found") { + return fmt.Errorf("%s is not installed on this machine", cmd) + } + return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said)) + } + if line := firstLine(said); line != "" { + return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line) + } + return fmt.Errorf("%s failed with status %d", cmd, r.Status) +} + +func firstLine(text string) string { + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} + +func lines(text string) []string { + var out []string + for _, l := range strings.Split(text, "\n") { + if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" { + out = append(out, l) + } + } + return out +} + +// text is a string argument; required says whether it may be absent. It is never something a +// command would read as an option, which under sudo would be root's option. +func text(args map[string]any, key string, required bool) (string, error) { + raw, present := args[key] + if !present || raw == nil { + if required { + return "", fmt.Errorf("%s is required", key) + } + return "", nil + } + s, ok := raw.(string) + if !ok { + return "", fmt.Errorf("%s must be a string", key) + } + s = strings.TrimSpace(s) + if required && s == "" { + return "", fmt.Errorf("%s is required", key) + } + if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") { + return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s) + } + return s, nil +} + +// whole is a whole-number argument with a default, kept within bounds. +func whole(args map[string]any, key string, def, least, most int) (int, error) { + raw, present := args[key] + if !present || raw == nil { + return def, nil + } + f, ok := raw.(float64) + if !ok || f != float64(int(f)) { + return 0, fmt.Errorf("%s must be a whole number", key) + } + n := int(f) + if n < least { + return 0, fmt.Errorf("%s must be at least %d", key, least) + } + if n > most { + n = most + } + return n, nil +} + +// flag is a boolean argument, false when absent. +func flag(args map[string]any, key string) (bool, error) { + raw, present := args[key] + if !present || raw == nil { + return false, nil + } + b, ok := raw.(bool) + if !ok { + return false, fmt.Errorf("%s must be true or false", key) + } + return b, nil +} + +// schema is a tool's input: its properties and the ones it requires. +func schema(properties map[string]any, required ...string) map[string]any { + s := map[string]any{"type": "object", "properties": properties} + if len(required) > 0 { + s["required"] = required + } + return s +} + +// unitProps reads a unit's properties as systemctl shows them. +func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) { + args := []string{"show", unit, "--no-pager"} + for _, p := range props { + args = append(args, "--property="+p) + } + out, err := m.Out("systemctl", args...) + if err != nil { + return nil, err + } + return keyValues(out, "="), nil +} + +// keyValues reads `keyvalue` lines; a line without the separator is skipped. +func keyValues(out, sep string) map[string]string { + kv := map[string]string{} + for _, l := range strings.Split(out, "\n") { + k, v, ok := strings.Cut(l, sep) + if ok { + kv[strings.TrimSpace(k)] = strings.TrimSpace(v) + } + } + return kv +} diff --git a/modules/pacman/cmd/pacman-tools/machine_test.go b/modules/pacman/cmd/pacman-tools/machine_test.go new file mode 100644 index 0000000..c561be8 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/machine_test.go @@ -0,0 +1,107 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" +) + +// call is one command a fake runner was asked to run. +type call struct { + name string + args []string +} + +func (c call) String() string { + if len(c.args) == 0 { + return c.name + } + return c.name + " " + strings.Join(c.args, " ") +} + +// fake is a runner answering by the command line it is given, recording every call. +func fake(answer func(c call) Ran, calls *[]call) Runner { + return func(_ context.Context, name string, args ...string) Ran { + c := call{name, append([]string(nil), args...)} + if calls != nil { + *calls = append(*calls, c) + } + return answer(c) + } +} + +// byLine answers from a table keyed by the whole command line, and refuses anything else as a +// command the test did not expect. +func byLine(table map[string]Ran, calls *[]call) Runner { + return fake(func(c call) Ran { + if r, ok := table[c.String()]; ok { + return r + } + return Ran{Status: 99, Stderr: "unexpected command: " + c.String()} + }, calls) +} + +func machine(run Runner, uid int) *Machine { + return &Machine{Run: run, UID: uid, User: "operator", Account: "operator", + ReadFile: func(string) ([]byte, error) { return nil, errNoFile }, + Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }, + Sleep: func(time.Duration) {}} +} + +type noFile struct{} + +func (noFile) Error() string { return "no such file" } + +var errNoFile = noFile{} + +func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) { + if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" { + t.Fatalf("not root: %s %v", p, a) + } + if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" { + t.Fatalf("root: %s %v", p, a) + } +} + +func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) { + cases := []struct { + r Ran + want string + }{ + {Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"}, + {Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"}, + {Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"}, + {Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"}, + } + for _, c := range cases { + m := machine(fake(func(call) Ran { return c.r }, nil), 1000) + if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%+v: %v, want %q", c.r, err, c.want) + } + } + m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000) + if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") { + t.Errorf("a missing program: %v", err) + } +} + +func TestAnArgumentIsNeverAnOption(t *testing.T) { + for _, bad := range []any{"-rf", "a\nb", 3.0} { + if _, err := text(map[string]any{"x": bad}, "x", true); err == nil { + t.Errorf("%v was accepted", bad) + } + } + if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" { + t.Errorf("a plain value: %q %v", s, err) + } + if _, err := text(map[string]any{}, "x", true); err == nil { + t.Error("a missing required value was accepted") + } + if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 { + t.Errorf("not bounded: %d", n) + } + if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil { + t.Error("below the least was accepted") + } +} diff --git a/modules/pacman/cmd/pacman-tools/main.go b/modules/pacman/cmd/pacman-tools/main.go new file mode 100644 index 0000000..06ead07 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/main.go @@ -0,0 +1,278 @@ +// pacman's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime +// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the +// package manager — search, info, what is installed and why, owners, files, updates, orphans, +// foreign packages, history, mirrors, the configuration in force, the distribution's news — and acts +// on it: a full upgrade, removing orphans, cleaning the cache, refreshing the mirrors. Acts go through +// sudo -n, and a transaction runs as a unit of its own (acts.go says why). +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// binaryName is what the build names this bundle's executable: the manifest's `binary`. +const binaryName = "pacman-tools" + +func bg() context.Context { return context.Background() } + +func main() { + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE): pacman. + if err := stdio.Serve("", tools(ThisMachine())); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var ( + pkgArg = map[string]any{"type": "string", "description": "a package's name"} + limitArg = func(def, most int) map[string]any { + return map[string]any{"type": "integer", "description": fmt.Sprintf("at most this many (default %d, at most %d)", def, most)} + } + sinceArg = map[string]any{"type": "string", "description": "a day, YYYY-MM-DD"} +) + +func tools(m *Machine) []stdio.Tool { + return []stdio.Tool{ + { + Name: "pacman_search", + Description: "Search the repositories (pacman -Ss): each package's repository, name, version, groups, whether it is installed and at which version, and its description.", + Input: schema(map[string]any{"query": map[string]any{"type": "string", "description": "words, each a regular expression; all must match"}, "limit": limitArg(50, 500)}, "query"), + Run: func(args map[string]any) (any, error) { + q, err := text(args, "query", true) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 50, 1, 500) + if err != nil { + return nil, err + } + return m.Search(q, n) + }, + }, + { + Name: "pacman_info", + Description: "One package's details (pacman -Qi, or -Si when it is not installed): version, description, dependencies, what requires it, sizes, dates, install reason; lists as lists.", + Input: schema(map[string]any{"package": pkgArg}, "package"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "package", true) + if err != nil { + return nil, err + } + return m.Info(p) + }, + }, + { + Name: "pacman_installed", + Description: "Installed packages with version, why each is installed (explicit or dependency) and whether it is foreign (in no repository); narrowed by name, reason or foreign; with totals.", + Input: schema(map[string]any{ + "match": map[string]any{"type": "string", "description": "only names holding this"}, + "reason": map[string]any{"type": "string", "enum": []string{"explicit", "dependency"}}, + "foreign": map[string]any{"type": "boolean", "description": "only foreign packages"}, + "limit": limitArg(5000, 20000), + }), + Run: func(args map[string]any) (any, error) { + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + reason, err := text(args, "reason", false) + if err != nil { + return nil, err + } + if reason != "" && reason != "explicit" && reason != "dependency" { + return nil, fmt.Errorf("reason is explicit or dependency") + } + foreign, err := flag(args, "foreign") + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 5000, 1, 20000) + if err != nil { + return nil, err + } + return m.Installed(match, reason, foreign, n) + }, + }, + { + Name: "pacman_owns", + Description: "Which installed package owns a path (pacman -Qo); owned false when none does.", + Input: schema(map[string]any{"path": map[string]any{"type": "string", "description": "an absolute path"}}, "path"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "path", true) + if err != nil { + return nil, err + } + return m.Owns(p) + }, + }, + { + Name: "pacman_files", + Description: "The paths an installed package placed (pacman -Ql), bounded.", + Input: schema(map[string]any{"package": pkgArg, "limit": limitArg(2000, 20000)}, "package"), + Run: func(args map[string]any) (any, error) { + p, err := text(args, "package", true) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 2000, 1, 20000) + if err != nil { + return nil, err + } + return m.Files(p, n) + }, + }, + { + Name: "pacman_updates", + Description: "What a full upgrade would change, each package from and to (checkupdates: the repositories are asked into a copy of their databases, so asking never sets up a partial upgrade). Needs the network.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Updates() }, + }, + { + Name: "pacman_upgrade", + Description: "Start a full system upgrade (pacman -Syu --noconfirm, through sudo -n) as a transient unit of its own that outlives the call, " + + "answering at once with the unit and the distribution's news since the last upgrade — read the news first. Given that unit, " + + "answer whether it is running, finished and succeeded, with the tail of its log.", + Input: schema(map[string]any{ + "unit": map[string]any{"type": "string", "description": "a unit this tool started, to read how it goes (optional)"}, + "lines": limitArg(60, 400), + }), + Run: func(args map[string]any) (any, error) { + unit, err := text(args, "unit", false) + if err != nil { + return nil, err + } + n, err := whole(args, "lines", 60, 1, 400) + if err != nil { + return nil, err + } + return m.Upgrade(unit, n) + }, + }, + { + Name: "pacman_orphans", + Description: "Packages installed as dependencies that nothing requires any more (pacman -Qdt), with versions.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Orphans() }, + }, + { + Name: "pacman_remove_orphans", + Description: "Remove orphans (pacman -Rs, through sudo -n, as a unit of its own): the names given, each of which must be an orphan, " + + "or every orphan with all: true. Changed configuration files are kept as .pacsave. Answers what was removed and the log.", + Input: schema(map[string]any{ + "names": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "orphans to remove"}, + "all": map[string]any{"type": "boolean", "description": "remove every orphan"}, + }), + Run: func(args map[string]any) (any, error) { + all, err := flag(args, "all") + if err != nil { + return nil, err + } + var names []string + if raw, ok := args["names"].([]any); ok { + for i := range raw { + n, err := text(map[string]any{"name": raw[i]}, "name", true) + if err != nil { + return nil, err + } + names = append(names, n) + } + } + return m.RemoveOrphans(names, all) + }, + }, + { + Name: "pacman_cache", + Description: "The package cache: files, bytes, interrupted downloads left behind, what paccache would remove keeping the last " + + "keep versions of each package (or only packages no longer installed), and the paccache timer. With clean: true it removes them (sudo -n).", + Input: schema(map[string]any{ + "keep": map[string]any{"type": "integer", "description": "versions of each package to keep (default 3)"}, + "uninstalled": map[string]any{"type": "boolean", "description": "only packages no longer installed"}, + "clean": map[string]any{"type": "boolean", "description": "remove them, rather than say what would go"}, + }), + Run: func(args map[string]any) (any, error) { + keep, err := whole(args, "keep", 3, 0, 100) + if err != nil { + return nil, err + } + un, err := flag(args, "uninstalled") + if err != nil { + return nil, err + } + clean, err := flag(args, "clean") + if err != nil { + return nil, err + } + return m.Cache(keep, un, clean) + }, + }, + { + Name: "pacman_history", + Description: "What the package manager did, from /var/log/pacman.log: each install, upgrade, downgrade, reinstall and removal since a day (default thirty days back), narrowed to an action or a name, with counts and the last full upgrade.", + Input: schema(map[string]any{ + "since": sinceArg, + "action": map[string]any{"type": "string", "enum": Actions}, + "match": map[string]any{"type": "string", "description": "only packages whose name holds this"}, + "limit": limitArg(500, 5000), + }), + Run: func(args map[string]any) (any, error) { + since, err := text(args, "since", false) + if err != nil { + return nil, err + } + action, err := text(args, "action", false) + if err != nil { + return nil, err + } + match, err := text(args, "match", false) + if err != nil { + return nil, err + } + n, err := whole(args, "limit", 500, 1, 5000) + if err != nil { + return nil, err + } + return m.History(since, action, match, n) + }, + }, + { + Name: "pacman_mirrors", + Description: "The mirror list in force (servers, commented ones, who generated it and when), reflector's options, its timer and its last run. With refresh: true, start reflector now (sudo -n), without waiting.", + Input: schema(map[string]any{"refresh": map[string]any{"type": "boolean", "description": "rank and rewrite the list now"}}), + Run: func(args map[string]any) (any, error) { + refresh, err := flag(args, "refresh") + if err != nil { + return nil, err + } + return m.MirrorList(refresh) + }, + }, + { + Name: "pacman_foreign", + Description: "Installed packages that no repository this machine syncs carries (pacman -Qm): built from the AUR or by hand, which the mesh cannot install.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Foreign() }, + }, + { + Name: "pacman_news", + Description: "The distribution's news posts since a day, or since the last full upgrade by default — what an upgrade may need a person to do. Fetched over https; no network is answered as reachable: false.", + Input: schema(map[string]any{"since": sinceArg}), + Run: func(args map[string]any) (any, error) { + since, err := text(args, "since", false) + if err != nil { + return nil, err + } + return m.News(since), nil + }, + }, + { + Name: "pacman_config", + Description: "The configuration pacman runs with, as pacman-conf resolves it: every option, each repository with its signature level and how many servers, and whether /etc/pacman.conf is the module's.", + Input: schema(map[string]any{}), + Run: func(map[string]any) (any, error) { return m.Conf() }, + }, + } +} diff --git a/modules/pacman/cmd/pacman-tools/manifest_test.go b/modules/pacman/cmd/pacman-tools/manifest_test.go new file mode 100644 index 0000000..4b46158 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/manifest_test.go @@ -0,0 +1,103 @@ +package main + +// The module's shape (novox/hq to-be 42 Phase 1, ADR 0207, research 027): it holds the +// node-package-manager seat and declares the package manager's own package; it owns pacman.conf +// whole — proven by pacman-conf on the rendered file, because a pacman.conf pacman cannot read is a +// machine that can neither install nor upgrade — and reflector's configuration, with the refresher +// and the cache cleaner on their timers. + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestItHoldsThePackageManagerSeatAndDeclaresItsPackage(t *testing.T) { + m := manifest(t) + if len(m.Claims) != 1 || m.Claims[0]["name"] != "node-package-manager" || m.Claims[0]["scope"] != "node" || m.Claims[0]["serves"] != nil { + t.Fatalf("claims: %v", m.Claims) + } + for id, pkg := range map[string]string{"package": "pacman", "contrib": "pacman-contrib", "reflector": "reflector"} { + if r := m.resource(t, id); r["package"] != pkg || r["absent"] != nil { + t.Errorf("%s: %v", id, r) + } + } + for id, unit := range map[string]string{"mirror-refresh": "reflector.timer", "cache-cleaning": "paccache.timer"} { + r := m.resource(t, id) + if r["unit"] != unit || r["state"] != "running" || r["boot"] != "enabled" { + t.Errorf("%s: %v", id, r) + } + } +} + +func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing.T) { + f := manifest(t).resource(t, "config") + content := f["content"].(string) + if f["path"] != "/etc/pacman.conf" || f["into"] != nil || !strings.HasPrefix(content, MeshHeader) { + t.Fatalf("%v", f) + } + var sections []string + for _, l := range strings.Split(content, "\n") { + if strings.HasPrefix(l, "[") { + sections = append(sections, l) + } + } + if strings.Join(sections, " ") != "[options] [core] [extra] [multilib]" { + t.Fatalf("sections: %v", sections) + } + for _, want := range []string{"\nColor\n", "\nCheckSpace\n", "\nVerbosePkgLists\n", "\nParallelDownloads = 5\n", "\nDownloadUser = alpm\n", "\nSigLevel = Required DatabaseOptional\n"} { + if !strings.Contains(content, want) { + t.Errorf("missing %q", strings.TrimSpace(want)) + } + } + conf, err := exec.LookPath("pacman-conf") + if err != nil { + t.Skip("pacman-conf is not installed here; the rendered file is not proven") + } + file := filepath.Join(t.TempDir(), "pacman.conf") + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + repos, err := exec.Command(conf, "--config", file, "--repo-list").CombinedOutput() + if err != nil || strings.Join(strings.Fields(string(repos)), " ") != "core extra multilib" { + t.Fatalf("pacman-conf --repo-list: %v\n%s", err, repos) + } + out, err := exec.Command(conf, "--config", file).CombinedOutput() + if err != nil { + t.Fatalf("pacman-conf refuses the file: %v\n%s", err, out) + } + c := ParseConf(string(out)) + if c.Options["ParallelDownloads"][0] != "5" || c.Options["DownloadUser"] == nil || c.Options["Color"] == nil || c.Options["VerbosePkgLists"] == nil { + t.Fatalf("pacman-conf does not read the options as written: %v", c.Options) + } +} + +func TestReflectorWritesTheListPacmanReads(t *testing.T) { + content := manifest(t).resource(t, "mirrors")["content"].(string) + opts := map[string]string{} + for _, l := range strings.Split(content, "\n") { + if l == "" || strings.HasPrefix(l, "#") { + continue + } + k, v, _ := strings.Cut(l, " ") + opts[k] = v + } + if opts["--save"] != Mirrorlist || opts["--protocol"] != "https" || opts["--latest"] != "20" || opts["--sort"] != "rate" || opts["--country"] == "" { + t.Fatalf("%v", opts) + } + if manifest(t).resource(t, "mirrors")["path"] != ReflectorConf { + t.Fatal("reflector reads its options from " + ReflectorConf) + } +} + +func TestTheReflectorPackageIsDeclaredBeforeTheFileItShips(t *testing.T) { + order := map[string]int{} + for i, r := range manifest(t).Resources { + order[r["id"].(string)] = i + } + if order["reflector"] > order["mirrors"] || order["contrib"] > order["cache-cleaning"] || order["reflector"] > order["mirror-refresh"] { + t.Fatalf("a package's file and timer come after the package: %v", order) + } +} diff --git a/modules/pacman/cmd/pacman-tools/mirrors.go b/modules/pacman/cmd/pacman-tools/mirrors.go new file mode 100644 index 0000000..9b772a6 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/mirrors.go @@ -0,0 +1,86 @@ +package main + +// The mirror list and its refresher (novox/hq to-be 42 Phase 1). On 2026-10-04 every machine's list +// had been generated once — by a tool no longer installed, or by a hosting provider's installer — and +// never again. The module installs reflector, owns its configuration and enables its weekly timer; +// this reads the list and the refresher's last run, and starts a refresh on demand. + +import ( + "strings" +) + +// Where the list is and how reflector is told to write it. +const ( + Mirrorlist = "/etc/pacman.d/mirrorlist" + ReflectorConf = "/etc/xdg/reflector/reflector.conf" +) + +// Mirrors is the mirror list and its refresher. +type Mirrors struct { + Servers []string `json:"servers"` + Commented int `json:"commented_servers"` + GeneratedBy string `json:"generated_by,omitempty"` + When string `json:"generated_when,omitempty"` + Reflector []string `json:"reflector_options"` + Timer map[string]string `json:"reflector_timer,omitempty"` + LastRun map[string]string `json:"reflector_last_run,omitempty"` + Refreshing bool `json:"refresh_started"` + Note string `json:"note,omitempty"` +} + +// ParseMirrorlist reads the servers in force, those commented out, and the generator's header. +func ParseMirrorlist(text string) Mirrors { + m := Mirrors{Servers: []string{}, Reflector: []string{}} + for _, l := range lines(text) { + l = strings.TrimSpace(l) + switch { + case strings.HasPrefix(l, "Server"): + if _, v, ok := strings.Cut(l, "="); ok { + m.Servers = append(m.Servers, strings.TrimSpace(v)) + } + case strings.HasPrefix(strings.TrimLeft(l, "# "), "Server"): + m.Commented++ + case strings.Contains(l, "generated by Reflector"): + m.GeneratedBy = "reflector" + case strings.HasPrefix(l, "# When:"): + m.When = strings.TrimSpace(strings.TrimPrefix(l, "# When:")) + case strings.HasPrefix(l, "## Generated on"): + m.GeneratedBy, m.When = "the distribution's mirrorlist", strings.TrimSpace(strings.TrimPrefix(l, "## Generated on")) + } + } + return m +} + +// MirrorList answers the list, reflector's options, its timer and its last run; refresh starts +// reflector now, without waiting, since ranking mirrors by rate takes longer than a call may. +func (m *Machine) MirrorList(refresh bool) (Mirrors, error) { + text, err := m.ReadFile(Mirrorlist) + if err != nil { + return Mirrors{}, err + } + out := ParseMirrorlist(string(text)) + if conf, err := m.ReadFile(ReflectorConf); err == nil { + for _, l := range lines(string(conf)) { + if l = strings.TrimSpace(l); !strings.HasPrefix(l, "#") { + out.Reflector = append(out.Reflector, l) + } + } + } + if t, err := m.unitProps("reflector.timer", "LoadState", "ActiveState", "UnitFileState", "LastTriggerUSec", "NextElapseUSecRealtime"); err == nil { + out.Timer = t + } + if s, err := m.unitProps("reflector.service", "LoadState", "ActiveState", "Result", "ExecMainExitTimestamp", "ExecMainStatus"); err == nil { + out.LastRun = s + } + if out.Timer["LoadState"] == "not-found" { + out.Note = "reflector is not installed here; the module installs it" + } + if refresh { + if _, err := m.Root("systemctl", "start", "--no-block", "reflector.service"); err != nil { + return out, err + } + out.Refreshing = true + out.Note = "reflector is ranking mirrors now; call again in a minute for the new list" + } + return out, nil +} diff --git a/modules/pacman/cmd/pacman-tools/news.go b/modules/pacman/cmd/pacman-tools/news.go new file mode 100644 index 0000000..acf440c --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/news.go @@ -0,0 +1,110 @@ +package main + +// The distribution's news since the last upgrade (novox/hq research 026/05: "an upgrade with the +// news first"). Arch posts what an upgrade needs a person to do — a manual intervention, a replaced +// package — in its news feed, and an upgrade that ignores it is how a machine breaks. Fetched over +// https; no network is an answer, never a failure. + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "time" +) + +// NewsFeed is the distribution's news, as RSS. +const NewsFeed = "https://archlinux.org/feeds/news/" + +// fetch is how the feed is read; a test replaces it. +var fetch = func(url string) ([]byte, error) { + client := http.Client{Timeout: 10 * time.Second} + res, err := client.Get(url) + if err != nil { + return nil, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", url, res.Status) + } + return io.ReadAll(io.LimitReader(res.Body, 4<<20)) +} + +// NewsItem is one post. +type NewsItem struct { + Title string `json:"title"` + Link string `json:"link"` + Published string `json:"published"` + Summary string `json:"summary"` +} + +type rss struct { + Items []struct { + Title string `xml:"title"` + Link string `xml:"link"` + PubDate string `xml:"pubDate"` + Description string `xml:"description"` + } `xml:"channel>item"` +} + +var tags = regexp.MustCompile(`<[^>]*>`) + +// ParseNews reads the feed's posts published after a time, newest first as the feed has them. +func ParseNews(body []byte, since time.Time) ([]NewsItem, error) { + var feed rss + if err := xml.Unmarshal(body, &feed); err != nil { + return nil, err + } + items := []NewsItem{} + for _, it := range feed.Items { + t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)) + if err != nil { + t, err = time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)) + } + if err != nil || !t.After(since) { + continue + } + summary := strings.Join(strings.Fields(tags.ReplaceAllString(it.Description, " ")), " ") + if len(summary) > 600 { + summary = summary[:600] + "…" + } + items = append(items, NewsItem{Title: it.Title, Link: it.Link, Published: t.Format(time.RFC3339), Summary: summary}) + } + return items, nil +} + +// News is the posts since a day (YYYY-MM-DD), or since the last full upgrade the log records. +func (m *Machine) News(since string) map[string]any { + out := map[string]any{"feed": NewsFeed, "items": []NewsItem{}} + var from time.Time + if since != "" { + t, err := time.ParseInLocation("2006-01-02", since, time.Local) + if err != nil { + out["error"] = fmt.Sprintf("since %q is not a day as YYYY-MM-DD", since) + return out + } + from = t + } else if last, err := m.LastUpgrade(); err == nil && !last.IsZero() { + from = last + out["since_last_full_upgrade"] = true + } else { + from = m.Now().AddDate(0, 0, -90) + } + out["since"] = from.Format(time.RFC3339) + body, err := fetch(NewsFeed) + if err != nil { + out["reachable"] = false + out["error"] = err.Error() + return out + } + out["reachable"] = true + items, err := ParseNews(body, from) + if err != nil { + out["error"] = "the feed could not be read: " + err.Error() + return out + } + out["items"] = items + return out +} diff --git a/modules/pacman/cmd/pacman-tools/pacman_test.go b/modules/pacman/cmd/pacman-tools/pacman_test.go new file mode 100644 index 0000000..83e5196 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/pacman_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "errors" + "strings" + "testing" + "time" +) + +const searchOut = `extra/zsh 5.9.2-1 [installed] + A very advanced and programmable command interpreter (shell) for UNIX +extra/ripgrep 15.2.0-1 [installed: 15.1.0-1] + A search tool +core/base-devel 1-2 (base-devel) + Basic tools to build Arch Linux packages +` + +func TestSearchReadsHeaderAndDescriptionAndWhatIsInstalled(t *testing.T) { + f := ParseSearch(searchOut) + if len(f) != 3 { + t.Fatalf("%+v", f) + } + if f[0].Repository != "extra" || f[0].Name != "zsh" || !f[0].Installed || f[0].InstalledAs != "5.9.2-1" || !strings.HasPrefix(f[0].Description, "A very advanced") { + t.Fatalf("%+v", f[0]) + } + if f[1].InstalledAs != "15.1.0-1" || f[1].Version != "15.2.0-1" { + t.Fatalf("%+v", f[1]) + } + if f[2].Installed || len(f[2].Groups) != 1 || f[2].Groups[0] != "base-devel" { + t.Fatalf("%+v", f[2]) + } +} + +func TestASearchThatFindsNothingIsEmptyAndAFailureIsAnError(t *testing.T) { + m := machine(fake(func(c call) Ran { return Ran{Status: 1} }, nil), 1000) + r, err := m.Search("nothing", 50) + if err != nil || r["count"] != 0 { + t.Fatalf("%v %v", r, err) + } + m = machine(fake(func(c call) Ran { return Ran{Status: 1, Stderr: "error: failed to initialize alpm library\n"} }, nil), 1000) + if _, err := m.Search("x", 50); err == nil || !strings.Contains(err.Error(), "failed to initialize") { + t.Fatalf("%v", err) + } +} + +const infoOut = `Name : zsh +Version : 5.9.2-1 +Depends On : pcre2 libcap gdbm +Optional Deps : grml-zsh-config: grml's zsh setup + zsh-doc: documentation [installed] +Required By : None +Install Reason : Explicitly installed + +` + +func TestInfoReadsListsAsListsAndFallsBackToTheRepositories(t *testing.T) { + p := ParseInfo(infoOut) + if len(p) != 1 { + t.Fatalf("%v", p) + } + if deps := p[0]["Depends On"].([]string); len(deps) != 3 || deps[2] != "gdbm" { + t.Fatalf("%v", p[0]["Depends On"]) + } + if opt := p[0]["Optional Deps"].([]string); len(opt) != 2 || !strings.HasPrefix(opt[1], "zsh-doc") { + t.Fatalf("%v", p[0]["Optional Deps"]) + } + if req := p[0]["Required By"].([]string); len(req) != 0 { + t.Fatalf("None is empty: %v", req) + } + var calls []call + m := machine(byLine(map[string]Ran{ + "pacman -Qi -- zsh": {Status: 1, Stderr: "error: package 'zsh' was not found\n"}, + "pacman -Si -- zsh": {Stdout: "Repository : extra\n" + infoOut}, + }, &calls), 1000) + r, err := m.Info("zsh") + if err != nil || r["installed"] != false || r["package"].(map[string]any)["Repository"] != "extra" { + t.Fatalf("%v %v", r, err) + } +} + +func TestInstalledSaysWhyAndWhatIsForeign(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "pacman -Q": {Stdout: "glibc 2.42-1\nyay 12.0-1\nzsh 5.9-1\n"}, + "pacman -Qeq": {Stdout: "yay\nzsh\n"}, + "pacman -Qmq": {Stdout: "yay\n"}, + }, nil), 1000) + r, err := m.Installed("", "", false, 10) + if err != nil { + t.Fatal(err) + } + pk := r["packages"].([]Package) + if pk[0].Reason != "dependency" || pk[1].Reason != "explicit" || !pk[1].Foreign || pk[2].Foreign { + t.Fatalf("%+v", pk) + } + if tot := r["totals"].(map[string]int); tot["explicit"] != 2 || tot["dependency"] != 1 || tot["foreign"] != 1 { + t.Fatalf("%v", tot) + } + r, _ = m.Installed("", "", true, 10) + if r["count"] != 1 { + t.Fatalf("foreign only: %v", r) + } + r, _ = m.Installed("", "explicit", false, 1) + if r["count"] != 2 || r["truncated"] != true { + t.Fatalf("bounded: %v", r) + } +} + +func TestOwnsAnswersNoOwnerAsAnAnswer(t *testing.T) { + m := machine(byLine(map[string]Ran{ + "pacman -Qo -- /usr/bin/zsh": {Stdout: "/usr/bin/zsh is owned by zsh 5.9.2-1\n"}, + "pacman -Qo -- /etc/hostname": {Status: 1, Stderr: "error: No package owns /etc/hostname\n"}, + "pacman -Qo -- /nope": {Status: 1, Stderr: "error: failed to read file '/nope': No such file or directory\n"}, + }, nil), 1000) + if r, err := m.Owns("/usr/bin/zsh"); err != nil || r["package"] != "zsh" || r["owned"] != true { + t.Fatalf("%v %v", r, err) + } + if r, err := m.Owns("/etc/hostname"); err != nil || r["owned"] != false { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Owns("/nope"); err == nil { + t.Fatal("a path that is not there is an error") + } + if _, err := m.Owns("relative"); err == nil { + t.Fatal("a relative path was taken") + } +} + +func TestUpdatesReadsCheckupdatesAndItsNothingToDo(t *testing.T) { + m := machine(byLine(map[string]Ran{"checkupdates": {Stdout: "linux 6.1-1 -> 6.2-1\nzsh 5.9-1 -> 5.9-2\n"}}, nil), 1000) + r, err := m.Updates() + if err != nil || r["count"] != 2 || r["updates"].([]Update)[0] != (Update{"linux", "6.1-1", "6.2-1"}) { + t.Fatalf("%v %v", r, err) + } + m = machine(byLine(map[string]Ran{"checkupdates": {Status: 2}}, nil), 1000) + if r, err := m.Updates(); err != nil || r["count"] != 0 { + t.Fatalf("%v %v", r, err) + } + m = machine(byLine(map[string]Ran{"checkupdates": {Status: 1, Stderr: "==> ERROR: Cannot fetch updates\n"}}, nil), 1000) + if _, err := m.Updates(); err == nil || !strings.Contains(err.Error(), "Cannot fetch updates") { + t.Fatalf("%v", err) + } +} + +func lockless(m *Machine) *Machine { + m.ReadFile = func(p string) ([]byte, error) { return nil, errNoFile } + return m +} + +func TestAnUpgradeRunsAsAUnitOfItsOwnThroughSudoAndBringsTheNews(t *testing.T) { + fetch = func(string) ([]byte, error) { return nil, errors.New("no network") } + var calls []call + m := lockless(machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000)) + r, err := m.Upgrade("", 60) + if err != nil { + t.Fatal(err) + } + unit := r["started"].(string) + if unit != "mesh-pacman-upgrade-1791115200" { + t.Fatalf("unit: %s", unit) + } + last := calls[len(calls)-1] + want := "sudo -n systemd-run --unit=" + unit + if !strings.HasPrefix(last.String(), want) || !strings.HasSuffix(last.String(), "--quiet pacman -Syu --noconfirm") || strings.Contains(last.String(), "--wait") { + t.Fatalf("started as: %s", last) + } + news := r["news"].(map[string]any) + if news["reachable"] != false || !strings.Contains(news["error"].(string), "no network") { + t.Fatalf("no network is an answer: %v", news) + } +} + +func TestAnUpgradeIsRefusedWhileTheDatabaseIsLocked(t *testing.T) { + fetch = func(string) ([]byte, error) { return nil, errors.New("offline") } + var calls []call + m := machine(fake(func(c call) Ran { return Ran{} }, &calls), 1000) + m.ReadFile = func(p string) ([]byte, error) { + if p == DBLock { + return []byte{}, nil + } + return nil, errNoFile + } + if _, err := m.Upgrade("", 60); err == nil || !strings.Contains(err.Error(), "another pacman holds") { + t.Fatalf("%v", err) + } + for _, c := range calls { + if c.name == "sudo" { + t.Fatal("started while locked") + } + } +} + +func TestAnUpgradesUnitIsReadBack(t *testing.T) { + unit := "mesh-pacman-upgrade-1791115200" + m := machine(byLine(map[string]Ran{ + "systemctl show " + unit + " --no-pager --property=LoadState --property=ActiveState --property=SubState --property=Result --property=ExecMainStatus": {Stdout: "LoadState=loaded\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\n"}, + "sudo -n journalctl --no-pager -o cat -n 60 -u " + unit: {Stdout: "error: failed to commit transaction (conflicting files)\n"}, + }, nil), 1000) + r, err := m.Upgrade(unit, 60) + if err != nil || r["finished"] != true || r["succeeded"] != false || r["exit_status"] != "1" || len(r["log"].([]string)) != 1 { + t.Fatalf("%v %v", r, err) + } + if _, err := m.Upgrade("sshd.service", 60); err == nil { + t.Fatal("a unit the tools did not start was read") + } +} + +func orphanMachine(calls *[]call) *Machine { + return lockless(machine(fake(func(c call) Ran { + switch { + case c.String() == "pacman -Qdt": + return Ran{Stdout: "argon2 20190702-6\nclang21 21.1.8-1\n"} + case c.name == "sudo" && c.args[1] == "systemd-run": + return Ran{} + case c.name == "sudo" && c.args[1] == "journalctl": + return Ran{Stdout: "removing argon2...\n"} + } + return Ran{Status: 99} + }, calls), 1000)) +} + +func TestRemovingOrphansTakesOnlyOrphansNamedOrAll(t *testing.T) { + var calls []call + m := orphanMachine(&calls) + if _, err := m.RemoveOrphans(nil, false); err == nil || !strings.Contains(err.Error(), "name the orphans") { + t.Fatalf("nothing named: %v", err) + } + if _, err := m.RemoveOrphans([]string{"glibc"}, false); err == nil || !strings.Contains(err.Error(), "glibc is not an orphan") { + t.Fatalf("not an orphan: %v", err) + } + r, err := m.RemoveOrphans([]string{"argon2"}, false) + if err != nil || strings.Join(r["removed"].([]string), ",") != "argon2" { + t.Fatalf("%v %v", r, err) + } + var run string + for _, c := range calls { + if c.name == "sudo" && c.args[1] == "systemd-run" { + run = c.String() + } + } + if !strings.Contains(run, "--wait pacman -Rs --noconfirm -- argon2") { + t.Fatalf("ran: %s", run) + } + r, _ = m.RemoveOrphans(nil, true) + if len(r["removed"].([]string)) != 2 { + t.Fatalf("all: %v", r) + } +} + +func TestCacheSaysWhatCleaningWouldFreeAndCleansThroughSudo(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + switch c.String() { + case "find /var/cache/pacman/pkg -mindepth 1 -maxdepth 1 -printf %y %s %f\n": + return Ran{Status: 1, Stdout: "f 1000 zsh-5.9-1-x86_64.pkg.tar.zst\nf 10 zsh-5.9-1-x86_64.pkg.tar.zst.sig\nd 4096 download-abc\n", Stderr: "find: permission denied\n"} + case "paccache -d -k 3": + return Ran{Stdout: "\n==> finished dry run: 12 candidates (disk space saved: 1.5 GiB)\n"} + case "sudo -n paccache -r -k 3": + return Ran{Stdout: "==> finished: 12 packages removed (disk space saved: 1.5 GiB)\n"} + } + if c.name == "systemctl" { + return Ran{Stdout: "ActiveState=active\nUnitFileState=enabled\n"} + } + return Ran{Status: 99} + }, &calls), 1000) + c, err := m.Cache(3, false, false) + if err != nil || c.Files != 1 || c.Bytes != 1010 || c.LeftDownloads != 1 || c.Candidates != 12 || c.Frees != "1.5 GiB" || c.Cleaned { + t.Fatalf("%+v %v", c, err) + } + c, err = m.Cache(3, false, true) + if err != nil || !c.Cleaned || c.Candidates != 12 || c.Timer["UnitFileState"] != "enabled" { + t.Fatalf("%+v %v", c, err) + } +} + +const pacmanLog = `[2026-09-24T17:47:36+0200] [PACMAN] starting full system upgrade +[2026-09-24T17:48:00+0200] [ALPM] upgraded linux (6.1-1 -> 6.2-1) +[2026-09-24T17:48:01+0200] [ALPM] installed zsh (5.9-1) +[2026-10-02T09:00:00+0200] [ALPM] removed ntp (4.2.8-1) +[2026-10-02T09:00:00+0200] [ALPM-SCRIPTLET] some words +[2022-01-01 10:00] [ALPM] installed old (1-1) +` + +func TestHistoryReadsTheLogSinceADayAndTheLastFullUpgrade(t *testing.T) { + m := machine(nil, 1000) + m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil } + r, err := m.History("2026-09-01", "", "", 10) + if err != nil { + t.Fatal(err) + } + ev := r["events"].([]Event) + if len(ev) != 3 || ev[0].From != "6.1-1" || ev[0].Version != "6.2-1" || ev[2].Action != "removed" { + t.Fatalf("%+v", ev) + } + if r["last_full_upgrade"] != "2026-09-24T17:47:36+02:00" { + t.Fatalf("%v", r["last_full_upgrade"]) + } + r, _ = m.History("2026-09-01", "removed", "", 10) + if r["count"] != 1 { + t.Fatalf("%v", r) + } + r, _ = m.History("2026-09-01", "", "", 1) + if r["truncated"] != true || r["events"].([]Event)[0].Package != "ntp" { + t.Fatalf("the newest are kept: %v", r) + } + if _, err := m.History("yesterday", "", "", 1); err == nil { + t.Fatal("not a day") + } + if _, err := m.History("", "exploded", "", 1); err == nil { + t.Fatal("not an action") + } +} + +const feed = ` +Arch Linux: Recent news updates +Manual intervention neededhttps://example.org/news/a/<p>Do this <b>first</b>.</p>Tue, 22 Sep 2026 09:09:27 +0000 +Old newshttps://example.org/news/b/oldMon, 01 Jun 2026 09:00:00 +0000 +` + +func TestNewsSinceTheLastUpgrade(t *testing.T) { + items, err := ParseNews([]byte(feed), time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)) + if err != nil || len(items) != 1 || items[0].Title != "Manual intervention needed" || items[0].Summary != "Do this first ." { + t.Fatalf("%+v %v", items, err) + } + fetch = func(string) ([]byte, error) { return []byte(feed), nil } + m := machine(nil, 1000) + m.ReadFile = func(p string) ([]byte, error) { return []byte(pacmanLog), nil } + n := m.News("") + if n["reachable"] != true || n["since_last_full_upgrade"] != true || len(n["items"].([]NewsItem)) != 0 { + t.Fatalf("after the last upgrade on the 24th, the post of the 22nd is old: %v", n) + } +} + +const mirrorlistReflector = `################################################################################ +################# Arch Linux mirrorlist generated by Reflector ################# +################################################################################ + +# With: reflector @/etc/xdg/reflector/reflector.conf +# When: 2024-06-12 21:26:34 UTC + +Server = https://mirror.example.org/archlinux/$repo/os/$arch +Server = https://mirror2.example.org/$repo/os/$arch +#Server = https://old.example.org/$repo/os/$arch +` + +func TestMirrorsReadTheListAndRefreshWithoutWaiting(t *testing.T) { + var calls []call + m := machine(fake(func(c call) Ran { + if c.name == "systemctl" && c.args[0] == "show" { + return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"} + } + return Ran{} + }, &calls), 1000) + m.ReadFile = func(p string) ([]byte, error) { + switch p { + case Mirrorlist: + return []byte(mirrorlistReflector), nil + case ReflectorConf: + return []byte("# comment\n--save /etc/pacman.d/mirrorlist\n--sort rate\n"), nil + } + return nil, errNoFile + } + r, err := m.MirrorList(true) + if err != nil || len(r.Servers) != 2 || r.Commented != 1 || r.GeneratedBy != "reflector" || r.When != "2024-06-12 21:26:34 UTC" || len(r.Reflector) != 2 || !r.Refreshing { + t.Fatalf("%+v %v", r, err) + } + if calls[len(calls)-1].String() != "sudo -n systemctl start --no-block reflector.service" { + t.Fatalf("%v", calls[len(calls)-1]) + } +} + +func TestConfigIsReadAsPacmanConfResolvesIt(t *testing.T) { + c := ParseConf("[options]\nHoldPkg = pacman\nHoldPkg = glibc\nCheckSpace\nParallelDownloads = 5\n[core]\nUsage = All\nServer = https://a/core\nServer = https://b/core\n[extra]\nServer = https://a/extra\n") + if len(c.Options["HoldPkg"]) != 2 || c.Options["ParallelDownloads"][0] != "5" || len(c.Repositories) != 2 || c.Repositories[0].Servers != 2 || c.Repositories[0].FirstServer != "https://a/core" { + t.Fatalf("%+v", c) + } +} diff --git a/modules/pacman/cmd/pacman-tools/query.go b/modules/pacman/cmd/pacman-tools/query.go new file mode 100644 index 0000000..881263a --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/query.go @@ -0,0 +1,404 @@ +package main + +// Reading the package manager (novox/hq to-be 42 Phase 1, research 026/05): what is installed and +// why, what a search finds, what owns a path, what a package holds, what is orphaned or foreign. +// Every one of these reads the local or sync databases, which any account may; none escalates. + +import ( + "fmt" + "path" + "regexp" + "sort" + "strings" +) + +// Found is one package a search found. +type Found struct { + Repository string `json:"repository"` + Name string `json:"name"` + Version string `json:"version"` + Groups []string `json:"groups,omitempty"` + Installed bool `json:"installed"` + InstalledAs string `json:"installed_version,omitempty"` + Description string `json:"description"` +} + +var searchHeader = regexp.MustCompile(`^(\S+)/(\S+) (\S+)(?: \(([^)]*)\))?(?: \[installed(?:: ([^\]]+))?\])?$`) + +// ParseSearch reads `pacman -Ss`: a header line per package and its description indented beneath. +func ParseSearch(out string) []Found { + found := []Found{} + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == "" { + continue + } + if strings.HasPrefix(l, " ") { + if n := len(found); n > 0 { + found[n-1].Description = strings.TrimSpace(strings.TrimSpace(found[n-1].Description + " " + strings.TrimSpace(l))) + } + continue + } + m := searchHeader.FindStringSubmatch(l) + if m == nil { + continue + } + f := Found{Repository: m[1], Name: m[2], Version: m[3], Installed: strings.Contains(l, "[installed")} + if m[4] != "" { + f.Groups = strings.Fields(m[4]) + } + if f.Installed { + f.InstalledAs = f.Version + if m[5] != "" { + f.InstalledAs = m[5] + } + } + found = append(found, f) + } + return found +} + +// none is pacman's way of saying a query found nothing: status 1 and nothing said. +func none(r Ran) bool { + return r.Status == 1 && r.Err == "" && strings.TrimSpace(r.Stdout+r.Stderr) == "" +} + +// query runs a pacman query whose empty answer is status 1, and fails only on a real failure. +func (m *Machine) query(args ...string) (string, error) { + r := m.Run(bg(), "pacman", args...) + if r.Status == 0 && r.Err == "" { + return r.Stdout, nil + } + if none(r) { + return "", nil + } + return "", failure("pacman", "pacman", r) +} + +// Search is `pacman -Ss` over the sync databases, bounded. +func (m *Machine) Search(words string, limit int) (map[string]any, error) { + out, err := m.query(append([]string{"-Ss", "--"}, strings.Fields(words)...)...) + if err != nil { + return nil, err + } + found := ParseSearch(out) + return bound("packages", found, limit), nil +} + +// bound is a list answered with its count, cut to a limit and saying so. +func bound[T any](key string, list []T, limit int) map[string]any { + out := map[string]any{"count": len(list), "truncated": false} + if limit > 0 && len(list) > limit { + list = list[:limit] + out["truncated"] = true + } + out[key] = list + return out +} + +// ParseInfo reads `pacman -Qi`/`-Si`: `Key : value` lines, continuation lines indented beneath. +// A field that is a list (two spaces between members) is answered as one, and "None" as empty. +func ParseInfo(out string) []map[string]any { + var pkgs []map[string]any + var cur map[string]any + last := "" + for _, l := range strings.Split(out, "\n") { + if strings.TrimSpace(l) == "" { + if cur != nil { + pkgs = append(pkgs, cur) + cur = nil + } + continue + } + if cur == nil { + cur = map[string]any{} + } + if k, v, ok := strings.Cut(l, " : "); ok && !strings.HasPrefix(l, " ") { + last = strings.TrimSpace(k) + cur[last] = infoValue(last, strings.TrimSpace(v)) + continue + } + // A continuation: the optional dependencies, one per line. + if last != "" { + v := strings.TrimSpace(l) + switch prev := cur[last].(type) { + case []string: + cur[last] = append(prev, v) + case string: + cur[last] = []string{prev, v} + } + } + } + if cur != nil { + pkgs = append(pkgs, cur) + } + return pkgs +} + +var listFields = map[string]bool{ + "Licenses": true, "Groups": true, "Provides": true, "Depends On": true, "Optional Deps": true, + "Required By": true, "Optional For": true, "Conflicts With": true, "Replaces": true, +} + +func infoValue(key, v string) any { + if !listFields[key] { + return v + } + if v == "None" { + return []string{} + } + if key == "Optional Deps" { + return []string{v} + } + return strings.Fields(v) +} + +// Info is one package as the local database knows it, or the sync databases when it is not installed. +func (m *Machine) Info(name string) (map[string]any, error) { + r := m.Run(bg(), "pacman", "-Qi", "--", name) + installed := true + if r.Status != 0 { + if r.Err != "" || !strings.Contains(r.Stderr, "was not found") { + return nil, failure("pacman", "pacman", r) + } + installed = false + if r = m.Run(bg(), "pacman", "-Si", "--", name); r.Status != 0 || r.Err != "" { + if strings.Contains(r.Stderr, "was not found") { + return nil, fmt.Errorf("no package %s, installed or in a repository", name) + } + return nil, failure("pacman", "pacman", r) + } + } + pkgs := ParseInfo(r.Stdout) + if len(pkgs) == 0 { + return nil, fmt.Errorf("pacman said nothing about %s", name) + } + return map[string]any{"installed": installed, "package": pkgs[0]}, nil +} + +// Package is an installed package and why it is installed. +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Reason string `json:"reason"` + Foreign bool `json:"foreign"` +} + +func nameVersions(out string) [][2]string { + var nv [][2]string + for _, l := range lines(out) { + if f := strings.Fields(l); len(f) >= 2 { + nv = append(nv, [2]string{f[0], f[1]}) + } + } + return nv +} + +func nameSet(out string) map[string]bool { + s := map[string]bool{} + for _, l := range lines(out) { + s[strings.TrimSpace(l)] = true + } + return s +} + +// Installed is every installed package with its version, whether it was installed explicitly or as +// a dependency, and whether it is foreign (in no repository this machine syncs). +func (m *Machine) Installed(match, reason string, foreignOnly bool, limit int) (map[string]any, error) { + all, err := m.query("-Q") + if err != nil { + return nil, err + } + explicit, err := m.query("-Qeq") + if err != nil { + return nil, err + } + foreign, err := m.query("-Qmq") + if err != nil { + return nil, err + } + ex, fo := nameSet(explicit), nameSet(foreign) + pkgs := []Package{} + counts := map[string]int{"explicit": 0, "dependency": 0, "foreign": 0} + for _, nv := range nameVersions(all) { + p := Package{Name: nv[0], Version: nv[1], Reason: "dependency", Foreign: fo[nv[0]]} + if ex[p.Name] { + p.Reason = "explicit" + } + counts[p.Reason]++ + if p.Foreign { + counts["foreign"]++ + } + if match != "" && !strings.Contains(p.Name, match) || reason != "" && p.Reason != reason || foreignOnly && !p.Foreign { + continue + } + pkgs = append(pkgs, p) + } + out := bound("packages", pkgs, limit) + out["totals"] = counts + return out, nil +} + +var ownedBy = regexp.MustCompile(`^(.*) is owned by (\S+) (\S+)$`) + +// Owns is which package owns a path. +func (m *Machine) Owns(p string) (map[string]any, error) { + if !path.IsAbs(p) { + return nil, fmt.Errorf("%q is not an absolute path", p) + } + r := m.Run(bg(), "pacman", "-Qo", "--", p) + if r.Status == 0 && r.Err == "" { + for _, l := range lines(r.Stdout) { + if o := ownedBy.FindStringSubmatch(l); o != nil { + return map[string]any{"path": o[1], "owned": true, "package": o[2], "version": o[3]}, nil + } + } + } + if r.Err == "" && strings.Contains(r.Stderr, "No package owns") { + return map[string]any{"path": p, "owned": false}, nil + } + return nil, failure("pacman", "pacman", r) +} + +// Files is what an installed package placed, bounded. +func (m *Machine) Files(name string, limit int) (map[string]any, error) { + r := m.Run(bg(), "pacman", "-Ql", "--", name) + if r.Status != 0 || r.Err != "" { + if strings.Contains(r.Stderr, "was not found") { + return nil, fmt.Errorf("%s is not installed", name) + } + return nil, failure("pacman", "pacman", r) + } + paths := []string{} + for _, l := range lines(r.Stdout) { + if _, p, ok := strings.Cut(l, " "); ok { + paths = append(paths, p) + } + } + out := bound("paths", paths, limit) + out["package"] = name + return out, nil +} + +// Orphans are packages installed as dependencies that nothing requires any more. +func (m *Machine) Orphans() (map[string]any, error) { + out, err := m.query("-Qdt") + if err != nil { + return nil, err + } + pkgs := []map[string]string{} + for _, nv := range nameVersions(out) { + pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]}) + } + return map[string]any{"count": len(pkgs), "orphans": pkgs}, nil +} + +// Foreign is every installed package no repository this machine syncs carries: built from the AUR +// or by hand, which the host's `package` shape cannot install (research 027, question 1). +func (m *Machine) Foreign() (map[string]any, error) { + out, err := m.query("-Qm") + if err != nil { + return nil, err + } + pkgs := []map[string]string{} + for _, nv := range nameVersions(out) { + pkgs = append(pkgs, map[string]string{"name": nv[0], "version": nv[1]}) + } + sort.Slice(pkgs, func(i, j int) bool { return pkgs[i]["name"] < pkgs[j]["name"] }) + return map[string]any{"count": len(pkgs), "packages": pkgs}, nil +} + +// Update is one package an upgrade would change. +type Update struct { + Name string `json:"name"` + From string `json:"from"` + To string `json:"to"` +} + +var updateLine = regexp.MustCompile(`^(\S+) (\S+) -> (\S+)`) + +// Updates is what a full upgrade would change, from checkupdates: a copy of the sync databases +// refreshed apart from the machine's own, so asking never makes a partial upgrade possible. +func (m *Machine) Updates() (map[string]any, error) { + r := m.Run(bg(), "checkupdates") + switch { + case r.Err == "ENOENT": + return nil, fmt.Errorf("checkupdates is not installed: it comes with pacman-contrib, which this module declares") + case r.Err == "" && r.Status == 2: + return map[string]any{"count": 0, "updates": []Update{}}, nil + case r.Err != "" || r.Status != 0: + return nil, failure("checkupdates", "checkupdates", r) + } + ups := []Update{} + for _, l := range lines(r.Stdout) { + if u := updateLine.FindStringSubmatch(strings.TrimSpace(l)); u != nil { + ups = append(ups, Update{u[1], u[2], u[3]}) + } + } + return map[string]any{"count": len(ups), "updates": ups}, nil +} + +// Config is the configuration pacman runs with, as pacman-conf resolves it. +type Config struct { + Options map[string][]string `json:"options"` + Repositories []Repository `json:"repositories"` + MeshOwned bool `json:"mesh_owned"` +} + +// Repository is one repository and where it is fetched from. +type Repository struct { + Name string `json:"name"` + Servers int `json:"servers"` + FirstServer string `json:"first_server,omitempty"` + SigLevel string `json:"sig_level,omitempty"` +} + +// MeshHeader is how the module's pacman.conf begins, which is how it is recognised. +const MeshHeader = "# The mesh's (module pacman" + +// ParseConf reads `pacman-conf`: [options] and each repository, with their values. +func ParseConf(out string) Config { + c := Config{Options: map[string][]string{}, Repositories: []Repository{}} + section := "" + for _, l := range lines(out) { + l = strings.TrimSpace(l) + if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") { + section = strings.Trim(l, "[]") + if section != "options" { + c.Repositories = append(c.Repositories, Repository{Name: section}) + } + continue + } + k, v, _ := strings.Cut(l, " = ") + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if section == "options" { + c.Options[k] = append(c.Options[k], v) + continue + } + if n := len(c.Repositories); n > 0 { + r := &c.Repositories[n-1] + switch k { + case "Server": + if r.Servers == 0 { + r.FirstServer = v + } + r.Servers++ + case "SigLevel": + r.SigLevel = strings.TrimSpace(r.SigLevel + " " + v) + } + } + } + return c +} + +// Conf is pacman's configuration in force, and whether /etc/pacman.conf is the module's. +func (m *Machine) Conf() (Config, error) { + out, err := m.Out("pacman-conf") + if err != nil { + return Config{}, err + } + c := ParseConf(out) + if text, err := m.ReadFile("/etc/pacman.conf"); err == nil { + c.MeshOwned = strings.HasPrefix(string(text), MeshHeader) + } + return c, nil +} diff --git a/modules/pacman/cmd/pacman-tools/shape_test.go b/modules/pacman/cmd/pacman-tools/shape_test.go new file mode 100644 index 0000000..33643d5 --- /dev/null +++ b/modules/pacman/cmd/pacman-tools/shape_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "encoding/json" + "os" + "testing" +) + +type resource map[string]any + +type manifestShape struct { + Module string `json:"module"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Claims []map[string]any `json:"claims"` + Tools []string `json:"tools"` + Resources []resource `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func manifest(t *testing.T) manifestShape { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifestShape + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func (m manifestShape) resource(t *testing.T, id string) resource { + t.Helper() + for _, r := range m.Resources { + if r["id"] == id { + return r + } + } + t.Fatalf("no resource %s", id) + return nil +} + +// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the +// bundle to the shape the builder compiles and the runtime loads. +func TestToolsAreTheManifests(t *testing.T) { + m := manifest(t) + names := map[string]bool{} + for _, tool := range tools(machine(nil, 1000)) { + if names[tool.Name] { + t.Errorf("%s is served twice", tool.Name) + } + names[tool.Name] = true + } + for _, want := range m.Tools { + if !names[want] { + t.Errorf("the manifest lists %s and the bundle does not serve it", want) + } + delete(names, want) + } + if len(names) != 0 { + t.Errorf("served and not listed: %v", names) + } + var tools map[string]any + for _, a := range m.Build.Artifacts { + if a["name"] == "tools" { + tools = a + } + } + if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" || + tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName { + t.Fatalf("the tools artifact: %v", tools) + } + if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName { + t.Fatalf("loads: %v", tools["loads"]) + } +} diff --git a/modules/pacman/go.mod b/modules/pacman/go.mod new file mode 100644 index 0000000..a88b95d --- /dev/null +++ b/modules/pacman/go.mod @@ -0,0 +1,5 @@ +module pacman + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/pacman/go.sum b/modules/pacman/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/pacman/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/pacman/module.json b/modules/pacman/module.json new file mode 100644 index 0000000..98ef244 --- /dev/null +++ b/modules/pacman/module.json @@ -0,0 +1,91 @@ +{ + "module": "pacman", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "claims": [ + { + "name": "node-package-manager", + "scope": "node" + } + ], + "tools": [ + "pacman_search", + "pacman_info", + "pacman_installed", + "pacman_owns", + "pacman_files", + "pacman_updates", + "pacman_upgrade", + "pacman_orphans", + "pacman_remove_orphans", + "pacman_cache", + "pacman_history", + "pacman_mirrors", + "pacman_foreign", + "pacman_news", + "pacman_config" + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "pacman" + }, + { + "id": "config", + "type": "file", + "path": "/etc/pacman.conf", + "mode": "0644", + "content": "# The mesh's (module pacman, novox/hq to-be 42): the package manager's configuration. Written\n# whole at every push: an edit here is overwritten, and the file a machine had before is kept once by\n# the host. Owned whole because [options] cannot take a block by appending: anything added at the end\n# of the file lands in the last repository's section.\n#\n# The repositories are the union of what the machines had enabled when the module was written\n# (core, extra, multilib). The options are the distribution's defaults with four more: colour on a\n# terminal, parallel downloads, package lists in columns, and downloads run as the unprivileged\n# alpm user, which pacman 7 creates.\n\n[options]\nHoldPkg = pacman glibc\nArchitecture = auto\nCheckSpace\nColor\nVerbosePkgLists\nParallelDownloads = 5\nDownloadUser = alpm\nSigLevel = Required DatabaseOptional\nLocalFileSigLevel = Optional\n\n[core]\nInclude = /etc/pacman.d/mirrorlist\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[multilib]\nInclude = /etc/pacman.d/mirrorlist\n" + }, + { + "id": "contrib", + "type": "package", + "package": "pacman-contrib" + }, + { + "id": "reflector", + "type": "package", + "package": "reflector" + }, + { + "id": "mirrors", + "type": "file", + "path": "/etc/xdg/reflector/reflector.conf", + "mode": "0644", + "content": "# The mesh's (module pacman, novox/hq to-be 42): how reflector refreshes the mirror list, weekly,\n# through reflector.timer. Written whole at every push. Before the module, every machine's list was\n# generated once and never again.\n--save /etc/pacman.d/mirrorlist\n--protocol https\n--country Belgium,Netherlands,Luxembourg,Germany,France\n--latest 20\n--sort rate\n" + }, + { + "id": "mirror-refresh", + "type": "service", + "unit": "reflector.timer", + "state": "running", + "boot": "enabled" + }, + { + "id": "cache-cleaning", + "type": "service", + "unit": "paccache.timer", + "state": "running", + "boot": "enabled" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/pacman-tools", + "binary": "pacman-tools", + "loads": [ + "pacman-tools" + ] + } + ] + } +}