Each node's reflector ranked its own list on its own timer, and on 2026-10-31 one node's lagging first mirror left it a Node.js version behind the rest. The list is now the module's state mirrors, key list: the node that opens a week's round ranks it with the module's reflector options, keeps only mirrors whose last sync is no older than the newest found, and the round's key names the list's revision. Every keeper writes /etc/pacman.d/mirrorlist whole from the key through the root helper's new verb mirrorlist, when the key changes and when the file differs, and never writes an empty or unreadable list. reflector.timer is declared stopped and disabled, and reflector.conf saves nothing. pacman_mirrors answers the shared list, its ranker and age and whether this node's file matches; with refresh it asks this node's keeper to rank again, refused while a round runs, naming it. The round's integration test proves the opening write and both nodes' files. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
483 lines
20 KiB
Go
483 lines
20 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: "Upgrade history: when each node's last system upgrade or rollback ran and how it ended; one run whole, with its steps.\n" +
|
|
"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: "Did this week's system upgrade run on every node, and how did each end (the weekly upgrade round, last upgrade of all nodes).\n" +
|
|
"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 mesh's one mirror list from the bus (ADR 0585): its servers, when and by which node it was ranked, its " +
|
|
"age and reflector's options, and whether this node's /etc/pacman.d/mirrorlist matches it (the file as found when " +
|
|
"not), with reflector's timer, which stays disabled. With refresh: true, this node's keeper ranks the list again " +
|
|
"for the whole mesh and every node writes it; refused while an upgrade round runs, naming the round.",
|
|
Input: schema(map[string]any{"refresh": map[string]any{"type": "boolean", "description": "rank the mesh's list again now"}}),
|
|
Run: func(args map[string]any) (any, error) {
|
|
refresh, err := flag(args, "refresh")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return m.MirrorsView(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() },
|
|
},
|
|
}
|
|
}
|