pacman: the package manager's configuration, mirrors and cache as a module
Mirrors were generated once and never again and caches never cleaned. The module holds node-package-manager (hq ADR 0207), declares pacman itself, owns /etc/pacman.conf whole — [options] cannot take an appended block — with the union of the enabled repositories and improved options, proven by pacman-conf in its test, and enables reflector.timer (its config owned) and paccache.timer. Fifteen tools from a Go bundle; transactions run as transient units so a call's timeout never kills pacman mid-transaction (to-be 42).
This commit is contained in:
@@ -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.
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 `key<sep>value` 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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<rss version="2.0"><channel><title>Arch Linux: Recent news updates</title>
|
||||||
|
<item><title>Manual intervention needed</title><link>https://example.org/news/a/</link><description><p>Do this <b>first</b>.</p></description><pubDate>Tue, 22 Sep 2026 09:09:27 +0000</pubDate></item>
|
||||||
|
<item><title>Old news</title><link>https://example.org/news/b/</link><description>old</description><pubDate>Mon, 01 Jun 2026 09:00:00 +0000</pubDate></item>
|
||||||
|
</channel></rss>`
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module pacman
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||||
@@ -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=
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user