473 lines
19 KiB
Go
473 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 node and a run, that run whole, every step with its time.",
|
|
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, with 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
|
|
}
|
|
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() },
|
|
},
|
|
}
|
|
}
|