Files
mesh-catalog/modules/pacman/cmd/pacman-tools/main.go
T
jschoubben ac5d257a1c Let no run read from pacman's log, nor a run whose transaction ended, hold an asked upgrade back; read one run of the addressed machine whole (issue 1841)
Every run imported from the log ends at finished or failed and is never checked after, so the
one-run-at-a-time rule counted each as still going: novox's ask of 2026-10-22 22:00 was refused on
its install-time run 20220123T194900Z, and every node holds such runs. The rule now holds back a new
run only for a run the mesh ran whose transaction may still go on.

pacman_upgrades refused a run without its node, and the console takes node out of the arguments of
an address that names the machine, so no run could be read whole: a run without a node is now that
machine's.
2026-10-22 22:04:23 +02:00

478 lines
19 KiB
Go

// 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"
"regexp"
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() {
// `pacman-tools keeper` is the module's process (keeper.go), `root` its root helper (root.go); without a
// word it serves the tools.
if len(os.Args) > 1 {
switch os.Args[1] {
case "keeper":
os.Exit(keeper())
case "root":
// the root helper (root.go, ADR 0510), and the command of a rollback's unit it starts
os.Exit(rootHelper())
case "root-rollback":
os.Exit(rootRollback(os.Args[2:]))
}
}
// 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"}
nodeArg = map[string]any{"type": "string", "description": "a node's name"}
nodeName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
)
// nodeOf is the node argument, empty when absent; a node's name is one key token.
func nodeOf(args map[string]any) (string, error) {
n, err := text(args, "node", false)
if err != nil {
return "", err
}
if n != "" && !nodeName.MatchString(n) {
return "", fmt.Errorf("node %q is not a node's name", n)
}
return n, nil
}
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: "A node's installed packages, from the bus (ADR 0507 §2): version, why each is installed (explicit or dependency), its repository, " +
"or foreign (in no repository); narrowed by name, reason or foreign; with totals, the time of the transaction they describe and whether they are current. " +
"This node's by default. (r)",
Input: schema(map[string]any{
"node": nodeArg,
"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) {
node, err := nodeOf(args)
if err != nil {
return nil, err
}
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.InstalledFrom(node, match, reason, foreign, n)
},
},
{
Name: "pacman_packages",
Description: "Every node's packages at once, from the bus (ADR 0507 §6): with package, that package on every node (version, reason, repository " +
"or foreign, the modules declaring it); without, per node its counts, the packages installed explicitly that no module assigned to it declares, " +
"and its foreign packages apart, then the packages whose versions differ between nodes and the packages on some nodes only. " +
"A node whose packages are over the bus's cap is marked not current. No machine is asked. (r)",
Input: schema(map[string]any{
"package": map[string]any{"type": "string", "description": "one package, on every node"},
"limit": limitArg(500, 20000),
}),
Run: func(args map[string]any) (any, error) {
p, err := text(args, "package", false)
if err != nil {
return nil, err
}
n, err := whole(args, "limit", 500, 1, 20000)
if err != nil {
return nil, err
}
return m.Packages(p, 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, from the bus (ADR 0507 §4), on one node or on every node: each package from and to, whether the " +
"node reached its mirrors (false: the list is the last it could confirm), and when the node's list last changed. The keeper reads it with " +
"checkupdates at its start, after every transaction, after every mirror refresh and once a day. (r)",
Input: schema(map[string]any{"node": nodeArg}),
Run: func(args map[string]any) (any, error) {
node, err := nodeOf(args)
if err != nil {
return nil, err
}
return m.UpdatesFor(node)
},
},
{
Name: "pacman_upgrade",
Description: "Ask for a full system upgrade of a node (this one unless named): the ask is written to the bus, where that node's keeper " +
"takes it and runs it as a run: the package record, the kernel check, the transaction as a unit of its own, and the after-upgrade " +
"check (ADR 0508 §8). Answers at once with the ask and the distribution's news since the last upgrade — read the news first; " +
"pacman_upgrades with the node shows the run that names the ask. Given a transaction unit, answers whether it is running, " +
"finished and succeeded, with the tail of its log.",
Input: schema(map[string]any{
"node": map[string]any{"type": "string", "description": "the node to upgrade (default: this one)"},
"unit": map[string]any{"type": "string", "description": "a transaction unit, 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
}
if unit != "" {
return m.UpgradeStatus(unit, n)
}
node, err := text(args, "node", false)
if err != nil {
return nil, err
}
if node == "" {
node = thisNode()
}
a, err := AskUpgrade(upgradeState(StateAsks), node, m.Now())
if err != nil {
return nil, err
}
return map[string]any{"asked": a, "follow": "pacman_upgrades with node " + node + " lists the run that names this ask", "news": m.News("")}, nil
},
},
{
Name: "pacman_upgrade_health",
Description: "This node's upgrades, read from the bus, as the module's health check (ADR 0508 §11): unhealthy while no upgrade-order " +
"is set, while the node's latest run is refused, failed or did not pass its after-upgrade check, hung past two hours, or held a " +
"kernel, and while the week's round stands still at this node or at the node it waits for, naming it and since when. (r)",
Input: schema(map[string]any{}),
Run: func(map[string]any) (any, error) {
return m.UpgradeHealth(), nil
},
},
{
Name: "pacman_upgrades",
Description: "Every full upgrade and rollback of every node, from the bus (ADR 0508 §1): each run's node, kind, what started it, " +
"its round, when it began and ended, its outcome (refused, finished, failed or running) and whether its after-upgrade check passed; " +
"narrowed to a node and to days, newest first. With a run, that run whole, every step with its time: the node's named, else this one's.",
Input: schema(map[string]any{
"node": map[string]any{"type": "string", "description": "only this node's runs"},
"from": sinceArg,
"until": map[string]any{"type": "string", "description": "a day, YYYY-MM-DD, the last one listed"},
"run": map[string]any{"type": "string", "description": "one run, by its id: of node, else of this node"},
"limit": limitArg(50, 1000),
}),
Run: func(args map[string]any) (any, error) {
var v [4]string
for i, k := range []string{"node", "from", "until", "run"} {
s, err := text(args, k, false)
if err != nil {
return nil, err
}
v[i] = s
}
n, err := whole(args, "limit", 50, 1, 1000)
if err != nil {
return nil, err
}
if v[3] != "" && v[0] == "" {
// An address naming the machine has its node taken out of the arguments (the console):
// the run is this machine's (issue 1841).
v[0] = thisNode()
}
return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n)
},
},
{
Name: "pacman_rollback",
Description: "Return a node to the package record a run took (ADR 0508 §7). Without apply it says what that would change: each package " +
"from its version now to the record's and where that version comes from (the node's package cache, the Arch Linux Archive's copy " +
"of that exact version, or the archive's repository on the record's date), the packages installed since (removed only with " +
"remove_added), and no foreign package. With apply it asks the node's keeper to carry it out as a run of its own, followed by " +
"the after-upgrade check; pacman_upgrades follows it. This node's by default.",
Input: schema(map[string]any{
"node": nodeArg,
"run": map[string]any{"type": "string", "description": "the run whose record to return to (pacman_upgrades lists them)"},
"remove_added": map[string]any{"type": "boolean", "description": "also remove the packages installed since the record"},
"apply": map[string]any{"type": "boolean", "description": "carry it out"},
}, "run"),
Run: func(args map[string]any) (any, error) {
node, err := nodeOf(args)
if err != nil {
return nil, err
}
run, err := text(args, "run", true)
if err != nil {
return nil, err
}
if !nodeName.MatchString(run) {
return nil, fmt.Errorf("run %q is not a run's id", run)
}
remove, err := flag(args, "remove_added")
if err != nil {
return nil, err
}
apply, err := flag(args, "apply")
if err != nil {
return nil, err
}
return m.Rollback(node, run, remove, apply)
},
},
{
Name: "pacman_round",
Description: "Where the week's upgrade rounds stand (ADR 0508 §5, §6): each round's order and opening, and every node's state in it: " +
"waiting (for the node named), turn, stalled (its turn came longer than round-start-minutes ago), running, passed, failed " +
"(the round stops there) or refused, with since when. With pass, a node and a reason, asks the operator on their channel to let " +
"the round go on past that node; it goes on only on the operator's Approve.",
Input: schema(map[string]any{
"round": map[string]any{"type": "string", "description": "one round, as 2026-W43 (default: the newest)"},
"pass": map[string]any{"type": "string", "description": "a node to ask the operator to pass"},
"reason": map[string]any{"type": "string", "description": "why the node should be passed: required with pass, and shown to the operator"},
"limit": limitArg(4, 52),
}),
Run: func(args map[string]any) (any, error) {
var v [3]string
for i, k := range []string{"round", "pass", "reason"} {
s, err := text(args, k, false)
if err != nil {
return nil, err
}
v[i] = s
}
if v[1] != "" {
return AskPass(upgradeState(StateRounds), upgradeState(StateRuns), upgradeState(StateAsks), v[0], v[1], v[2], m.Now())
}
n, err := whole(args, "limit", 4, 1, 52)
if err != nil {
return nil, err
}
return ListRounds(upgradeState(StateRounds), upgradeState(StateRuns), v[0], m.Now(), roundStartMinutes(), 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: "A node's packages that no repository it syncs carries, from the bus: built from the AUR or by hand, which the mesh neither " +
"upgrades nor removes (ADR 0507 §7). This node's by default. (r)",
Input: schema(map[string]any{"node": nodeArg}),
Run: func(args map[string]any) (any, error) {
node, err := nodeOf(args)
if err != nil {
return nil, err
}
return m.ForeignFrom(node)
},
},
{
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() },
},
}
}