Merge pull request 'Workstation basics: fonts, docker-compose, snapd, flatpak, cups, bluetooth, xclip, dmenu (hq to-be 42 phase 2), tools in Go' (#269) from feat/phase-2-workstation-basics-rebased into main
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
# bluetooth
|
||||||
|
|
||||||
|
Bluetooth on the two workstations (novox/hq research 027/02, to-be 42 phase 2 step 9).
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| the Bluetooth stack and its daemon | package `bluez` |
|
||||||
|
| `bluetoothctl`, which the tools speak through | package `bluez-utils` |
|
||||||
|
| the daemon, running and enabled | `bluetooth.service` |
|
||||||
|
|
||||||
|
All official. `/etc/bluetooth/main.conf` is the package's file, unchanged on both workstations
|
||||||
|
(every setting commented out). The module states nothing in it, so it declares nothing there.
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **The stack is declared, not a dependency of something else.** On both workstations `bluez` is
|
||||||
|
installed only as a dependency. Removing the applet that pulled it in would have left it an orphan
|
||||||
|
for the next clean-up to take, and Bluetooth with it.
|
||||||
|
- **An owner for the daemon**, running and enabled on both today with nothing recording why.
|
||||||
|
- **Headphones from the mesh.** `bluetooth_connect` and `bluetooth_devices` (with battery) answer from
|
||||||
|
any machine, without the applet.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. bluez's bus policy lets
|
||||||
|
the account act; if it ever refuses (`AccessDenied`), the act is repeated through `sudo -n`. An act
|
||||||
|
whose output says it failed (`Failed to …`, `org.bluez.Error…`, `not available`) is an error, whatever
|
||||||
|
bluetoothctl's exit status.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `bluetooth_controller` (r) | address, name, powered, discoverable, pairable, discovering |
|
||||||
|
| `bluetooth_power` (r/a) | read, or switch the controller on or off |
|
||||||
|
| `bluetooth_devices` (r) | all, paired, connected or trusted devices: kind, paired, bonded, trusted, blocked, connected, battery where reported |
|
||||||
|
| `bluetooth_scan` (r) | discover for 1 to 15 s (default 8); the unpaired devices found, strongest signal first |
|
||||||
|
| `bluetooth_connect` / `bluetooth_disconnect` (a) | one device; connect waits up to 15 s |
|
||||||
|
| `bluetooth_trust` (a) | trust, or untrust |
|
||||||
|
| `bluetooth_pair` (a) | pair with an agent that confirms nothing (headphones, speakers), then trust. A device that shows a code is paired from the desktop |
|
||||||
|
| `bluetooth_remove` (a) | forget a device |
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
Nothing on disk on either workstation: both packages are installed, and the service is enabled and
|
||||||
|
running. `bluez` becomes explicitly the mesh's.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
|
||||||
|
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
|
||||||
|
That line is the `i3` module's to keep or drop.
|
||||||
|
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var macAddress = regexp.MustCompile(`^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$`)
|
||||||
|
|
||||||
|
func addressOf(args map[string]any) (string, error) {
|
||||||
|
a, err := text(args, "address")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if !macAddress.MatchString(a) {
|
||||||
|
return "", fmt.Errorf("%q is not a Bluetooth address (six hex pairs separated by colons)", a)
|
||||||
|
}
|
||||||
|
return strings.ToUpper(a), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]|\x01|\x02`)
|
||||||
|
|
||||||
|
// btFailed are the words bluetoothctl uses for an act that did not happen, whatever its exit status.
|
||||||
|
var btFailed = regexp.MustCompile(`(?m)(Failed to \w+|not available|org\.bluez\.Error\.\w+|No default controller available)`)
|
||||||
|
|
||||||
|
// bt runs bluetoothctl once, non-interactively, as the account; bluez's bus policy lets the
|
||||||
|
// account act, and if it refuses, the act is run through sudo -n.
|
||||||
|
func bt(timeout time.Duration, args ...string) (string, error) {
|
||||||
|
c := Cmd{Name: "bluetoothctl", Args: args, Timeout: timeout}
|
||||||
|
r := run(c)
|
||||||
|
if strings.Contains(r.Stdout+r.Stderr, "AccessDenied") || strings.Contains(r.Stdout+r.Stderr, "Not authorized") {
|
||||||
|
c.Root = true
|
||||||
|
r = run(c)
|
||||||
|
}
|
||||||
|
out := ansi.ReplaceAllString(r.Stdout+"\n"+r.Stderr, "")
|
||||||
|
if r.Error != "" {
|
||||||
|
return out, failure(c, r)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "No default controller available") {
|
||||||
|
return out, fmt.Errorf("this machine has no Bluetooth controller bluez can use: none is present, it is blocked (rfkill), or bluetooth.service is not running")
|
||||||
|
}
|
||||||
|
if m := btFailed.FindString(out); m != "" || r.Status != 0 {
|
||||||
|
said := strings.TrimSpace(out)
|
||||||
|
if said == "" {
|
||||||
|
said = fmt.Sprintf("exit status %d", r.Status)
|
||||||
|
}
|
||||||
|
return out, fmt.Errorf("bluetoothctl %s: %s", strings.Join(args, " "), tail(said, 1000))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields reads bluetoothctl's "\tKey: value" lines; a key seen twice keeps its first value.
|
||||||
|
func fields(s string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if !strings.HasPrefix(l, "\t") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(l), ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, seen := out[k]; !seen {
|
||||||
|
out[k] = strings.TrimSpace(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func yes(v string) bool { return v == "yes" }
|
||||||
|
|
||||||
|
// ControllerAnswer is what bluetooth_controller answers.
|
||||||
|
type ControllerAnswer struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Powered bool `json:"powered"`
|
||||||
|
PowerState string `json:"power_state,omitempty"`
|
||||||
|
Discoverable bool `json:"discoverable"`
|
||||||
|
Pairable bool `json:"pairable"`
|
||||||
|
Discovering bool `json:"discovering"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controller answers the default controller.
|
||||||
|
func Controller() (ControllerAnswer, error) {
|
||||||
|
out, err := bt(CallTimeout, "show")
|
||||||
|
if err != nil {
|
||||||
|
return ControllerAnswer{}, err
|
||||||
|
}
|
||||||
|
c := ControllerAnswer{}
|
||||||
|
for _, l := range strings.Split(out, "\n") {
|
||||||
|
if f := strings.Fields(l); len(f) >= 2 && f[0] == "Controller" {
|
||||||
|
c.Address = f[1]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Address == "" {
|
||||||
|
return ControllerAnswer{}, fmt.Errorf("bluetoothctl show answered no controller: %s", tail(strings.TrimSpace(out), 500))
|
||||||
|
}
|
||||||
|
f := fields(out)
|
||||||
|
c.Name, c.Alias, c.PowerState = f["Name"], f["Alias"], f["PowerState"]
|
||||||
|
c.Powered, c.Discoverable, c.Pairable, c.Discovering = yes(f["Powered"]), yes(f["Discoverable"]), yes(f["Pairable"]), yes(f["Discovering"])
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Power switches the controller on or off.
|
||||||
|
func Power(on bool) (map[string]any, error) {
|
||||||
|
word := "off"
|
||||||
|
if on {
|
||||||
|
word = "on"
|
||||||
|
}
|
||||||
|
if _, err := bt(CallTimeout, "power", word); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, err := Controller()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"powered": c.Powered, "asked": word}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device is one device bluez knows.
|
||||||
|
type Device struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Icon string `json:"kind,omitempty"`
|
||||||
|
Paired bool `json:"paired"`
|
||||||
|
Bonded bool `json:"bonded"`
|
||||||
|
Trusted bool `json:"trusted"`
|
||||||
|
Blocked bool `json:"blocked"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Battery *int `json:"battery_percent,omitempty"`
|
||||||
|
RSSI *int `json:"rssi,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var inParens = regexp.MustCompile(`\((-?[0-9]+)\)`)
|
||||||
|
|
||||||
|
// number reads "0x50 (80)" or "-62" as a number.
|
||||||
|
func number(v string) *int {
|
||||||
|
if m := inParens.FindStringSubmatch(v); m != nil {
|
||||||
|
v = m[1]
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &n
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceInfo asks bluez for one device.
|
||||||
|
func deviceInfo(address string) (Device, error) {
|
||||||
|
out, err := bt(CallTimeout, "info", address)
|
||||||
|
if err != nil {
|
||||||
|
return Device{}, err
|
||||||
|
}
|
||||||
|
f := fields(out)
|
||||||
|
d := Device{Address: address, Name: f["Name"], Icon: f["Icon"], Paired: yes(f["Paired"]), Bonded: yes(f["Bonded"]),
|
||||||
|
Trusted: yes(f["Trusted"]), Blocked: yes(f["Blocked"]), Connected: yes(f["Connected"])}
|
||||||
|
if d.Name == "" {
|
||||||
|
d.Name = f["Alias"]
|
||||||
|
}
|
||||||
|
if v, ok := f["Battery Percentage"]; ok {
|
||||||
|
d.Battery = number(v)
|
||||||
|
}
|
||||||
|
if v, ok := f["RSSI"]; ok {
|
||||||
|
d.RSSI = number(v)
|
||||||
|
}
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listed reads "Device <address> <name>" lines.
|
||||||
|
func listed(out string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
addrs := []string{}
|
||||||
|
for _, l := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Fields(strings.TrimSpace(l))
|
||||||
|
if len(f) >= 2 && f[0] == "Device" && macAddress.MatchString(f[1]) && !seen[f[1]] {
|
||||||
|
seen[f[1]] = true
|
||||||
|
addrs = append(addrs, f[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return addrs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Devices answers the devices bluez knows, with each one's state.
|
||||||
|
func Devices(which string) (map[string]any, error) {
|
||||||
|
if err := oneOf("which", which, "all", "paired", "connected", "trusted"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := []string{"devices"}
|
||||||
|
if which != "all" {
|
||||||
|
args = append(args, strings.ToUpper(which[:1])+which[1:])
|
||||||
|
}
|
||||||
|
out, err := bt(CallTimeout, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
devices := []Device{}
|
||||||
|
for _, a := range listed(out) {
|
||||||
|
d, err := deviceInfo(a)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
devices = append(devices, d)
|
||||||
|
}
|
||||||
|
sort.SliceStable(devices, func(i, k int) bool {
|
||||||
|
if devices[i].Connected != devices[k].Connected {
|
||||||
|
return devices[i].Connected
|
||||||
|
}
|
||||||
|
return devices[i].Name < devices[k].Name
|
||||||
|
})
|
||||||
|
return map[string]any{"which": which, "count": len(devices), "devices": devices}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan discovers for a while and answers the devices found that are not paired.
|
||||||
|
func Scan(seconds int) (map[string]any, error) {
|
||||||
|
// bluetoothctl's own --timeout ends the scan; the command's bound is a little longer.
|
||||||
|
limit := time.Duration(seconds+4) * time.Second
|
||||||
|
if _, err := bt(limit, "--timeout", strconv.Itoa(seconds), "scan", "on"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := bt(CallTimeout, "devices")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := []Device{}
|
||||||
|
for _, a := range listed(out) {
|
||||||
|
// A device seen a moment ago may have gone out of reach: it is skipped, not a failure.
|
||||||
|
d, err := deviceInfo(a)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !d.Paired {
|
||||||
|
found = append(found, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.SliceStable(found, func(i, k int) bool {
|
||||||
|
ri, rk := -1000, -1000
|
||||||
|
if found[i].RSSI != nil {
|
||||||
|
ri = *found[i].RSSI
|
||||||
|
}
|
||||||
|
if found[k].RSSI != nil {
|
||||||
|
rk = *found[k].RSSI
|
||||||
|
}
|
||||||
|
return ri > rk
|
||||||
|
})
|
||||||
|
return map[string]any{"seconds": seconds, "count": len(found), "found": found}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act runs one act on a device and answers the device's state afterwards.
|
||||||
|
func Act(verb, address string) (map[string]any, error) {
|
||||||
|
limit := CallTimeout
|
||||||
|
if verb == "connect" {
|
||||||
|
// A connect waits for the device; bluetoothctl's own timeout ends it first.
|
||||||
|
if _, err := bt(limit, "--timeout", "15", verb, address); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else if _, err := bt(limit, verb, address); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if verb == "remove" {
|
||||||
|
return map[string]any{"act": verb, "address": address, "removed": true}, nil
|
||||||
|
}
|
||||||
|
d, err := deviceInfo(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"act": verb, "device": d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair pairs a device with an agent that confirms nothing, then trusts it.
|
||||||
|
func Pair(address string) (map[string]any, error) {
|
||||||
|
if _, err := bt(CallTimeout, "--agent", "NoInputNoOutput", "--timeout", "15", "pair", address); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Act("trust", address)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsTheStackItsToolsAndTheDaemon(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "bluetooth")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "bluez,bluez-utils" {
|
||||||
|
t.Errorf("packages %s: the applet and the TUI are the operator's", got)
|
||||||
|
}
|
||||||
|
s := m.services()["bluetooth.service"]
|
||||||
|
if s == nil || s["state"] != "running" || s["boot"] != "enabled" {
|
||||||
|
t.Errorf("%v", s)
|
||||||
|
}
|
||||||
|
if len(m.Resources) != 3 {
|
||||||
|
t.Errorf("no configuration file: /etc/bluetooth/main.conf is the package's, unchanged on both workstations: %v", m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = "Controller 4C:82:A9:97:01:8E (public)\n\tName: g14\n\tAlias: g14\n\tPowered: yes\n\tPowerState: on\n\tDiscoverable: no\n\tPairable: yes\n\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n\tDiscovering: no\n"
|
||||||
|
|
||||||
|
func headphones(connected bool) string {
|
||||||
|
c := "no"
|
||||||
|
extra := ""
|
||||||
|
if connected {
|
||||||
|
c, extra = "yes", "\tBattery Percentage: 0x50 (80)\n"
|
||||||
|
}
|
||||||
|
return "Device 80:99:E7:C2:29:DA (public)\n\tName: WH-1000XM4\n\tAlias: WH-1000XM4\n\tIcon: audio-headset\n\tPaired: yes\n\tBonded: yes\n\tTrusted: yes\n\tBlocked: no\n\tConnected: " + c + "\n" + extra + "\tUUID: Headset (00001108-0000-1000-8000-00805f9b34fb)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControllerReadsShow(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result { return ok("\x1b[0;94m" + show) })
|
||||||
|
c, err := Controller()
|
||||||
|
if err != nil || c.Address != "4C:82:A9:97:01:8E" || c.Name != "g14" || !c.Powered || c.Discoverable || !c.Pairable {
|
||||||
|
t.Fatalf("%+v %v", c, err)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return ok("No default controller available\n") })
|
||||||
|
if _, err := Controller(); err == nil || !strings.Contains(err.Error(), "no Bluetooth controller") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDevicesAskEachOneAndReportBatteryWhereGiven(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
switch line {
|
||||||
|
case "bluetoothctl devices Paired":
|
||||||
|
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice 2C:41:A1:E4:EC:86 Earmuffs\n")
|
||||||
|
case "bluetoothctl info 80:99:E7:C2:29:DA":
|
||||||
|
return ok(headphones(true))
|
||||||
|
}
|
||||||
|
return ok("Device 2C:41:A1:E4:EC:86 (public)\n\tName: Earmuffs\n\tPaired: yes\n\tConnected: no\n")
|
||||||
|
})
|
||||||
|
got, err := Devices("paired")
|
||||||
|
devices := got["devices"].([]Device)
|
||||||
|
if err != nil || got["count"] != 2 || !devices[0].Connected || devices[0].Battery == nil || *devices[0].Battery != 80 || devices[1].Battery != nil {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "bluetoothctl devices Paired" {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
if _, err := Devices("nearby"); err == nil {
|
||||||
|
t.Error("an unknown which")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnActThatFailsIsAnErrorWhateverTheExitStatus(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
return ok("Attempting to connect to 80:99:E7:C2:29:DA\nFailed to connect: org.bluez.Error.Failed br-connection-page-timeout\n")
|
||||||
|
})
|
||||||
|
if _, err := Act("connect", "80:99:E7:C2:29:DA"); err == nil || !strings.Contains(err.Error(), "page-timeout") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stdout: "Device 00:11:22:33:44:55 not available\n"} })
|
||||||
|
if _, err := Act("trust", "00:11:22:33:44:55"); err == nil || !strings.Contains(err.Error(), "not available") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectWaitsWithBluetoothctlsOwnTimeoutAndAnswersTheState(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.Contains(line, "connect") {
|
||||||
|
return ok("Attempting to connect\n[CHG] Device Connected: yes\nConnection successful\n")
|
||||||
|
}
|
||||||
|
return ok(headphones(true))
|
||||||
|
})
|
||||||
|
got, err := Act("connect", "80:99:E7:C2:29:DA")
|
||||||
|
if err != nil || !got["device"].(Device).Connected {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "bluetoothctl --timeout 15 connect 80:99:E7:C2:29:DA" || f.asked[0].Timeout != CallTimeout {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnActBluezRefusesTheAccountIsRetriedThroughSudo(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.HasPrefix(line, "sudo") {
|
||||||
|
return ok("Changing power off succeeded\n" + show)
|
||||||
|
}
|
||||||
|
return ok("Failed to set power off: org.freedesktop.DBus.Error.AccessDenied\n")
|
||||||
|
})
|
||||||
|
if _, err := Power(false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l := f.lines(); l[0] != "bluetoothctl power off" || l[1] != "sudo -n bluetoothctl power off" {
|
||||||
|
t.Errorf("%v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScanIsBoundedAndAnswersUnpairedDevicesStrongestFirst(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(line, "scan on"):
|
||||||
|
return ok("Discovery started\n[NEW] Device AA:BB:CC:DD:EE:01 Speaker\n")
|
||||||
|
case line == "bluetoothctl devices":
|
||||||
|
return ok("Device 80:99:E7:C2:29:DA WH-1000XM4\nDevice AA:BB:CC:DD:EE:01 Speaker\nDevice AA:BB:CC:DD:EE:02 Phone\nDevice AA:BB:CC:DD:EE:03 Gone\n")
|
||||||
|
case strings.HasSuffix(line, "EE:01"):
|
||||||
|
return ok("Device AA:BB:CC:DD:EE:01\n\tName: Speaker\n\tPaired: no\n\tRSSI: 0xffffffc4 (-60)\n")
|
||||||
|
case strings.HasSuffix(line, "EE:02"):
|
||||||
|
return ok("Device AA:BB:CC:DD:EE:02\n\tName: Phone\n\tPaired: no\n\tRSSI: -40\n")
|
||||||
|
case strings.HasSuffix(line, "EE:03"):
|
||||||
|
return Result{Status: 1, Stdout: "Device AA:BB:CC:DD:EE:03 not available\n"}
|
||||||
|
}
|
||||||
|
return ok(headphones(false))
|
||||||
|
})
|
||||||
|
got, err := Scan(8)
|
||||||
|
found := got["found"].([]Device)
|
||||||
|
if err != nil || len(found) != 2 || found[0].Name != "Phone" || *found[1].RSSI != -60 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "bluetoothctl --timeout 8 scan on" || f.asked[0].Timeout.Seconds() != 12 {
|
||||||
|
t.Errorf("%v %v", f.lines()[0], f.asked[0].Timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPairUsesAnAgentThatConfirmsNothingAndThenTrusts(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.Contains(line, " pair ") {
|
||||||
|
return ok("Pairing successful\n")
|
||||||
|
}
|
||||||
|
return ok(headphones(false))
|
||||||
|
})
|
||||||
|
if _, err := Pair("80:99:e7:c2:29:da"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if l := f.lines(); l[0] != "bluetoothctl --agent NoInputNoOutput --timeout 15 pair 80:99:e7:c2:29:da" || l[1] != "bluetoothctl trust 80:99:e7:c2:29:da" {
|
||||||
|
t.Errorf("%v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnAddressIsSixHexPairs(t *testing.T) {
|
||||||
|
for _, bad := range []string{"", "80:99:E7:C2:29", "80:99:E7:C2:29:DA; rm", "--help", "GG:99:E7:C2:29:DA"} {
|
||||||
|
if _, err := addressOf(map[string]any{"address": bad}); err == nil {
|
||||||
|
t.Errorf("%q accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a, err := addressOf(map[string]any{"address": "80:99:e7:c2:29:da"}); err != nil || a != "80:99:E7:C2:29:DA" {
|
||||||
|
t.Errorf("%s %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
// The bluetooth module's tools (novox/hq research 027/02, 026/05): the controller, the devices with
|
||||||
|
// their state and battery, scanning, and pairing, connecting, trusting and forgetting a device. A Go
|
||||||
|
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it runs as the operator
|
||||||
|
// account, and speaks to bluez through bluetoothctl.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"bluetoothctl": "the bluez-utils package, which this module installs",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var addressArg = map[string]any{"type": "string", "description": "the device's address, such as 80:99:E7:C2:29:DA, as bluetooth_devices answers it"}
|
||||||
|
|
||||||
|
func withAddress(f func(string) (any, error)) func(map[string]any) (any, error) {
|
||||||
|
return func(args map[string]any) (any, error) {
|
||||||
|
a, err := addressOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return f(a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "bluetooth_controller",
|
||||||
|
Description: "The machine's Bluetooth controller: address, name, powered, discoverable, pairable, discovering. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Controller() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_power",
|
||||||
|
Description: "Whether the controller is powered; with on, switch it on or off. (r/a)",
|
||||||
|
Input: map[string]any{"on": map[string]any{"type": "boolean", "description": "power the controller on (true) or off (false)"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
if _, given := args["on"]; !given {
|
||||||
|
c, err := Controller()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"powered": c.Powered}, nil
|
||||||
|
}
|
||||||
|
on, err := optFlag(args, "on", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Power(on)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_devices",
|
||||||
|
Description: "The devices bluez knows: every one, or only the paired, connected or trusted. Each with its " +
|
||||||
|
"name, kind, paired, bonded, trusted, blocked, connected, and its battery where the device reports it. (r)",
|
||||||
|
Input: map[string]any{"which": map[string]any{"type": "string", "enum": []string{"all", "paired", "connected", "trusted"}, "description": "which devices (default all)"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
which, err := optText(args, "which", "all")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Devices(which)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_scan",
|
||||||
|
Description: "Discover devices nearby for a few seconds (default 8, at most 15), and answer the ones not " +
|
||||||
|
"paired, with their signal strength. (r)",
|
||||||
|
Input: map[string]any{"seconds": map[string]any{"type": "integer", "description": "how long to scan (default 8, at most 15)"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
s, err := optWhole(args, "seconds", 8, 1, 15)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Scan(s)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_connect",
|
||||||
|
Description: "Connect a paired device, such as headphones. Answers its state afterwards. (a)",
|
||||||
|
Input: map[string]any{"address": addressArg},
|
||||||
|
Run: withAddress(func(a string) (any, error) { return Act("connect", a) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_disconnect",
|
||||||
|
Description: "Disconnect a device. (a)",
|
||||||
|
Input: map[string]any{"address": addressArg},
|
||||||
|
Run: withAddress(func(a string) (any, error) { return Act("disconnect", a) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_trust",
|
||||||
|
Description: "Trust a device, so it may connect by itself; or with trusted false, stop trusting it. (a)",
|
||||||
|
Input: map[string]any{"address": addressArg, "trusted": map[string]any{"type": "boolean", "description": "trust (default) or untrust"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
a, err := addressOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
trusted, err := optFlag(args, "trusted", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if trusted {
|
||||||
|
return Act("trust", a)
|
||||||
|
}
|
||||||
|
return Act("untrust", a)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_pair",
|
||||||
|
Description: "Pair a device found by a scan, and trust it. Works for a device that needs no code to be " +
|
||||||
|
"confirmed, such as headphones; one that shows a code is paired from the desktop. (a)",
|
||||||
|
Input: map[string]any{"address": addressArg},
|
||||||
|
Run: withAddress(func(a string) (any, error) { return Pair(a) }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bluetooth_remove",
|
||||||
|
Description: "Forget a device: unpair it and drop what bluez knows of it. (a)",
|
||||||
|
Input: map[string]any{"address": addressArg},
|
||||||
|
Run: withAddress(func(a string) (any, error) { return Act("remove", a) }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module bluetooth
|
||||||
|
|
||||||
|
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,53 @@
|
|||||||
|
{
|
||||||
|
"module": "bluetooth",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager",
|
||||||
|
"service-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"bluetooth_controller",
|
||||||
|
"bluetooth_power",
|
||||||
|
"bluetooth_devices",
|
||||||
|
"bluetooth_scan",
|
||||||
|
"bluetooth_connect",
|
||||||
|
"bluetooth_disconnect",
|
||||||
|
"bluetooth_trust",
|
||||||
|
"bluetooth_pair",
|
||||||
|
"bluetooth_remove"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "stack",
|
||||||
|
"type": "package",
|
||||||
|
"package": "bluez"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "utilities",
|
||||||
|
"type": "package",
|
||||||
|
"package": "bluez-utils"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "daemon",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "bluetooth.service",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/bluetooth-tools",
|
||||||
|
"binary": "bluetooth-tools",
|
||||||
|
"loads": [
|
||||||
|
"bluetooth-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# cups
|
||||||
|
|
||||||
|
Printing on the two workstations (novox/hq research 027/02: "`cups` with the printer's driver"; to-be 42
|
||||||
|
phase 2 step 9).
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| the print scheduler | package `cups` |
|
||||||
|
| driverless printing: the filters that turn a document into what an IPP Everywhere printer takes | package `cups-filters` |
|
||||||
|
| the scheduler, started on demand and at boot | `cups.socket` and `cups.service`, running and enabled |
|
||||||
|
|
||||||
|
All from the official repositories. The queues (`/etc/cups/printers.conf`, the PPDs CUPS generates)
|
||||||
|
and the default printer are CUPS's own state, set through its tools. They are found (ADR 0182), and
|
||||||
|
the module declares none of them.
|
||||||
|
|
||||||
|
## The printer's driver: none needed for the Brother
|
||||||
|
|
||||||
|
Research 027 asked for "`cups` with the printer's driver". Measured on 2026-10-04:
|
||||||
|
|
||||||
|
| workstation | queue | device | prints through | driver package installed |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| laptop | `Brother` (MFC-L8690CDW) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8690cdw` (AUR), unused |
|
||||||
|
| desktop | `Brother_MFC_Novox` (default) | `ipp://` on the LAN | **IPP Everywhere, driverless** | `brother-mfc-l8390cdw` and its `-debug` (AUR), unused |
|
||||||
|
| desktop | `Kanjuro` (Canon PIXMA MG4200) | `cnijnet:` | the vendor's PPD and filter | `cnijfilter-mg4200` 3.80 (AUR) |
|
||||||
|
|
||||||
|
**Both Brother queues already print without a vendor driver.** CUPS's own IPP Everywhere support,
|
||||||
|
with `cups-filters`, is the whole driver. The vendor packages installed beside them serve no queue,
|
||||||
|
and the laptop's pulls in 32-bit glibc from multilib for a filter nothing runs. So the module declares
|
||||||
|
no driver, and the Brother needs nothing outside the official repositories.
|
||||||
|
|
||||||
|
**The Canon is the exception, and it is blocked.** Its driver is a user-repository package from 2012,
|
||||||
|
with its own network backend. Like snapd, it waits for the mesh's package repository (research 027
|
||||||
|
question 1, option P2). Until then it stays as found on the desktop. If the printer answers IPP (check
|
||||||
|
with `cups_drivers` on a fresh queue, or `driverless` from `cups-filters`), a driverless queue replaces
|
||||||
|
it and the question goes away.
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **An owner for the scheduler.** It runs on both workstations today, enabled by nothing the mesh records.
|
||||||
|
- **No driver package that nothing uses.** The README's one-off step below removes them, once.
|
||||||
|
- **Supplies and state from anywhere.** `cups_printers` answers each queue's toner levels and flags a
|
||||||
|
low one. It also answers why a queue stopped, without opening the printer's page.
|
||||||
|
- **The laptop has no default printer**, so a print without a named printer fails there.
|
||||||
|
`cups_default` sets one; it is the operator's choice, not declared.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account. CUPS lets any account
|
||||||
|
print and cancel its own jobs. Setting the default, resuming a printer, and cancelling another
|
||||||
|
account's job are kept for its administrators, so those go through `sudo -n`; a cancel tries the
|
||||||
|
account first.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `cups_printers` (r) | every queue: state, enabled, accepting, default, device, make and model, driverless or not, state reasons, supply levels (low flagged) |
|
||||||
|
| `cups_queue` (r) | jobs waiting or printing, or the completed ones, newest first, bounded |
|
||||||
|
| `cups_cancel` (a) | one job, or every job on a printer |
|
||||||
|
| `cups_print` (a) | a file on this machine to a printer or the default, with copies and IPP options; answers the job id |
|
||||||
|
| `cups_default` (r/a) | read the default, or set it |
|
||||||
|
| `cups_resume` (a) | enable a stopped printer and make it accept jobs |
|
||||||
|
| `cups_drivers` (r) | how each queue prints, the packages that bring filters and backends (foreign ones flagged), findings, and the driver models CUPS offers, filtered |
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
Nothing on disk on either workstation: both have `cups` (explicit) and `cups-filters` (as its
|
||||||
|
dependency), with `cups.socket` and `cups.service` enabled and running. The packages become the
|
||||||
|
mesh's; `cups-filters` is now declared explicitly.
|
||||||
|
|
||||||
|
## The one-off step for the operator (ADR 0182)
|
||||||
|
|
||||||
|
The mesh removes nothing it did not install. Once the Brother queues are confirmed printing (they do
|
||||||
|
today), remove the unused vendor drivers, once:
|
||||||
|
|
||||||
|
- **laptop:** `brother-mfc-l8690cdw`, then whatever `pacman -Qdtq` shows it alone pulled in
|
||||||
|
(`lib32-glibc`).
|
||||||
|
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
|
||||||
|
the Canon queue is used.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
|
||||||
|
`system-config-printer` on the desktop, and `cnijfilter-mg4200`.
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var queueName = regexp.MustCompile(`^[A-Za-z0-9_.@-]{1,127}$`)
|
||||||
|
|
||||||
|
func checkPrinter(p string) error {
|
||||||
|
if !queueName.MatchString(p) || strings.HasPrefix(p, "-") {
|
||||||
|
return fmt.Errorf("%q is not a printer queue's name", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var optionName = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`)
|
||||||
|
var optionValue = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,128}$`)
|
||||||
|
|
||||||
|
// optionsOf reads the print options object.
|
||||||
|
func optionsOf(args map[string]any) (map[string]string, error) {
|
||||||
|
v, ok := args["options"]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
m, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("options must be an object of names to values")
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for k, val := range m {
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok || !optionName.MatchString(k) || !optionValue.MatchString(s) {
|
||||||
|
return nil, fmt.Errorf("option %s=%v is not an IPP option name and a plain value", k, val)
|
||||||
|
}
|
||||||
|
out[k] = s
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// lpoptionsOf reads lpoptions' answer: name=value pairs, a value quoted with ' or with backslash escapes.
|
||||||
|
func lpoptionsOf(s string) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
for i < len(s) && s[i] == ' ' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
start := i
|
||||||
|
for i < len(s) && s[i] != '=' && s[i] != ' ' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
key := s[start:i]
|
||||||
|
if i >= len(s) || s[i] != '=' {
|
||||||
|
if key != "" {
|
||||||
|
out[key] = ""
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
var b strings.Builder
|
||||||
|
for i < len(s) && s[i] != ' ' {
|
||||||
|
switch s[i] {
|
||||||
|
case '\'':
|
||||||
|
i++
|
||||||
|
for i < len(s) && s[i] != '\'' {
|
||||||
|
if s[i] == '\\' && i+1 < len(s) {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
case '\\':
|
||||||
|
if i+1 < len(s) {
|
||||||
|
b.WriteByte(s[i+1])
|
||||||
|
}
|
||||||
|
i += 2
|
||||||
|
default:
|
||||||
|
b.WriteByte(s[i])
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out[key] = b.String()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker is one supply the printer reports.
|
||||||
|
type Marker struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Level int `json:"level_percent"`
|
||||||
|
Low bool `json:"low"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printer is one queue.
|
||||||
|
type Printer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Accepting bool `json:"accepting"`
|
||||||
|
Default bool `json:"default"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
MakeModel string `json:"make_and_model"`
|
||||||
|
Driverless bool `json:"driverless"`
|
||||||
|
Shared bool `json:"shared"`
|
||||||
|
Reasons []string `json:"reasons"`
|
||||||
|
Markers []Marker `json:"markers"`
|
||||||
|
Since string `json:"since,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintersAnswer is what cups_printers answers.
|
||||||
|
type PrintersAnswer struct {
|
||||||
|
Scheduler string `json:"scheduler"`
|
||||||
|
Default string `json:"default,omitempty"`
|
||||||
|
Printers []Printer `json:"printers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func lpstat(args ...string) (string, error) {
|
||||||
|
r, err := call(Cmd{Name: "lpstat", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(r.Stderr, "Scheduler is not running") || strings.Contains(r.Stderr, "Connection refused") {
|
||||||
|
return "", fmt.Errorf("the print scheduler is not running on this machine: %s", firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
// lpstat answers "No destinations added." with a non-zero status: that is no printers.
|
||||||
|
if strings.Contains(r.Stderr, "No destinations added") {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r.Stdout, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPrinter() (string, error) {
|
||||||
|
out, err := lpstat("-d")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, after, ok := strings.Cut(out, "system default destination: "); ok {
|
||||||
|
return strings.TrimSpace(firstLine(after)), nil
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Printers answers every queue with its state, device, model and supplies.
|
||||||
|
func Printers() (PrintersAnswer, error) {
|
||||||
|
out := PrintersAnswer{Printers: []Printer{}}
|
||||||
|
sched, err := lpstat("-r")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Scheduler = strings.TrimSpace(sched)
|
||||||
|
if out.Default, err = defaultPrinter(); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
ps, err := lpstat("-p")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
var cur *Printer
|
||||||
|
for _, l := range strings.Split(ps, "\n") {
|
||||||
|
if strings.HasPrefix(l, "printer ") {
|
||||||
|
f := strings.Fields(l)
|
||||||
|
if len(f) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Printers = append(out.Printers, Printer{Name: f[1], Reasons: []string{}, Markers: []Marker{}})
|
||||||
|
cur = &out.Printers[len(out.Printers)-1]
|
||||||
|
rest := strings.Join(f[2:], " ")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(rest, "is idle"):
|
||||||
|
cur.State = "idle"
|
||||||
|
case strings.HasPrefix(rest, "now printing"):
|
||||||
|
cur.State = "printing"
|
||||||
|
default:
|
||||||
|
cur.State = "stopped"
|
||||||
|
}
|
||||||
|
// "disabled since …" (stopped), or "enabled since …" after the state.
|
||||||
|
cur.Enabled = !strings.HasPrefix(rest, "disabled") && !strings.Contains(rest, "disabled since")
|
||||||
|
if _, since, found := strings.Cut(rest, " since "); found {
|
||||||
|
cur.Since = strings.TrimSuffix(strings.TrimSpace(since), " -")
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur != nil && strings.HasPrefix(l, "\t") && strings.TrimSpace(l) != "" {
|
||||||
|
cur.Message = strings.TrimSpace(cur.Message + " " + strings.TrimSpace(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc, err := lpstat("-a")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
accepting := map[string]bool{}
|
||||||
|
for _, l := range lines(acc) {
|
||||||
|
if f := strings.Fields(l); len(f) >= 2 && f[1] == "accepting" {
|
||||||
|
accepting[f[0]] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out.Printers {
|
||||||
|
p := &out.Printers[i]
|
||||||
|
p.Accepting = accepting[p.Name]
|
||||||
|
p.Default = p.Name == out.Default
|
||||||
|
r, err := call(Cmd{Name: "lpoptions", Args: []string{"-p", p.Name}})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
o := lpoptionsOf(r.Stdout)
|
||||||
|
p.URI = o["device-uri"]
|
||||||
|
p.MakeModel = o["printer-make-and-model"]
|
||||||
|
p.Driverless = driverless(p.MakeModel, p.URI)
|
||||||
|
p.Shared = o["printer-is-shared"] == "true"
|
||||||
|
for _, reason := range strings.Split(o["printer-state-reasons"], ",") {
|
||||||
|
if reason = strings.TrimSpace(reason); reason != "" && reason != "none" {
|
||||||
|
p.Reasons = append(p.Reasons, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Markers = markersOf(o)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverless says a queue prints without a vendor driver: CUPS's own IPP Everywhere model, or a
|
||||||
|
// driverless URI.
|
||||||
|
func driverless(model, uri string) bool {
|
||||||
|
m := strings.ToLower(model)
|
||||||
|
return strings.Contains(m, "ipp everywhere") || strings.Contains(m, "driverless") || strings.HasPrefix(uri, "implicitclass:") ||
|
||||||
|
strings.HasPrefix(uri, "ipp://") && strings.Contains(m, "everywhere")
|
||||||
|
}
|
||||||
|
|
||||||
|
func markersOf(o map[string]string) []Marker {
|
||||||
|
split := func(k string) []string {
|
||||||
|
if o[k] == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return strings.Split(o[k], ",")
|
||||||
|
}
|
||||||
|
names, levels, lows, types := split("marker-names"), split("marker-levels"), split("marker-low-levels"), split("marker-types")
|
||||||
|
out := []Marker{}
|
||||||
|
for i, n := range names {
|
||||||
|
if i >= len(levels) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
level, err := strconv.Atoi(strings.TrimSpace(levels[i]))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := Marker{Name: strings.TrimSpace(n), Level: level}
|
||||||
|
if i < len(types) {
|
||||||
|
m.Type = strings.TrimSpace(types[i])
|
||||||
|
}
|
||||||
|
if i < len(lows) {
|
||||||
|
if low, err := strconv.Atoi(strings.TrimSpace(lows[i])); err == nil && level >= 0 && level <= low {
|
||||||
|
m.Low = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Job is one print job.
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Printer string `json:"printer"`
|
||||||
|
User string `json:"user"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Submitted string `json:"submitted"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue answers the jobs waiting, or the finished ones.
|
||||||
|
func Queue(printer string, completed bool, limit int) (map[string]any, error) {
|
||||||
|
args := []string{}
|
||||||
|
if completed {
|
||||||
|
args = append(args, "-W", "completed")
|
||||||
|
}
|
||||||
|
args = append(args, "-o")
|
||||||
|
if printer != "" {
|
||||||
|
if err := checkPrinter(printer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args = append(args, printer)
|
||||||
|
}
|
||||||
|
out, err := lpstat(args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs := []Job{}
|
||||||
|
for _, l := range lines(out) {
|
||||||
|
f := strings.Fields(l)
|
||||||
|
if len(f) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i := strings.LastIndex(f[0], "-")
|
||||||
|
if i <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size, _ := strconv.ParseInt(f[2], 10, 64)
|
||||||
|
jobs = append(jobs, Job{ID: f[0], Printer: f[0][:i], User: f[1], Bytes: size, Submitted: strings.Join(f[3:], " ")})
|
||||||
|
}
|
||||||
|
sort.SliceStable(jobs, func(a, b int) bool { return jobNumber(jobs[a].ID) > jobNumber(jobs[b].ID) })
|
||||||
|
total := len(jobs)
|
||||||
|
if len(jobs) > limit {
|
||||||
|
jobs = jobs[:limit]
|
||||||
|
}
|
||||||
|
return map[string]any{"count": total, "jobs": jobs, "completed": completed}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobNumber(id string) int {
|
||||||
|
n, _ := strconv.Atoi(id[strings.LastIndex(id, "-")+1:])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobID = regexp.MustCompile(`^([A-Za-z0-9_.@-]+-)?[0-9]+$`)
|
||||||
|
|
||||||
|
// refused says CUPS kept an act for its administrators.
|
||||||
|
func refused(r Result) bool {
|
||||||
|
s := r.Stderr + r.Stdout
|
||||||
|
return strings.Contains(s, "Forbidden") || strings.Contains(s, "not-authorized") || strings.Contains(s, "Not authorized") || strings.Contains(s, "not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// asAccountThenRoot runs an act as the account, and through sudo -n when CUPS refuses the account.
|
||||||
|
func asAccountThenRoot(c Cmd) (Result, bool, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, false, nil
|
||||||
|
}
|
||||||
|
if !refused(r) {
|
||||||
|
return r, false, failure(c, r)
|
||||||
|
}
|
||||||
|
c.Root = true
|
||||||
|
r, err := call(c)
|
||||||
|
return r, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel cancels one job, or every job on a printer.
|
||||||
|
func Cancel(job, printer string, all bool) (map[string]any, error) {
|
||||||
|
var c Cmd
|
||||||
|
switch {
|
||||||
|
case all:
|
||||||
|
if printer == "" {
|
||||||
|
return nil, fmt.Errorf("all needs printer: cancelling every job on every printer is not offered")
|
||||||
|
}
|
||||||
|
if err := checkPrinter(printer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c = Cmd{Name: "cancel", Args: []string{"-a", printer}}
|
||||||
|
case job != "":
|
||||||
|
if !jobID.MatchString(job) {
|
||||||
|
return nil, fmt.Errorf("%q is not a job id", job)
|
||||||
|
}
|
||||||
|
c = Cmd{Name: "cancel", Args: []string{job}}
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("give job, or printer with all")
|
||||||
|
}
|
||||||
|
_, escalated, err := asAccountThenRoot(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"cancelled": strings.Join(c.Args, " "), "as_root": escalated}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestID = regexp.MustCompile(`request id is (\S+)`)
|
||||||
|
|
||||||
|
// statFile tells a regular file. Tests replace it.
|
||||||
|
var statFile = func(p string) error {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("%s is not a regular file", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print sends a file to a printer.
|
||||||
|
func Print(file, printer string, copies int, opts map[string]string, title string) (map[string]any, error) {
|
||||||
|
if !filepath.IsAbs(file) {
|
||||||
|
return nil, fmt.Errorf("file must be an absolute path, not %q", file)
|
||||||
|
}
|
||||||
|
if err := statFile(file); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot print %s: %v", file, err)
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
if printer != "" {
|
||||||
|
if err := checkPrinter(printer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args = append(args, "-d", printer)
|
||||||
|
}
|
||||||
|
args = append(args, "-n", strconv.Itoa(copies))
|
||||||
|
names := make([]string, 0, len(opts))
|
||||||
|
for k := range opts {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, k := range names {
|
||||||
|
args = append(args, "-o", k+"="+opts[k])
|
||||||
|
}
|
||||||
|
if title == "" {
|
||||||
|
title = filepath.Base(file)
|
||||||
|
}
|
||||||
|
args = append(args, "-t", title, "--", file)
|
||||||
|
r, err := call(Cmd{Name: "lp", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(r.Stderr, "No default destination") {
|
||||||
|
return nil, fmt.Errorf("no printer given and this machine has no default printer: name one, or set one with cups_default")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := requestID.FindStringSubmatch(r.Stdout)
|
||||||
|
if m == nil {
|
||||||
|
return nil, fmt.Errorf("lp answered no job id: %s", strings.TrimSpace(r.Stdout+r.Stderr))
|
||||||
|
}
|
||||||
|
return map[string]any{"job": m[1], "file": file, "copies": copies, "follow": "cups_queue"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default answers the default printer, or sets it.
|
||||||
|
func Default(printer string) (map[string]any, error) {
|
||||||
|
if printer == "" {
|
||||||
|
d, err := defaultPrinter()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"default": d}, nil
|
||||||
|
}
|
||||||
|
if err := checkPrinter(printer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
was, err := defaultPrinter()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := call(Cmd{Name: "lpadmin", Args: []string{"-d", printer}, Root: true}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"default": printer, "was": was}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resume enables a printer and makes it accept jobs.
|
||||||
|
func Resume(printer string) (map[string]any, error) {
|
||||||
|
if err := checkPrinter(printer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, c := range []string{"cupsenable", "cupsaccept"} {
|
||||||
|
if _, err := call(Cmd{Name: c, Args: []string{printer}, Root: true}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"printer": printer, "enabled": true, "accepting": true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriverPackage is a package that brings filters or backends.
|
||||||
|
type DriverPackage struct {
|
||||||
|
Package string `json:"package"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Foreign bool `json:"foreign"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Model is one driver model CUPS offers.
|
||||||
|
type Model struct {
|
||||||
|
PPD string `json:"ppd"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueueDriver is how one queue prints.
|
||||||
|
type QueueDriver struct {
|
||||||
|
Printer string `json:"printer"`
|
||||||
|
MakeModel string `json:"make_and_model"`
|
||||||
|
Driverless bool `json:"driverless"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DriversAnswer is what cups_drivers answers.
|
||||||
|
type DriversAnswer struct {
|
||||||
|
Queues []QueueDriver `json:"queues"`
|
||||||
|
Packages []DriverPackage `json:"driver_packages"`
|
||||||
|
Models []Model `json:"models"`
|
||||||
|
Matched int `json:"models_matched"`
|
||||||
|
Findings []string `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// driverDirs are where drivers put what CUPS runs.
|
||||||
|
var driverDirs = []string{"/usr/lib/cups/filter", "/usr/lib/cups/backend"}
|
||||||
|
|
||||||
|
// basePackages bring CUPS's own filters and backends, not a printer's driver.
|
||||||
|
var basePackages = map[string]bool{"cups": true, "cups-filters": true, "libcups": true, "ghostscript": true, "cups-pdf": false}
|
||||||
|
|
||||||
|
// Drivers answers how each queue prints and which packages bring drivers.
|
||||||
|
func Drivers(match string, limit int) (DriversAnswer, error) {
|
||||||
|
out := DriversAnswer{Queues: []QueueDriver{}, Packages: []DriverPackage{}, Models: []Model{}, Findings: []string{}}
|
||||||
|
ps, err := Printers()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, p := range ps.Printers {
|
||||||
|
out.Queues = append(out.Queues, QueueDriver{Printer: p.Name, MakeModel: p.MakeModel, Driverless: p.Driverless, URI: p.URI})
|
||||||
|
}
|
||||||
|
r := run(Cmd{Name: "pacman", Args: append([]string{"-Qo"}, driverDirs...)})
|
||||||
|
if r.Error != "" {
|
||||||
|
return out, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
if _, after, ok := strings.Cut(l, " is owned by "); ok {
|
||||||
|
f := strings.Fields(after)
|
||||||
|
if len(f) >= 2 && !seen[f[0]] && !basePackages[f[0]] {
|
||||||
|
seen[f[0]] = true
|
||||||
|
out.Packages = append(out.Packages, DriverPackage{Package: f[0], Version: f[1]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out.Packages) > 0 {
|
||||||
|
r := run(Cmd{Name: "pacman", Args: []string{"-Qqm"}})
|
||||||
|
foreign := map[string]bool{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
foreign[strings.TrimSpace(l)] = true
|
||||||
|
}
|
||||||
|
for i := range out.Packages {
|
||||||
|
out.Packages[i].Foreign = foreign[out.Packages[i].Package]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Packages, func(i, k int) bool { return out.Packages[i].Package < out.Packages[k].Package })
|
||||||
|
m, err := call(Cmd{Name: "lpinfo", Args: []string{"-m"}})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, l := range lines(m.Stdout) {
|
||||||
|
ppd, desc, _ := strings.Cut(l, " ")
|
||||||
|
if match != "" && !strings.Contains(strings.ToLower(desc), strings.ToLower(match)) && !strings.Contains(strings.ToLower(ppd), strings.ToLower(match)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Matched++
|
||||||
|
if len(out.Models) < limit {
|
||||||
|
out.Models = append(out.Models, Model{PPD: ppd, Description: desc})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Which driver packages no queue prints through.
|
||||||
|
allDriverless := len(out.Queues) > 0
|
||||||
|
for _, q := range out.Queues {
|
||||||
|
allDriverless = allDriverless && q.Driverless
|
||||||
|
}
|
||||||
|
for _, p := range out.Packages {
|
||||||
|
if p.Foreign {
|
||||||
|
out.Findings = append(out.Findings, "driver package "+p.Package+" is not from the official repositories")
|
||||||
|
}
|
||||||
|
if allDriverless {
|
||||||
|
out.Findings = append(out.Findings, "every queue prints driverless, so no queue uses the driver package "+p.Package)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsTheSchedulerAndDriverlessPrintingAndNoVendorDriver(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "cups")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "cups,cups-filters" {
|
||||||
|
t.Errorf("packages %s: a vendor driver is from outside the official repositories, and no queue measured needs one but the desktop's Canon", got)
|
||||||
|
}
|
||||||
|
s := m.services()
|
||||||
|
for _, u := range []string{"cups.socket", "cups.service"} {
|
||||||
|
if s[u] == nil || s[u]["state"] != "running" || s[u]["boot"] != "enabled" {
|
||||||
|
t.Errorf("%s: %v", u, s[u])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "file" {
|
||||||
|
t.Errorf("the queues and cupsd's files are CUPS's own: %v", r["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The desktop's two queues on 2026-10-04.
|
||||||
|
func theDesktop(t *testing.T, more func(line string, c Cmd) (Result, bool)) *fake {
|
||||||
|
return using(t, func(line string, c Cmd) Result {
|
||||||
|
if more != nil {
|
||||||
|
if r, handled := more(line, c); handled {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch line {
|
||||||
|
case "lpstat -r":
|
||||||
|
return ok("scheduler is running\n")
|
||||||
|
case "lpstat -d":
|
||||||
|
return ok("system default destination: Brother_MFC_Novox\n")
|
||||||
|
case "lpstat -p":
|
||||||
|
return ok("printer Brother_MFC_Novox is idle. enabled since Mon Jun 29 21:34:30 2026\n" +
|
||||||
|
"printer Kanjuro disabled since Sun Jun 9 11:16:03 2024 -\n\tPaused\n")
|
||||||
|
case "lpstat -a":
|
||||||
|
return ok("Brother_MFC_Novox accepting requests since Mon Jun 29 21:34:30 2026\nKanjuro not accepting requests since Sun Jun 9 11:16:03 2024 -\n\tRejecting Jobs\n")
|
||||||
|
case "lpoptions -p Brother_MFC_Novox":
|
||||||
|
return ok(`copies=1 device-uri=ipp://192.0.2.171/ipp/port1 finishings=3 marker-levels=70,100,8,100 marker-low-levels=10,10,10,10 marker-names='Black\ Toner\ Cartridge,Cyan\ Toner\ Cartridge,Magenta\ Toner\ Cartridge,Yellow\ Toner\ Cartridge' marker-types=toner,toner,toner,toner printer-is-shared=false printer-make-and-model='Printer - IPP Everywhere' printer-state-reasons=none`)
|
||||||
|
case "lpoptions -p Kanjuro":
|
||||||
|
return ok(`device-uri=cnijnet:/18-0C-AC-B0-62-83 printer-is-shared=false printer-make-and-model='Canon MG4200 series Ver.3.80' printer-state-reasons=paused`)
|
||||||
|
}
|
||||||
|
return Result{Status: 9, Stderr: "unexpected " + line}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintersReadsStateDeviceModelAndSupplies(t *testing.T) {
|
||||||
|
theDesktop(t, nil)
|
||||||
|
got, err := Printers()
|
||||||
|
if err != nil || len(got.Printers) != 2 || got.Default != "Brother_MFC_Novox" || got.Scheduler != "scheduler is running" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
b, k := got.Printers[0], got.Printers[1]
|
||||||
|
if b.State != "idle" || !b.Enabled || !b.Accepting || !b.Default || !b.Driverless || b.URI != "ipp://192.0.2.171/ipp/port1" || len(b.Reasons) != 0 {
|
||||||
|
t.Errorf("%+v", b)
|
||||||
|
}
|
||||||
|
if len(b.Markers) != 4 || b.Markers[0].Name != "Black Toner Cartridge" || b.Markers[2].Level != 8 || !b.Markers[2].Low || b.Markers[0].Low {
|
||||||
|
t.Errorf("markers %+v", b.Markers)
|
||||||
|
}
|
||||||
|
if k.State != "stopped" || k.Enabled || k.Accepting || k.Driverless || strings.Join(k.Reasons, ",") != "paused" || k.Message != "Paused" {
|
||||||
|
t.Errorf("%+v", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintersWithoutAQueueOrAScheduler(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
if line == "lpstat -r" {
|
||||||
|
return ok("scheduler is running\n")
|
||||||
|
}
|
||||||
|
return Result{Status: 1, Stderr: "lpstat: No destinations added.\n"}
|
||||||
|
})
|
||||||
|
got, err := Printers()
|
||||||
|
if err != nil || len(got.Printers) != 0 {
|
||||||
|
t.Fatalf("no queue is an empty answer, not a failure: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lpstat: Scheduler is not running.\n"} })
|
||||||
|
if _, err := Printers(); err == nil || !strings.Contains(err.Error(), "scheduler is not running") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLpoptionsQuotingIsRead(t *testing.T) {
|
||||||
|
o := lpoptionsOf(`a=1 b='x y' c=p\ q d e=`)
|
||||||
|
if o["a"] != "1" || o["b"] != "x y" || o["c"] != "p q" || o["d"] != "" || o["e"] != "" {
|
||||||
|
t.Errorf("%v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQueueReadsJobsNewestFirstAndBounded(t *testing.T) {
|
||||||
|
f := using(t, func(string, Cmd) Result {
|
||||||
|
return ok("Brother-6 jochen 1024 Mon Jul 8 12:15:34 2024\nBrother-8 jochen 2048 Mon Jul 8 12:19:56 2024\nBrother-7 other 1024 Mon Jul 8 12:15:23 2024\n")
|
||||||
|
})
|
||||||
|
got, err := Queue("Brother", true, 2)
|
||||||
|
jobs := got["jobs"].([]Job)
|
||||||
|
if err != nil || got["count"] != 3 || len(jobs) != 2 || jobs[0].ID != "Brother-8" || jobs[0].Printer != "Brother" || jobs[0].Bytes != 2048 || jobs[0].Submitted != "Mon Jul 8 12:19:56 2024" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "lpstat -W completed -o Brother" {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
if _, err := Queue("-h", false, 5); err == nil {
|
||||||
|
t.Error("an option as a printer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCancelTriesTheAccountThenRootWhenCUPSRefusesIt(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if !strings.HasPrefix(line, "sudo") {
|
||||||
|
return Result{Status: 1, Stderr: "cancel: Forbidden\n"}
|
||||||
|
}
|
||||||
|
return ok("")
|
||||||
|
})
|
||||||
|
got, err := Cancel("Brother-12", "", false)
|
||||||
|
if err != nil || got["as_root"] != true {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if strings.Join(f.lines(), "|") != "cancel Brother-12|sudo -n cancel Brother-12" {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
f = using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
if got, err := Cancel("", "Brother", true); err != nil || got["as_root"] != false || f.lines()[0] != "cancel -a Brother" {
|
||||||
|
t.Fatalf("%v %v %v", got, err, f.lines())
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "cancel: Unknown job 99\n"} })
|
||||||
|
if _, err := Cancel("99", "", false); err == nil || !strings.Contains(err.Error(), "Unknown job") {
|
||||||
|
t.Errorf("a failure that is not a refusal is not retried as root: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range [][3]string{{"", "", ""}, {"12; rm", "", ""}, {"", "", "all"}} {
|
||||||
|
if _, err := Cancel(bad[0], bad[1], bad[2] == "all"); err == nil {
|
||||||
|
t.Errorf("%v accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintChecksTheFileAndOptionsAndAnswersTheJob(t *testing.T) {
|
||||||
|
was := statFile
|
||||||
|
defer func() { statFile = was }()
|
||||||
|
statFile = func(p string) error {
|
||||||
|
if p == "/home/op/doc.pdf" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errString("no such file")
|
||||||
|
}
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("request id is Brother-13 (1 file(s))\n") })
|
||||||
|
got, err := Print("/home/op/doc.pdf", "Brother", 2, map[string]string{"sides": "two-sided-long-edge", "media": "A4"}, "")
|
||||||
|
if err != nil || got["job"] != "Brother-13" {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if l := f.lines()[0]; l != "lp -d Brother -n 2 -o media=A4 -o sides=two-sided-long-edge -t doc.pdf -- /home/op/doc.pdf" {
|
||||||
|
t.Errorf("%s", l)
|
||||||
|
}
|
||||||
|
if _, err := Print("doc.pdf", "", 1, nil, ""); err == nil {
|
||||||
|
t.Error("a relative file")
|
||||||
|
}
|
||||||
|
if _, err := Print("/home/op/missing.pdf", "", 1, nil, ""); err == nil {
|
||||||
|
t.Error("a missing file")
|
||||||
|
}
|
||||||
|
if _, err := optionsOf(map[string]any{"options": map[string]any{"sides": "x y"}}); err == nil {
|
||||||
|
t.Error("an option value with a space")
|
||||||
|
}
|
||||||
|
if _, err := optionsOf(map[string]any{"options": map[string]any{"-o": "x"}}); err == nil {
|
||||||
|
t.Error("an option name that is an option")
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "lp: Error - No default destination."} })
|
||||||
|
if _, err := Print("/home/op/doc.pdf", "", 1, nil, ""); err == nil || !strings.Contains(err.Error(), "no default printer") {
|
||||||
|
t.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errString string
|
||||||
|
|
||||||
|
func (e errString) Error() string { return string(e) }
|
||||||
|
|
||||||
|
func TestDefaultReadsAndSetsThroughSudo(t *testing.T) {
|
||||||
|
f := theDesktop(t, func(line string, c Cmd) (Result, bool) {
|
||||||
|
if strings.HasPrefix(line, "sudo -n lpadmin") {
|
||||||
|
return ok(""), true
|
||||||
|
}
|
||||||
|
return Result{}, false
|
||||||
|
})
|
||||||
|
got, err := Default("")
|
||||||
|
if err != nil || got["default"] != "Brother_MFC_Novox" {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = Default("Kanjuro")
|
||||||
|
if err != nil || got["default"] != "Kanjuro" || got["was"] != "Brother_MFC_Novox" {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if l := f.lines(); l[len(l)-1] != "sudo -n lpadmin -d Kanjuro" {
|
||||||
|
t.Errorf("%v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResumeEnablesAndAcceptsThroughSudo(t *testing.T) {
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
if _, err := Resume("Kanjuro"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Join(f.lines(), "|") != "sudo -n cupsenable Kanjuro|sudo -n cupsaccept Kanjuro" {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriversNamesForeignDriverPackagesAndOnesNoQueueUses(t *testing.T) {
|
||||||
|
theDesktop(t, func(line string, c Cmd) (Result, bool) {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "pacman -Qo"):
|
||||||
|
return ok("/usr/lib/cups/filter/ is owned by brother-mfc-l8390cdw 3.5.1-2\n/usr/lib/cups/filter/ is owned by cups 2:2.4.19-1\n/usr/lib/cups/filter/ is owned by cups-filters 2.0.1-2\n/usr/lib/cups/backend/ is owned by cnijfilter-mg4200 3.80-6\n/usr/lib/cups/backend/ is owned by cups 2:2.4.19-1\n"), true
|
||||||
|
case line == "pacman -Qqm":
|
||||||
|
return ok("brother-mfc-l8390cdw\ncnijfilter-mg4200\nsnapd\n"), true
|
||||||
|
case line == "lpinfo -m":
|
||||||
|
return ok("drv:///sample.drv/dymo.ppd DYMO Label Printer\ncanonmg4200.ppd Canon MG4200 series Ver.3.80\neverywhere IPP Everywhere\n"), true
|
||||||
|
}
|
||||||
|
return Result{}, false
|
||||||
|
})
|
||||||
|
got, err := Drivers("canon", 10)
|
||||||
|
if err != nil || len(got.Queues) != 2 || len(got.Packages) != 2 || got.Matched != 1 || got.Models[0].PPD != "canonmg4200.ppd" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if !got.Packages[0].Foreign || got.Packages[0].Package != "brother-mfc-l8390cdw" {
|
||||||
|
t.Errorf("%+v", got.Packages)
|
||||||
|
}
|
||||||
|
all := strings.Join(got.Findings, ";")
|
||||||
|
if !strings.Contains(all, "cnijfilter-mg4200 is not from the official") || strings.Contains(all, "every queue prints driverless") {
|
||||||
|
t.Errorf("the Canon queue uses its driver, so not every queue is driverless: %s", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// The cups module's tools (novox/hq research 027/02, 026/05): the printers, their state, supplies and
|
||||||
|
// driver, the queue, and printing, cancelling and choosing the default. A Go bundle the node's runtime
|
||||||
|
// launches over stdio (ADR 0188, ADR 0193); it runs as the operator account. An act CUPS keeps for
|
||||||
|
// its administrators goes through `sudo -n`.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"lpstat": "the cups package, which this module installs",
|
||||||
|
"lpoptions": "the cups package, which this module installs",
|
||||||
|
"lp": "the cups package, which this module installs",
|
||||||
|
"cancel": "the cups package, which this module installs",
|
||||||
|
"lpadmin": "the cups package, which this module installs",
|
||||||
|
"lpinfo": "the cups package, which this module installs",
|
||||||
|
"cupsenable": "the cups package, which this module installs",
|
||||||
|
"cupsaccept": "the cups package, which this module installs",
|
||||||
|
"pacman": "this is not an Arch machine",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var printerArg = map[string]any{"type": "string", "description": "the printer's queue name, as cups_printers answers it"}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "cups_printers",
|
||||||
|
Description: "Every printer queue: state, whether it is enabled and accepting jobs, the default, its device " +
|
||||||
|
"address, make and model, whether it prints driverless (IPP Everywhere), the reasons for its state, and " +
|
||||||
|
"supply levels where the printer reports them. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Printers() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_queue",
|
||||||
|
Description: "The jobs waiting or printing, on every printer or one; or, with completed, the finished ones. " +
|
||||||
|
"Each with its id, printer, owner, size and when it was submitted. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"printer": printerArg,
|
||||||
|
"completed": map[string]any{"type": "boolean", "description": "the finished jobs instead"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "at most this many, newest first (default 50, at most 500)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
p, err := optText(args, "printer", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
done, err := optFlag(args, "completed", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
limit, err := optWhole(args, "limit", 50, 1, 500)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Queue(p, done, limit)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_cancel",
|
||||||
|
Description: "Cancel one job by its id (\"Brother-12\" or 12), or every job on a printer with all. Another " +
|
||||||
|
"account's job is cancelled through sudo -n. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"job": map[string]any{"type": "string", "description": "the job id"},
|
||||||
|
"printer": printerArg,
|
||||||
|
"all": map[string]any{"type": "boolean", "description": "every job on printer"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
job, err := optText(args, "job", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := optText(args, "printer", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
all, err := optFlag(args, "all", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Cancel(job, p, all)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_print",
|
||||||
|
Description: "Print a file on this machine, to a printer or the default, with copies and IPP options such as " +
|
||||||
|
"sides=two-sided-long-edge or media=A4. Answers the job id. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"file": map[string]any{"type": "string", "description": "the file's absolute path on this machine"},
|
||||||
|
"printer": printerArg,
|
||||||
|
"copies": map[string]any{"type": "integer", "description": "copies (default 1, at most 99)"},
|
||||||
|
"options": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"}, "description": "IPP options, name to value"},
|
||||||
|
"title": map[string]any{"type": "string", "description": "the job's title (default the file's name)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
file, err := text(args, "file")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := optText(args, "printer", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
copies, err := optWhole(args, "copies", 1, 1, 99)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
opts, err := optionsOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
title, err := optText(args, "title", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Print(file, p, copies, opts, title)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_default",
|
||||||
|
Description: "The machine's default printer; with printer, make that printer the default (through sudo -n). " +
|
||||||
|
"The default is CUPS's own setting, kept as the operator chose it: the mesh does not declare it. (r/a)",
|
||||||
|
Input: map[string]any{"printer": printerArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
p, err := optText(args, "printer", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Default(p)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_resume",
|
||||||
|
Description: "Enable a printer and make it accept jobs again, after CUPS stopped it on an error. Through sudo -n. (a)",
|
||||||
|
Input: map[string]any{"printer": printerArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
p, err := text(args, "printer")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Resume(p)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cups_drivers",
|
||||||
|
Description: "What each printer prints through (driverless or a driver's PPD), the packages that bring drivers " +
|
||||||
|
"and backends, which of them are from outside the official repositories, and the driver models CUPS " +
|
||||||
|
"offers, filtered by match. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"match": map[string]any{"type": "string", "description": "only models whose description contains this, any case"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "at most this many models (default 50, at most 1000)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
match, err := optText(args, "match", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
limit, err := optWhole(args, "limit", 50, 0, 1000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Drivers(match, limit)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module cups
|
||||||
|
|
||||||
|
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,58 @@
|
|||||||
|
{
|
||||||
|
"module": "cups",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager",
|
||||||
|
"service-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"cups_printers",
|
||||||
|
"cups_queue",
|
||||||
|
"cups_cancel",
|
||||||
|
"cups_print",
|
||||||
|
"cups_default",
|
||||||
|
"cups_resume",
|
||||||
|
"cups_drivers"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "cups"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "driverless",
|
||||||
|
"type": "package",
|
||||||
|
"package": "cups-filters"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "socket",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "cups.socket",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "scheduler",
|
||||||
|
"type": "service",
|
||||||
|
"unit": "cups.service",
|
||||||
|
"state": "running",
|
||||||
|
"boot": "enabled"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/cups-tools",
|
||||||
|
"binary": "cups-tools",
|
||||||
|
"loads": [
|
||||||
|
"cups-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# dmenu
|
||||||
|
|
||||||
|
The suckless menu, as a module (novox/hq research 026/04: "`dmenu` is a module of its own (official
|
||||||
|
repositories), able to hold the same seat on a machine that wants it"; to-be 42 phase 2 step 7).
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| `dmenu`, `dmenu_run`, `dmenu_path`, `stest` | package `dmenu` (official repositories) |
|
||||||
|
|
||||||
|
## Improves: two broken calls work by existing
|
||||||
|
|
||||||
|
Research 026/04 measured "plain `dmenu` in two places" in the operator's configuration, with dmenu
|
||||||
|
installed on neither workstation, so both failed. Measured again on 2026-10-04, the two are one line on
|
||||||
|
each workstation, in the notifier's configuration:
|
||||||
|
|
||||||
|
- `~/.config/dunst/dunstrc`: `dmenu = /usr/bin/dmenu -p dunst:`
|
||||||
|
|
||||||
|
It is the menu dunst opens to pick a notification's action or link. Every other menu in the
|
||||||
|
operator's scripts calls `rofi -dmenu`. Once this module installs the package, `/usr/bin/dmenu` exists
|
||||||
|
and that menu opens. The `dunst` module may later point the line at the launcher seat's command
|
||||||
|
instead (below). The line is that module's to own, so this module does not touch it.
|
||||||
|
|
||||||
|
## The seat it would hold: not claimed yet
|
||||||
|
|
||||||
|
Research 026/04 gives `node-launcher` to `rofi`, with a dmenu-compatible command as part of its
|
||||||
|
protocol, and lets `dmenu` hold the same seat on a machine that wants it. **The seat is not in the
|
||||||
|
controller's seat table yet**, and a claim on an unknown seat is refused. So this module claims
|
||||||
|
nothing. Its tool `dmenu_menu` is shaped like the seat's `menu` verb (research 026/05): show a list,
|
||||||
|
answer the chosen line. When the seat is recorded, the claim is one line here, serving `menu`.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON. `(r)` reads; `(d)` acts in the operator's session.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `dmenu_menu` (d) | show up to 1000 choices, with a prompt, as one line or a vertical list, case-insensitive by default. Answers the chosen line and its index; a typed line that is not a choice (`typed`); `cancelled` when dismissed; `timed_out` when not answered in time (default 20 s, at most 25, below the runtime's 30 s call limit) |
|
||||||
|
| `dmenu_session` (r) | the session the menu would appear in and how it was found, or why there is none; dmenu's version |
|
||||||
|
|
||||||
|
**Reaching the session** works as the `xclip` module's README describes: the runtime runs as the
|
||||||
|
account with no session words, and `session.go` finds the account's display and cookie from its own
|
||||||
|
processes. With no session, the tool says so and shows nothing. A keyboard held by another program
|
||||||
|
(a locked screen, an open menu) is answered as such.
|
||||||
|
|
||||||
|
The menu draws in fontconfig's `monospace` at dmenu's default size, which the `fonts` module makes
|
||||||
|
JetBrains Mono Nerd Font.
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
On both workstations, the `dmenu` package is installed, which neither has today. Nothing else.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
The notifier's configuration, and every `rofi -dmenu` call in the operator's scripts.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MostWait is the longest a menu stays open: below the runtime's 30 s call limit, so an unanswered
|
||||||
|
// menu is answered as such rather than as a call the runtime gave up on.
|
||||||
|
const MostWait = 25
|
||||||
|
|
||||||
|
// MostChoices bounds the list.
|
||||||
|
const MostChoices = 1000
|
||||||
|
|
||||||
|
// Ask is one menu.
|
||||||
|
type Ask struct {
|
||||||
|
Choices []string
|
||||||
|
Prompt string
|
||||||
|
Lines int
|
||||||
|
CaseInsensitive bool
|
||||||
|
Timeout int
|
||||||
|
}
|
||||||
|
|
||||||
|
// MenuAnswer is what dmenu_menu answers.
|
||||||
|
type MenuAnswer struct {
|
||||||
|
Chosen string `json:"chosen,omitempty"`
|
||||||
|
Index int `json:"index"`
|
||||||
|
Typed bool `json:"typed"`
|
||||||
|
Cancelled bool `json:"cancelled"`
|
||||||
|
TimedOut bool `json:"timed_out"`
|
||||||
|
Session Session `json:"session"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Menu shows the choices and answers the one taken. dmenu prints the selected (or typed) line and
|
||||||
|
// exits 0; it exits 1 with nothing printed when dismissed.
|
||||||
|
func Menu(a Ask) (MenuAnswer, error) {
|
||||||
|
if len(a.Choices) == 0 {
|
||||||
|
return MenuAnswer{}, fmt.Errorf("choices is required: at least one line")
|
||||||
|
}
|
||||||
|
if len(a.Choices) > MostChoices {
|
||||||
|
return MenuAnswer{}, fmt.Errorf("%d choices; at most %d are shown", len(a.Choices), MostChoices)
|
||||||
|
}
|
||||||
|
for _, c := range a.Choices {
|
||||||
|
if strings.ContainsAny(c, "\n\r") {
|
||||||
|
return MenuAnswer{}, fmt.Errorf("a choice holds a line break: %q", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(a.Prompt, "\n\r") {
|
||||||
|
return MenuAnswer{}, fmt.Errorf("the prompt holds a line break")
|
||||||
|
}
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
return MenuAnswer{}, err
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
if a.CaseInsensitive {
|
||||||
|
args = append(args, "-i")
|
||||||
|
}
|
||||||
|
if a.Lines > 0 {
|
||||||
|
args = append(args, "-l", strconv.Itoa(a.Lines))
|
||||||
|
}
|
||||||
|
if a.Prompt != "" {
|
||||||
|
args = append(args, "-p", a.Prompt)
|
||||||
|
}
|
||||||
|
c := Cmd{Name: "dmenu", Args: args, Stdin: strings.Join(a.Choices, "\n") + "\n", Env: s.Env(), Timeout: time.Duration(a.Timeout) * time.Second}
|
||||||
|
r := run(c)
|
||||||
|
out := MenuAnswer{Index: -1, Session: s}
|
||||||
|
switch {
|
||||||
|
case r.Error == "timeout":
|
||||||
|
out.TimedOut = true
|
||||||
|
return out, nil
|
||||||
|
case r.Error != "":
|
||||||
|
return MenuAnswer{}, failure(c, r)
|
||||||
|
case strings.Contains(r.Stderr, "cannot open display"):
|
||||||
|
return MenuAnswer{}, fmt.Errorf("the X session at %s (found by %s) refused the connection", s.Display, s.FoundBy)
|
||||||
|
case strings.Contains(r.Stderr, "cannot grab keyboard"):
|
||||||
|
return MenuAnswer{}, fmt.Errorf("dmenu could not take the keyboard: another program holds it (a locked screen, an open menu)")
|
||||||
|
case r.Status == 1 && strings.TrimSpace(r.Stdout) == "":
|
||||||
|
out.Cancelled = true
|
||||||
|
return out, nil
|
||||||
|
case r.Status != 0:
|
||||||
|
return MenuAnswer{}, failure(c, r)
|
||||||
|
}
|
||||||
|
out.Chosen = strings.TrimRight(r.Stdout, "\r\n")
|
||||||
|
for i, ch := range a.Choices {
|
||||||
|
if ch == out.Chosen {
|
||||||
|
out.Index = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Typed = out.Index < 0
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionCheck answers the session the menu would appear in, and dmenu's version.
|
||||||
|
func SessionCheck() (map[string]any, error) {
|
||||||
|
out := map[string]any{}
|
||||||
|
if r := run(Cmd{Name: "dmenu", Args: []string{"-v"}}); r.Error == "" {
|
||||||
|
out["dmenu"] = strings.TrimSpace(r.Stdout + r.Stderr)
|
||||||
|
} else {
|
||||||
|
out["dmenu"] = failure(Cmd{Name: "dmenu"}, r).Error()
|
||||||
|
}
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
out["found"], out["why"] = false, err.Error()
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
out["found"], out["session"] = true, s
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsThePackageAndClaimsNoSeatYet(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
// holdsTheBundle also holds it to no claim: node-launcher is not in the controller's seat table
|
||||||
|
// yet (README).
|
||||||
|
holdsTheBundle(t, m, "dmenu")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "dmenu" || len(m.Resources) != 1 {
|
||||||
|
t.Errorf("packages %s, resources %v", got, m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aSession(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"})
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMenuShowsTheChoicesInTheSessionAndAnswersTheOneTaken(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("Lock\n") })
|
||||||
|
got, err := Menu(Ask{Choices: []string{"Shutdown", "Lock"}, Prompt: "power:", Lines: 5, CaseInsensitive: true, Timeout: 20})
|
||||||
|
if err != nil || got.Chosen != "Lock" || got.Index != 1 || got.Typed || got.Cancelled || got.TimedOut {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
c := f.asked[0]
|
||||||
|
if f.lines()[0] != "dmenu -i -l 5 -p power:" || c.Stdin != "Shutdown\nLock\n" || c.Timeout != 20*time.Second {
|
||||||
|
t.Errorf("%v %+v", f.lines(), c)
|
||||||
|
}
|
||||||
|
if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" {
|
||||||
|
t.Errorf("env %v", c.Env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMenuTellsTypedDismissedAndUnansweredApart(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
answer := ok("something else\n")
|
||||||
|
using(t, func(string, Cmd) Result { return answer })
|
||||||
|
got, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||||
|
if err != nil || !got.Typed || got.Index != -1 || got.Chosen != "something else" {
|
||||||
|
t.Errorf("typed: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
answer = Result{Status: 1}
|
||||||
|
got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||||
|
if err != nil || !got.Cancelled || got.Chosen != "" {
|
||||||
|
t.Errorf("dismissed: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
answer = Result{Status: 124, Error: "timeout"}
|
||||||
|
got, err = Menu(Ask{Choices: []string{"a"}, Timeout: 5})
|
||||||
|
if err != nil || !got.TimedOut {
|
||||||
|
t.Errorf("unanswered: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
answer = Result{Status: 1, Stderr: "cannot grab keyboard\n"}
|
||||||
|
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "keyboard") {
|
||||||
|
t.Errorf("a held keyboard: %v", err)
|
||||||
|
}
|
||||||
|
answer = Result{Status: 1, Stderr: "cannot open display\n"}
|
||||||
|
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "refused") {
|
||||||
|
t.Errorf("a refusing display: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMenuRefusesWhatCannotBeShownAndRunsNothingWithoutASession(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
for _, bad := range []Ask{{}, {Choices: []string{"a\nb"}}, {Choices: []string{"a"}, Prompt: "x\ny"}, {Choices: make([]string, MostChoices+1)}} {
|
||||||
|
if _, err := Menu(bad); err == nil {
|
||||||
|
t.Errorf("%+v accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
aMachine(t, map[string]string{})
|
||||||
|
if _, err := Menu(Ask{Choices: []string{"a"}, Timeout: 5}); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
if len(f.asked) != 0 {
|
||||||
|
t.Errorf("ran %v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheWaitIsBelowTheRuntimesCallLimit(t *testing.T) {
|
||||||
|
if MostWait >= 30 {
|
||||||
|
t.Fatalf("a menu may stay open %d s; the runtime gives a call 30", MostWait)
|
||||||
|
}
|
||||||
|
n, _ := optWhole(map[string]any{"timeout_seconds": float64(600)}, "timeout_seconds", 20, 1, MostWait)
|
||||||
|
if n != MostWait {
|
||||||
|
t.Errorf("%d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCheckSaysTheVersionAndTheSession(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
using(t, func(string, Cmd) Result { return ok("dmenu-5.4\n") })
|
||||||
|
got, err := SessionCheck()
|
||||||
|
if err != nil || got["dmenu"] != "dmenu-5.4" || got["found"] != true {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||||
|
got, _ = SessionCheck()
|
||||||
|
if !strings.Contains(got["dmenu"].(string), "not installed") {
|
||||||
|
t.Errorf("%v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// The dmenu module's tool (novox/hq research 026/04, 026/05): show the operator a menu of choices in
|
||||||
|
// the graphical session and answer the one chosen — the dmenu-compatible command as a tool. A Go
|
||||||
|
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account
|
||||||
|
// and reaches the account's X session as session.go finds it; with no session, it says so.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"dmenu": "the dmenu package, which this module installs",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "dmenu_menu",
|
||||||
|
Description: "Show the operator a menu of choices on their screen and answer the line chosen, its index, or " +
|
||||||
|
"that the menu was dismissed or not answered in time (default 20 s, at most 25). The operator may also " +
|
||||||
|
"type a line that is not a choice. Needs the operator's graphical session. (d)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "the lines to choose from, at most 1000, none with a line break"},
|
||||||
|
"prompt": map[string]any{"type": "string", "description": "a prompt shown left of the input"},
|
||||||
|
"lines": map[string]any{"type": "integer", "description": "show the choices as a vertical list of this many lines (default 0: one horizontal line; at most 40)"},
|
||||||
|
"case_insensitive": map[string]any{"type": "boolean", "description": "match what is typed regardless of case (default true)"},
|
||||||
|
"timeout_seconds": map[string]any{"type": "integer", "description": "close the menu unanswered after this long (default 20, at most 25)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
choices, err := optList(args, "choices")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prompt, err := optText(args, "prompt", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := optWhole(args, "lines", 0, 0, 40)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ci, err := optFlag(args, "case_insensitive", true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout, err := optWhole(args, "timeout_seconds", 20, 1, MostWait)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Menu(Ask{Choices: choices, Prompt: prompt, Lines: n, CaseInsensitive: ci, Timeout: timeout})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "dmenu_session",
|
||||||
|
Description: "Which X session the menu would appear in and how it was found, or why there is none; and dmenu's version. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return SessionCheck() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// session.go is the same file in the bundles whose tools act in the operator's graphical session
|
||||||
|
// (xclip, dmenu): how a process the node's tool runtime launched reaches that session.
|
||||||
|
//
|
||||||
|
// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the
|
||||||
|
// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X
|
||||||
|
// server accepts a client that names its display and presents the cookie in the authority file, and
|
||||||
|
// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is
|
||||||
|
// readable by the account. So the session is found, not configured:
|
||||||
|
//
|
||||||
|
// 1. the process's own DISPLAY, when the runtime happens to have one;
|
||||||
|
// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from
|
||||||
|
// /proc/<pid>/environ (the window manager's, by preference), whose socket exists;
|
||||||
|
// 3. else the only X socket there is, with the authority file in the account's home.
|
||||||
|
//
|
||||||
|
// When none is found the tool says that no graphical session of the account is running, and does
|
||||||
|
// nothing.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is the operator's X session as a tool reaches it.
|
||||||
|
type Session struct {
|
||||||
|
Display string `json:"display"`
|
||||||
|
XAuthority string `json:"xauthority,omitempty"`
|
||||||
|
// FoundBy says how: "environment", "process <pid> (<name>)" or "socket".
|
||||||
|
FoundBy string `json:"found_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env is what a command needs to reach the session.
|
||||||
|
func (s Session) Env() []string {
|
||||||
|
env := []string{"DISPLAY=" + s.Display}
|
||||||
|
if s.XAuthority != "" {
|
||||||
|
env = append(env, "XAUTHORITY="+s.XAuthority)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the session is looked for. Tests point these at a tree of their own.
|
||||||
|
var (
|
||||||
|
procRoot = "/proc"
|
||||||
|
x11Sockets = "/tmp/.X11-unix"
|
||||||
|
getenv = os.Getenv
|
||||||
|
myUID = os.Getuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionWMs are the programs whose environment is the session's own, preferred over any other
|
||||||
|
// process's (a terminal's child may carry a stale or forwarded DISPLAY).
|
||||||
|
var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true}
|
||||||
|
|
||||||
|
func accountHome() string {
|
||||||
|
if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h := strings.TrimSpace(getenv("HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h, _ := os.UserHomeDir()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one.
|
||||||
|
func socketOf(display string) string {
|
||||||
|
if !strings.HasPrefix(display, ":") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := strings.TrimPrefix(display, ":")
|
||||||
|
if i := strings.IndexByte(n, '.'); i >= 0 {
|
||||||
|
n = n[:i]
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(n); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(x11Sockets, "X"+n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSession answers the account's X session, or an error saying there is none.
|
||||||
|
func findSession() (Session, error) {
|
||||||
|
if d := strings.TrimSpace(getenv("DISPLAY")); d != "" {
|
||||||
|
if s := socketOf(d); s == "" || exists(s) {
|
||||||
|
return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type seen struct {
|
||||||
|
Session
|
||||||
|
wm bool
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
found := map[string]*seen{}
|
||||||
|
entries, _ := os.ReadDir(procRoot)
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(procRoot, e.Name())
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var display, auth string
|
||||||
|
for _, kv := range strings.Split(string(raw), "\x00") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(kv, "DISPLAY="):
|
||||||
|
display = strings.TrimPrefix(kv, "DISPLAY=")
|
||||||
|
case strings.HasPrefix(kv, "XAUTHORITY="):
|
||||||
|
auth = strings.TrimPrefix(kv, "XAUTHORITY=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if display == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := socketOf(display); s == "" || !exists(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm, _ := os.ReadFile(filepath.Join(dir, "comm"))
|
||||||
|
name := strings.TrimSpace(string(comm))
|
||||||
|
key := display + "\x00" + auth
|
||||||
|
if found[key] == nil {
|
||||||
|
found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}}
|
||||||
|
}
|
||||||
|
f := found[key]
|
||||||
|
f.count++
|
||||||
|
if sessionWMs[name] && !f.wm {
|
||||||
|
f.wm = true
|
||||||
|
f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
all := make([]*seen, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
all = append(all, f)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, k int) bool {
|
||||||
|
if all[i].wm != all[k].wm {
|
||||||
|
return all[i].wm
|
||||||
|
}
|
||||||
|
if all[i].count != all[k].count {
|
||||||
|
return all[i].count > all[k].count
|
||||||
|
}
|
||||||
|
return all[i].Display < all[k].Display
|
||||||
|
})
|
||||||
|
return all[0].Session, nil
|
||||||
|
}
|
||||||
|
sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*"))
|
||||||
|
if len(sockets) == 1 {
|
||||||
|
s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"}
|
||||||
|
if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) {
|
||||||
|
s.XAuthority = a
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if len(sockets) > 1 {
|
||||||
|
return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets)
|
||||||
|
}
|
||||||
|
return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aMachine gives findSession a /proc and an X socket directory of the test's own.
|
||||||
|
func aMachine(t *testing.T, env map[string]string) (proc, sockets string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11")
|
||||||
|
for _, d := range []string{proc, sockets} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv
|
||||||
|
procRoot, x11Sockets = proc, sockets
|
||||||
|
getenv = func(k string) string { return env[k] }
|
||||||
|
t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv })
|
||||||
|
return proc, sockets
|
||||||
|
}
|
||||||
|
|
||||||
|
func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(proc, strconv.Itoa(pid))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aSocket(t *testing.T, dir, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) {
|
||||||
|
proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"})
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale")
|
||||||
|
aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority")
|
||||||
|
aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority")
|
||||||
|
aProcess(t, proc, 6, "sshd", "PATH=/bin")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") {
|
||||||
|
t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s)
|
||||||
|
}
|
||||||
|
if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" {
|
||||||
|
t.Fatalf("env %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
_ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600)
|
||||||
|
_, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home})
|
||||||
|
aSocket(t, sockets, "X0")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) {
|
||||||
|
_, sockets := aMachine(t, map[string]string{})
|
||||||
|
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("none: %v", err)
|
||||||
|
}
|
||||||
|
aSocket(t, sockets, "X0")
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") {
|
||||||
|
t.Fatalf("two: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) {
|
||||||
|
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"})
|
||||||
|
aSocket(t, sockets, "X2")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil || s.Display != ":2" || s.FoundBy != "environment" {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module dmenu
|
||||||
|
|
||||||
|
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,33 @@
|
|||||||
|
{
|
||||||
|
"module": "dmenu",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"dmenu_menu",
|
||||||
|
"dmenu_session"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "dmenu"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/dmenu-tools",
|
||||||
|
"binary": "dmenu-tools",
|
||||||
|
"loads": [
|
||||||
|
"dmenu-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# docker-compose
|
||||||
|
|
||||||
|
Compose, for development work on the two workstations (novox/hq research 027/02: "the distribution's
|
||||||
|
package and nothing else", assigned only to the workstations; to-be 42 phase 2 step 9). The servers
|
||||||
|
run nothing through compose.
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| compose, as the container runtime's plugin (`docker compose`) and as `docker-compose` | package `docker-compose` |
|
||||||
|
|
||||||
|
Nothing else. The runtime, its configuration, buildx and the `docker` group are the `docker` module's
|
||||||
|
(to-be 42 phase 1 step 8). This module needs that runtime on the machine. Until the `docker` module
|
||||||
|
holds `node-container-runtime` there, nothing in the mesh says so, and the tools answer that the
|
||||||
|
runtime is missing or unreachable rather than an empty list.
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **An owner for a package both workstations already carry.** On both, `docker-compose` 5.5.0 is
|
||||||
|
installed explicitly by hand, as the plugin in `/usr/lib/docker/cli-plugins`. Assigning the module
|
||||||
|
changes nothing on disk; from then on the package is the mesh's, upgraded with the machine and
|
||||||
|
given back when the module goes.
|
||||||
|
- **The projects become visible from the mesh** without a shell on the machine: which are running,
|
||||||
|
where their files are, their containers, logs and rendered configuration.
|
||||||
|
- **No account-level copy of the plugin** exists on either workstation (`~/.docker/cli-plugins` is
|
||||||
|
empty), so there is no second compose to remove.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts. They run as the operator account, which reaches the runtime
|
||||||
|
through the `docker` group. Nothing goes through `sudo`.
|
||||||
|
|
||||||
|
A project is named by `dir`, its absolute directory, or by `project`, its name. A directory docker
|
||||||
|
already knows a project for is that project, with the files it was started from, overrides included.
|
||||||
|
Any other directory must hold a compose file.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `docker_compose_projects` (r) | every project docker knows, running or stopped: status, working directory (from the containers' labels), compose files, services, containers running of total |
|
||||||
|
| `docker_compose_ps` (r) | one project's containers: service, state, health, exit code, image, published ports |
|
||||||
|
| `docker_compose_logs` (r) | the last lines per service (default 200, at most 5000), optionally `since`; cut at 256 KiB |
|
||||||
|
| `docker_compose_config` (r) | the rendered configuration. Values of environment variables, build arguments and labels whose names suggest a secret, and inline secret or config content, are replaced with `[redacted]`, and the answer counts them |
|
||||||
|
| `docker_compose_up` (a) | `up --detach`, with the pull policy (default `missing`) and optionally `--build`, for all or some services |
|
||||||
|
| `docker_compose_down` (a) | `down`: containers and networks. **Volumes are kept**: no tool here removes data |
|
||||||
|
| `docker_compose_restart` (a) | restart all or some services |
|
||||||
|
| `docker_compose_pull` (a) | pull images without starting anything |
|
||||||
|
| `docker_compose_job` (r) | a long act's state: running or finished, exit status, the end of its output; without an id, every act this process knows |
|
||||||
|
|
||||||
|
**Acts are jobs.** An `up` that pulls or builds takes minutes, and the runtime gives a call 30 s. Each
|
||||||
|
act runs inside the tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is
|
||||||
|
answered with its output, and a failed one as an error. One still running is answered with its job id,
|
||||||
|
which `docker_compose_job` follows. A job ends if the runtime restarts the bundle.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
The projects themselves are the operator's work, under the operator's directories. Measured on
|
||||||
|
2026-10-04:
|
||||||
|
|
||||||
|
- **laptop:** `anton-lavinmq` and `anton-traefik` running, `anton-redis` stopped.
|
||||||
|
- **desktop:** `anton-lavinmq`, `lavinmq` (from `/services/lavinmq`, a predecessor's directory) and
|
||||||
|
`registry` running.
|
||||||
|
|
||||||
|
Whether the desktop's `lavinmq` and `registry` should still run is the operator's call;
|
||||||
|
`docker_compose_down` with their directory stops them.
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Project is one compose project as docker knows it.
|
||||||
|
type Project struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
WorkingDir string `json:"working_dir,omitempty"`
|
||||||
|
ConfigFiles []string `json:"config_files"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
Running int `json:"running"`
|
||||||
|
Containers int `json:"containers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectsAnswer is what docker_compose_projects answers.
|
||||||
|
type ProjectsAnswer struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Projects []Project `json:"projects"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// composeFiles are the names compose looks for in a directory, in its order.
|
||||||
|
var composeFiles = []string{"compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"}
|
||||||
|
|
||||||
|
// statDir says whether a path is a directory. Tests replace it.
|
||||||
|
var statDir = func(p string) bool {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
return err == nil && info.IsDir()
|
||||||
|
}
|
||||||
|
|
||||||
|
// statFile says whether a path is a regular file. Tests replace it.
|
||||||
|
var statFile = func(p string) bool {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
return err == nil && info.Mode().IsRegular()
|
||||||
|
}
|
||||||
|
|
||||||
|
// docker runs one docker command and names a daemon the account cannot reach as such.
|
||||||
|
func docker(args ...string) (Result, error) {
|
||||||
|
r, err := call(Cmd{Name: "docker", Args: args})
|
||||||
|
if err != nil && strings.Contains(r.Stderr, "permission denied") && strings.Contains(r.Stderr, "docker.sock") {
|
||||||
|
return r, fmt.Errorf("the account cannot reach the container runtime's socket (permission denied): it is not in the docker group, or has not logged in since it was added. The docker module owns the group's members")
|
||||||
|
}
|
||||||
|
if err != nil && strings.Contains(r.Stderr, "Cannot connect to the Docker daemon") {
|
||||||
|
return r, fmt.Errorf("the container runtime is not running on this machine: %s", firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
return r, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projects merges what compose lists with what the containers' labels say.
|
||||||
|
func Projects() (ProjectsAnswer, error) {
|
||||||
|
r, err := docker("compose", "ls", "--all", "--format", "json")
|
||||||
|
if err != nil {
|
||||||
|
return ProjectsAnswer{}, err
|
||||||
|
}
|
||||||
|
var listed []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Status string `json:"Status"`
|
||||||
|
ConfigFiles string `json:"ConfigFiles"`
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(r.Stdout); s != "" {
|
||||||
|
if err := json.Unmarshal([]byte(s), &listed); err != nil {
|
||||||
|
return ProjectsAnswer{}, fmt.Errorf("docker compose ls answered what is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
by := map[string]*Project{}
|
||||||
|
get := func(name string) *Project {
|
||||||
|
if by[name] == nil {
|
||||||
|
by[name] = &Project{Name: name, ConfigFiles: []string{}, Services: []string{}}
|
||||||
|
}
|
||||||
|
return by[name]
|
||||||
|
}
|
||||||
|
for _, l := range listed {
|
||||||
|
p := get(l.Name)
|
||||||
|
p.Status = l.Status
|
||||||
|
p.ConfigFiles = splitFiles(l.ConfigFiles)
|
||||||
|
}
|
||||||
|
r, err = docker("ps", "-a", "--filter", "label=com.docker.compose.project", "--format",
|
||||||
|
`{{.Label "com.docker.compose.project"}}`+"\t"+`{{.Label "com.docker.compose.project.working_dir"}}`+"\t"+
|
||||||
|
`{{.Label "com.docker.compose.project.config_files"}}`+"\t"+`{{.Label "com.docker.compose.service"}}`+"\t{{.State}}")
|
||||||
|
if err != nil {
|
||||||
|
return ProjectsAnswer{}, err
|
||||||
|
}
|
||||||
|
services := map[string]map[string]bool{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) < 5 || f[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := get(f[0])
|
||||||
|
if p.WorkingDir == "" {
|
||||||
|
p.WorkingDir = f[1]
|
||||||
|
}
|
||||||
|
if len(p.ConfigFiles) == 0 {
|
||||||
|
p.ConfigFiles = splitFiles(f[2])
|
||||||
|
}
|
||||||
|
if services[f[0]] == nil {
|
||||||
|
services[f[0]] = map[string]bool{}
|
||||||
|
}
|
||||||
|
if f[3] != "" {
|
||||||
|
services[f[0]][f[3]] = true
|
||||||
|
}
|
||||||
|
p.Containers++
|
||||||
|
if f[4] == "running" {
|
||||||
|
p.Running++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := ProjectsAnswer{Projects: []Project{}}
|
||||||
|
for name, p := range by {
|
||||||
|
for s := range services[name] {
|
||||||
|
p.Services = append(p.Services, s)
|
||||||
|
}
|
||||||
|
sort.Strings(p.Services)
|
||||||
|
if p.WorkingDir == "" && len(p.ConfigFiles) > 0 {
|
||||||
|
p.WorkingDir = filepath.Dir(p.ConfigFiles[0])
|
||||||
|
}
|
||||||
|
out.Projects = append(out.Projects, *p)
|
||||||
|
}
|
||||||
|
sort.Slice(out.Projects, func(i, k int) bool { return out.Projects[i].Name < out.Projects[k].Name })
|
||||||
|
out.Count = len(out.Projects)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitFiles(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, f := range strings.Split(s, ",") {
|
||||||
|
if f = strings.TrimSpace(f); f != "" {
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Target is the project a tool acts on, and how compose is told which it is.
|
||||||
|
type Target struct {
|
||||||
|
Project string `json:"project,omitempty"`
|
||||||
|
Dir string `json:"dir,omitempty"`
|
||||||
|
Files []string `json:"files,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// args are compose's own options naming the target.
|
||||||
|
func (t Target) args() []string {
|
||||||
|
out := []string{"compose"}
|
||||||
|
if t.Dir != "" {
|
||||||
|
out = append(out, "--project-directory", t.Dir)
|
||||||
|
}
|
||||||
|
for _, f := range t.Files {
|
||||||
|
out = append(out, "-f", f)
|
||||||
|
}
|
||||||
|
if t.Project != "" {
|
||||||
|
out = append(out, "-p", t.Project)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||||
|
|
||||||
|
// targetOf reads dir or project. A directory docker already knows a project for is that project,
|
||||||
|
// with the files it was started from; otherwise it must hold a compose file. needFiles says the
|
||||||
|
// tool reads the files (config, up, pull), so a project known only by its containers is not enough.
|
||||||
|
func targetOf(args map[string]any, needFiles bool) (Target, error) {
|
||||||
|
dir, err := optText(args, "dir", "")
|
||||||
|
if err != nil {
|
||||||
|
return Target{}, err
|
||||||
|
}
|
||||||
|
name, err := optText(args, "project", "")
|
||||||
|
if err != nil {
|
||||||
|
return Target{}, err
|
||||||
|
}
|
||||||
|
if dir == "" && name == "" {
|
||||||
|
return Target{}, fmt.Errorf("give dir, the project's directory, or project, its name")
|
||||||
|
}
|
||||||
|
if dir != "" {
|
||||||
|
if !filepath.IsAbs(dir) {
|
||||||
|
return Target{}, fmt.Errorf("dir must be an absolute path, not %q", dir)
|
||||||
|
}
|
||||||
|
dir = filepath.Clean(dir)
|
||||||
|
if !statDir(dir) {
|
||||||
|
return Target{}, fmt.Errorf("%s is not a directory on this machine", dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name != "" && !projectName.MatchString(name) {
|
||||||
|
return Target{}, fmt.Errorf("%q is not a compose project name", name)
|
||||||
|
}
|
||||||
|
known, err := Projects()
|
||||||
|
if err != nil {
|
||||||
|
return Target{}, err
|
||||||
|
}
|
||||||
|
for _, p := range known.Projects {
|
||||||
|
if (dir != "" && p.WorkingDir == dir) || (dir == "" && p.Name == name) {
|
||||||
|
if name != "" && p.Name != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := Target{Project: p.Name, Dir: p.WorkingDir}
|
||||||
|
present := len(p.ConfigFiles) > 0
|
||||||
|
for _, f := range p.ConfigFiles {
|
||||||
|
present = present && statFile(f)
|
||||||
|
}
|
||||||
|
if present {
|
||||||
|
t.Files = p.ConfigFiles
|
||||||
|
} else if needFiles && !hasComposeFile(t.Dir) {
|
||||||
|
return Target{}, fmt.Errorf("project %s was started from %s, which is no longer there", p.Name, strings.Join(p.ConfigFiles, ", "))
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return Target{}, fmt.Errorf("no compose project named %s is known to docker here: give dir, its directory", name)
|
||||||
|
}
|
||||||
|
if !hasComposeFile(dir) {
|
||||||
|
return Target{}, fmt.Errorf("%s holds no compose file (%s)", dir, strings.Join(composeFiles, ", "))
|
||||||
|
}
|
||||||
|
return Target{Dir: dir, Project: name}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasComposeFile(dir string) bool {
|
||||||
|
for _, f := range composeFiles {
|
||||||
|
if statFile(filepath.Join(dir, f)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Container is one of a project's containers.
|
||||||
|
type Container struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Service string `json:"service"`
|
||||||
|
State string `json:"state"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Health string `json:"health,omitempty"`
|
||||||
|
ExitCode int `json:"exit_code"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
Ports []string `json:"ports"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PsAnswer is what docker_compose_ps answers.
|
||||||
|
type PsAnswer struct {
|
||||||
|
Target Target `json:"target"`
|
||||||
|
Containers []Container `json:"containers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonObjects reads compose's JSON output, which is one array or one object per line by version.
|
||||||
|
func jsonObjects(s string, into any) error {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
s = "[]"
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(s, "[") {
|
||||||
|
s = "[" + strings.Join(lines(s), ",") + "]"
|
||||||
|
}
|
||||||
|
return json.Unmarshal([]byte(s), into)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ps answers a project's containers.
|
||||||
|
func Ps(t Target) (PsAnswer, error) {
|
||||||
|
r, err := docker(append(t.args(), "ps", "-a", "--format", "json")...)
|
||||||
|
if err != nil {
|
||||||
|
return PsAnswer{}, err
|
||||||
|
}
|
||||||
|
var raw []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
Service string `json:"Service"`
|
||||||
|
State string `json:"State"`
|
||||||
|
Status string `json:"Status"`
|
||||||
|
Health string `json:"Health"`
|
||||||
|
ExitCode int `json:"ExitCode"`
|
||||||
|
Image string `json:"Image"`
|
||||||
|
Publishers []struct {
|
||||||
|
URL string `json:"URL"`
|
||||||
|
TargetPort int `json:"TargetPort"`
|
||||||
|
PublishedPort int `json:"PublishedPort"`
|
||||||
|
Protocol string `json:"Protocol"`
|
||||||
|
} `json:"Publishers"`
|
||||||
|
}
|
||||||
|
if err := jsonObjects(r.Stdout, &raw); err != nil {
|
||||||
|
return PsAnswer{}, fmt.Errorf("docker compose ps answered what is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
out := PsAnswer{Target: t, Containers: []Container{}}
|
||||||
|
for _, c := range raw {
|
||||||
|
ports := []string{}
|
||||||
|
for _, p := range c.Publishers {
|
||||||
|
if p.PublishedPort == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ports = append(ports, fmt.Sprintf("%s:%d->%d/%s", p.URL, p.PublishedPort, p.TargetPort, p.Protocol))
|
||||||
|
}
|
||||||
|
out.Containers = append(out.Containers, Container{Name: c.Name, Service: c.Service, State: c.State, Status: c.Status,
|
||||||
|
Health: c.Health, ExitCode: c.ExitCode, Image: c.Image, Ports: ports})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogsAnswer is what docker_compose_logs answers.
|
||||||
|
type LogsAnswer struct {
|
||||||
|
Target Target `json:"target"`
|
||||||
|
Lines []string `json:"lines"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var since = regexp.MustCompile(`^[0-9A-Za-z:.+-]+$`)
|
||||||
|
|
||||||
|
// Logs answers a project's last lines.
|
||||||
|
func Logs(t Target, services []string, n int, from string) (LogsAnswer, error) {
|
||||||
|
args := append(t.args(), "logs", "--no-color", "--timestamps", "--tail", fmt.Sprint(n))
|
||||||
|
if from != "" {
|
||||||
|
if !since.MatchString(from) {
|
||||||
|
return LogsAnswer{}, fmt.Errorf("since %q is neither a duration nor a timestamp", from)
|
||||||
|
}
|
||||||
|
args = append(args, "--since", from)
|
||||||
|
}
|
||||||
|
for _, s := range services {
|
||||||
|
if err := plainName("service", s); err != nil {
|
||||||
|
return LogsAnswer{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r, err := docker(append(args, services...)...)
|
||||||
|
if err != nil {
|
||||||
|
return LogsAnswer{}, err
|
||||||
|
}
|
||||||
|
// compose writes the containers' output on its stdout, and its own complaints on stderr.
|
||||||
|
return LogsAnswer{Target: t, Lines: lines(r.Stdout), Truncated: r.Truncated}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigAnswer is what docker_compose_config answers.
|
||||||
|
type ConfigAnswer struct {
|
||||||
|
Target Target `json:"target"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
Redacted int `json:"redacted"`
|
||||||
|
Rendered map[string]any `json:"rendered"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretish is a name whose value is not shown.
|
||||||
|
var secretish = regexp.MustCompile(`(?i)(pass|secret|token|key|credential|auth|private|cert|cookie|session|salt|dsn|api)`)
|
||||||
|
|
||||||
|
// redact replaces the values of secret-looking names in the maps compose renders.
|
||||||
|
func redact(v any, count *int) {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for k, child := range x {
|
||||||
|
switch k {
|
||||||
|
case "environment", "args", "labels", "build_args":
|
||||||
|
if m, ok := child.(map[string]any); ok {
|
||||||
|
for name, val := range m {
|
||||||
|
if val != nil && secretish.MatchString(name) {
|
||||||
|
m[name] = "[redacted]"
|
||||||
|
*count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
case "content":
|
||||||
|
// An inline config or secret: its content is the secret itself.
|
||||||
|
if _, ok := child.(string); ok {
|
||||||
|
x[k] = "[redacted]"
|
||||||
|
*count++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
redact(child, count)
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, child := range x {
|
||||||
|
redact(child, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config answers the rendered configuration.
|
||||||
|
func Config(t Target) (ConfigAnswer, error) {
|
||||||
|
r, err := docker(append(t.args(), "config", "--format", "json")...)
|
||||||
|
if err != nil {
|
||||||
|
return ConfigAnswer{}, err
|
||||||
|
}
|
||||||
|
var rendered map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(r.Stdout), &rendered); err != nil {
|
||||||
|
return ConfigAnswer{}, fmt.Errorf("docker compose config answered what is not JSON: %v", err)
|
||||||
|
}
|
||||||
|
out := ConfigAnswer{Target: t, Services: []string{}, Rendered: rendered}
|
||||||
|
if s, ok := rendered["services"].(map[string]any); ok {
|
||||||
|
for name := range s {
|
||||||
|
out.Services = append(out.Services, name)
|
||||||
|
}
|
||||||
|
sort.Strings(out.Services)
|
||||||
|
}
|
||||||
|
redact(rendered, &out.Redacted)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActAnswer is what an act answers: the target and the job that carries it.
|
||||||
|
type ActAnswer struct {
|
||||||
|
Act string `json:"act"`
|
||||||
|
Target Target `json:"target"`
|
||||||
|
Job Job `json:"job"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func act(name string, t Target, extra ...string) (ActAnswer, error) {
|
||||||
|
j, err := actAsJob(Cmd{Name: "docker", Args: append(append(t.args(), name), extra...)})
|
||||||
|
if err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
return ActAnswer{Act: name, Target: t, Job: j}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkServices(services []string) error {
|
||||||
|
for _, s := range services {
|
||||||
|
if err := plainName("service", s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Up brings a project up, detached.
|
||||||
|
func Up(t Target, services []string, build bool, pull string) (ActAnswer, error) {
|
||||||
|
if err := oneOf("pull", pull, "missing", "always", "never"); err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
if err := checkServices(services); err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
extra := []string{"--detach", "--pull", pull}
|
||||||
|
if build {
|
||||||
|
extra = append(extra, "--build")
|
||||||
|
}
|
||||||
|
return act("up", t, append(extra, services...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Down stops and removes a project's containers and networks, keeping its volumes.
|
||||||
|
func Down(t Target) (ActAnswer, error) {
|
||||||
|
return act("down", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart restarts a project's containers.
|
||||||
|
func Restart(t Target, services []string) (ActAnswer, error) {
|
||||||
|
if err := checkServices(services); err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
return act("restart", t, services...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull pulls a project's images.
|
||||||
|
func Pull(t Target, services []string) (ActAnswer, error) {
|
||||||
|
if err := checkServices(services); err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
return act("pull", t, services...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsComposesPackageAndNothingElse(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "docker_compose")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "docker-compose" {
|
||||||
|
t.Errorf("packages %s: buildx and the runtime are the docker module's", got)
|
||||||
|
}
|
||||||
|
if len(m.Resources) != 1 {
|
||||||
|
t.Errorf("one resource, the package: %v", m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lsJSON = `[{"Name":"anton-lavinmq","Status":"running(1)","ConfigFiles":"/home/op/hub/lavinmq/docker-compose.yml"},{"Name":"old","Status":"exited(2)","ConfigFiles":"/srv/old/compose.yaml,/srv/old/compose.override.yaml"}]`
|
||||||
|
|
||||||
|
const psLabels = "anton-lavinmq\t/home/op/hub/lavinmq\t/home/op/hub/lavinmq/docker-compose.yml\tlavinmq\trunning\n" +
|
||||||
|
"old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tweb\texited\n" +
|
||||||
|
"old\t/srv/old\t/srv/old/compose.yaml,/srv/old/compose.override.yaml\tdb\texited\n"
|
||||||
|
|
||||||
|
// aDocker answers compose ls and the labelled ps, and hands every other line to rest.
|
||||||
|
func aDocker(t *testing.T, rest func(line string) Result) *fake {
|
||||||
|
return using(t, func(line string, c Cmd) Result {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "docker compose ls"):
|
||||||
|
return ok(lsJSON)
|
||||||
|
case strings.HasPrefix(line, "docker ps -a --filter label=com.docker.compose.project"):
|
||||||
|
return ok(psLabels)
|
||||||
|
}
|
||||||
|
if rest != nil {
|
||||||
|
return rest(line)
|
||||||
|
}
|
||||||
|
return ok("")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func onDisk(t *testing.T, dirs, files []string) {
|
||||||
|
t.Helper()
|
||||||
|
wasD, wasF := statDir, statFile
|
||||||
|
in := func(set []string) func(string) bool {
|
||||||
|
return func(p string) bool {
|
||||||
|
for _, s := range set {
|
||||||
|
if s == p {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statDir, statFile = in(dirs), in(files)
|
||||||
|
t.Cleanup(func() { statDir, statFile = wasD, wasF })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectsMergesComposesListWithTheContainersLabels(t *testing.T) {
|
||||||
|
aDocker(t, nil)
|
||||||
|
got, err := Projects()
|
||||||
|
if err != nil || got.Count != 2 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
p := got.Projects[0]
|
||||||
|
if p.Name != "anton-lavinmq" || p.WorkingDir != "/home/op/hub/lavinmq" || p.Running != 1 || p.Containers != 1 || p.Status != "running(1)" {
|
||||||
|
t.Errorf("%+v", p)
|
||||||
|
}
|
||||||
|
o := got.Projects[1]
|
||||||
|
if strings.Join(o.Services, ",") != "db,web" || len(o.ConfigFiles) != 2 || o.Running != 0 || o.Containers != 2 {
|
||||||
|
t.Errorf("%+v", o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectsNamesADaemonTheAccountCannotReach(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result {
|
||||||
|
return Result{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock"}
|
||||||
|
})
|
||||||
|
if _, err := Projects(); err == nil || !strings.Contains(err.Error(), "docker group") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATargetIsAKnownProjectWithItsFilesOrADirectoryHoldingAComposeFile(t *testing.T) {
|
||||||
|
aDocker(t, nil)
|
||||||
|
onDisk(t, []string{"/home/op/hub/lavinmq", "/srv/new", "/srv/empty", "/srv/old"},
|
||||||
|
[]string{"/home/op/hub/lavinmq/docker-compose.yml", "/srv/new/compose.yaml"})
|
||||||
|
got, err := targetOf(map[string]any{"dir": "/home/op/hub/lavinmq/"}, true)
|
||||||
|
if err != nil || got.Project != "anton-lavinmq" || len(got.Files) != 1 {
|
||||||
|
t.Fatalf("a known directory: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if a := strings.Join(got.args(), " "); a != "compose --project-directory /home/op/hub/lavinmq -f /home/op/hub/lavinmq/docker-compose.yml -p anton-lavinmq" {
|
||||||
|
t.Errorf("args %s", a)
|
||||||
|
}
|
||||||
|
got, err = targetOf(map[string]any{"dir": "/srv/new"}, true)
|
||||||
|
if err != nil || got.Project != "" || got.Dir != "/srv/new" {
|
||||||
|
t.Fatalf("a new directory: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
got, err = targetOf(map[string]any{"project": "old"}, false)
|
||||||
|
if err != nil || got.Dir != "/srv/old" || got.Files != nil {
|
||||||
|
t.Fatalf("a project whose files are gone, for an act that needs none: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := targetOf(map[string]any{"project": "old"}, true); err == nil || !strings.Contains(err.Error(), "no longer there") {
|
||||||
|
t.Errorf("its files are needed: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []map[string]any{{}, {"dir": "rel/path"}, {"dir": "/nope"}, {"dir": "/srv/empty"}, {"project": "Bad Name"}, {"project": "unknown"}} {
|
||||||
|
if _, err := targetOf(bad, false); err == nil {
|
||||||
|
t.Errorf("%v was accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPsReadsEitherJSONShapeAndKeepsOnlyPublishedPorts(t *testing.T) {
|
||||||
|
aDocker(t, func(line string) Result {
|
||||||
|
return ok(`{"Name":"a-web-1","Service":"web","State":"running","Status":"Up 2 hours","Health":"healthy","ExitCode":0,"Image":"nginx","Publishers":[{"URL":"0.0.0.0","TargetPort":80,"PublishedPort":8080,"Protocol":"tcp"},{"URL":"","TargetPort":443,"PublishedPort":0,"Protocol":"tcp"}]}
|
||||||
|
{"Name":"a-db-1","Service":"db","State":"exited","Status":"Exited (1)","ExitCode":1,"Image":"postgres","Publishers":[]}`)
|
||||||
|
})
|
||||||
|
got, err := Ps(Target{Project: "a"})
|
||||||
|
if err != nil || len(got.Containers) != 2 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if c := got.Containers[0]; strings.Join(c.Ports, ",") != "0.0.0.0:8080->80/tcp" || c.Health != "healthy" {
|
||||||
|
t.Errorf("%+v", c)
|
||||||
|
}
|
||||||
|
var arr []map[string]any
|
||||||
|
if err := jsonObjects(`[{"Name":"x"}]`, &arr); err != nil || len(arr) != 1 {
|
||||||
|
t.Errorf("an array: %v %v", arr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogsAreBoundedAndRefuseAnOptionInAName(t *testing.T) {
|
||||||
|
f := aDocker(t, func(line string) Result { return Result{Stdout: "web-1 | a\nweb-1 | b\n", Truncated: true} })
|
||||||
|
got, err := Logs(Target{Project: "a"}, []string{"web"}, 50, "10m")
|
||||||
|
if err != nil || len(got.Lines) != 2 || !got.Truncated {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if l := f.lines()[0]; l != "docker compose -p a logs --no-color --timestamps --tail 50 --since 10m web" {
|
||||||
|
t.Errorf("%s", l)
|
||||||
|
}
|
||||||
|
if _, err := Logs(Target{Project: "a"}, []string{"--follow"}, 5, ""); err == nil {
|
||||||
|
t.Error("an option as a service")
|
||||||
|
}
|
||||||
|
if _, err := Logs(Target{Project: "a"}, nil, 5, "1h; rm"); err == nil {
|
||||||
|
t.Error("a since that is neither")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRedactsSecretLookingValuesAndInlineContent(t *testing.T) {
|
||||||
|
aDocker(t, func(string) Result {
|
||||||
|
return ok(`{"name":"a","services":{"web":{"image":"nginx","environment":{"DB_PASSWORD":"hunter2","PORT":"80","API_TOKEN":"t"},"build":{"args":{"NPM_TOKEN":"n","NODE_ENV":"production"}}}},"secrets":{"s":{"content":"raw"}}}`)
|
||||||
|
})
|
||||||
|
got, err := Config(Target{Dir: "/srv/a"})
|
||||||
|
if err != nil || got.Redacted != 4 || strings.Join(got.Services, ",") != "web" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(got.Rendered)
|
||||||
|
for _, secret := range []string{"hunter2", `"t"`, `"n"`, "raw"} {
|
||||||
|
if strings.Contains(string(raw), secret) {
|
||||||
|
t.Errorf("%s shown: %s", secret, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, kept := range []string{`"PORT":"80"`, `"NODE_ENV":"production"`, `"image":"nginx"`} {
|
||||||
|
if !strings.Contains(string(raw), kept) {
|
||||||
|
t.Errorf("%s hidden: %s", kept, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigAnInvalidFileIsComposesError(t *testing.T) {
|
||||||
|
aDocker(t, func(string) Result {
|
||||||
|
return Result{Status: 15, Stderr: "services.web Additional property foo is not allowed"}
|
||||||
|
})
|
||||||
|
if _, err := Config(Target{Dir: "/srv/a"}); err == nil || !strings.Contains(err.Error(), "Additional property") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActsAreJobsAsTheAccountKeepVolumesAndCheckTheirArguments(t *testing.T) {
|
||||||
|
f := aDocker(t, nil)
|
||||||
|
tg := Target{Dir: "/srv/a", Project: "a"}
|
||||||
|
if got, err := Up(tg, []string{"web"}, true, "always"); err != nil || got.Job.Running || got.Act != "up" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := Down(tg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Restart(tg, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Pull(tg, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"docker compose --project-directory /srv/a -p a up --detach --pull always --build web",
|
||||||
|
"docker compose --project-directory /srv/a -p a down",
|
||||||
|
"docker compose --project-directory /srv/a -p a restart",
|
||||||
|
"docker compose --project-directory /srv/a -p a pull",
|
||||||
|
}
|
||||||
|
if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("asked\n%s\nwant\n%s", got, strings.Join(want, "\n"))
|
||||||
|
}
|
||||||
|
for _, l := range f.lines() {
|
||||||
|
if strings.Contains(l, "sudo") || strings.Contains(l, "-v") || strings.Contains(l, "--volumes") {
|
||||||
|
t.Errorf("%s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Up(tg, nil, false, "sometimes"); err == nil {
|
||||||
|
t.Error("an unknown pull policy")
|
||||||
|
}
|
||||||
|
if _, err := Restart(tg, []string{"-t"}); err == nil {
|
||||||
|
t.Error("an option as a service")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedActIsAnError(t *testing.T) {
|
||||||
|
aDocker(t, func(string) Result {
|
||||||
|
return Result{Status: 1, Stderr: "Error response from daemon: port is already allocated"}
|
||||||
|
})
|
||||||
|
if _, err := Up(Target{Dir: "/srv/a"}, nil, false, "missing"); err == nil || !strings.Contains(err.Error(), "already allocated") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
|
||||||
|
// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
|
||||||
|
// act is started as a job inside this process, waited on for a while, and answered either finished
|
||||||
|
// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
|
||||||
|
// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
|
||||||
|
const (
|
||||||
|
JobLimit = 15 * time.Minute
|
||||||
|
JobWait = 18 * time.Second
|
||||||
|
keptJobs = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is one long act, as its tool answers it.
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"job"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
Finished *time.Time `json:"finished,omitempty"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Status *int `json:"status,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobBook struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seq int
|
||||||
|
jobs map[string]*Job
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs = &jobBook{jobs: map[string]*Job{}}
|
||||||
|
|
||||||
|
// startJob runs c in the background, held to JobLimit.
|
||||||
|
func startJob(c Cmd) *Job {
|
||||||
|
c.Timeout = JobLimit
|
||||||
|
name, args := argv(c)
|
||||||
|
jobs.mu.Lock()
|
||||||
|
jobs.seq++
|
||||||
|
j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
|
||||||
|
Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
|
||||||
|
jobs.jobs[j.ID] = j
|
||||||
|
jobs.forgetOldest()
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
r := run(c)
|
||||||
|
var err error
|
||||||
|
if r.Status != 0 || r.Error != "" {
|
||||||
|
err = failure(c, r)
|
||||||
|
}
|
||||||
|
jobs.mu.Lock()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
j.Finished, j.Running = &now, false
|
||||||
|
status := r.Status
|
||||||
|
j.Status = &status
|
||||||
|
if err != nil {
|
||||||
|
j.Error = err.Error()
|
||||||
|
}
|
||||||
|
j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
|
||||||
|
j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
close(j.done)
|
||||||
|
}()
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
|
||||||
|
func (b *jobBook) forgetOldest() {
|
||||||
|
if len(b.jobs) <= keptJobs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := make([]*Job, 0, len(b.jobs))
|
||||||
|
for _, j := range b.jobs {
|
||||||
|
all = append(all, j)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
|
||||||
|
for _, j := range all {
|
||||||
|
if len(b.jobs) <= keptJobs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !j.Running {
|
||||||
|
delete(b.jobs, j.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
|
||||||
|
func awaitJob(j *Job, d time.Duration) Job {
|
||||||
|
select {
|
||||||
|
case <-j.done:
|
||||||
|
case <-time.After(d):
|
||||||
|
}
|
||||||
|
return snapshot(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(j *Job) Job {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
defer jobs.mu.Unlock()
|
||||||
|
c := *j
|
||||||
|
c.done = nil
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobByID answers a job by its id, or says it is not known to this process.
|
||||||
|
func jobByID(id string) (Job, error) {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
j, ok := jobs.jobs[id]
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
|
||||||
|
}
|
||||||
|
return snapshot(j), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listJobs answers every job this process knows, newest first.
|
||||||
|
func listJobs() []Job {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
all := make([]*Job, 0, len(jobs.jobs))
|
||||||
|
for _, j := range jobs.jobs {
|
||||||
|
all = append(all, j)
|
||||||
|
}
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
|
||||||
|
out := make([]Job, 0, len(all))
|
||||||
|
for _, j := range all {
|
||||||
|
out = append(out, snapshot(j))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
|
||||||
|
// A finished job that failed is answered as an error, so a failed act is never read as success.
|
||||||
|
func actAsJob(c Cmd) (Job, error) {
|
||||||
|
j := awaitJob(startJob(c), JobWait)
|
||||||
|
if !j.Running && j.Error != "" {
|
||||||
|
return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
|
||||||
|
}
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
if c.Timeout != JobLimit {
|
||||||
|
t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "bad") {
|
||||||
|
return Result{Status: 2, Stderr: "it broke"}
|
||||||
|
}
|
||||||
|
return ok("done")
|
||||||
|
})
|
||||||
|
j, err := actAsJob(Cmd{Name: "good"})
|
||||||
|
if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
|
||||||
|
t.Fatalf("%+v %v", j, err)
|
||||||
|
}
|
||||||
|
if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
|
||||||
|
t.Fatalf("a failed job: %v", err)
|
||||||
|
}
|
||||||
|
got, err := jobByID(j.ID)
|
||||||
|
if err != nil || got.ID != j.ID {
|
||||||
|
t.Fatalf("by id: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := jobByID("nope"); err == nil {
|
||||||
|
t.Fatal("an unknown job")
|
||||||
|
}
|
||||||
|
if len(listJobs()) < 2 {
|
||||||
|
t.Fatal("listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
using(t, func(line string, c Cmd) Result { <-release; return ok("") })
|
||||||
|
j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
|
||||||
|
if !j.Running || j.ID == "" {
|
||||||
|
t.Fatalf("%+v", j)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
got, _ := jobByID(j.ID)
|
||||||
|
if got.Running {
|
||||||
|
t.Fatalf("finished afterwards: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
// The docker-compose module's tools (novox/hq research 027/02, 026/05): the compose projects on this
|
||||||
|
// machine, their containers, logs and rendered configuration, and bringing one up, down or round
|
||||||
|
// again by its directory. A Go bundle the node's runtime launches over stdio (ADR 0188, ADR 0193); it
|
||||||
|
// runs as the operator account, which reaches the container runtime through the docker group.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"docker": "the docker module installs the container runtime; this module adds compose to it",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// where is the argument schema every tool that acts on one project takes.
|
||||||
|
var where = map[string]any{
|
||||||
|
"dir": map[string]any{"type": "string", "description": "the project's directory, absolute: where its compose file is"},
|
||||||
|
"project": map[string]any{"type": "string", "description": "the project's name, for a project docker already knows (instead of dir)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func with(extra map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
for k, v := range where {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for k, v := range extra {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
var servicesArg = map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "only these services (default all)"}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "docker_compose_projects",
|
||||||
|
Description: "The compose projects docker knows on this machine, running or stopped: name, status, working " +
|
||||||
|
"directory, compose files, services, and containers running of total. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Projects() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_ps",
|
||||||
|
Description: "One project's containers: service, state, health, exit code, image and published ports. (r)",
|
||||||
|
Input: with(nil),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Ps(t)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_logs",
|
||||||
|
Description: "One project's logs, the last lines of each service (default 200, at most 5000), optionally since a " +
|
||||||
|
"time (\"10m\", \"2026-10-04T12:00:00\"). Cut at 256 KiB. (r)",
|
||||||
|
Input: with(map[string]any{
|
||||||
|
"services": servicesArg,
|
||||||
|
"tail": map[string]any{"type": "integer", "description": "lines per service (default 200, at most 5000)"},
|
||||||
|
"since": map[string]any{"type": "string", "description": "only lines since this: a duration such as 10m or a timestamp"},
|
||||||
|
}),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services, err := optList(args, "services")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n, err := optWhole(args, "tail", 200, 1, 5000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
since, err := optText(args, "since", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Logs(t, services, n, since)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_config",
|
||||||
|
Description: "A project's configuration as compose renders it: files merged, variables filled. Values of " +
|
||||||
|
"environment variables, build arguments and labels whose names suggest a secret are replaced with " +
|
||||||
|
"[redacted]. An invalid file is answered as the error compose gives. (r)",
|
||||||
|
Input: with(nil),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Config(t)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_up",
|
||||||
|
Description: "Bring a project up, detached: create and start its containers, building or pulling what is " +
|
||||||
|
"missing. Answers when finished, or after 18 s with a job to follow with docker_compose_job. (a)",
|
||||||
|
Input: with(map[string]any{
|
||||||
|
"services": servicesArg,
|
||||||
|
"build": map[string]any{"type": "boolean", "description": "build images before starting (--build)"},
|
||||||
|
"pull": map[string]any{"type": "string", "enum": []string{"missing", "always", "never"}, "description": "pull policy (default missing)"},
|
||||||
|
}),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services, err := optList(args, "services")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build, err := optFlag(args, "build", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pull, err := optText(args, "pull", "missing")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Up(t, services, build, pull)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_down",
|
||||||
|
Description: "Stop and remove a project's containers and networks. Its volumes are kept: removing data is not " +
|
||||||
|
"this tool's. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: with(nil),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Down(t)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_restart",
|
||||||
|
Description: "Restart a project's containers, or some of its services. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: with(map[string]any{"services": servicesArg}),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services, err := optList(args, "services")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Restart(t, services)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_pull",
|
||||||
|
Description: "Pull a project's images, or some services', without starting anything. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: with(map[string]any{"services": servicesArg}),
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, err := targetOf(args, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services, err := optList(args, "services")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Pull(t, services)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "docker_compose_job",
|
||||||
|
Description: "A long act this module started (up, down, restart, pull): running or finished, its exit status " +
|
||||||
|
"and the end of its output. Without job, every act this process knows, newest first. (r)",
|
||||||
|
Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
id, err := optText(args, "job", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return map[string]any{"jobs": listJobs()}, nil
|
||||||
|
}
|
||||||
|
return jobByID(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module docker-compose
|
||||||
|
|
||||||
|
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,40 @@
|
|||||||
|
{
|
||||||
|
"module": "docker-compose",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"docker_compose_projects",
|
||||||
|
"docker_compose_ps",
|
||||||
|
"docker_compose_logs",
|
||||||
|
"docker_compose_config",
|
||||||
|
"docker_compose_up",
|
||||||
|
"docker_compose_down",
|
||||||
|
"docker_compose_restart",
|
||||||
|
"docker_compose_pull",
|
||||||
|
"docker_compose_job"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "docker-compose"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/docker-compose-tools",
|
||||||
|
"binary": "docker-compose-tools",
|
||||||
|
"loads": [
|
||||||
|
"docker-compose-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# flatpak
|
||||||
|
|
||||||
|
Flatpak applications on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are
|
||||||
|
modules, on the two workstations only"; to-be 42 phase 2 step 9).
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| flatpak | package `flatpak` (official repositories) |
|
||||||
|
|
||||||
|
**The Flathub remote comes with the package.** The package ships `flathub.flatpakrepo` in
|
||||||
|
`/usr/share/flatpak/remotes.d`, and flatpak adds every file there to the system installation as a
|
||||||
|
remote. The module therefore declares no remote file of its own. `flatpak_remotes` checks that the
|
||||||
|
system installation has Flathub, and says so when it does not.
|
||||||
|
|
||||||
|
The installed applications and runtimes, and their data, are the operator's: found (ADR 0182).
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **The laptop gains flatpak**, which only the desktop had, with Flathub.
|
||||||
|
- **Unused runtimes become visible and removable.** On the desktop on 2026-10-04, `/var/lib/flatpak`
|
||||||
|
held 5.1 GB for three applications. They are Nextcloud on KDE 6.7, Warehouse on GNOME 46, and Plex on
|
||||||
|
Freedesktop 23.08. Beside them sat the whole Freedesktop 22.08 platform, both of its GL extensions,
|
||||||
|
and older codec and style extensions that nothing uses. `flatpak_unused` lists them read-only;
|
||||||
|
`flatpak_remove_unused` lets flatpak remove them.
|
||||||
|
- **A duplicate remote is named.** The desktop has Flathub configured twice, once in the system
|
||||||
|
installation and once in the account's, which has nothing installed from it. `flatpak_remotes`
|
||||||
|
reports it.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account. An act on the system
|
||||||
|
installation (the default) goes through `sudo -n`; one on the account's own installation does not.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `flatpak_list` (r) | applications: id, name, version, branch, origin, installation, size in bytes |
|
||||||
|
| `flatpak_runtimes` (r) | runtimes and extensions, the same way |
|
||||||
|
| `flatpak_remotes` (r) | both installations' remotes, how many refs come from each, findings |
|
||||||
|
| `flatpak_updates` (r) | refs with a newer commit on their remote (needs the network) |
|
||||||
|
| `flatpak_unused` (r) | runtimes nothing needs, computed without changing anything (below), with the space each takes |
|
||||||
|
| `flatpak_disk_usage` (r) | each installation's directory on disk, and the 50 largest refs |
|
||||||
|
| `flatpak_install` (a) | install from a remote (default `flathub`) into the system or the account installation |
|
||||||
|
| `flatpak_remove` (a) | uninstall, keeping the application's data unless `delete_data` |
|
||||||
|
| `flatpak_update` (a) | update one ref, or everything in an installation |
|
||||||
|
| `flatpak_remove_unused` (a) | `uninstall --unused`: flatpak decides |
|
||||||
|
| `flatpak_job` (r) | a long act's state and the end of its output |
|
||||||
|
|
||||||
|
**Unused, computed.** `flatpak uninstall --unused` has no dry run: it asks, and answering *yes* removes.
|
||||||
|
So the read tool works it out from what flatpak already says. A runtime is in use when one of these
|
||||||
|
holds:
|
||||||
|
|
||||||
|
- an installed application names it as its runtime or SDK;
|
||||||
|
- it fills an extension point declared in the metadata of something in use, matched by id
|
||||||
|
(subdirectories included) and accepted version;
|
||||||
|
- it is pinned.
|
||||||
|
|
||||||
|
The SDK is counted as used to err on the safe side. flatpak's own `--unused` may also remove an
|
||||||
|
application's SDK.
|
||||||
|
|
||||||
|
**Acts are jobs.** An install or update downloads hundreds of megabytes. Each act runs inside the
|
||||||
|
tool's process for up to 15 minutes, and is waited on for 18 s. A finished act is answered, and a
|
||||||
|
failed one as an error. One still running is answered with a job id for `flatpak_job`.
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
- **laptop:** `flatpak` is installed, with Flathub as the system remote. Nothing else.
|
||||||
|
- **desktop:** nothing on disk; the package, installed by hand, becomes the mesh's.
|
||||||
|
|
||||||
|
## The one-off step for the operator (ADR 0182)
|
||||||
|
|
||||||
|
On the desktop, the account's own Flathub remote duplicates the system's and has nothing installed
|
||||||
|
from it. If it is not wanted: `flatpak remote-delete --user flathub`, once. The mesh did not make it,
|
||||||
|
so it does not remove it.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
The applications and runtimes, the account installation under `~/.local/share/flatpak`, application
|
||||||
|
data under `~/.var/app`, and any remote other than the package's Flathub.
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// refs are an application or runtime id, optionally with arch and branch: org.gimp.GIMP,
|
||||||
|
// org.freedesktop.Platform/x86_64/23.08, app/org.gimp.GIMP/x86_64/stable.
|
||||||
|
var refs = regexp.MustCompile(`^((app|runtime)/)?[A-Za-z][A-Za-z0-9_-]*(\.[A-Za-z0-9_-]+)+(/[A-Za-z0-9_]*(/[A-Za-z0-9._-]*)?)?$`)
|
||||||
|
|
||||||
|
func checkRef(r string) error {
|
||||||
|
if !refs.MatchString(r) {
|
||||||
|
return fmt.Errorf("%q is not a flatpak id or ref", r)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var remoteName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||||
|
|
||||||
|
// Ref is one installed application or runtime.
|
||||||
|
type Ref struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Arch string `json:"arch"`
|
||||||
|
Ref string `json:"ref"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Installation string `json:"installation"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// sizeBytes reads flatpak's human size ("275.8 MB", with a no-break space): decimal units.
|
||||||
|
func sizeBytes(s string) int64 {
|
||||||
|
f := strings.Fields(strings.ReplaceAll(s, " ", " "))
|
||||||
|
if len(f) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseFloat(f[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
unit := map[string]float64{"bytes": 1, "byte": 1, "B": 1, "kB": 1e3, "KB": 1e3, "MB": 1e6, "GB": 1e9, "TB": 1e12}
|
||||||
|
if len(f) > 1 {
|
||||||
|
if m, ok := unit[f[1]]; ok {
|
||||||
|
n *= m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return int64(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installFlag(inst string) []string {
|
||||||
|
if inst == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"--" + inst}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listRefs runs flatpak list for apps or runtimes.
|
||||||
|
func listRefs(kind, inst string) ([]Ref, error) {
|
||||||
|
args := append([]string{"list", "--" + kind, "--columns=application,name,version,branch,arch,origin,installation,size,ref"}, installFlag(inst)...)
|
||||||
|
r, err := call(Cmd{Name: "flatpak", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Ref{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) < 9 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
size := strings.ReplaceAll(f[7], " ", " ")
|
||||||
|
out = append(out, Ref{ID: f[0], Name: f[1], Version: f[2], Branch: f[3], Arch: f[4], Origin: f[5], Installation: f[6], Size: size, Bytes: sizeBytes(size), Ref: f[8]})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAnswer is what flatpak_list and flatpak_runtimes answer.
|
||||||
|
type ListAnswer struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Refs []Ref `json:"refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// List answers the applications or runtimes.
|
||||||
|
func List(kind, inst string) (ListAnswer, error) {
|
||||||
|
got, err := listRefs(kind, inst)
|
||||||
|
if err != nil {
|
||||||
|
return ListAnswer{}, err
|
||||||
|
}
|
||||||
|
out := ListAnswer{Count: len(got), Refs: got}
|
||||||
|
for _, r := range got {
|
||||||
|
out.Bytes += r.Bytes
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote is one remote of one installation.
|
||||||
|
type Remote struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Installation string `json:"installation"`
|
||||||
|
Priority string `json:"priority"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
Installed int `json:"installed_from"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemotesAnswer is what flatpak_remotes answers.
|
||||||
|
type RemotesAnswer struct {
|
||||||
|
Remotes []Remote `json:"remotes"`
|
||||||
|
Flathub bool `json:"flathub_system"`
|
||||||
|
Findings []string `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remotes answers both installations' remotes and what is wrong with them.
|
||||||
|
func Remotes() (RemotesAnswer, error) {
|
||||||
|
r, err := call(Cmd{Name: "flatpak", Args: []string{"remotes", "--show-disabled", "--columns=name,url,options,priority"}})
|
||||||
|
if err != nil {
|
||||||
|
return RemotesAnswer{}, err
|
||||||
|
}
|
||||||
|
all, err := listRefs("app", "")
|
||||||
|
if err != nil {
|
||||||
|
return RemotesAnswer{}, err
|
||||||
|
}
|
||||||
|
rt, err := listRefs("runtime", "")
|
||||||
|
if err != nil {
|
||||||
|
return RemotesAnswer{}, err
|
||||||
|
}
|
||||||
|
from := map[string]int{}
|
||||||
|
for _, x := range append(all, rt...) {
|
||||||
|
from[x.Origin+"\x00"+x.Installation]++
|
||||||
|
}
|
||||||
|
out := RemotesAnswer{Remotes: []Remote{}, Findings: []string{}}
|
||||||
|
byURL := map[string][]string{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inst := "system"
|
||||||
|
disabled := false
|
||||||
|
for _, o := range strings.Split(f[2], ",") {
|
||||||
|
switch strings.TrimSpace(o) {
|
||||||
|
case "user":
|
||||||
|
inst = "user"
|
||||||
|
case "disabled":
|
||||||
|
disabled = true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(o), "system") {
|
||||||
|
inst = strings.TrimSpace(o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rem := Remote{Name: f[0], URL: f[1], Installation: inst, Priority: f[3], Disabled: disabled, Installed: from[f[0]+"\x00"+inst]}
|
||||||
|
out.Remotes = append(out.Remotes, rem)
|
||||||
|
byURL[strings.TrimRight(rem.URL, "/")] = append(byURL[strings.TrimRight(rem.URL, "/")], rem.Name+" ("+inst+")")
|
||||||
|
if inst == "system" && rem.Name == "flathub" && !disabled {
|
||||||
|
out.Flathub = true
|
||||||
|
}
|
||||||
|
if rem.Installed == 0 {
|
||||||
|
out.Findings = append(out.Findings, fmt.Sprintf("remote %s in the %s installation has nothing installed from it", rem.Name, inst))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !out.Flathub {
|
||||||
|
out.Findings = append(out.Findings, "the system installation has no enabled flathub remote: the flatpak package ships one in /usr/share/flatpak/remotes.d, so it was removed or disabled by hand")
|
||||||
|
}
|
||||||
|
for url, names := range byURL {
|
||||||
|
if len(names) > 1 {
|
||||||
|
sort.Strings(names)
|
||||||
|
out.Findings = append(out.Findings, fmt.Sprintf("%s is configured %d times: %s", url, len(names), strings.Join(names, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out.Findings)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates answers what an update would change.
|
||||||
|
func Updates(inst string) (map[string]any, error) {
|
||||||
|
args := append([]string{"remote-ls", "--updates", "--columns=application,branch,version,origin,ref"}, installFlag(inst)...)
|
||||||
|
r, err := call(Cmd{Name: "flatpak", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []map[string]string{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) >= 5 {
|
||||||
|
out = append(out, map[string]string{"id": f[0], "branch": f[1], "version": f[2], "origin": f[3], "ref": f[4]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"count": len(out), "updates": out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extension is one extension point a ref's metadata declares.
|
||||||
|
type Extension struct {
|
||||||
|
ID string
|
||||||
|
Versions []string
|
||||||
|
Subdirs bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// extensionsOf reads [Extension …] groups from a ref's metadata, with the versions each accepts:
|
||||||
|
// versions, else version, else the branch of the ref declaring it.
|
||||||
|
func extensionsOf(metadata, branch string) []Extension {
|
||||||
|
out := []Extension{}
|
||||||
|
var cur *Extension
|
||||||
|
flush := func() {
|
||||||
|
if cur != nil {
|
||||||
|
if len(cur.Versions) == 0 {
|
||||||
|
cur.Versions = []string{branch}
|
||||||
|
}
|
||||||
|
out = append(out, *cur)
|
||||||
|
}
|
||||||
|
cur = nil
|
||||||
|
}
|
||||||
|
var version, versions string
|
||||||
|
for _, l := range strings.Split(metadata, "\n") {
|
||||||
|
l = strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(l, "[") {
|
||||||
|
if cur != nil {
|
||||||
|
cur.Versions = pickVersions(versions, version)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
version, versions = "", ""
|
||||||
|
if strings.HasPrefix(l, "[Extension ") && strings.HasSuffix(l, "]") {
|
||||||
|
cur = &Extension{ID: strings.TrimSuffix(strings.TrimPrefix(l, "[Extension "), "]")}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, found := strings.Cut(l, "=")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(k) {
|
||||||
|
case "version":
|
||||||
|
version = strings.TrimSpace(v)
|
||||||
|
case "versions":
|
||||||
|
versions = strings.TrimSpace(v)
|
||||||
|
case "subdirectories":
|
||||||
|
cur.Subdirs = strings.TrimSpace(v) == "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cur != nil {
|
||||||
|
cur.Versions = pickVersions(versions, version)
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickVersions(versions, version string) []string {
|
||||||
|
if versions != "" {
|
||||||
|
out := []string{}
|
||||||
|
for _, v := range strings.Split(versions, ";") {
|
||||||
|
if v = strings.TrimSpace(v); v != "" {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if version != "" {
|
||||||
|
return []string{version}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fills says whether a runtime fills an extension point.
|
||||||
|
func (e Extension) fills(r Ref) bool {
|
||||||
|
if !(r.ID == e.ID || (e.Subdirs && strings.HasPrefix(r.ID, e.ID+"."))) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, v := range e.Versions {
|
||||||
|
if v == r.Branch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnusedAnswer is what flatpak_unused answers.
|
||||||
|
type UnusedAnswer struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Unused []Ref `json:"unused"`
|
||||||
|
Pinned []string `json:"pinned"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(inst string, extra ...string) (string, error) {
|
||||||
|
r, err := call(Cmd{Name: "flatpak", Args: append(append([]string{"info"}, installFlag(inst)...), extra...)})
|
||||||
|
return strings.TrimSpace(r.Stdout), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unused computes what no installed application needs.
|
||||||
|
func Unused() (UnusedAnswer, error) {
|
||||||
|
apps, err := listRefs("app", "")
|
||||||
|
if err != nil {
|
||||||
|
return UnusedAnswer{}, err
|
||||||
|
}
|
||||||
|
runtimes, err := listRefs("runtime", "")
|
||||||
|
if err != nil {
|
||||||
|
return UnusedAnswer{}, err
|
||||||
|
}
|
||||||
|
key := func(inst, ref string) string {
|
||||||
|
return inst + "\x00" + strings.TrimPrefix(strings.TrimPrefix(ref, "runtime/"), "app/")
|
||||||
|
}
|
||||||
|
installed := map[string]Ref{}
|
||||||
|
for _, r := range runtimes {
|
||||||
|
installed[key(r.Installation, r.Ref)] = r
|
||||||
|
}
|
||||||
|
used := map[string]bool{}
|
||||||
|
type item struct {
|
||||||
|
inst, ref, branch string
|
||||||
|
}
|
||||||
|
queue := []item{}
|
||||||
|
// A user installation's application may use a system runtime; a system one only system runtimes.
|
||||||
|
mark := func(inst, ref string) {
|
||||||
|
for _, where := range []string{inst, "system"} {
|
||||||
|
k := key(where, ref)
|
||||||
|
if r, ok := installed[k]; ok && !used[k] {
|
||||||
|
used[k] = true
|
||||||
|
queue = append(queue, item{where, r.Ref, r.Branch})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range apps {
|
||||||
|
for _, flag := range []string{"--show-runtime", "--show-sdk"} {
|
||||||
|
ref, err := info(a.Installation, flag, a.Ref)
|
||||||
|
if err != nil {
|
||||||
|
return UnusedAnswer{}, err
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
mark(a.Installation, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue = append(queue, item{a.Installation, a.Ref, a.Branch})
|
||||||
|
}
|
||||||
|
pinned := []string{}
|
||||||
|
for _, inst := range []string{"system", "user"} {
|
||||||
|
r := run(Cmd{Name: "flatpak", Args: []string{"pin", "--" + inst}})
|
||||||
|
if r.Status != 0 || r.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
p := strings.TrimSpace(l)
|
||||||
|
if strings.HasPrefix(p, "runtime/") {
|
||||||
|
pinned = append(pinned, p)
|
||||||
|
mark(inst, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(queue) > 0 {
|
||||||
|
it := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
meta, err := info(it.inst, "--show-metadata", it.ref)
|
||||||
|
if err != nil {
|
||||||
|
return UnusedAnswer{}, err
|
||||||
|
}
|
||||||
|
for _, e := range extensionsOf(meta, it.branch) {
|
||||||
|
for k, r := range installed {
|
||||||
|
if !used[k] && e.fills(r) && (r.Installation == it.inst || r.Installation == "system") {
|
||||||
|
used[k] = true
|
||||||
|
queue = append(queue, item{r.Installation, r.Ref, r.Branch})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := UnusedAnswer{Unused: []Ref{}, Pinned: pinned,
|
||||||
|
Note: "Computed without changing anything. flatpak_remove_unused lets flatpak decide, which may differ in detail (a locale or debug extension listed here by nothing)."}
|
||||||
|
for k, r := range installed {
|
||||||
|
if !used[k] {
|
||||||
|
out.Unused = append(out.Unused, r)
|
||||||
|
out.Bytes += r.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Unused, func(i, k int) bool { return out.Unused[i].Ref < out.Unused[k].Ref })
|
||||||
|
out.Count = len(out.Unused)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where each installation lives.
|
||||||
|
var installDirs = map[string]string{"system": "/var/lib/flatpak", "user": ".local/share/flatpak"}
|
||||||
|
|
||||||
|
// DiskAnswer is what flatpak_disk_usage answers.
|
||||||
|
type DiskAnswer struct {
|
||||||
|
Installations map[string]int64 `json:"installation_bytes"`
|
||||||
|
Largest []Ref `json:"largest"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskUsage measures each installation's directory and lists the largest refs.
|
||||||
|
func DiskUsage() (DiskAnswer, error) {
|
||||||
|
out := DiskAnswer{Installations: map[string]int64{}, Largest: []Ref{}}
|
||||||
|
for inst, dir := range installDirs {
|
||||||
|
if !filepath.IsAbs(dir) {
|
||||||
|
dir = filepath.Join(accountHome(), dir)
|
||||||
|
}
|
||||||
|
// du exits 1 when a file is unreadable and still prints the total of what it could read.
|
||||||
|
r := run(Cmd{Name: "du", Args: []string{"-sb", dir}})
|
||||||
|
if r.Error != "" {
|
||||||
|
return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
|
||||||
|
}
|
||||||
|
f := strings.Fields(r.Stdout)
|
||||||
|
if len(f) == 0 {
|
||||||
|
if strings.Contains(r.Stderr, "No such file") {
|
||||||
|
out.Installations[inst] = 0
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return DiskAnswer{}, failure(Cmd{Name: "du", Args: []string{"-sb", dir}}, r)
|
||||||
|
}
|
||||||
|
n, _ := strconv.ParseInt(f[0], 10, 64)
|
||||||
|
out.Installations[inst] = n
|
||||||
|
if r.Status != 0 {
|
||||||
|
out.Note = "some files were unreadable to the account, so a total is a lower bound"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apps, err := listRefs("app", "")
|
||||||
|
if err != nil {
|
||||||
|
return DiskAnswer{}, err
|
||||||
|
}
|
||||||
|
rts, err := listRefs("runtime", "")
|
||||||
|
if err != nil {
|
||||||
|
return DiskAnswer{}, err
|
||||||
|
}
|
||||||
|
all := append(apps, rts...)
|
||||||
|
sort.Slice(all, func(i, k int) bool { return all[i].Bytes > all[k].Bytes })
|
||||||
|
if len(all) > 50 {
|
||||||
|
all = all[:50]
|
||||||
|
}
|
||||||
|
out.Largest = all
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountHome() string {
|
||||||
|
return homeFrom(getenv)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActAnswer is what an act answers.
|
||||||
|
type ActAnswer struct {
|
||||||
|
Act string `json:"act"`
|
||||||
|
Ref string `json:"ref,omitempty"`
|
||||||
|
Installation string `json:"installation"`
|
||||||
|
Job Job `json:"job"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// act runs flatpak as a job; the system installation needs root, the account's does not.
|
||||||
|
func act(verb, ref, inst string, args ...string) (ActAnswer, error) {
|
||||||
|
c := Cmd{Name: "flatpak", Args: append([]string{verb, "--" + inst, "--noninteractive", "-y"}, args...), Root: inst == "system"}
|
||||||
|
j, err := actAsJob(c)
|
||||||
|
if err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
return ActAnswer{Act: verb, Ref: ref, Installation: inst, Job: j}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install installs a ref from a remote.
|
||||||
|
func Install(ref, remote, inst string) (ActAnswer, error) {
|
||||||
|
if !remoteName.MatchString(remote) {
|
||||||
|
return ActAnswer{}, fmt.Errorf("%q is not a remote's name", remote)
|
||||||
|
}
|
||||||
|
return act("install", ref, inst, remote, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove uninstalls a ref.
|
||||||
|
func Remove(ref, inst string, deleteData bool) (ActAnswer, error) {
|
||||||
|
if deleteData {
|
||||||
|
return act("uninstall", ref, inst, "--delete-data", ref)
|
||||||
|
}
|
||||||
|
return act("uninstall", ref, inst, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates one ref, or everything.
|
||||||
|
func Update(ref, inst string) (ActAnswer, error) {
|
||||||
|
if ref == "" {
|
||||||
|
return act("update", "", inst)
|
||||||
|
}
|
||||||
|
return act("update", ref, inst, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUnused lets flatpak uninstall what it finds unused.
|
||||||
|
func RemoveUnused(inst string) (ActAnswer, error) {
|
||||||
|
return act("uninstall", "", inst, "--unused")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getenv and homeFrom read the account's home the way the runtime gives it.
|
||||||
|
var getenv = func(k string) string { return strings.TrimSpace(os.Getenv(k)) }
|
||||||
|
|
||||||
|
func homeFrom(env func(string) string) string {
|
||||||
|
if h := env("MESH_OPERATOR_HOME"); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
return env("HOME")
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsFlatpaksPackageAndFlathubComesWithIt(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "flatpak")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "flatpak" {
|
||||||
|
t.Errorf("packages %s", got)
|
||||||
|
}
|
||||||
|
// The package ships flathub in /usr/share/flatpak/remotes.d: the module declares no remote file.
|
||||||
|
if len(m.Resources) != 1 {
|
||||||
|
t.Errorf("one resource: %v", m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nb = " "
|
||||||
|
|
||||||
|
// The desktop's installation on 2026-10-04, as flatpak list prints it.
|
||||||
|
var apps = "com.nextcloud.desktopclient.nextcloud\tNextcloud Desktop\t3.14\tstable\tx86_64\tflathub\tsystem\t275.8" + nb + "MB\tcom.nextcloud.desktopclient.nextcloud/x86_64/stable\n" +
|
||||||
|
"tv.plex.PlexDesktop\tPlex\t1.9\tstable\tx86_64\tflathub\tsystem\t368.0" + nb + "MB\ttv.plex.PlexDesktop/x86_64/stable\n"
|
||||||
|
|
||||||
|
var runtimes = strings.Join([]string{
|
||||||
|
"org.freedesktop.Platform\tFreedesktop Platform\t22.08.1\t22.08\tx86_64\tflathub\tsystem\t576.7" + nb + "MB\torg.freedesktop.Platform/x86_64/22.08",
|
||||||
|
"org.freedesktop.Platform\tFreedesktop Platform\t23.08.1\t23.08\tx86_64\tflathub\tsystem\t598.3" + nb + "MB\torg.freedesktop.Platform/x86_64/23.08",
|
||||||
|
"org.freedesktop.Platform.GL.default\tMesa\t\t22.08\tx86_64\tflathub\tsystem\t442.1" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/22.08",
|
||||||
|
"org.freedesktop.Platform.GL.default\tMesa\t\t23.08\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08",
|
||||||
|
"org.freedesktop.Platform.GL.default\tMesa\t\t23.08-extra\tx86_64\tflathub\tsystem\t533.8" + nb + "MB\torg.freedesktop.Platform.GL.default/x86_64/23.08-extra",
|
||||||
|
"org.freedesktop.Platform.openh264\topenh264\t\t2.2.0\tx86_64\tflathub\tsystem\t790.0" + nb + "kB\torg.freedesktop.Platform.openh264/x86_64/2.2.0",
|
||||||
|
"org.freedesktop.Sdk\tSdk\t\t23.08\tx86_64\tflathub\tsystem\t1.6" + nb + "GB\torg.freedesktop.Sdk/x86_64/23.08",
|
||||||
|
"org.kde.Platform\tKDE\t\t6.7\tx86_64\tflathub\tsystem\t931.4" + nb + "MB\torg.kde.Platform/x86_64/6.7",
|
||||||
|
"org.kde.KStyle.Adwaita\tAdwaita\t\t5.15-21.08\tx86_64\tflathub\tsystem\t16.3" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/5.15-21.08",
|
||||||
|
"org.kde.KStyle.Adwaita\tAdwaita\t\t6.7\tx86_64\tflathub\tsystem\t20.6" + nb + "MB\torg.kde.KStyle.Adwaita/x86_64/6.7",
|
||||||
|
}, "\n") + "\n"
|
||||||
|
|
||||||
|
const platformMeta = `[Runtime]
|
||||||
|
name=org.freedesktop.Platform
|
||||||
|
|
||||||
|
[Extension org.freedesktop.Platform.GL]
|
||||||
|
versions = 23.08;23.08-extra;1.4
|
||||||
|
version = 1.4
|
||||||
|
directory = lib/x86_64-linux-gnu/GL
|
||||||
|
subdirectories = true
|
||||||
|
|
||||||
|
[Extension org.freedesktop.Platform.openh264]
|
||||||
|
directory = lib/openh264
|
||||||
|
version = 2.2.0
|
||||||
|
|
||||||
|
[Extension org.freedesktop.Platform.Timezones]
|
||||||
|
directory = share/zoneinfo
|
||||||
|
`
|
||||||
|
|
||||||
|
const kdeMeta = `[Runtime]
|
||||||
|
name=org.kde.Platform
|
||||||
|
|
||||||
|
[Extension org.kde.KStyle]
|
||||||
|
directory = lib/plugins/styles
|
||||||
|
subdirectories = true
|
||||||
|
version = 6.7
|
||||||
|
|
||||||
|
[Extension org.freedesktop.Platform.GL]
|
||||||
|
versions = 23.08;1.4
|
||||||
|
subdirectories = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func theDesktop(t *testing.T) *fake {
|
||||||
|
return using(t, func(line string, c Cmd) Result {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "flatpak list --app"):
|
||||||
|
return ok(apps)
|
||||||
|
case strings.HasPrefix(line, "flatpak list --runtime"):
|
||||||
|
return ok(runtimes)
|
||||||
|
case strings.Contains(line, "--show-runtime com.nextcloud"):
|
||||||
|
return ok("org.kde.Platform/x86_64/6.7\n")
|
||||||
|
case strings.Contains(line, "--show-runtime tv.plex"):
|
||||||
|
return ok("org.freedesktop.Platform/x86_64/23.08\n")
|
||||||
|
case strings.Contains(line, "--show-sdk tv.plex"):
|
||||||
|
return ok("org.freedesktop.Sdk/x86_64/23.08\n")
|
||||||
|
case strings.Contains(line, "--show-sdk"):
|
||||||
|
return ok("org.kde.Sdk/x86_64/6.7\n")
|
||||||
|
case strings.Contains(line, "--show-metadata org.freedesktop.Platform/x86_64/23.08"):
|
||||||
|
return ok(platformMeta)
|
||||||
|
case strings.Contains(line, "--show-metadata org.kde.Platform"):
|
||||||
|
return ok(kdeMeta)
|
||||||
|
case strings.Contains(line, "--show-metadata"):
|
||||||
|
return ok("[Application]\nname=x\n")
|
||||||
|
case strings.HasPrefix(line, "flatpak pin"):
|
||||||
|
return ok("")
|
||||||
|
case strings.HasPrefix(line, "flatpak remotes"):
|
||||||
|
return ok("flathub\thttps://dl.flathub.org/repo/\tsystem\t1\nflathub\thttps://dl.flathub.org/repo/\tuser\t1\n")
|
||||||
|
}
|
||||||
|
return Result{Status: 9, Stderr: "unexpected " + line}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnusedIsWhatNoApplicationNeedsDirectlyOrThroughAnExtensionPoint(t *testing.T) {
|
||||||
|
f := theDesktop(t)
|
||||||
|
got, err := Unused()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
names := []string{}
|
||||||
|
for _, r := range got.Unused {
|
||||||
|
names = append(names, r.Ref)
|
||||||
|
}
|
||||||
|
want := "org.freedesktop.Platform.GL.default/x86_64/22.08,org.freedesktop.Platform/x86_64/22.08,org.kde.KStyle.Adwaita/x86_64/5.15-21.08"
|
||||||
|
if strings.Join(names, ",") != want {
|
||||||
|
t.Errorf("unused\n%s\nwant\n%s", strings.Join(names, ","), want)
|
||||||
|
}
|
||||||
|
if got.Bytes != 576700000+442100000+16300000 {
|
||||||
|
t.Errorf("bytes %d", got.Bytes)
|
||||||
|
}
|
||||||
|
for _, l := range f.lines() {
|
||||||
|
if strings.Contains(l, "uninstall") || strings.HasPrefix(l, "sudo") {
|
||||||
|
t.Errorf("unused only reads: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnExtensionPointAcceptsItsVersionsOrTheDeclaringBranch(t *testing.T) {
|
||||||
|
ext := extensionsOf(platformMeta, "23.08")
|
||||||
|
if len(ext) != 3 {
|
||||||
|
t.Fatalf("%+v", ext)
|
||||||
|
}
|
||||||
|
gl, h264, tz := ext[0], ext[1], ext[2]
|
||||||
|
if !gl.Subdirs || strings.Join(gl.Versions, ";") != "23.08;23.08-extra;1.4" {
|
||||||
|
t.Errorf("versions win over version: %+v", gl)
|
||||||
|
}
|
||||||
|
if h264.Subdirs || strings.Join(h264.Versions, ";") != "2.2.0" {
|
||||||
|
t.Errorf("%+v", h264)
|
||||||
|
}
|
||||||
|
if strings.Join(tz.Versions, ";") != "23.08" {
|
||||||
|
t.Errorf("no version is the declaring branch: %+v", tz)
|
||||||
|
}
|
||||||
|
if !gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "23.08-extra"}) || gl.fills(Ref{ID: "org.freedesktop.Platform.GL.default", Branch: "22.08"}) {
|
||||||
|
t.Error("a subdirectory extension, by branch")
|
||||||
|
}
|
||||||
|
if h264.fills(Ref{ID: "org.freedesktop.Platform.openh264.x", Branch: "2.2.0"}) {
|
||||||
|
t.Error("without subdirectories only the id itself")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemotesFindTheDuplicateAndTheEmptyOne(t *testing.T) {
|
||||||
|
theDesktop(t)
|
||||||
|
got, err := Remotes()
|
||||||
|
if err != nil || len(got.Remotes) != 2 || !got.Flathub {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.Remotes[0].Installed != 12 || got.Remotes[1].Installed != 0 {
|
||||||
|
t.Errorf("%+v", got.Remotes)
|
||||||
|
}
|
||||||
|
all := strings.Join(got.Findings, ";")
|
||||||
|
if !strings.Contains(all, "flathub in the user installation has nothing installed") || !strings.Contains(all, "configured 2 times") {
|
||||||
|
t.Errorf("%s", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReadsSizesAsBytes(t *testing.T) {
|
||||||
|
theDesktop(t)
|
||||||
|
got, err := List("app", "")
|
||||||
|
if err != nil || got.Count != 2 || got.Refs[0].Bytes != 275800000 || got.Refs[0].Size != "275.8 MB" || got.Bytes != 643800000 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
for in, want := range map[string]int64{"1.6" + nb + "GB": 1600000000, "790.0 kB": 790000, "12 bytes": 12, "": 0} {
|
||||||
|
if b := sizeBytes(in); b != want {
|
||||||
|
t.Errorf("%q: %d", in, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActsOnTheSystemInstallationEscalateAndTheAccountsDoNot(t *testing.T) {
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("done") })
|
||||||
|
if got, err := Install("org.gimp.GIMP", "flathub", "system"); err != nil || got.Job.Running {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := Install("org.gimp.GIMP", "flathub", "user"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Remove("org.gimp.GIMP", "system", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Update("", "user"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := RemoveUnused("system"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"sudo -n flatpak install --system --noninteractive -y flathub org.gimp.GIMP",
|
||||||
|
"flatpak install --user --noninteractive -y flathub org.gimp.GIMP",
|
||||||
|
"sudo -n flatpak uninstall --system --noninteractive -y --delete-data org.gimp.GIMP",
|
||||||
|
"flatpak update --user --noninteractive -y",
|
||||||
|
"sudo -n flatpak uninstall --system --noninteractive -y --unused",
|
||||||
|
}
|
||||||
|
if got := strings.Join(f.lines(), "\n"); got != strings.Join(want, "\n") {
|
||||||
|
t.Errorf("asked\n%s", got)
|
||||||
|
}
|
||||||
|
if _, err := Install("org.gimp.GIMP", "--from=x", "system"); err == nil {
|
||||||
|
t.Error("an option as a remote")
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"--assumeyes", "gimp", "org.gimp.GIMP; rm", "org/../x"} {
|
||||||
|
if checkRef(bad) == nil {
|
||||||
|
t.Errorf("%q accepted as a ref", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, good := range []string{"org.gimp.GIMP", "org.freedesktop.Platform/x86_64/23.08", "app/org.gimp.GIMP/x86_64/stable", "org.freedesktop.Platform.GL.default//23.08-extra"} {
|
||||||
|
if err := checkRef(good); err != nil {
|
||||||
|
t.Errorf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAFailedInstallIsAnErrorAndAMissingFlatpakIsSaid(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result {
|
||||||
|
return Result{Status: 1, Stderr: "error: Nothing matches org.nope.Nope in remote flathub"}
|
||||||
|
})
|
||||||
|
if _, err := Install("org.nope.Nope", "flathub", "user"); err == nil || !strings.Contains(err.Error(), "Nothing matches") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||||
|
if _, err := List("app", ""); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageReadsEachInstallationAndToleratesAnUnreadableFile(t *testing.T) {
|
||||||
|
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
switch {
|
||||||
|
case line == "du -sb /var/lib/flatpak":
|
||||||
|
return Result{Status: 1, Stdout: "5094728394\t/var/lib/flatpak\n", Stderr: "du: cannot read directory 'x': Permission denied"}
|
||||||
|
case line == "du -sb /home/op/.local/share/flatpak":
|
||||||
|
return Result{Status: 1, Stderr: "du: cannot access '/home/op/.local/share/flatpak': No such file or directory"}
|
||||||
|
case strings.Contains(line, "--app"):
|
||||||
|
return ok(apps)
|
||||||
|
}
|
||||||
|
return ok(runtimes)
|
||||||
|
})
|
||||||
|
got, err := DiskUsage()
|
||||||
|
if err != nil || got.Installations["system"] != 5094728394 || got.Installations["user"] != 0 || got.Note == "" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.Largest[0].ID != "org.freedesktop.Sdk" {
|
||||||
|
t.Errorf("largest first: %+v", got.Largest[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// jobs.go is the same file in the bundles whose acts can outlast one call (flatpak, docker-compose):
|
||||||
|
// an install or an `up` that pulls images takes minutes, and the runtime gives a call 30 s. Such an
|
||||||
|
// act is started as a job inside this process, waited on for a while, and answered either finished
|
||||||
|
// or with the job's id for the module's `_job` tool to follow. A job ends with this process: if the
|
||||||
|
// runtime restarts the bundle, a running job is cut off, and its id is then unknown.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// JobLimit is the longest a job may run; JobWait how long an act waits before answering a job id.
|
||||||
|
const (
|
||||||
|
JobLimit = 15 * time.Minute
|
||||||
|
JobWait = 18 * time.Second
|
||||||
|
keptJobs = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// Job is one long act, as its tool answers it.
|
||||||
|
type Job struct {
|
||||||
|
ID string `json:"job"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Started time.Time `json:"started"`
|
||||||
|
Finished *time.Time `json:"finished,omitempty"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Status *int `json:"status,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type jobBook struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
seq int
|
||||||
|
jobs map[string]*Job
|
||||||
|
}
|
||||||
|
|
||||||
|
var jobs = &jobBook{jobs: map[string]*Job{}}
|
||||||
|
|
||||||
|
// startJob runs c in the background, held to JobLimit.
|
||||||
|
func startJob(c Cmd) *Job {
|
||||||
|
c.Timeout = JobLimit
|
||||||
|
name, args := argv(c)
|
||||||
|
jobs.mu.Lock()
|
||||||
|
jobs.seq++
|
||||||
|
j := &Job{ID: fmt.Sprintf("%d-%d", time.Now().Unix(), jobs.seq), Command: strings.TrimSpace(name + " " + strings.Join(args, " ")),
|
||||||
|
Started: time.Now().UTC(), Running: true, done: make(chan struct{})}
|
||||||
|
jobs.jobs[j.ID] = j
|
||||||
|
jobs.forgetOldest()
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
go func() {
|
||||||
|
r := run(c)
|
||||||
|
var err error
|
||||||
|
if r.Status != 0 || r.Error != "" {
|
||||||
|
err = failure(c, r)
|
||||||
|
}
|
||||||
|
jobs.mu.Lock()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
j.Finished, j.Running = &now, false
|
||||||
|
status := r.Status
|
||||||
|
j.Status = &status
|
||||||
|
if err != nil {
|
||||||
|
j.Error = err.Error()
|
||||||
|
}
|
||||||
|
j.Output = tail(strings.TrimSpace(r.Stdout+"\n"+r.Stderr), 16<<10)
|
||||||
|
j.Truncated = r.Truncated || len(r.Stdout)+len(r.Stderr) > 16<<10
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
close(j.done)
|
||||||
|
}()
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// forgetOldest keeps the book bounded; finished jobs go first. Called with the lock held.
|
||||||
|
func (b *jobBook) forgetOldest() {
|
||||||
|
if len(b.jobs) <= keptJobs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
all := make([]*Job, 0, len(b.jobs))
|
||||||
|
for _, j := range b.jobs {
|
||||||
|
all = append(all, j)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, k int) bool { return all[i].Started.Before(all[k].Started) })
|
||||||
|
for _, j := range all {
|
||||||
|
if len(b.jobs) <= keptJobs {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !j.Running {
|
||||||
|
delete(b.jobs, j.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitJob waits up to d for a job to finish and answers a copy of it as it then stands.
|
||||||
|
func awaitJob(j *Job, d time.Duration) Job {
|
||||||
|
select {
|
||||||
|
case <-j.done:
|
||||||
|
case <-time.After(d):
|
||||||
|
}
|
||||||
|
return snapshot(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapshot(j *Job) Job {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
defer jobs.mu.Unlock()
|
||||||
|
c := *j
|
||||||
|
c.done = nil
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// jobByID answers a job by its id, or says it is not known to this process.
|
||||||
|
func jobByID(id string) (Job, error) {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
j, ok := jobs.jobs[id]
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
return Job{}, fmt.Errorf("no job %s in this process: it was never started here, was forgotten after %d newer ones, or the bundle has restarted since", id, keptJobs)
|
||||||
|
}
|
||||||
|
return snapshot(j), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listJobs answers every job this process knows, newest first.
|
||||||
|
func listJobs() []Job {
|
||||||
|
jobs.mu.Lock()
|
||||||
|
all := make([]*Job, 0, len(jobs.jobs))
|
||||||
|
for _, j := range jobs.jobs {
|
||||||
|
all = append(all, j)
|
||||||
|
}
|
||||||
|
jobs.mu.Unlock()
|
||||||
|
sort.Slice(all, func(i, k int) bool { return all[i].Started.After(all[k].Started) })
|
||||||
|
out := make([]Job, 0, len(all))
|
||||||
|
for _, j := range all {
|
||||||
|
out = append(out, snapshot(j))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// actAsJob starts c and answers the job once it finishes or JobWait passes, whichever is first.
|
||||||
|
// A finished job that failed is answered as an error, so a failed act is never read as success.
|
||||||
|
func actAsJob(c Cmd) (Job, error) {
|
||||||
|
j := awaitJob(startJob(c), JobWait)
|
||||||
|
if !j.Running && j.Error != "" {
|
||||||
|
return j, fmt.Errorf("%s (job %s)", j.Error, j.ID)
|
||||||
|
}
|
||||||
|
return j, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJobsAFastActIsAnsweredFinishedAndAFailedOneAsAnError(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
if c.Timeout != JobLimit {
|
||||||
|
t.Errorf("a job is held to JobLimit, not %s", c.Timeout)
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "bad") {
|
||||||
|
return Result{Status: 2, Stderr: "it broke"}
|
||||||
|
}
|
||||||
|
return ok("done")
|
||||||
|
})
|
||||||
|
j, err := actAsJob(Cmd{Name: "good"})
|
||||||
|
if err != nil || j.Running || j.Status == nil || *j.Status != 0 || j.Output != "done" {
|
||||||
|
t.Fatalf("%+v %v", j, err)
|
||||||
|
}
|
||||||
|
if _, err := actAsJob(Cmd{Name: "bad"}); err == nil || !strings.Contains(err.Error(), "it broke") {
|
||||||
|
t.Fatalf("a failed job: %v", err)
|
||||||
|
}
|
||||||
|
got, err := jobByID(j.ID)
|
||||||
|
if err != nil || got.ID != j.ID {
|
||||||
|
t.Fatalf("by id: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := jobByID("nope"); err == nil {
|
||||||
|
t.Fatal("an unknown job")
|
||||||
|
}
|
||||||
|
if len(listJobs()) < 2 {
|
||||||
|
t.Fatal("listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJobsASlowActIsAnsweredRunningWithItsID(t *testing.T) {
|
||||||
|
release := make(chan struct{})
|
||||||
|
using(t, func(line string, c Cmd) Result { <-release; return ok("") })
|
||||||
|
j := awaitJob(startJob(Cmd{Name: "slow"}), 50*time.Millisecond)
|
||||||
|
if !j.Running || j.ID == "" {
|
||||||
|
t.Fatalf("%+v", j)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
got, _ := jobByID(j.ID)
|
||||||
|
if got.Running {
|
||||||
|
t.Fatalf("finished afterwards: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// The flatpak module's tools (novox/hq research 027/02, 026/05): the applications and runtimes in
|
||||||
|
// both installations, the remotes, pending updates, what nothing uses any more and the space it all
|
||||||
|
// takes; and installing, removing and updating. A Go bundle the node's runtime launches over stdio
|
||||||
|
// (ADR 0188, ADR 0193); it runs as the operator account. An act on the system installation goes
|
||||||
|
// through `sudo -n`; one on the account's own installation does not.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"flatpak": "the flatpak package, which this module installs",
|
||||||
|
"du": "the coreutils package",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var installationArg = map[string]any{"type": "string", "enum": []string{"system", "user"},
|
||||||
|
"description": "the system installation (default), or the account's own"}
|
||||||
|
|
||||||
|
func installationOf(args map[string]any, def string) (string, error) {
|
||||||
|
i, err := optText(args, "installation", def)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if i == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return i, oneOf("installation", i, "system", "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func refArg(args map[string]any, key string) (string, error) {
|
||||||
|
r, err := text(args, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return r, checkRef(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "flatpak_list",
|
||||||
|
Description: "The installed applications, in both installations or one: id, name, version, branch, origin, " +
|
||||||
|
"installation and size. (r)",
|
||||||
|
Input: map[string]any{"installation": installationArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
inst, err := installationOf(args, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return List("app", inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_runtimes",
|
||||||
|
Description: "The installed runtimes and extensions, in both installations or one: id, branch, origin, installation and size. (r)",
|
||||||
|
Input: map[string]any{"installation": installationArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
inst, err := installationOf(args, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return List("runtime", inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_remotes",
|
||||||
|
Description: "The remotes of both installations, with how many installed refs come from each, and findings: " +
|
||||||
|
"Flathub missing from the system installation, the same remote in both installations, a remote nothing " +
|
||||||
|
"is installed from. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Remotes() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_updates",
|
||||||
|
Description: "What an update would change: each ref with a newer commit on its remote. Asks the remotes, so it needs the network. (r)",
|
||||||
|
Input: map[string]any{"installation": installationArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
inst, err := installationOf(args, "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Updates(inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_unused",
|
||||||
|
Description: "The runtimes and extensions no installed application needs any more, computed without changing " +
|
||||||
|
"anything: a runtime is used when an application names it as its runtime or SDK, when it fills an " +
|
||||||
|
"extension point of something used, or when it is pinned. With the space each takes. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Unused() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_disk_usage",
|
||||||
|
Description: "The space flatpak takes: each installation's directory on disk, and the applications and " +
|
||||||
|
"runtimes by size, largest first. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return DiskUsage() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_install",
|
||||||
|
Description: "Install an application or runtime from a remote (default flathub), in the system installation " +
|
||||||
|
"(default) or the account's. Answers when finished, or after 18 s with a job to follow with flatpak_job. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"ref": map[string]any{"type": "string", "description": "an application id such as org.gimp.GIMP, or a full ref"},
|
||||||
|
"remote": map[string]any{"type": "string", "description": "the remote (default flathub)"},
|
||||||
|
"installation": installationArg,
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
ref, err := refArg(args, "ref")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
remote, err := optText(args, "remote", "flathub")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inst, err := installationOf(args, "system")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Install(ref, remote, inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_remove",
|
||||||
|
Description: "Uninstall an application or runtime, keeping its data unless delete_data is set. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"ref": map[string]any{"type": "string", "description": "the application id or ref"},
|
||||||
|
"installation": installationArg,
|
||||||
|
"delete_data": map[string]any{"type": "boolean", "description": "remove the application's data in the home too"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
ref, err := refArg(args, "ref")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
inst, err := installationOf(args, "system")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
del, err := optFlag(args, "delete_data", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Remove(ref, inst, del)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_update",
|
||||||
|
Description: "Update one ref, or everything in an installation when no ref is given. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"ref": map[string]any{"type": "string", "description": "the application id or ref (default all)"},
|
||||||
|
"installation": installationArg,
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
ref, err := optText(args, "ref", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ref != "" {
|
||||||
|
if err := checkRef(ref); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inst, err := installationOf(args, "system")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Update(ref, inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_remove_unused",
|
||||||
|
Description: "Uninstall what flatpak itself finds unused in one installation (system by default). " +
|
||||||
|
"flatpak_unused shows what that is beforehand. Answers when finished, or with a job to follow. (a)",
|
||||||
|
Input: map[string]any{"installation": installationArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
inst, err := installationOf(args, "system")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return RemoveUnused(inst)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "flatpak_job",
|
||||||
|
Description: "A long act this module started: running or finished, its exit status and the end of its output. Without job, every act this process knows. (r)",
|
||||||
|
Input: map[string]any{"job": map[string]any{"type": "string", "description": "the job id an act answered"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
id, err := optText(args, "job", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return map[string]any{"jobs": listJobs()}, nil
|
||||||
|
}
|
||||||
|
return jobByID(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module flatpak
|
||||||
|
|
||||||
|
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,42 @@
|
|||||||
|
{
|
||||||
|
"module": "flatpak",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"flatpak_list",
|
||||||
|
"flatpak_runtimes",
|
||||||
|
"flatpak_remotes",
|
||||||
|
"flatpak_updates",
|
||||||
|
"flatpak_unused",
|
||||||
|
"flatpak_disk_usage",
|
||||||
|
"flatpak_install",
|
||||||
|
"flatpak_remove",
|
||||||
|
"flatpak_update",
|
||||||
|
"flatpak_remove_unused",
|
||||||
|
"flatpak_job"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "flatpak"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/flatpak-tools",
|
||||||
|
"binary": "flatpak-tools",
|
||||||
|
"loads": [
|
||||||
|
"flatpak-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# fonts
|
||||||
|
|
||||||
|
The faces the workstations draw text with, as a module (novox/hq research 026/04, to-be 42 phase 2
|
||||||
|
step 1). Assigned to the two workstations.
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where | class (ADR 0182) |
|
||||||
|
|---|---|---|
|
||||||
|
| JetBrains Mono Nerd Font, for monospace: terminal, window manager, bar, launcher, prompt | package `ttf-jetbrains-mono-nerd` | package |
|
||||||
|
| Inter, for the interface: GTK, Qt, notifications | package `inter-font` | package |
|
||||||
|
| Nerd Fonts Symbols, the icon fallback for any face | package `ttf-nerd-fonts-symbols` | package |
|
||||||
|
| Noto, for serif and every other script | package `noto-fonts` | package |
|
||||||
|
| Noto Color Emoji | package `noto-fonts-emoji` | package |
|
||||||
|
| what `monospace`, `sans-serif`, `system-ui`, `serif` and `emoji` mean | `~/.config/fontconfig/conf.d/50-mesh-fonts.conf` | owned, whole |
|
||||||
|
|
||||||
|
All five packages are in the distribution's official repositories.
|
||||||
|
|
||||||
|
**Why the account's fontconfig directory, not `/etc/fonts/conf.d`.** The choice of faces is the
|
||||||
|
operator's taste on the operator's machine, and every program that draws for the operator runs as the
|
||||||
|
account. The account's directory is read by fontconfig's stock `50-user.conf`, before the
|
||||||
|
distribution's `60-latin.conf`, so the module's file decides the generic families without touching
|
||||||
|
anything the distribution owns. Root's tools and any other account keep the distribution's defaults.
|
||||||
|
A system file would need a symlink into `conf.d` the way the distribution does it, which the mesh
|
||||||
|
never makes (ADR 0012).
|
||||||
|
|
||||||
|
**The file in short.** Each generic family prefers the decided face, bound `same` as the request.
|
||||||
|
Measured with fontconfig 2.18 on a workstation: a face added with the default (weak) binding loses to
|
||||||
|
`Noto Sans Mono` for a plain `monospace` request, because the distribution's own rule decides first.
|
||||||
|
The Nerd Fonts symbols and the colour emoji are appended last to every pattern, so an icon or emoji a
|
||||||
|
face lacks is still drawn, and they never displace a face that has the character.
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **Every program agrees on monospace.** Today `monospace` is `Noto Sans Mono`, while the terminal, the
|
||||||
|
window manager and the bar use a hand-copied Hack. After the push, all of them can say the family
|
||||||
|
and get one face.
|
||||||
|
- **Configurations naming a font that is not installed stop falling back to a proportional face.** The
|
||||||
|
launcher's theme asks for `Iosevka Nerd Font` and its power menu for `JetBrains Mono Nerd Font`.
|
||||||
|
Neither is installed on the laptop, so fontconfig falls back to `sans-serif`. The file maps
|
||||||
|
`Hack Nerd Font`, `MesloLGS NF` and `Iosevka Nerd Font` to `monospace` and the old JetBrains name to
|
||||||
|
the new one, for as long as those families are not installed. Where one still is, it is used as
|
||||||
|
before.
|
||||||
|
- **Packages instead of copies.** The hand-copied files can go (below), and an upgrade of a face is a
|
||||||
|
package upgrade.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `fonts_families` (r) | installed families: styles, file count, monospaced or not, and source (`package`, `account` for a hand copy, `other`) |
|
||||||
|
| `fonts_match` (r) | what each generic family resolves to, beside the decided face, with `all_as_decided`; or any patterns given |
|
||||||
|
| `fonts_glyph` (r) | which installed families have a character (given as itself or `U+F120`), and which face monospace and sans-serif draw it with |
|
||||||
|
| `fonts_sources` (r) | every hand-copied font file with its family, duplicates, and whether a package now provides the family; marks each `remove` with the reasons; lists packaged families with their package. Removes nothing |
|
||||||
|
| `fonts_config` (r) | whether the module's file is in place and loaded by fontconfig, and the account's other fontconfig files beside it |
|
||||||
|
| `fonts_cache_rebuild` (a) | rebuild the account's font cache, or with `system: true` the system's (through `sudo -n`) |
|
||||||
|
|
||||||
|
## The one-off steps for the operator (ADR 0182)
|
||||||
|
|
||||||
|
The mesh removes nothing it did not place. After the first push that assigns this module, on each
|
||||||
|
workstation:
|
||||||
|
|
||||||
|
1. **Remove the hand-copied files** in `~/.local/share/fonts` that `fonts_sources` marks `remove`, then
|
||||||
|
run `fonts_cache_rebuild`. Measured on 2026-10-04:
|
||||||
|
- **laptop:** the four `HackNerdFont-*.ttf`; the four `MesloLGS NF *.ttf`; the four
|
||||||
|
`MesloLGS%20NF%20*.ttf`, which are the same bytes under URL-encoded names. Twelve files, 30 MB.
|
||||||
|
Nothing stays.
|
||||||
|
- **desktop:** the same twelve, plus `Iosevka-Nerd-Font-Complete.ttf` and
|
||||||
|
`JetBrains-Mono-Nerd-Font-Complete.ttf` (version-2 Nerd builds). `GrapeNuts-Regular.ttf` and
|
||||||
|
`Icomoon-Feather.ttf` came from a theme's repository. They are kept until the theme module decides
|
||||||
|
what it ships.
|
||||||
|
- Do this **after** the modules below name the new family, or the terminal and the bar fall back
|
||||||
|
through the file's aliases to `monospace`, which is the right face anyway.
|
||||||
|
2. **Nothing else.** No fontconfig file of the account's own exists today on either workstation.
|
||||||
|
|
||||||
|
## Modules that must name the family
|
||||||
|
|
||||||
|
Fonts are not a seat (research 026/04): this module says what the generic families mean, and each
|
||||||
|
desktop module names its family. Each switches to `JetBrainsMono Nerd Font` when it is written:
|
||||||
|
|
||||||
|
| module | today, on both workstations |
|
||||||
|
|---|---|
|
||||||
|
| `xterm` | `~/.Xresources.d/xterm`: `*faceName: Hack Nerd Font` |
|
||||||
|
| `i3` | `~/.config/i3/config`: `font pango: Hack Nerd Font 11`, three times (the window manager and both bars) |
|
||||||
|
| the bar (`i3status-rust`) | takes the font i3's bar block names, above |
|
||||||
|
| `rofi` | `theme.rasi`: `Iosevka Nerd Font 10`; `powermenu.rasi`: `JetBrains Mono Nerd Font 10` and `Hack Nerd Font bold 32` |
|
||||||
|
| `dunst` | `font = Hack Nerd Font 10`, which becomes `Inter` (the interface face) |
|
||||||
|
| the prompt (`powerlevel10k`) | nothing: it uses whichever Nerd Font the terminal has |
|
||||||
|
|
||||||
|
The DPI fixed in an X resource is the display server's setting (issue 168), not this module's.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
- `noto-fonts-cjk` and `noto-fonts-extra`, installed on both workstations, and every other font
|
||||||
|
package. The module does not remove a package it did not install.
|
||||||
|
- The distribution's fontconfig files under `/etc/fonts`.
|
||||||
|
- The theme's two fonts on the desktop, above.
|
||||||
@@ -0,0 +1,522 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigFile is where the module's fontconfig file is placed, under the account's home. fontconfig's
|
||||||
|
// stock 50-user.conf reads this directory, before the distribution's 60-latin.conf, so what it says
|
||||||
|
// is what the generic families mean.
|
||||||
|
const ConfigFile = ".config/fontconfig/conf.d/50-mesh-fonts.conf"
|
||||||
|
|
||||||
|
// Decided is the face each generic family means (novox/hq research 026/04).
|
||||||
|
var Decided = map[string]string{
|
||||||
|
"monospace": "JetBrainsMono Nerd Font",
|
||||||
|
"sans-serif": "Inter",
|
||||||
|
"system-ui": "Inter",
|
||||||
|
"serif": "Noto Serif",
|
||||||
|
"emoji": "Noto Color Emoji",
|
||||||
|
}
|
||||||
|
|
||||||
|
// generics is the order fonts_match answers them in.
|
||||||
|
var generics = []string{"monospace", "sans-serif", "system-ui", "serif", "emoji"}
|
||||||
|
|
||||||
|
// Retired are the monospace families the desktop's files named before the module; a hand-copied
|
||||||
|
// file of one of them is replaced by the decided face.
|
||||||
|
var Retired = []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font", "JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font"}
|
||||||
|
|
||||||
|
// fontDirs are the account's own font directories, relative to its home.
|
||||||
|
var fontDirs = []string{".local/share/fonts", ".fonts"}
|
||||||
|
|
||||||
|
const packagedRoot = "/usr/share/fonts/"
|
||||||
|
|
||||||
|
// Family is one installed family.
|
||||||
|
type Family struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
Styles []string `json:"styles"`
|
||||||
|
Files int `json:"files"`
|
||||||
|
Monospace bool `json:"monospace"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FamiliesAnswer is what fonts_families answers.
|
||||||
|
type FamiliesAnswer struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Families []Family `json:"families"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceOf(file string) string {
|
||||||
|
home := accountHome()
|
||||||
|
switch {
|
||||||
|
case home != "" && strings.HasPrefix(file, strings.TrimRight(home, "/")+"/"):
|
||||||
|
return "account"
|
||||||
|
case strings.HasPrefix(file, packagedRoot):
|
||||||
|
return "package"
|
||||||
|
}
|
||||||
|
return "other"
|
||||||
|
}
|
||||||
|
|
||||||
|
func accountHome() string {
|
||||||
|
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h, _ := os.UserHomeDir()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// Families lists what fc-list knows, by family.
|
||||||
|
func Families(contains string, limit int) (FamiliesAnswer, error) {
|
||||||
|
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n"}})
|
||||||
|
if err != nil {
|
||||||
|
return FamiliesAnswer{}, err
|
||||||
|
}
|
||||||
|
by := map[string]*Family{}
|
||||||
|
styles := map[string]map[string]bool{}
|
||||||
|
sources := map[string]map[string]bool{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) < 4 || f[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := f[0]
|
||||||
|
if contains != "" && !strings.Contains(strings.ToLower(name), strings.ToLower(contains)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if by[name] == nil {
|
||||||
|
by[name] = &Family{Family: name, Monospace: true}
|
||||||
|
styles[name], sources[name] = map[string]bool{}, map[string]bool{}
|
||||||
|
}
|
||||||
|
fam := by[name]
|
||||||
|
fam.Files++
|
||||||
|
if f[1] != "" {
|
||||||
|
styles[name][f[1]] = true
|
||||||
|
}
|
||||||
|
// fontconfig's spacing: 100 mono, 110 charcell, 90 dual; absent is proportional.
|
||||||
|
if f[2] != "100" && f[2] != "110" && f[2] != "90" {
|
||||||
|
fam.Monospace = false
|
||||||
|
}
|
||||||
|
sources[name][sourceOf(f[3])] = true
|
||||||
|
}
|
||||||
|
out := FamiliesAnswer{Families: []Family{}}
|
||||||
|
for name, fam := range by {
|
||||||
|
fam.Styles = keys(styles[name])
|
||||||
|
fam.Source = strings.Join(keys(sources[name]), "+")
|
||||||
|
out.Families = append(out.Families, *fam)
|
||||||
|
}
|
||||||
|
sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
|
||||||
|
out.Count = len(out.Families)
|
||||||
|
if len(out.Families) > limit {
|
||||||
|
out.Families, out.Truncated = out.Families[:limit], true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func keys(m map[string]bool) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved is what one pattern resolves to.
|
||||||
|
type Resolved struct {
|
||||||
|
Asked string `json:"asked"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
File string `json:"file"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Decided string `json:"decided,omitempty"`
|
||||||
|
AsDecided *bool `json:"as_decided,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPattern(p string) error {
|
||||||
|
if strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\n\t") {
|
||||||
|
return fmt.Errorf("%q is not a fontconfig pattern", p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolve(pattern string) (Resolved, error) {
|
||||||
|
if err := checkPattern(pattern); err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
r, err := call(Cmd{Name: "fc-match", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{file}", pattern}})
|
||||||
|
if err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
f := strings.Split(strings.TrimSpace(r.Stdout), "\t")
|
||||||
|
if len(f) < 3 || f[0] == "" {
|
||||||
|
return Resolved{}, fmt.Errorf("fc-match answered nothing usable for %q: %q", pattern, strings.TrimSpace(r.Stdout))
|
||||||
|
}
|
||||||
|
return Resolved{Asked: pattern, Family: f[0], Style: f[1], File: f[2], Source: sourceOf(f[2])}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchAnswer is what fonts_match answers.
|
||||||
|
type MatchAnswer struct {
|
||||||
|
Resolved []Resolved `json:"resolved"`
|
||||||
|
// AllAsDecided is set when only the generics were asked, and says whether each is the decided face.
|
||||||
|
AllAsDecided *bool `json:"all_as_decided,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match resolves the generic families, or the patterns given.
|
||||||
|
func Match(patterns []string) (MatchAnswer, error) {
|
||||||
|
generic := len(patterns) == 0
|
||||||
|
if generic {
|
||||||
|
patterns = generics
|
||||||
|
}
|
||||||
|
out := MatchAnswer{Resolved: []Resolved{}}
|
||||||
|
all := true
|
||||||
|
for _, p := range patterns {
|
||||||
|
r, err := resolve(p)
|
||||||
|
if err != nil {
|
||||||
|
return MatchAnswer{}, err
|
||||||
|
}
|
||||||
|
if want, ok := Decided[p]; ok {
|
||||||
|
agree := r.Family == want
|
||||||
|
r.Decided, r.AsDecided = want, &agree
|
||||||
|
all = all && agree
|
||||||
|
}
|
||||||
|
out.Resolved = append(out.Resolved, r)
|
||||||
|
}
|
||||||
|
if generic {
|
||||||
|
out.AllAsDecided = &all
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// codePoint reads one character, or a code point written U+XXXX or 0xXXXX.
|
||||||
|
func codePoint(s string) (rune, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if utf8.RuneCountInString(s) == 1 {
|
||||||
|
r, _ := utf8.DecodeRuneInString(s)
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
up := strings.ToUpper(s)
|
||||||
|
for _, prefix := range []string{"U+", "0X"} {
|
||||||
|
if strings.HasPrefix(up, prefix) {
|
||||||
|
n, err := strconv.ParseUint(up[len(prefix):], 16, 32)
|
||||||
|
if err != nil || n == 0 || n > utf8.MaxRune {
|
||||||
|
return 0, fmt.Errorf("%q is not a code point", s)
|
||||||
|
}
|
||||||
|
return rune(n), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("give one character, or its code point as U+XXXX or 0xXXXX, not %q", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlyphAnswer is what fonts_glyph answers.
|
||||||
|
type GlyphAnswer struct {
|
||||||
|
CodePoint string `json:"code_point"`
|
||||||
|
Character string `json:"character"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Families []Family `json:"families"`
|
||||||
|
DrawnBy map[string]string `json:"drawn_by"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glyph answers which fonts have a character and which face the generics would draw it with.
|
||||||
|
func Glyph(s string) (GlyphAnswer, error) {
|
||||||
|
cp, err := codePoint(s)
|
||||||
|
if err != nil {
|
||||||
|
return GlyphAnswer{}, err
|
||||||
|
}
|
||||||
|
hx := strconv.FormatInt(int64(cp), 16)
|
||||||
|
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{style[0]}\t%{spacing}\t%{file}\n", ":charset=" + hx}})
|
||||||
|
if err != nil {
|
||||||
|
return GlyphAnswer{}, err
|
||||||
|
}
|
||||||
|
by := map[string]*Family{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) < 4 || f[0] == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if by[f[0]] == nil {
|
||||||
|
by[f[0]] = &Family{Family: f[0], Monospace: f[2] == "100" || f[2] == "110" || f[2] == "90", Source: sourceOf(f[3]), Styles: []string{}}
|
||||||
|
}
|
||||||
|
by[f[0]].Files++
|
||||||
|
}
|
||||||
|
out := GlyphAnswer{CodePoint: fmt.Sprintf("U+%04X", cp), Character: string(cp), Families: []Family{}, DrawnBy: map[string]string{}}
|
||||||
|
for _, f := range by {
|
||||||
|
out.Families = append(out.Families, *f)
|
||||||
|
}
|
||||||
|
sort.Slice(out.Families, func(i, k int) bool { return out.Families[i].Family < out.Families[k].Family })
|
||||||
|
out.Count = len(out.Families)
|
||||||
|
if len(out.Families) > 200 {
|
||||||
|
out.Families, out.Truncated = out.Families[:200], true
|
||||||
|
}
|
||||||
|
for _, g := range []string{"monospace", "sans-serif"} {
|
||||||
|
res, err := resolve(g + ":charset=" + hx)
|
||||||
|
if err != nil {
|
||||||
|
return GlyphAnswer{}, err
|
||||||
|
}
|
||||||
|
out.DrawnBy[g] = res.Family
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandFile is one font file copied into the account's home.
|
||||||
|
type HandFile struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Family string `json:"family"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Remove bool `json:"remove"`
|
||||||
|
Why []string `json:"why,omitempty"`
|
||||||
|
sum string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packaged is a family a package installs.
|
||||||
|
type Packaged struct {
|
||||||
|
Family string `json:"family"`
|
||||||
|
Package string `json:"package"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourcesAnswer is what fonts_sources answers.
|
||||||
|
type SourcesAnswer struct {
|
||||||
|
Hand []HandFile `json:"hand_copied"`
|
||||||
|
HandBytes int64 `json:"hand_copied_bytes"`
|
||||||
|
RemoveCount int `json:"removable"`
|
||||||
|
Packaged []Packaged `json:"packaged"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontExt = map[string]bool{".ttf": true, ".otf": true, ".ttc": true, ".otc": true, ".pfb": true, ".pcf": true, ".woff": true, ".woff2": true, ".bdf": true}
|
||||||
|
|
||||||
|
// handFiles walks the account's font directories.
|
||||||
|
func handFiles(home string) ([]HandFile, error) {
|
||||||
|
out := []HandFile{}
|
||||||
|
for _, d := range fontDirs {
|
||||||
|
root := filepath.Join(home, d)
|
||||||
|
err := filepath.WalkDir(root, func(p string, e fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
if p == root && os.IsNotExist(err) {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e.IsDir() || !fontExt[strings.ToLower(filepath.Ext(p))] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := e.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
_, err = io.Copy(h, f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = append(out, HandFile{File: p, Bytes: info.Size(), sum: hex.EncodeToString(h.Sum(nil))})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && err != filepath.SkipDir {
|
||||||
|
return nil, fmt.Errorf("reading %s: %w", root, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, k int) bool { return out[i].File < out[k].File })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sources says which font files were copied by hand, which of them can go, and which families
|
||||||
|
// packages install.
|
||||||
|
func Sources() (SourcesAnswer, error) {
|
||||||
|
home := accountHome()
|
||||||
|
hand, err := handFiles(home)
|
||||||
|
if err != nil {
|
||||||
|
return SourcesAnswer{}, err
|
||||||
|
}
|
||||||
|
out := SourcesAnswer{Hand: hand, Packaged: []Packaged{},
|
||||||
|
Note: "Nothing is removed by this tool. The mesh removes nothing it did not place (ADR 0182): a file marked remove is for the operator to delete, once; then run fonts_cache_rebuild."}
|
||||||
|
if len(hand) > 0 {
|
||||||
|
args := []string{"--format", "%{file}\t%{family[0]}\t%{style[0]}\n"}
|
||||||
|
for _, h := range hand {
|
||||||
|
args = append(args, h.File)
|
||||||
|
}
|
||||||
|
r, err := call(Cmd{Name: "fc-scan", Args: args})
|
||||||
|
if err != nil {
|
||||||
|
return SourcesAnswer{}, err
|
||||||
|
}
|
||||||
|
named := map[string][2]string{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) >= 3 {
|
||||||
|
if _, seen := named[f[0]]; !seen {
|
||||||
|
named[f[0]] = [2]string{f[1], f[2]}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range out.Hand {
|
||||||
|
n := named[out.Hand[i].File]
|
||||||
|
out.Hand[i].Family, out.Hand[i].Style = n[0], n[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Families the packages install, each with one file to ask pacman about.
|
||||||
|
r, err := call(Cmd{Name: "fc-list", Args: []string{"--format", "%{family[0]}\t%{file}\n"}})
|
||||||
|
if err != nil {
|
||||||
|
return SourcesAnswer{}, err
|
||||||
|
}
|
||||||
|
fileOf := map[string]string{}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
f := strings.Split(l, "\t")
|
||||||
|
if len(f) >= 2 && f[0] != "" && strings.HasPrefix(f[1], packagedRoot) {
|
||||||
|
if _, seen := fileOf[f[0]]; !seen {
|
||||||
|
fileOf[f[0]] = f[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
owner := map[string]string{}
|
||||||
|
if len(fileOf) > 0 {
|
||||||
|
args := []string{"-Qo"}
|
||||||
|
for _, fam := range keys(boolSet(fileOf)) {
|
||||||
|
args = append(args, fileOf[fam])
|
||||||
|
}
|
||||||
|
// pacman exits 1 when one file is unowned and still answers the rest: read what it said.
|
||||||
|
r := run(Cmd{Name: "pacman", Args: args})
|
||||||
|
if r.Error != "" {
|
||||||
|
return SourcesAnswer{}, failure(Cmd{Name: "pacman", Args: []string{"-Qo"}}, r)
|
||||||
|
}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
// "/usr/share/fonts/x.ttf is owned by noto-fonts 1:2026.08.01-1"
|
||||||
|
if i := strings.Index(l, " is owned by "); i > 0 {
|
||||||
|
pkg := strings.Fields(l[i+len(" is owned by "):])
|
||||||
|
if len(pkg) > 0 {
|
||||||
|
owner[l[:i]] = pkg[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, fam := range keys(boolSet(fileOf)) {
|
||||||
|
pkg := owner[fileOf[fam]]
|
||||||
|
if pkg == "" {
|
||||||
|
pkg = "(no package)"
|
||||||
|
}
|
||||||
|
out.Packaged = append(out.Packaged, Packaged{Family: fam, Package: pkg})
|
||||||
|
}
|
||||||
|
// Which hand-copied files can go, and why.
|
||||||
|
firstOf := map[string]string{}
|
||||||
|
for i := range out.Hand {
|
||||||
|
h := &out.Hand[i]
|
||||||
|
if prev, dup := firstOf[h.sum]; dup {
|
||||||
|
h.Why = append(h.Why, "the same bytes as "+filepath.Base(prev))
|
||||||
|
} else {
|
||||||
|
firstOf[h.sum] = h.File
|
||||||
|
}
|
||||||
|
if strings.Contains(filepath.Base(h.File), "%20") {
|
||||||
|
h.Why = append(h.Why, "a URL-encoded copy of a name")
|
||||||
|
}
|
||||||
|
if pkg := owner[fileOf[h.Family]]; h.Family != "" && pkg != "" {
|
||||||
|
h.Why = append(h.Why, "its family is installed by the package "+pkg)
|
||||||
|
}
|
||||||
|
for _, r := range Retired {
|
||||||
|
if h.Family == r {
|
||||||
|
h.Why = append(h.Why, "a monospace face the desktop named before the fonts module; "+Decided["monospace"]+" replaces it once the terminal, window manager, bar and launcher name that family")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.Remove = len(h.Why) > 0
|
||||||
|
out.HandBytes += h.Bytes
|
||||||
|
if h.Remove {
|
||||||
|
out.RemoveCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolSet(m map[string]string) map[string]bool {
|
||||||
|
out := map[string]bool{}
|
||||||
|
for k := range m {
|
||||||
|
out[k] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigAnswer is what fonts_config answers.
|
||||||
|
type ConfigAnswer struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Present bool `json:"present"`
|
||||||
|
Loaded bool `json:"loaded"`
|
||||||
|
Bytes int `json:"bytes,omitempty"`
|
||||||
|
Others []string `json:"account_files_beside_it"`
|
||||||
|
Note string `json:"note,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config says whether the module's file is in place and loaded.
|
||||||
|
func Config() (ConfigAnswer, error) {
|
||||||
|
path := filepath.Join(accountHome(), ConfigFile)
|
||||||
|
out := ConfigAnswer{Path: path, Others: []string{}}
|
||||||
|
if b, err := os.ReadFile(path); err == nil {
|
||||||
|
out.Present, out.Bytes = true, len(b)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return ConfigAnswer{}, fmt.Errorf("reading %s: %w", path, err)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(filepath.Dir(path))
|
||||||
|
for _, e := range entries {
|
||||||
|
if p := filepath.Join(filepath.Dir(path), e.Name()); p != path {
|
||||||
|
out.Others = append(out.Others, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(filepath.Join(accountHome(), ".config/fontconfig/fonts.conf")); err == nil && len(b) > 0 {
|
||||||
|
out.Others = append(out.Others, filepath.Join(accountHome(), ".config/fontconfig/fonts.conf"))
|
||||||
|
}
|
||||||
|
r, err := call(Cmd{Name: "fc-conflist"})
|
||||||
|
if err != nil {
|
||||||
|
return ConfigAnswer{}, err
|
||||||
|
}
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
// "+ /path: description" for a file in use, "- /path" for one skipped
|
||||||
|
if strings.HasPrefix(l, "+ "+path+":") || strings.TrimSpace(l) == "+ "+path {
|
||||||
|
out.Loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !out.Present {
|
||||||
|
out.Note = "the file is not in place: the fonts module is not assigned to this machine, or its push has not reached it"
|
||||||
|
} else if !out.Loaded {
|
||||||
|
out.Note = "the file is in place and fontconfig does not load it: check that /etc/fonts/conf.d/50-user.conf is enabled"
|
||||||
|
}
|
||||||
|
if len(out.Others) > 0 {
|
||||||
|
out.Note = strings.TrimSpace(out.Note + " The other files are the account's own: they are read too, and one sorting after 50-mesh-fonts.conf can override it.")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildAnswer is what fonts_cache_rebuild answers.
|
||||||
|
type RebuildAnswer struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ElapsedMS int64 `json:"elapsed_ms"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CacheRebuild rebuilds the account's font cache, or the system's.
|
||||||
|
func CacheRebuild(system bool) (RebuildAnswer, error) {
|
||||||
|
c := Cmd{Name: "fc-cache", Args: []string{"-f"}}
|
||||||
|
scope := "account"
|
||||||
|
if system {
|
||||||
|
c, scope = Cmd{Name: "fc-cache", Args: []string{"-s", "-f"}, Root: true}, "system"
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r, err := call(c)
|
||||||
|
if err != nil {
|
||||||
|
return RebuildAnswer{}, err
|
||||||
|
}
|
||||||
|
return RebuildAnswer{Scope: scope, ElapsedMS: time.Since(start).Milliseconds(), Output: tail(strings.TrimSpace(r.Stdout+r.Stderr), 4000)}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestInstallsTheFiveDecidedFacesAndOwnsOneAccountFile(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "fonts")
|
||||||
|
want := "inter-font,noto-fonts,noto-fonts-emoji,ttf-jetbrains-mono-nerd,ttf-nerd-fonts-symbols"
|
||||||
|
if got := strings.Join(m.packages(), ","); got != want {
|
||||||
|
t.Errorf("packages %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
files := 0
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "file" {
|
||||||
|
files++
|
||||||
|
}
|
||||||
|
if r["type"] == "service" || r["type"] == "directory" {
|
||||||
|
t.Errorf("a font needs no %s: %v", r["type"], r["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := m.resource("defaults")
|
||||||
|
if files != 1 || f == nil {
|
||||||
|
t.Fatalf("one owned file, got %d", files)
|
||||||
|
}
|
||||||
|
if f["path"] != "${machine:account-home}/"+ConfigFile || f["owner"] != "${machine:account}" || f["mode"] != "0644" {
|
||||||
|
t.Errorf("the file is the account's, at %s: %v", ConfigFile, f)
|
||||||
|
}
|
||||||
|
if _, into := f["into"]; into {
|
||||||
|
t.Error("the file is the module's whole, not written into")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fontconfig's document, as far as the module's file uses it.
|
||||||
|
type fcDoc struct {
|
||||||
|
Aliases []struct {
|
||||||
|
Binding string `xml:"binding,attr"`
|
||||||
|
Family string `xml:"family"`
|
||||||
|
Prefer []string `xml:"prefer>family"`
|
||||||
|
Accept []string `xml:"accept>family"`
|
||||||
|
} `xml:"alias"`
|
||||||
|
Matches []struct {
|
||||||
|
Target string `xml:"target,attr"`
|
||||||
|
Edits []struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Mode string `xml:"mode,attr"`
|
||||||
|
String string `xml:"string"`
|
||||||
|
} `xml:"edit"`
|
||||||
|
} `xml:"match"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFontconfigFileMapsEachGenericToTheDecidedFaceStronglyAndFallsBackLast(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
content, _ := m.resource("defaults")["content"].(string)
|
||||||
|
if !strings.HasPrefix(content, "<?xml") || !strings.Contains(content, `<!DOCTYPE fontconfig`) {
|
||||||
|
t.Fatal("not a fontconfig document")
|
||||||
|
}
|
||||||
|
if strings.Contains(content, "$") {
|
||||||
|
t.Error("the file holds no placeholder: it is the same on every machine")
|
||||||
|
}
|
||||||
|
var doc fcDoc
|
||||||
|
if err := xml.Unmarshal([]byte(content), &doc); err != nil {
|
||||||
|
t.Fatalf("not well-formed: %v", err)
|
||||||
|
}
|
||||||
|
firstPrefer := map[string]string{}
|
||||||
|
order := []string{}
|
||||||
|
for _, a := range doc.Aliases {
|
||||||
|
order = append(order, a.Family)
|
||||||
|
if len(a.Prefer) > 0 {
|
||||||
|
firstPrefer[a.Family] = a.Prefer[0]
|
||||||
|
if a.Binding != "same" {
|
||||||
|
t.Errorf("%s is bound %q: a weakly added face loses to the distribution's choice", a.Family, a.Binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for generic, face := range Decided {
|
||||||
|
if firstPrefer[generic] != face {
|
||||||
|
t.Errorf("%s prefers %q, decided %q", generic, firstPrefer[generic], face)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The retired families lead to monospace, and are placed before the monospace rule.
|
||||||
|
mono := strings.Index(strings.Join(order, "|"), "|monospace|")
|
||||||
|
for _, r := range []string{"Hack Nerd Font", "MesloLGS NF", "Iosevka Nerd Font"} {
|
||||||
|
at := strings.Index(strings.Join(order, "|"), r)
|
||||||
|
if at < 0 || at > mono {
|
||||||
|
t.Errorf("%s is not mapped to monospace before the monospace rule", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(doc.Matches) != 1 || doc.Matches[0].Target != "pattern" {
|
||||||
|
t.Fatalf("one pattern match for the fallbacks: %+v", doc.Matches)
|
||||||
|
}
|
||||||
|
got := []string{}
|
||||||
|
for _, e := range doc.Matches[0].Edits {
|
||||||
|
if e.Name != "family" || e.Mode != "append_last" {
|
||||||
|
t.Errorf("a fallback is appended last, never prepended: %+v", e)
|
||||||
|
}
|
||||||
|
got = append(got, e.String)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != "Symbols Nerd Font,Noto Color Emoji" {
|
||||||
|
t.Errorf("fallbacks %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFamiliesGroupsFilesAndTellsAHandCopiedFaceFromAPackagedOne(t *testing.T) {
|
||||||
|
t.Setenv("MESH_OPERATOR_HOME", "/home/op")
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
return ok("Inter\tRegular\t\t/usr/share/fonts/inter/Inter.ttc\n" +
|
||||||
|
"Inter\tBold\t\t/usr/share/fonts/inter/Inter-Bold.ttc\n" +
|
||||||
|
"Hack Nerd Font\tRegular\t100\t/home/op/.local/share/fonts/Hack.ttf\n" +
|
||||||
|
"Noto Sans Mono\tRegular\t100\t/usr/share/fonts/noto/NotoSansMono.ttf\n")
|
||||||
|
})
|
||||||
|
got, err := Families("", 10)
|
||||||
|
if err != nil || got.Count != 3 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if f := got.Families[0]; f.Family != "Hack Nerd Font" || f.Source != "account" || !f.Monospace {
|
||||||
|
t.Errorf("%+v", f)
|
||||||
|
}
|
||||||
|
if f := got.Families[1]; f.Family != "Inter" || f.Files != 2 || f.Monospace || f.Source != "package" || len(f.Styles) != 2 {
|
||||||
|
t.Errorf("%+v", f)
|
||||||
|
}
|
||||||
|
got, _ = Families("noto", 10)
|
||||||
|
if got.Count != 1 {
|
||||||
|
t.Errorf("filtered: %+v", got)
|
||||||
|
}
|
||||||
|
got, _ = Families("", 1)
|
||||||
|
if !got.Truncated || len(got.Families) != 1 || got.Count != 3 {
|
||||||
|
t.Errorf("bounded: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchSaysWhetherEachGenericIsTheDecidedFace(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
switch c.Args[len(c.Args)-1] {
|
||||||
|
case "monospace":
|
||||||
|
return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/JetBrainsMonoNerdFont-Regular.ttf")
|
||||||
|
case "sans-serif", "system-ui":
|
||||||
|
return ok("Noto Sans\tRegular\t/usr/share/fonts/noto/NotoSans-Regular.ttf")
|
||||||
|
case "serif":
|
||||||
|
return ok("Noto Serif\tRegular\t/usr/share/fonts/noto/NotoSerif-Regular.ttf")
|
||||||
|
}
|
||||||
|
return ok("Noto Color Emoji\tRegular\t/usr/share/fonts/noto/NotoColorEmoji.ttf")
|
||||||
|
})
|
||||||
|
got, err := Match(nil)
|
||||||
|
if err != nil || len(got.Resolved) != 5 || got.AllAsDecided == nil || *got.AllAsDecided {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if r := got.Resolved[0]; !*r.AsDecided || r.Source != "package" {
|
||||||
|
t.Errorf("monospace: %+v", r)
|
||||||
|
}
|
||||||
|
if r := got.Resolved[1]; *r.AsDecided || r.Decided != "Inter" {
|
||||||
|
t.Errorf("sans-serif is not Inter here: %+v", r)
|
||||||
|
}
|
||||||
|
if len(f.asked) != 5 {
|
||||||
|
t.Errorf("one fc-match per generic: %v", f.lines())
|
||||||
|
}
|
||||||
|
got, _ = Match([]string{"Inter:bold"})
|
||||||
|
if got.AllAsDecided != nil || got.Resolved[0].AsDecided != nil {
|
||||||
|
t.Errorf("a pattern of the caller's own has no decided face: %+v", got)
|
||||||
|
}
|
||||||
|
if _, err := Match([]string{"--help"}); err == nil {
|
||||||
|
t.Error("an option as a pattern")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMatchAnEmptyAnswerIsAnError(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
if _, err := Match(nil); err == nil {
|
||||||
|
t.Fatal("an empty fc-match answer was read as a face")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGlyphReadsACharacterOrACodePointAndAsksForIt(t *testing.T) {
|
||||||
|
for in, want := range map[string]rune{"\uf120": 0xf120, "U+F120": 0xf120, "0x1f600": 0x1f600, "a": 'a', "é": 'é'} {
|
||||||
|
got, err := codePoint(in)
|
||||||
|
if err != nil || got != want {
|
||||||
|
t.Errorf("%q: %x %v", in, got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"ab", "U+ZZ", "U+0", "120"} {
|
||||||
|
if _, err := codePoint(bad); err == nil {
|
||||||
|
t.Errorf("%q was read as a code point", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if c.Name == "fc-list" {
|
||||||
|
return ok("Symbols Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/SymbolsNerdFont-Regular.ttf\n" +
|
||||||
|
"JetBrainsMono Nerd Font\tBold\t100\t/usr/share/fonts/TTF/a.ttf\nJetBrainsMono Nerd Font\tRegular\t100\t/usr/share/fonts/TTF/b.ttf\n")
|
||||||
|
}
|
||||||
|
return ok("JetBrainsMono Nerd Font\tRegular\t/usr/share/fonts/TTF/b.ttf")
|
||||||
|
})
|
||||||
|
got, err := Glyph("U+F120")
|
||||||
|
if err != nil || got.Count != 2 || got.CodePoint != "U+F120" || got.DrawnBy["monospace"] != "JetBrainsMono Nerd Font" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(f.lines()[0], ":charset=f120") || !strings.Contains(f.lines()[1], "monospace:charset=f120") {
|
||||||
|
t.Errorf("asked %v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourcesMarksDuplicatesEncodedNamesPackagedAndRetiredFacesForRemoval(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||||
|
dir := filepath.Join(home, ".local/share/fonts")
|
||||||
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
|
write := func(name, body string) string {
|
||||||
|
p := filepath.Join(dir, name)
|
||||||
|
_ = os.WriteFile(p, []byte(body), 0o644)
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
meslo := write("MesloLGS NF Regular.ttf", "meslo")
|
||||||
|
encoded := write("MesloLGS%20NF%20Regular.ttf", "meslo")
|
||||||
|
grape := write("GrapeNuts-Regular.ttf", "grape")
|
||||||
|
noto := write("NotoSans-Copy.ttf", "noto")
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, ".uuid"), []byte("x"), 0o644)
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
switch c.Name {
|
||||||
|
case "fc-scan":
|
||||||
|
return ok(meslo + "\tMesloLGS NF\tRegular\n" + encoded + "\tMesloLGS NF\tRegular\n" + grape + "\tGrape Nuts\tRegular\n" + noto + "\tNoto Sans\tRegular\n")
|
||||||
|
case "fc-list":
|
||||||
|
return ok("Noto Sans\t/usr/share/fonts/noto/NotoSans-Regular.ttf\nNoto Sans\t/usr/share/fonts/noto/NotoSans-Bold.ttf\nInter\t/usr/share/fonts/inter/Inter.ttc\nMesloLGS NF\t" + meslo + "\n")
|
||||||
|
case "pacman":
|
||||||
|
return Result{Status: 1, Stdout: "/usr/share/fonts/noto/NotoSans-Regular.ttf is owned by noto-fonts 1:2026.08.01-1\n", Stderr: "error: No package owns /usr/share/fonts/inter/Inter.ttc\n"}
|
||||||
|
}
|
||||||
|
return Result{Status: 9}
|
||||||
|
})
|
||||||
|
got, err := Sources()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got.Hand) != 4 {
|
||||||
|
t.Fatalf("four font files, the .uuid is not one: %+v", got.Hand)
|
||||||
|
}
|
||||||
|
by := map[string]HandFile{}
|
||||||
|
for _, h := range got.Hand {
|
||||||
|
by[filepath.Base(h.File)] = h
|
||||||
|
}
|
||||||
|
if h := by["GrapeNuts-Regular.ttf"]; h.Remove || h.Family != "Grape Nuts" {
|
||||||
|
t.Errorf("a face nothing replaces is kept: %+v", h)
|
||||||
|
}
|
||||||
|
if h := by["MesloLGS%20NF%20Regular.ttf"]; !h.Remove || len(h.Why) < 3 {
|
||||||
|
t.Errorf("an encoded duplicate of a retired face: %+v", h)
|
||||||
|
}
|
||||||
|
if h := by["NotoSans-Copy.ttf"]; !h.Remove || !strings.Contains(strings.Join(h.Why, ";"), "noto-fonts") {
|
||||||
|
t.Errorf("a face a package installs: %+v", h)
|
||||||
|
}
|
||||||
|
if got.RemoveCount != 3 || got.HandBytes != int64(len("meslo")*2+len("grape")+len("noto")) {
|
||||||
|
t.Errorf("%d removable, %d bytes", got.RemoveCount, got.HandBytes)
|
||||||
|
}
|
||||||
|
pk := map[string]string{}
|
||||||
|
for _, p := range got.Packaged {
|
||||||
|
pk[p.Family] = p.Package
|
||||||
|
}
|
||||||
|
if pk["Noto Sans"] != "noto-fonts" || pk["Inter"] != "(no package)" || pk["MesloLGS NF"] != "" {
|
||||||
|
t.Errorf("packaged %v", pk)
|
||||||
|
}
|
||||||
|
for _, l := range f.lines() {
|
||||||
|
if strings.Contains(l, "rm ") || strings.HasPrefix(l, "sudo") {
|
||||||
|
t.Errorf("sources only reads: %s", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSaysWhetherTheFileIsInPlaceAndLoaded(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("MESH_OPERATOR_HOME", home)
|
||||||
|
path := filepath.Join(home, ConfigFile)
|
||||||
|
using(t, func(string, Cmd) Result { return ok("+ " + path + ": The mesh\n- /etc/fonts/conf.d/x.conf\n") })
|
||||||
|
got, err := Config()
|
||||||
|
if err != nil || got.Present || !strings.Contains(got.Note, "not in place") {
|
||||||
|
t.Fatalf("absent: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||||
|
_ = os.WriteFile(path, []byte("<fontconfig/>"), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(filepath.Dir(path), "99-mine.conf"), []byte("x"), 0o644)
|
||||||
|
got, err = Config()
|
||||||
|
if err != nil || !got.Present || !got.Loaded || len(got.Others) != 1 {
|
||||||
|
t.Fatalf("present: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCacheRebuildIsTheAccountsUnlessTheSystemIsAskedWhichNeedsRoot(t *testing.T) {
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
if got, err := CacheRebuild(false); err != nil || got.Scope != "account" {
|
||||||
|
t.Fatal(got, err)
|
||||||
|
}
|
||||||
|
if got, err := CacheRebuild(true); err != nil || got.Scope != "system" {
|
||||||
|
t.Fatal(got, err)
|
||||||
|
}
|
||||||
|
if l := f.lines(); l[0] != "fc-cache -f" || l[1] != "sudo -n fc-cache -s -f" {
|
||||||
|
t.Errorf("%v", l)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "sudo: a password is required"} })
|
||||||
|
if _, err := CacheRebuild(true); err == nil || !strings.Contains(err.Error(), "sudo -n refused") {
|
||||||
|
t.Errorf("a refused escalation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// The fonts module's tools (novox/hq research 026/04, 026/05): what faces the account has, what the
|
||||||
|
// generic families resolve to, which face draws a character, which font files were copied by hand
|
||||||
|
// and may go, and rebuilding the font cache. A Go bundle the node's runtime launches and speaks MCP
|
||||||
|
// to over stdio (ADR 0188, ADR 0193); it runs as the operator account.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// providedBy names what installs a program the tools run, for a failure that says so.
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"fc-list": "the fontconfig package",
|
||||||
|
"fc-match": "the fontconfig package",
|
||||||
|
"fc-scan": "the fontconfig package",
|
||||||
|
"fc-cache": "the fontconfig package",
|
||||||
|
"fc-conflist": "the fontconfig package",
|
||||||
|
"pacman": "this is not an Arch machine",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "fonts_families",
|
||||||
|
Description: "The font families installed for the operator account, each with its styles, how many files, " +
|
||||||
|
"whether it is monospaced, and where it comes from: package (under /usr/share/fonts), account (copied into " +
|
||||||
|
"the home by hand) or other. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"contains": map[string]any{"type": "string", "description": "only families whose name contains this, any case"},
|
||||||
|
"limit": map[string]any{"type": "integer", "description": "at most this many families (default 500, at most 2000)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
contains, err := optText(args, "contains", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
limit, err := optWhole(args, "limit", 500, 1, 2000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Families(contains, limit)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fonts_match",
|
||||||
|
Description: "What the generic families resolve to for this account: monospace, sans-serif, system-ui, serif and " +
|
||||||
|
"emoji by default, or the patterns given. Each answer names the face, its file, the face the fonts module " +
|
||||||
|
"decided on, and whether they agree. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"patterns": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": "fontconfig patterns to resolve instead, such as \"monospace:bold\" or \"Inter\""},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
patterns, err := optList(args, "patterns")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Match(patterns)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fonts_glyph",
|
||||||
|
Description: "Which installed fonts have a given character, and which face monospace and sans-serif would draw " +
|
||||||
|
"it with. The character is given as itself or as a code point (U+F120, 0xF120). (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"character": map[string]any{"type": "string", "description": "one character, or its code point as U+XXXX or 0xXXXX"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
ch, err := text(args, "character")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Glyph(ch)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fonts_sources",
|
||||||
|
Description: "Every font file copied into the account's font directories by hand, with its family, whether it " +
|
||||||
|
"duplicates another, and whether a package now provides that family; and the families installed by " +
|
||||||
|
"packages, with the package. Says which hand-copied files can be removed and why; removes nothing. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Sources() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fonts_config",
|
||||||
|
Description: "The fontconfig file the fonts module owns: whether it is in place, whether fontconfig loads it, " +
|
||||||
|
"and the account's other fontconfig files beside it. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Config() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "fonts_cache_rebuild",
|
||||||
|
Description: "Rebuild the font cache: the account's (default), or the system's with system: true, which needs " +
|
||||||
|
"root and goes through sudo without a prompt. Answers how long it took. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"system": map[string]any{"type": "boolean", "description": "rebuild the system cache instead of the account's"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
system, err := optFlag(args, "system", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return CacheRebuild(system)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module fonts
|
||||||
|
|
||||||
|
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,65 @@
|
|||||||
|
{
|
||||||
|
"module": "fonts",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"fonts_families",
|
||||||
|
"fonts_match",
|
||||||
|
"fonts_glyph",
|
||||||
|
"fonts_sources",
|
||||||
|
"fonts_config",
|
||||||
|
"fonts_cache_rebuild"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "monospace",
|
||||||
|
"type": "package",
|
||||||
|
"package": "ttf-jetbrains-mono-nerd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "interface",
|
||||||
|
"type": "package",
|
||||||
|
"package": "inter-font"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "symbols",
|
||||||
|
"type": "package",
|
||||||
|
"package": "ttf-nerd-fonts-symbols"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "noto",
|
||||||
|
"type": "package",
|
||||||
|
"package": "noto-fonts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "emoji",
|
||||||
|
"type": "package",
|
||||||
|
"package": "noto-fonts-emoji"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "defaults",
|
||||||
|
"type": "file",
|
||||||
|
"path": "${machine:account-home}/.config/fontconfig/conf.d/50-mesh-fonts.conf",
|
||||||
|
"owner": "${machine:account}",
|
||||||
|
"mode": "0644",
|
||||||
|
"content": "<?xml version=\"1.0\"?>\n<!DOCTYPE fontconfig SYSTEM \"urn:fontconfig:fonts.dtd\">\n<!--\n The mesh's file (module fonts, novox/hq research 026/04): which face each generic family means for\n this account, so every program that asks for monospace, sans-serif, serif or emoji gets the same\n face. Replaced at every push. Other files in this directory are yours.\n-->\n<fontconfig>\n <description>The mesh: the faces monospace, sans-serif, serif and emoji mean</description>\n\n <!--\n Families the desktop's files named before this module, mapped to monospace for as long as they\n are not installed: a configuration still naming one gets the chosen monospace face rather than\n fontconfig's last resort, sans-serif. Where the family is still installed, it is used as before.\n Placed first, so the monospace they lead to is resolved by the rule below.\n -->\n <alias><family>Hack Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>MesloLGS NF</family><accept><family>monospace</family></accept></alias>\n <alias><family>Iosevka Nerd Font</family><accept><family>monospace</family></accept></alias>\n <alias><family>JetBrains Mono Nerd Font</family><accept><family>JetBrainsMono Nerd Font</family></accept></alias>\n\n <!--\n The decided faces, bound the same as the request: a program asking for monospace asks strongly,\n and a face added weakly loses to the distribution's own choice (measured with fontconfig 2.18).\n -->\n <alias binding=\"same\"><family>monospace</family><prefer><family>JetBrainsMono Nerd Font</family></prefer></alias>\n <alias binding=\"same\"><family>sans-serif</family><prefer><family>Inter</family><family>Noto Sans</family></prefer></alias>\n <alias binding=\"same\"><family>system-ui</family><prefer><family>Inter</family></prefer></alias>\n <alias binding=\"same\"><family>serif</family><prefer><family>Noto Serif</family></prefer></alias>\n <alias binding=\"same\"><family>emoji</family><prefer><family>Noto Color Emoji</family></prefer></alias>\n\n <!--\n Fallbacks for any face: an icon a face lacks comes from the Nerd Fonts symbols, an emoji from\n Noto Color Emoji. Appended last, so they never displace a face that has the character.\n -->\n <match target=\"pattern\">\n <edit name=\"family\" mode=\"append_last\"><string>Symbols Nerd Font</string></edit>\n <edit name=\"family\" mode=\"append_last\"><string>Noto Color Emoji</string></edit>\n </match>\n</fontconfig>\n"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/fonts-tools",
|
||||||
|
"binary": "fonts-tools",
|
||||||
|
"loads": [
|
||||||
|
"fonts-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# snapd
|
||||||
|
|
||||||
|
Snaps on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are modules, on the
|
||||||
|
two workstations only"; to-be 42 phase 2 step 9).
|
||||||
|
|
||||||
|
## Status: tools only, the package blocked
|
||||||
|
|
||||||
|
**snapd is not in the distribution's official repositories.** It is a user-repository (AUR) package:
|
||||||
|
on the desktop it is installed as a foreign package, and `pacman -Si snapd` finds nothing. The host's
|
||||||
|
`package` shape installs from the official repositories only, so this module cannot declare it.
|
||||||
|
|
||||||
|
Neither form of ADR 0205 fits either. snapd is a daemon in compiled code with setuid helpers, a socket,
|
||||||
|
services and a system mount, so it is not a pinned archive of plain files. The way out is research 027
|
||||||
|
question 1, option P2: the build machine builds user-repository packages into a package repository the
|
||||||
|
mesh serves. Until that exists:
|
||||||
|
|
||||||
|
- **The module declares no resources.** It does not even declare the units snapd brings
|
||||||
|
(`snapd.socket`, `snapd.apparmor.service`). On a workstation without the package, the laptop today,
|
||||||
|
those units do not exist, and the host would fail the module there. A declaration that cannot hold
|
||||||
|
on every machine the module is assigned to is not written.
|
||||||
|
- **The tools are written and work wherever snapd is installed.** Where it is not, every tool says
|
||||||
|
that, rather than answering an empty list.
|
||||||
|
|
||||||
|
When the repository exists, the module gains, in one change:
|
||||||
|
|
||||||
|
1. the package `snapd`;
|
||||||
|
2. `snapd.socket` enabled and running;
|
||||||
|
3. `snapd.apparmor.service` enabled only if the kernel runs AppArmor (below);
|
||||||
|
4. its tests.
|
||||||
|
|
||||||
|
## Improves (once it owns the package)
|
||||||
|
|
||||||
|
- **The disabled revisions become visible.** snapd keeps old revisions for rollback. On the desktop on
|
||||||
|
2026-10-04 that was 1.7 GB of snaps, of which about 0.7 GB were disabled revisions: the previous
|
||||||
|
`code`, `core18`, `core20` and `snapd`. `snapd_disk_usage` answers it.
|
||||||
|
- **A unit that does nothing is named.** On the desktop `snapd.apparmor.service` is enabled, but the
|
||||||
|
kernel's security modules are `capability,landlock,lockdown,yama,bpf`. There is no AppArmor, so the
|
||||||
|
profiles it would load are enforced by nothing, and strict snaps run unconfined. `snapd_status` says
|
||||||
|
so. Turning AppArmor on is a kernel command-line change, which is the `kernel` module's, and is the
|
||||||
|
operator's choice.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account; acts go through `sudo -n`.
|
||||||
|
Acts use `--no-wait`: snapd carries them out in the background, and the answer is snapd's change id,
|
||||||
|
followed with `snapd_changes`. No act outlasts the 20 s a call has.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `snapd_status` (r) | installed or not, version, the four units' enabled and active states, AppArmor in the kernel, `/snap` present, and findings |
|
||||||
|
| `snapd_list` (r) | every revision: version, revision, tracking, publisher, notes, disabled |
|
||||||
|
| `snapd_info` (r) | one snap: fields, commands, channels |
|
||||||
|
| `snapd_updates` (r) | what a refresh would change |
|
||||||
|
| `snapd_disk_usage` (r) | bytes per snap and revision, the disabled revisions' share, the total |
|
||||||
|
| `snapd_services` (r) | the services snaps provide |
|
||||||
|
| `snapd_changes` (r) | recent changes, or one change's tasks |
|
||||||
|
| `snapd_install` (a) | install, optionally from a channel and in classic confinement |
|
||||||
|
| `snapd_remove` (a) | remove, keeping a snapshot unless `purge` |
|
||||||
|
| `snapd_refresh` (a) | refresh one snap, or all |
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
Nothing on disk, on either workstation: the module declares nothing.
|
||||||
|
|
||||||
|
- **desktop:** the tools answer for its snaps: `code` (classic), its bases, `gtk-common-themes`,
|
||||||
|
`gnome-3-28-1804`, `snapd`.
|
||||||
|
- **laptop:** snapd is not installed, and every tool says so.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
Everything: the package, its units, `/snap`, the installed snaps and their data.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their
|
||||||
|
// revisions and the space they take, the store's pending updates, snapd's changes, and installing,
|
||||||
|
// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188,
|
||||||
|
// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`.
|
||||||
|
//
|
||||||
|
// The module installs nothing: snapd is not in the distribution's official repositories (README).
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)",
|
||||||
|
"systemctl": "the systemd package",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameArg = map[string]any{"type": "string", "description": "the snap's name"}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "snapd_status",
|
||||||
|
Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " +
|
||||||
|
"whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " +
|
||||||
|
"for classic snaps. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Status() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_list",
|
||||||
|
Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " +
|
||||||
|
"whether the revision is disabled (kept for rollback, taking space). (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return List() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_info",
|
||||||
|
Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)",
|
||||||
|
Input: map[string]any{"name": nameArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
name, err := snapName(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Info(name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_updates",
|
||||||
|
Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Updates() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_disk_usage",
|
||||||
|
Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " +
|
||||||
|
"kept for rollback, and the total. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return DiskUsage() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_services",
|
||||||
|
Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return Services() },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_changes",
|
||||||
|
Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " +
|
||||||
|
"one change's tasks. An act answers the change id to follow here. (r)",
|
||||||
|
Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
id, err := optText(args, "id", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Changes(id)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_install",
|
||||||
|
Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " +
|
||||||
|
"carries it out in the background; the answer is the change id to follow with snapd_changes. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"name": nameArg,
|
||||||
|
"channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"},
|
||||||
|
"classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
name, err := snapName(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
channel, err := optText(args, "channel", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
classic, err := optFlag(args, "classic", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Install(name, channel, classic)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_remove",
|
||||||
|
Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"name": nameArg,
|
||||||
|
"purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
name, err := snapName(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
purge, err := optFlag(args, "purge", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Remove(name, purge)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "snapd_refresh",
|
||||||
|
Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)",
|
||||||
|
Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
name, err := optText(args, "name", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if name != "" {
|
||||||
|
if err := checkSnapName(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Refresh(name)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// snapNames are what the store accepts as a snap's name, optionally with an instance key.
|
||||||
|
var snapNames = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}(_[a-z0-9]{1,10})?$`)
|
||||||
|
|
||||||
|
func checkSnapName(name string) error {
|
||||||
|
if !snapNames.MatchString(name) {
|
||||||
|
return fmt.Errorf("%q is not a snap name", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func snapName(args map[string]any) (string, error) {
|
||||||
|
name, err := text(args, "name")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, checkSnapName(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var plain = []string{"--unicode=never", "--color=never"}
|
||||||
|
|
||||||
|
// Where snapd keeps what it knows. Tests point these elsewhere.
|
||||||
|
var (
|
||||||
|
snapsDir = "/var/lib/snapd/snaps"
|
||||||
|
lsmFile = "/sys/kernel/security/lsm"
|
||||||
|
snapRoot = "/snap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UnitState is one unit's state.
|
||||||
|
type UnitState struct {
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Enabled string `json:"enabled"`
|
||||||
|
Active string `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusAnswer is what snapd_status answers.
|
||||||
|
type StatusAnswer struct {
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Units []UnitState `json:"units"`
|
||||||
|
AppArmor bool `json:"kernel_apparmor"`
|
||||||
|
ClassicRoot bool `json:"classic_root"`
|
||||||
|
Findings []string `json:"findings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status says whether snapd is here and working.
|
||||||
|
func Status() (StatusAnswer, error) {
|
||||||
|
out := StatusAnswer{Units: []UnitState{}, Findings: []string{}}
|
||||||
|
r := run(Cmd{Name: "snap", Args: []string{"version"}})
|
||||||
|
if r.Error == "not-found" {
|
||||||
|
out.Findings = append(out.Findings, "snapd is not installed on this machine")
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if r.Status != 0 || r.Error != "" {
|
||||||
|
return out, failure(Cmd{Name: "snap", Args: []string{"version"}}, r)
|
||||||
|
}
|
||||||
|
out.Installed = true
|
||||||
|
for _, l := range lines(r.Stdout) {
|
||||||
|
if f := strings.Fields(l); len(f) >= 2 && f[0] == "snapd" {
|
||||||
|
out.Version = f[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range []string{"snapd.socket", "snapd.service", "snapd.apparmor.service", "apparmor.service"} {
|
||||||
|
// is-enabled and is-active answer on stdout and exit non-zero for "disabled" and "inactive":
|
||||||
|
// a state, not a failure.
|
||||||
|
en := run(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}})
|
||||||
|
ac := run(Cmd{Name: "systemctl", Args: []string{"is-active", u}})
|
||||||
|
if en.Error != "" || ac.Error != "" {
|
||||||
|
return out, failure(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}, en)
|
||||||
|
}
|
||||||
|
out.Units = append(out.Units, UnitState{Unit: u, Enabled: firstLine(en.Stdout), Active: firstLine(ac.Stdout)})
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(lsmFile); err == nil {
|
||||||
|
for _, m := range strings.Split(strings.TrimSpace(string(b)), ",") {
|
||||||
|
out.AppArmor = out.AppArmor || m == "apparmor"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := os.Stat(snapRoot)
|
||||||
|
out.ClassicRoot = err == nil
|
||||||
|
if out.Units[0].Enabled != "enabled" {
|
||||||
|
out.Findings = append(out.Findings, "snapd.socket is not enabled: snapd does not start on demand")
|
||||||
|
}
|
||||||
|
if !out.AppArmor {
|
||||||
|
out.Findings = append(out.Findings, "the kernel does not run AppArmor: strict snaps run without their confinement")
|
||||||
|
}
|
||||||
|
if out.Units[2].Enabled == "enabled" && !out.AppArmor {
|
||||||
|
out.Findings = append(out.Findings, "snapd.apparmor.service is enabled with no AppArmor in the kernel: it loads profiles nothing enforces")
|
||||||
|
}
|
||||||
|
if !out.ClassicRoot {
|
||||||
|
out.Findings = append(out.Findings, "/snap does not exist: classic snaps cannot run")
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snap is one installed revision.
|
||||||
|
type Snap struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Tracking string `json:"tracking"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
Notes []string `json:"notes"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAnswer is what snapd_list answers.
|
||||||
|
type ListAnswer struct {
|
||||||
|
Snaps []Snap `json:"snaps"`
|
||||||
|
Active int `json:"active"`
|
||||||
|
Disabled int `json:"disabled_revisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// columns reads a table snap prints: a header line, then whitespace-separated columns, the last
|
||||||
|
// taking the rest of the line.
|
||||||
|
func columns(s string, n int) [][]string {
|
||||||
|
out := [][]string{}
|
||||||
|
for i, l := range lines(s) {
|
||||||
|
if i == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f := strings.Fields(l)
|
||||||
|
if len(f) < n {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(f) > n {
|
||||||
|
f = append(f[:n-1], strings.Join(f[n-1:], " "))
|
||||||
|
}
|
||||||
|
out = append(out, f)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// List answers every installed snap and revision.
|
||||||
|
func List() (ListAnswer, error) {
|
||||||
|
r, err := call(Cmd{Name: "snap", Args: append([]string{"list", "--all"}, plain...)})
|
||||||
|
if err != nil {
|
||||||
|
return ListAnswer{}, err
|
||||||
|
}
|
||||||
|
out := ListAnswer{Snaps: []Snap{}}
|
||||||
|
for _, f := range columns(r.Stdout, 6) {
|
||||||
|
s := Snap{Name: f[0], Version: f[1], Revision: f[2], Tracking: f[3], Publisher: strings.TrimRight(f[4], "*"), Notes: []string{}}
|
||||||
|
if f[5] != "-" {
|
||||||
|
s.Notes = strings.Split(f[5], ",")
|
||||||
|
}
|
||||||
|
for _, n := range s.Notes {
|
||||||
|
s.Disabled = s.Disabled || n == "disabled"
|
||||||
|
}
|
||||||
|
if s.Disabled {
|
||||||
|
out.Disabled++
|
||||||
|
} else {
|
||||||
|
out.Active++
|
||||||
|
}
|
||||||
|
out.Snaps = append(out.Snaps, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoAnswer is what snapd_info answers.
|
||||||
|
type InfoAnswer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Fields map[string]string `json:"fields"`
|
||||||
|
Commands []string `json:"commands"`
|
||||||
|
Channels map[string]string `json:"channels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info reads snap info's YAML-like answer: top-level key: value lines, and the commands and
|
||||||
|
// channels blocks.
|
||||||
|
func Info(name string) (InfoAnswer, error) {
|
||||||
|
r, err := call(Cmd{Name: "snap", Args: append([]string{"info"}, append(plain, name)...)})
|
||||||
|
if err != nil {
|
||||||
|
return InfoAnswer{}, err
|
||||||
|
}
|
||||||
|
out := InfoAnswer{Name: name, Fields: map[string]string{}, Commands: []string{}, Channels: map[string]string{}}
|
||||||
|
block := ""
|
||||||
|
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||||
|
if strings.TrimSpace(l) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(l, " ") {
|
||||||
|
block = ""
|
||||||
|
k, v, found := strings.Cut(l, ":")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
switch {
|
||||||
|
case v == "" || v == "|":
|
||||||
|
block = k
|
||||||
|
default:
|
||||||
|
out.Fields[k] = v
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := strings.TrimSpace(l)
|
||||||
|
switch block {
|
||||||
|
case "commands":
|
||||||
|
out.Commands = append(out.Commands, strings.TrimPrefix(t, "- "))
|
||||||
|
case "channels":
|
||||||
|
if k, v, found := strings.Cut(t, ":"); found {
|
||||||
|
out.Channels[k] = strings.Join(strings.Fields(v), " ")
|
||||||
|
}
|
||||||
|
case "description":
|
||||||
|
out.Fields["description"] = strings.TrimSpace(out.Fields["description"] + " " + t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update is one pending refresh.
|
||||||
|
type Update struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Size string `json:"size"`
|
||||||
|
Publisher string `json:"publisher"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updates answers what a refresh would change.
|
||||||
|
func Updates() (map[string]any, error) {
|
||||||
|
r, err := call(Cmd{Name: "snap", Args: append([]string{"refresh", "--list"}, plain...)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []Update{}
|
||||||
|
if !strings.Contains(r.Stdout+r.Stderr, "All snaps up to date") {
|
||||||
|
for _, f := range columns(r.Stdout, 6) {
|
||||||
|
out = append(out, Update{Name: f[0], Version: f[1], Revision: f[2], Size: f[3], Publisher: strings.TrimRight(f[4], "*")})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"updates": out, "count": len(out)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revision is one revision's file.
|
||||||
|
type Revision struct {
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapUsage is the space one snap's revisions take.
|
||||||
|
type SnapUsage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
Revisions []Revision `json:"revisions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskAnswer is what snapd_disk_usage answers.
|
||||||
|
type DiskAnswer struct {
|
||||||
|
Dir string `json:"dir"`
|
||||||
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
|
Reclaimable int64 `json:"disabled_revisions_bytes"`
|
||||||
|
Snaps []SnapUsage `json:"snaps"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiskUsage measures the snap files and marks the disabled revisions.
|
||||||
|
func DiskUsage() (DiskAnswer, error) {
|
||||||
|
listed, err := List()
|
||||||
|
if err != nil {
|
||||||
|
return DiskAnswer{}, err
|
||||||
|
}
|
||||||
|
disabled := map[string]bool{}
|
||||||
|
for _, s := range listed.Snaps {
|
||||||
|
if s.Disabled {
|
||||||
|
disabled[s.Name+"_"+s.Revision] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files, err := filepath.Glob(filepath.Join(snapsDir, "*.snap"))
|
||||||
|
if err != nil {
|
||||||
|
return DiskAnswer{}, err
|
||||||
|
}
|
||||||
|
out := DiskAnswer{Dir: snapsDir, Snaps: []SnapUsage{},
|
||||||
|
Note: "A disabled revision is kept by snapd for rollback (refresh.retain); removing one is `snap remove --revision`, which no tool here does."}
|
||||||
|
by := map[string]*SnapUsage{}
|
||||||
|
for _, f := range files {
|
||||||
|
info, err := os.Stat(f)
|
||||||
|
if err != nil {
|
||||||
|
return DiskAnswer{}, err
|
||||||
|
}
|
||||||
|
base := strings.TrimSuffix(filepath.Base(f), ".snap")
|
||||||
|
i := strings.LastIndex(base, "_")
|
||||||
|
if i <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, rev := base[:i], base[i+1:]
|
||||||
|
if by[name] == nil {
|
||||||
|
by[name] = &SnapUsage{Name: name, Revisions: []Revision{}}
|
||||||
|
}
|
||||||
|
d := disabled[base]
|
||||||
|
by[name].Revisions = append(by[name].Revisions, Revision{Revision: rev, Bytes: info.Size(), Disabled: d})
|
||||||
|
by[name].Bytes += info.Size()
|
||||||
|
out.TotalBytes += info.Size()
|
||||||
|
if d {
|
||||||
|
out.Reclaimable += info.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range by {
|
||||||
|
sort.Slice(u.Revisions, func(i, k int) bool {
|
||||||
|
a, _ := strconv.Atoi(u.Revisions[i].Revision)
|
||||||
|
b, _ := strconv.Atoi(u.Revisions[k].Revision)
|
||||||
|
return a < b
|
||||||
|
})
|
||||||
|
out.Snaps = append(out.Snaps, *u)
|
||||||
|
}
|
||||||
|
sort.Slice(out.Snaps, func(i, k int) bool { return out.Snaps[i].Bytes > out.Snaps[k].Bytes })
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Services answers the snaps' services.
|
||||||
|
func Services() (map[string]any, error) {
|
||||||
|
r, err := call(Cmd{Name: "snap", Args: append([]string{"services"}, plain...)})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := []map[string]string{}
|
||||||
|
if !strings.Contains(r.Stdout+r.Stderr, "no services") {
|
||||||
|
for _, f := range columns(r.Stdout, 4) {
|
||||||
|
out = append(out, map[string]string{"service": f[0], "startup": f[1], "current": f[2], "notes": f[3]})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"services": out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change is one of snapd's changes, or one task of a change.
|
||||||
|
type Change struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Spawn string `json:"spawn"`
|
||||||
|
Ready string `json:"ready,omitempty"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var changeID = regexp.MustCompile(`^[0-9]+$`)
|
||||||
|
|
||||||
|
// Changes answers snapd's recent changes, or one change's tasks.
|
||||||
|
func Changes(id string) (map[string]any, error) {
|
||||||
|
args := append([]string{"changes", "--abs-time"}, plain...)
|
||||||
|
n := 5
|
||||||
|
if id != "" {
|
||||||
|
if !changeID.MatchString(id) {
|
||||||
|
return nil, fmt.Errorf("%q is not a change id", id)
|
||||||
|
}
|
||||||
|
args, n = append([]string{"tasks", "--abs-time"}, append(plain, id)...), 4
|
||||||
|
}
|
||||||
|
r := run(Cmd{Name: "snap", Args: args})
|
||||||
|
if strings.Contains(r.Stdout+r.Stderr, "no changes found") {
|
||||||
|
return map[string]any{"changes": []Change{}}, nil
|
||||||
|
}
|
||||||
|
if r.Status != 0 || r.Error != "" {
|
||||||
|
return nil, failure(Cmd{Name: "snap", Args: args}, r)
|
||||||
|
}
|
||||||
|
out := []Change{}
|
||||||
|
for _, f := range columns(r.Stdout, n) {
|
||||||
|
c := Change{}
|
||||||
|
if n == 5 {
|
||||||
|
c.ID, f = f[0], f[1:]
|
||||||
|
}
|
||||||
|
c.Status, c.Spawn, c.Ready, c.Summary = f[0], f[1], f[2], f[3]
|
||||||
|
if c.Ready == "-" {
|
||||||
|
c.Ready = ""
|
||||||
|
}
|
||||||
|
out = append(out, c)
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
return map[string]any{"id": id, "tasks": out}, nil
|
||||||
|
}
|
||||||
|
return map[string]any{"changes": out}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActAnswer is what an act answers: the change snapd carries it out in.
|
||||||
|
type ActAnswer struct {
|
||||||
|
Act string `json:"act"`
|
||||||
|
Snap string `json:"snap,omitempty"`
|
||||||
|
Change string `json:"change,omitempty"`
|
||||||
|
Said string `json:"said,omitempty"`
|
||||||
|
Follow string `json:"follow,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// act runs one snap act with --no-wait, which answers snapd's change id at once.
|
||||||
|
func act(verb, name string, extra ...string) (ActAnswer, error) {
|
||||||
|
args := append([]string{verb, "--no-wait"}, extra...)
|
||||||
|
if name != "" {
|
||||||
|
args = append(args, name)
|
||||||
|
}
|
||||||
|
r, err := call(Cmd{Name: "snap", Args: args, Root: true})
|
||||||
|
if err != nil {
|
||||||
|
return ActAnswer{}, err
|
||||||
|
}
|
||||||
|
out := ActAnswer{Act: verb, Snap: name}
|
||||||
|
if id := strings.TrimSpace(r.Stdout); changeID.MatchString(id) {
|
||||||
|
out.Change, out.Follow = id, "snapd_changes with id "+id
|
||||||
|
} else {
|
||||||
|
out.Said = strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var channelName = regexp.MustCompile(`^[a-z0-9][a-z0-9./_-]*$`)
|
||||||
|
|
||||||
|
// Install installs a snap.
|
||||||
|
func Install(name, channel string, classic bool) (ActAnswer, error) {
|
||||||
|
extra := []string{}
|
||||||
|
if channel != "" {
|
||||||
|
if !channelName.MatchString(channel) {
|
||||||
|
return ActAnswer{}, fmt.Errorf("%q is not a channel", channel)
|
||||||
|
}
|
||||||
|
extra = append(extra, "--channel="+channel)
|
||||||
|
}
|
||||||
|
if classic {
|
||||||
|
extra = append(extra, "--classic")
|
||||||
|
}
|
||||||
|
return act("install", name, extra...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove removes a snap.
|
||||||
|
func Remove(name string, purge bool) (ActAnswer, error) {
|
||||||
|
if purge {
|
||||||
|
return act("remove", name, "--purge")
|
||||||
|
}
|
||||||
|
return act("remove", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh refreshes one snap, or all.
|
||||||
|
func Refresh(name string) (ActAnswer, error) {
|
||||||
|
return act("refresh", name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestDeclaresNothingTheHostCouldNotInstall(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "snapd")
|
||||||
|
// snapd is not in the official repositories: no package, and no unit that only that package
|
||||||
|
// brings, until the mesh's package repository builds it (research 027 question 1).
|
||||||
|
if len(m.Resources) != 0 {
|
||||||
|
t.Errorf("resources %v", m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const listAll = `Name Version Rev Tracking Publisher Notes
|
||||||
|
bare 1.0 5 latest/stable canonical** base
|
||||||
|
code 04c0d99f 266 latest/stable vscode** disabled,classic
|
||||||
|
code 07f806f9 267 latest/stable vscode** classic
|
||||||
|
gtk-common-themes 0.1-81-g442e511 1535 latest/stable canonical** -
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestListReadsEachRevisionAndMarksTheDisabledOnes(t *testing.T) {
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok(listAll) })
|
||||||
|
got, err := List()
|
||||||
|
if err != nil || len(got.Snaps) != 4 || got.Disabled != 1 || got.Active != 3 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if s := got.Snaps[1]; s.Name != "code" || s.Revision != "266" || !s.Disabled || s.Publisher != "vscode" || strings.Join(s.Notes, ",") != "disabled,classic" {
|
||||||
|
t.Errorf("%+v", s)
|
||||||
|
}
|
||||||
|
if s := got.Snaps[3]; len(s.Notes) != 0 || s.Disabled {
|
||||||
|
t.Errorf("%+v", s)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "snap list --all --unicode=never --color=never" {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestToolsSayWhenSnapdIsNotInstalled(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||||
|
if _, err := List(); err == nil || !strings.Contains(err.Error(), "not in the official repositories") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
got, err := Status()
|
||||||
|
if err != nil || got.Installed || !strings.Contains(strings.Join(got.Findings, ";"), "not installed") {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusNamesWhatIsWrongWithTheInstallation(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wasLSM, wasRoot := lsmFile, snapRoot
|
||||||
|
lsmFile, snapRoot = filepath.Join(dir, "lsm"), filepath.Join(dir, "snap")
|
||||||
|
defer func() { lsmFile, snapRoot = wasLSM, wasRoot }()
|
||||||
|
_ = os.WriteFile(lsmFile, []byte("capability,landlock,lockdown,yama,bpf\n"), 0o644)
|
||||||
|
_ = os.Mkdir(snapRoot, 0o755)
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
switch {
|
||||||
|
case line == "snap version":
|
||||||
|
return ok("snap 2.76.2-2\nsnapd 2.76.2-2\nseries 16\n")
|
||||||
|
case strings.HasSuffix(line, "is-enabled snapd.service"), strings.HasSuffix(line, "is-enabled apparmor.service"):
|
||||||
|
return Result{Status: 1, Stdout: "disabled\n"}
|
||||||
|
case strings.Contains(line, "is-enabled"):
|
||||||
|
return ok("enabled\n")
|
||||||
|
case strings.Contains(line, "is-active snapd.service"):
|
||||||
|
return ok("active\n")
|
||||||
|
}
|
||||||
|
return Result{Status: 3, Stdout: "inactive\n"}
|
||||||
|
})
|
||||||
|
got, err := Status()
|
||||||
|
if err != nil || !got.Installed || got.Version != "2.76.2-2" || got.AppArmor || !got.ClassicRoot || len(got.Units) != 4 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.Units[1].Enabled != "disabled" || got.Units[1].Active != "active" {
|
||||||
|
t.Errorf("socket-activated service: %+v", got.Units[1])
|
||||||
|
}
|
||||||
|
all := strings.Join(got.Findings, ";")
|
||||||
|
if !strings.Contains(all, "without their confinement") || !strings.Contains(all, "nothing enforces") || strings.Contains(all, "/snap does not exist") {
|
||||||
|
t.Errorf("%s", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInfoReadsFieldsCommandsAndChannels(t *testing.T) {
|
||||||
|
using(t, func(string, Cmd) Result {
|
||||||
|
return ok(`name: code
|
||||||
|
summary: Code editing. Redefined.
|
||||||
|
publisher: Visual Studio Code (vscode**)
|
||||||
|
license: unset
|
||||||
|
description: |
|
||||||
|
Visual Studio Code is a new choice
|
||||||
|
of tool.
|
||||||
|
commands:
|
||||||
|
- code
|
||||||
|
- code.url-handler
|
||||||
|
tracking: latest/stable
|
||||||
|
channels:
|
||||||
|
latest/stable: 07f806f9 2026-09-30 (267) 543MB classic
|
||||||
|
latest/candidate: ^
|
||||||
|
`)
|
||||||
|
})
|
||||||
|
got, err := Info("code")
|
||||||
|
if err != nil || got.Fields["summary"] != "Code editing. Redefined." || len(got.Commands) != 2 || got.Fields["tracking"] != "latest/stable" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if got.Channels["latest/stable"] != "07f806f9 2026-09-30 (267) 543MB classic" || got.Fields["description"] != "Visual Studio Code is a new choice of tool." {
|
||||||
|
t.Errorf("%+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatesAndChangesReadNothingToDoAsEmpty(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.Contains(line, "refresh --list") {
|
||||||
|
return Result{Stderr: "All snaps up to date.\n"}
|
||||||
|
}
|
||||||
|
return Result{Status: 1, Stderr: "error: no changes found\n"}
|
||||||
|
})
|
||||||
|
u, err := Updates()
|
||||||
|
if err != nil || u["count"] != 0 {
|
||||||
|
t.Fatalf("%v %v", u, err)
|
||||||
|
}
|
||||||
|
c, err := Changes("")
|
||||||
|
if err != nil || len(c["changes"].([]Change)) != 0 {
|
||||||
|
t.Fatalf("%v %v", c, err)
|
||||||
|
}
|
||||||
|
if _, err := Changes("12; reboot"); err == nil {
|
||||||
|
t.Error("a change id that is not a number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChangesAndTasksAreReadByColumn(t *testing.T) {
|
||||||
|
using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.Contains(line, "tasks") {
|
||||||
|
return ok("Status Spawn Ready Summary\nDone 2026-10-01T18:57:00+02:00 2026-10-01T18:57:10+02:00 Download snap \"code\" (267)\n")
|
||||||
|
}
|
||||||
|
return ok("ID Status Spawn Ready Summary\n42 Doing 2026-10-04T10:00:00+02:00 - Install \"hello\" snap\n")
|
||||||
|
})
|
||||||
|
c, err := Changes("")
|
||||||
|
ch := c["changes"].([]Change)
|
||||||
|
if err != nil || len(ch) != 1 || ch[0].ID != "42" || ch[0].Ready != "" || ch[0].Summary != `Install "hello" snap` {
|
||||||
|
t.Fatalf("%+v %v", c, err)
|
||||||
|
}
|
||||||
|
c, _ = Changes("42")
|
||||||
|
if tasks := c["tasks"].([]Change); len(tasks) != 1 || tasks[0].Status != "Done" || tasks[0].Summary != `Download snap "code" (267)` {
|
||||||
|
t.Errorf("%+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestActsGoThroughSudoWithoutWaitingAndAnswerTheChange(t *testing.T) {
|
||||||
|
f := using(t, func(line string, c Cmd) Result {
|
||||||
|
if strings.Contains(line, "refresh") {
|
||||||
|
return Result{Stderr: "All snaps up to date.\n"}
|
||||||
|
}
|
||||||
|
return ok("57\n")
|
||||||
|
})
|
||||||
|
got, err := Install("hello-world", "latest/edge", true)
|
||||||
|
if err != nil || got.Change != "57" || !strings.Contains(got.Follow, "57") {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := Remove("hello-world", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r, err := Refresh("")
|
||||||
|
if err != nil || r.Change != "" || !strings.Contains(r.Said, "up to date") {
|
||||||
|
t.Fatalf("%+v %v", r, err)
|
||||||
|
}
|
||||||
|
want := "sudo -n snap install --no-wait --channel=latest/edge --classic hello-world\n" +
|
||||||
|
"sudo -n snap remove --no-wait --purge hello-world\n" +
|
||||||
|
"sudo -n snap refresh --no-wait"
|
||||||
|
if got := strings.Join(f.lines(), "\n"); got != want {
|
||||||
|
t.Errorf("asked\n%s", got)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"--classic", "Hello", "a b", "../x"} {
|
||||||
|
if err := checkSnapName(bad); err == nil {
|
||||||
|
t.Errorf("%q accepted as a snap name", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := Install("x", "--dangerous", false); err == nil {
|
||||||
|
t.Error("an option as a channel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageMeasuresEachRevisionAndWhatDisabledOnesTake(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
was := snapsDir
|
||||||
|
snapsDir = dir
|
||||||
|
defer func() { snapsDir = was }()
|
||||||
|
for name, size := range map[string]int{"code_266.snap": 500, "code_267.snap": 510, "bare_5.snap": 4} {
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o600)
|
||||||
|
}
|
||||||
|
using(t, func(string, Cmd) Result { return ok(listAll) })
|
||||||
|
got, err := DiskUsage()
|
||||||
|
if err != nil || got.TotalBytes != 1014 || got.Reclaimable != 500 || len(got.Snaps) != 2 {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if c := got.Snaps[0]; c.Name != "code" || c.Bytes != 1010 || !c.Revisions[0].Disabled || c.Revisions[1].Disabled {
|
||||||
|
t.Errorf("%+v", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module snapd
|
||||||
|
|
||||||
|
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,31 @@
|
|||||||
|
{
|
||||||
|
"module": "snapd",
|
||||||
|
"version": "1",
|
||||||
|
"tools": [
|
||||||
|
"snapd_status",
|
||||||
|
"snapd_list",
|
||||||
|
"snapd_info",
|
||||||
|
"snapd_updates",
|
||||||
|
"snapd_disk_usage",
|
||||||
|
"snapd_services",
|
||||||
|
"snapd_changes",
|
||||||
|
"snapd_install",
|
||||||
|
"snapd_remove",
|
||||||
|
"snapd_refresh"
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/snapd-tools",
|
||||||
|
"binary": "snapd-tools",
|
||||||
|
"loads": [
|
||||||
|
"snapd-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# xclip
|
||||||
|
|
||||||
|
The command-line X clipboard, as a module (novox/hq research 026/04: "`xclip` is a module of its own,
|
||||||
|
a package and nothing else. It is the tool scripts depend on, and a module that needs it requires
|
||||||
|
it"; to-be 42 phase 2 step 7).
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
| what | where |
|
||||||
|
|---|---|
|
||||||
|
| `xclip` | package `xclip` (official repositories) |
|
||||||
|
|
||||||
|
Nothing else. The clipboard's history is the clipboard manager's (`node-clipboard`, a later module).
|
||||||
|
This one is the plain clipboard, without a manager.
|
||||||
|
|
||||||
|
## Improves
|
||||||
|
|
||||||
|
- **A declared dependency.** The window manager's screenshot script copies through `xclip` on both
|
||||||
|
workstations, and nothing said it needed it. A module that ships such a script requires this one.
|
||||||
|
- **The clipboard from the mesh.** Text can be put on the operator's clipboard from any machine, for
|
||||||
|
example a command or a link prepared elsewhere, and read back. That is safe with a clipboard manager
|
||||||
|
too: the manager takes what `xclip` offers into its history.
|
||||||
|
|
||||||
|
## Reaching the operator's session
|
||||||
|
|
||||||
|
The node's tool runtime is a system service running as the operator account. It is given no session
|
||||||
|
words: no `DISPLAY`, no `XAUTHORITY`. Measured on both workstations on 2026-10-04:
|
||||||
|
|
||||||
|
- the runtime runs as the account, in the machine's own mount namespace, with no private `/tmp`;
|
||||||
|
- the X server's socket is `/tmp/.X11-unix/X1`;
|
||||||
|
- the cookie is in `~/.Xauthority`, readable by the account;
|
||||||
|
- the window manager's environment names both.
|
||||||
|
|
||||||
|
So a child of the runtime **can** reach the session. `session.go` finds it in this order:
|
||||||
|
|
||||||
|
1. the process's own `DISPLAY`;
|
||||||
|
2. else the `DISPLAY` and `XAUTHORITY` of the account's running processes, read from
|
||||||
|
`/proc/<pid>/environ` (the window manager's by preference), whose socket exists;
|
||||||
|
3. else the only X socket, with `~/.Xauthority`.
|
||||||
|
|
||||||
|
Checked from a shell with both variables unset: it found `:1` through the window manager's process,
|
||||||
|
and the server answered. With no session (nobody logged in to the desktop), every tool answers that no
|
||||||
|
graphical session of the account is running, and runs nothing. A server that refuses the cookie is
|
||||||
|
named with the display and how it was found.
|
||||||
|
|
||||||
|
**What it does not cover.** A Wayland session: there `wl-clipboard` is the tool, and a Wayland module
|
||||||
|
carries it (research 026/04). Also, a copy is held by `xclip`'s own background process, a child of the
|
||||||
|
tool bundle. If the runtime restarts the bundle before another program takes the selection, the copied
|
||||||
|
text is gone.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
All answer JSON. `(r)` reads; `(d)` acts in the operator's session.
|
||||||
|
|
||||||
|
| tool | what |
|
||||||
|
|---|---|
|
||||||
|
| `xclip_copy` (d) | put text (at most 1 MiB) on the clipboard, primary or secondary selection, as a given type (default UTF-8 text) |
|
||||||
|
| `xclip_paste` (r) | what a selection holds: text, or base64 for a type that is not text; `empty` when there is nothing; cut at 256 KiB |
|
||||||
|
| `xclip_targets` (r) | the types a selection is offered as, without reading it |
|
||||||
|
| `xclip_session` (r) | the session the tools reach and how it was found, or why there is none, and whether the server answers |
|
||||||
|
|
||||||
|
## What changes when it is assigned
|
||||||
|
|
||||||
|
Nothing on disk on either workstation: `xclip` is installed explicitly on both.
|
||||||
|
|
||||||
|
## Leaves as found
|
||||||
|
|
||||||
|
The scripts that call `xclip` (the window manager's screenshot script), which are their own modules'.
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||||
|
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||||
|
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||||
|
// than shared; a change to one copy is made to all eight.
|
||||||
|
//
|
||||||
|
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||||
|
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||||
|
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||||
|
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||||
|
// started when it takes longer;
|
||||||
|
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||||
|
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds every command is held to.
|
||||||
|
const (
|
||||||
|
CallTimeout = 20 * time.Second
|
||||||
|
MostOutput = 256 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cmd is one command a tool runs.
|
||||||
|
type Cmd struct {
|
||||||
|
Name string
|
||||||
|
Args []string
|
||||||
|
// Stdin is written to the command's standard input when not empty.
|
||||||
|
Stdin string
|
||||||
|
// Env is added to this process's own environment.
|
||||||
|
Env []string
|
||||||
|
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||||
|
Root bool
|
||||||
|
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||||
|
Timeout time.Duration
|
||||||
|
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||||
|
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||||
|
// until the child exits.
|
||||||
|
Detached bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is what a command did.
|
||||||
|
type Result struct {
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||||
|
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs a command. Tests replace it; nothing else does.
|
||||||
|
type Runner func(Cmd) Result
|
||||||
|
|
||||||
|
var (
|
||||||
|
run Runner = execRun
|
||||||
|
euid = os.Geteuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||||
|
// process is not root.
|
||||||
|
func argv(c Cmd) (string, []string) {
|
||||||
|
if c.Root && euid() != 0 {
|
||||||
|
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||||
|
}
|
||||||
|
return c.Name, c.Args
|
||||||
|
}
|
||||||
|
|
||||||
|
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||||
|
type bounded struct {
|
||||||
|
b bytes.Buffer
|
||||||
|
cut bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *bounded) Write(p []byte) (int, error) {
|
||||||
|
room := MostOutput - w.b.Len()
|
||||||
|
if room <= 0 {
|
||||||
|
w.cut = w.cut || len(p) > 0
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if len(p) > room {
|
||||||
|
w.b.Write(p[:room])
|
||||||
|
w.cut = true
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
return w.b.Write(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func execRun(c Cmd) Result {
|
||||||
|
timeout := c.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = CallTimeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
name, args := argv(c)
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||||
|
if !c.Detached {
|
||||||
|
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error {
|
||||||
|
if cmd.Process != nil {
|
||||||
|
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cmd.WaitDelay = 2 * time.Second
|
||||||
|
if c.Stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||||
|
}
|
||||||
|
var out, errs bounded
|
||||||
|
var outFile, errFile *os.File
|
||||||
|
if c.Detached {
|
||||||
|
var err error
|
||||||
|
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(outFile.Name())
|
||||||
|
defer outFile.Close()
|
||||||
|
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||||
|
return Result{Status: 127, Error: err.Error()}
|
||||||
|
}
|
||||||
|
defer os.Remove(errFile.Name())
|
||||||
|
defer errFile.Close()
|
||||||
|
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||||
|
} else {
|
||||||
|
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||||
|
}
|
||||||
|
err := cmd.Run()
|
||||||
|
if c.Detached {
|
||||||
|
for _, f := range []struct {
|
||||||
|
file *os.File
|
||||||
|
into *bounded
|
||||||
|
}{{outFile, &out}, {errFile, &errs}} {
|
||||||
|
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||||
|
_, _ = io.Copy(f.into, f.file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||||
|
var exit *exec.ExitError
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
case ctx.Err() == context.DeadlineExceeded:
|
||||||
|
r.Status, r.Error = 124, "timeout"
|
||||||
|
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||||
|
r.Status, r.Error = 127, "not-found"
|
||||||
|
case errors.As(err, &exit):
|
||||||
|
r.Status = exit.ExitCode()
|
||||||
|
default:
|
||||||
|
r.Status, r.Error = 127, err.Error()
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// call runs a command and answers its result, or an error naming what went wrong.
|
||||||
|
func call(c Cmd) (Result, error) {
|
||||||
|
r := run(c)
|
||||||
|
if r.Status == 0 && r.Error == "" {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||||
|
// status with the end of what it said.
|
||||||
|
func failure(c Cmd, r Result) error {
|
||||||
|
program, _ := argv(c)
|
||||||
|
switch {
|
||||||
|
case r.Error == "not-found" && program == "sudo":
|
||||||
|
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||||
|
case r.Error == "not-found":
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case r.Error == "timeout":
|
||||||
|
limit := c.Timeout
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = CallTimeout
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||||
|
case r.Error != "":
|
||||||
|
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||||
|
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||||
|
if hint, ok := providedBy[c.Name]; ok {
|
||||||
|
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||||
|
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||||
|
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||||
|
c.Name, firstLine(r.Stderr))
|
||||||
|
}
|
||||||
|
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||||
|
if said == "" {
|
||||||
|
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||||
|
}
|
||||||
|
if said == "" {
|
||||||
|
said = "and said nothing"
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstLine(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||||
|
return s[:i]
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func tail(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "…" + s[len(s)-n:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines are a command's output lines, blank ones dropped.
|
||||||
|
func lines(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
out = append(out, strings.TrimRight(l, "\r"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments, read the way a tool's JSON arguments arrive.
|
||||||
|
|
||||||
|
func text(args map[string]any, key string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return "", fmt.Errorf("%s is required", key)
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return "", fmt.Errorf("%s must not be empty", key)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optText(args map[string]any, key, def string) (string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("%s must be a string", key)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||||
|
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
f, ok := v.(float64)
|
||||||
|
if !ok {
|
||||||
|
if i, isInt := v.(int); isInt {
|
||||||
|
f = float64(i)
|
||||||
|
} else {
|
||||||
|
return 0, fmt.Errorf("%s must be a number", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
b, ok := v.(bool)
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("%s must be true or false", key)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optList(args map[string]any, key string) ([]string, error) {
|
||||||
|
v, ok := args[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
items, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
s, ok := it.(string)
|
||||||
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
|
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneOf refuses a value outside a closed set.
|
||||||
|
func oneOf(key, value string, allowed ...string) error {
|
||||||
|
for _, a := range allowed {
|
||||||
|
if value == a {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||||
|
// snap, application and printer names never do.
|
||||||
|
func plainName(key, value string) error {
|
||||||
|
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||||
|
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Tests of kit.go, the same in each workstation module.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fake records the commands asked and answers each from a function of the command line.
|
||||||
|
type fake struct {
|
||||||
|
asked []Cmd
|
||||||
|
answer func(line string, c Cmd) Result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) runner() Runner {
|
||||||
|
return func(c Cmd) Result {
|
||||||
|
f.asked = append(f.asked, c)
|
||||||
|
name, args := argv(c)
|
||||||
|
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||||
|
if f.answer == nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
return f.answer(line, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fake) lines() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, c := range f.asked {
|
||||||
|
name, args := argv(c)
|
||||||
|
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// using installs a fake runner and a non-root uid for one test.
|
||||||
|
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||||
|
t.Helper()
|
||||||
|
f := &fake{answer: answer}
|
||||||
|
wasRun, wasUID := run, euid
|
||||||
|
run, euid = f.runner(), func() int { return 1000 }
|
||||||
|
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||||
|
|
||||||
|
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||||
|
t.Fatalf("not root: %s %v", name, args)
|
||||||
|
}
|
||||||
|
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||||
|
t.Fatalf("a read is run as the account: %s", name)
|
||||||
|
}
|
||||||
|
euid = func() int { return 0 }
|
||||||
|
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||||
|
t.Fatalf("as root no sudo: %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||||
|
was := euid
|
||||||
|
defer func() { euid = was }()
|
||||||
|
euid = func() int { return 1000 }
|
||||||
|
cases := []struct {
|
||||||
|
c Cmd
|
||||||
|
r Result
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||||
|
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||||
|
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||||
|
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||||
|
}
|
||||||
|
for _, k := range cases {
|
||||||
|
err := failure(k.c, k.r)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||||
|
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||||
|
var w bounded
|
||||||
|
big := strings.Repeat("a", MostOutput+10)
|
||||||
|
n, _ := w.Write([]byte(big))
|
||||||
|
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||||
|
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||||
|
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||||
|
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||||
|
t.Fatalf("%+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||||
|
if r.Error != "timeout" {
|
||||||
|
t.Fatalf("a slow command: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||||
|
if r.Error != "not-found" {
|
||||||
|
t.Fatalf("a missing program: %+v", r)
|
||||||
|
}
|
||||||
|
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||||
|
if r.Stdout != "given" {
|
||||||
|
t.Fatalf("stdin: %+v", r)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||||
|
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||||
|
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||||
|
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||||
|
if _, err := text(args, "missing"); err == nil {
|
||||||
|
t.Error("a missing required string")
|
||||||
|
}
|
||||||
|
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||||
|
t.Errorf("held to most: %d", n)
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||||
|
t.Error("below least")
|
||||||
|
}
|
||||||
|
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||||
|
t.Error("a fraction")
|
||||||
|
}
|
||||||
|
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||||
|
t.Errorf("list: %v", l)
|
||||||
|
}
|
||||||
|
if b, _ := optFlag(args, "b", false); !b {
|
||||||
|
t.Error("flag")
|
||||||
|
}
|
||||||
|
if err := plainName("name", "--all"); err == nil {
|
||||||
|
t.Error("an option as a name")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// The xclip module's tools (novox/hq research 026/04, 026/05): the plain X clipboard without a
|
||||||
|
// manager. Copy puts text on a selection, paste reads one, targets says what a selection offers. A Go
|
||||||
|
// bundle the node's runtime launches over stdio (ADR 0188, ADR 0193). It runs as the operator account
|
||||||
|
// and reaches the account's X session as session.go finds it; with no session, every tool says so.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providedBy = map[string]string{
|
||||||
|
"xclip": "the xclip package, which this module installs",
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := stdio.Serve("", tools()); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var selectionArg = map[string]any{"type": "string", "enum": Selections, "description": "which selection: clipboard (default), primary or secondary"}
|
||||||
|
|
||||||
|
func selectionOf(args map[string]any) (string, error) {
|
||||||
|
s, err := optText(args, "selection", "clipboard")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return s, oneOf("selection", s, Selections...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tools() []stdio.Tool {
|
||||||
|
return []stdio.Tool{
|
||||||
|
{
|
||||||
|
Name: "xclip_copy",
|
||||||
|
Description: "Put text on the operator's clipboard (or the primary or secondary selection), as a given type " +
|
||||||
|
"(default plain UTF-8 text). Needs the operator's graphical session. (d)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"text": map[string]any{"type": "string", "description": "what to copy, at most 1 MiB"},
|
||||||
|
"selection": selectionArg,
|
||||||
|
"type": map[string]any{"type": "string", "description": "the content's type, such as text/html (default UTF8_STRING)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
t, ok := args["text"].(string)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("text is required, as a string (it may be empty)")
|
||||||
|
}
|
||||||
|
sel, err := selectionOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
typ, err := optText(args, "type", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Copy(t, sel, typ)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "xclip_paste",
|
||||||
|
Description: "What the operator's clipboard (or another selection) holds now, as text, or as base64 for a " +
|
||||||
|
"type that is not text. Cut at 256 KiB. Needs the operator's graphical session. (r)",
|
||||||
|
Input: map[string]any{
|
||||||
|
"selection": selectionArg,
|
||||||
|
"type": map[string]any{"type": "string", "description": "the type to ask for, such as text/html or image/png (default UTF8_STRING)"},
|
||||||
|
},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
sel, err := selectionOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
typ, err := optText(args, "type", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Paste(sel, typ)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "xclip_targets",
|
||||||
|
Description: "The types a selection is offered as right now (text, HTML, an image…), without reading the content. (r)",
|
||||||
|
Input: map[string]any{"selection": selectionArg},
|
||||||
|
Run: func(args map[string]any) (any, error) {
|
||||||
|
sel, err := selectionOf(args)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return Targets(sel)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "xclip_session",
|
||||||
|
Description: "Which X session the clipboard tools reach and how it was found, or why there is none; and " +
|
||||||
|
"whether the X server answers. (r)",
|
||||||
|
Input: map[string]any{},
|
||||||
|
Run: func(map[string]any) (any, error) { return SessionCheck() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||||
|
// definition so the module's own tests can hold it to what it says.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifest struct {
|
||||||
|
Module string `json:"module"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Claims []any `json:"claims"`
|
||||||
|
Seats []any `json:"seats"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
Resources []map[string]any `json:"resources"`
|
||||||
|
Build struct {
|
||||||
|
Artifacts []map[string]any `json:"artifacts"`
|
||||||
|
} `json:"build"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func readManifest(t *testing.T) manifest {
|
||||||
|
t.Helper()
|
||||||
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
t.Fatalf("module.json: %v", err)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m manifest) resource(id string) map[string]any {
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["id"] == id {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// packages are the packages the module installs, sorted.
|
||||||
|
func (m manifest) packages() []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "package" && r["absent"] != true {
|
||||||
|
out = append(out, r["package"].(string))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// services are the units the module declares, by unit name.
|
||||||
|
func (m manifest) services() map[string]map[string]any {
|
||||||
|
out := map[string]map[string]any{}
|
||||||
|
for _, r := range m.Resources {
|
||||||
|
if r["type"] == "service" {
|
||||||
|
out[r["unit"].(string)] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||||
|
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||||
|
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||||
|
t.Helper()
|
||||||
|
registered := []string{}
|
||||||
|
for _, tool := range tools() {
|
||||||
|
registered = append(registered, tool.Name)
|
||||||
|
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||||
|
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||||
|
}
|
||||||
|
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||||
|
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||||
|
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||||
|
}
|
||||||
|
if len(m.Build.Artifacts) != 1 {
|
||||||
|
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||||
|
}
|
||||||
|
cwd, _ := os.Getwd()
|
||||||
|
binary := filepath.Base(cwd)
|
||||||
|
a := m.Build.Artifacts[0]
|
||||||
|
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||||
|
for k, v := range want {
|
||||||
|
if a[k] != v {
|
||||||
|
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||||
|
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||||
|
}
|
||||||
|
if m.Claims != nil || m.Seats != nil {
|
||||||
|
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// session.go is the same file in the bundles whose tools act in the operator's graphical session
|
||||||
|
// (xclip, dmenu): how a process the node's tool runtime launched reaches that session.
|
||||||
|
//
|
||||||
|
// The runtime is a system service running as the operator account (novox/hq ADR 0175 §4), in the
|
||||||
|
// machine's own mount namespace, and is given no session words: no DISPLAY, no XAUTHORITY. An X
|
||||||
|
// server accepts a client that names its display and presents the cookie in the authority file, and
|
||||||
|
// both are the account's: the display's socket is in /tmp/.X11-unix, and the cookie file is
|
||||||
|
// readable by the account. So the session is found, not configured:
|
||||||
|
//
|
||||||
|
// 1. the process's own DISPLAY, when the runtime happens to have one;
|
||||||
|
// 2. else the DISPLAY and XAUTHORITY of the account's own running processes, read from
|
||||||
|
// /proc/<pid>/environ (the window manager's, by preference), whose socket exists;
|
||||||
|
// 3. else the only X socket there is, with the authority file in the account's home.
|
||||||
|
//
|
||||||
|
// When none is found the tool says that no graphical session of the account is running, and does
|
||||||
|
// nothing.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Session is the operator's X session as a tool reaches it.
|
||||||
|
type Session struct {
|
||||||
|
Display string `json:"display"`
|
||||||
|
XAuthority string `json:"xauthority,omitempty"`
|
||||||
|
// FoundBy says how: "environment", "process <pid> (<name>)" or "socket".
|
||||||
|
FoundBy string `json:"found_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env is what a command needs to reach the session.
|
||||||
|
func (s Session) Env() []string {
|
||||||
|
env := []string{"DISPLAY=" + s.Display}
|
||||||
|
if s.XAuthority != "" {
|
||||||
|
env = append(env, "XAUTHORITY="+s.XAuthority)
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where the session is looked for. Tests point these at a tree of their own.
|
||||||
|
var (
|
||||||
|
procRoot = "/proc"
|
||||||
|
x11Sockets = "/tmp/.X11-unix"
|
||||||
|
getenv = os.Getenv
|
||||||
|
myUID = os.Getuid
|
||||||
|
)
|
||||||
|
|
||||||
|
// sessionWMs are the programs whose environment is the session's own, preferred over any other
|
||||||
|
// process's (a terminal's child may carry a stale or forwarded DISPLAY).
|
||||||
|
var sessionWMs = map[string]bool{"i3": true, "sway": true, "xinit": true, "i3bar": true, "picom": true, "dunst": true}
|
||||||
|
|
||||||
|
func accountHome() string {
|
||||||
|
if h := strings.TrimSpace(getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if h := strings.TrimSpace(getenv("HOME")); h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
h, _ := os.UserHomeDir()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// socketOf is the local socket of a display such as ":1" or ":1.0", or "" for a remote one.
|
||||||
|
func socketOf(display string) string {
|
||||||
|
if !strings.HasPrefix(display, ":") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
n := strings.TrimPrefix(display, ":")
|
||||||
|
if i := strings.IndexByte(n, '.'); i >= 0 {
|
||||||
|
n = n[:i]
|
||||||
|
}
|
||||||
|
if _, err := strconv.Atoi(n); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(x11Sockets, "X"+n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func exists(p string) bool {
|
||||||
|
_, err := os.Stat(p)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSession answers the account's X session, or an error saying there is none.
|
||||||
|
func findSession() (Session, error) {
|
||||||
|
if d := strings.TrimSpace(getenv("DISPLAY")); d != "" {
|
||||||
|
if s := socketOf(d); s == "" || exists(s) {
|
||||||
|
return Session{Display: d, XAuthority: getenv("XAUTHORITY"), FoundBy: "environment"}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type seen struct {
|
||||||
|
Session
|
||||||
|
wm bool
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
found := map[string]*seen{}
|
||||||
|
entries, _ := os.ReadDir(procRoot)
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dir := filepath.Join(procRoot, e.Name())
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != myUID() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var display, auth string
|
||||||
|
for _, kv := range strings.Split(string(raw), "\x00") {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(kv, "DISPLAY="):
|
||||||
|
display = strings.TrimPrefix(kv, "DISPLAY=")
|
||||||
|
case strings.HasPrefix(kv, "XAUTHORITY="):
|
||||||
|
auth = strings.TrimPrefix(kv, "XAUTHORITY=")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if display == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if s := socketOf(display); s == "" || !exists(s) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm, _ := os.ReadFile(filepath.Join(dir, "comm"))
|
||||||
|
name := strings.TrimSpace(string(comm))
|
||||||
|
key := display + "\x00" + auth
|
||||||
|
if found[key] == nil {
|
||||||
|
found[key] = &seen{Session: Session{Display: display, XAuthority: auth, FoundBy: fmt.Sprintf("process %d (%s)", pid, name)}}
|
||||||
|
}
|
||||||
|
f := found[key]
|
||||||
|
f.count++
|
||||||
|
if sessionWMs[name] && !f.wm {
|
||||||
|
f.wm = true
|
||||||
|
f.FoundBy = fmt.Sprintf("process %d (%s)", pid, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(found) > 0 {
|
||||||
|
all := make([]*seen, 0, len(found))
|
||||||
|
for _, f := range found {
|
||||||
|
all = append(all, f)
|
||||||
|
}
|
||||||
|
sort.Slice(all, func(i, k int) bool {
|
||||||
|
if all[i].wm != all[k].wm {
|
||||||
|
return all[i].wm
|
||||||
|
}
|
||||||
|
if all[i].count != all[k].count {
|
||||||
|
return all[i].count > all[k].count
|
||||||
|
}
|
||||||
|
return all[i].Display < all[k].Display
|
||||||
|
})
|
||||||
|
return all[0].Session, nil
|
||||||
|
}
|
||||||
|
sockets, _ := filepath.Glob(filepath.Join(x11Sockets, "X*"))
|
||||||
|
if len(sockets) == 1 {
|
||||||
|
s := Session{Display: ":" + strings.TrimPrefix(filepath.Base(sockets[0]), "X"), FoundBy: "socket"}
|
||||||
|
if a := filepath.Join(accountHome(), ".Xauthority"); exists(a) {
|
||||||
|
s.XAuthority = a
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if len(sockets) > 1 {
|
||||||
|
return Session{}, fmt.Errorf("no process of this account names its X display, and there are %d X sockets in %s: which one is the operator's session cannot be told", len(sockets), x11Sockets)
|
||||||
|
}
|
||||||
|
return Session{}, fmt.Errorf("no graphical session of this account is running on this machine: no process of the account has DISPLAY set, and there is no X socket in %s. A desktop tool acts only while the operator is logged in to the graphical session", x11Sockets)
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// aMachine gives findSession a /proc and an X socket directory of the test's own.
|
||||||
|
func aMachine(t *testing.T, env map[string]string) (proc, sockets string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
proc, sockets = filepath.Join(root, "proc"), filepath.Join(root, "x11")
|
||||||
|
for _, d := range []string{proc, sockets} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wasProc, wasX, wasEnv := procRoot, x11Sockets, getenv
|
||||||
|
procRoot, x11Sockets = proc, sockets
|
||||||
|
getenv = func(k string) string { return env[k] }
|
||||||
|
t.Cleanup(func() { procRoot, x11Sockets, getenv = wasProc, wasX, wasEnv })
|
||||||
|
return proc, sockets
|
||||||
|
}
|
||||||
|
|
||||||
|
func aProcess(t *testing.T, proc string, pid int, comm string, env ...string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := filepath.Join(proc, strconv.Itoa(pid))
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aSocket(t *testing.T, dir, name string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), nil, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheWindowManagersDisplayAndCookieAreTheSessions(t *testing.T) {
|
||||||
|
proc, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": "/home/op"})
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
aProcess(t, proc, 3, "bash", "DISPLAY=:9", "XAUTHORITY=/stale")
|
||||||
|
aProcess(t, proc, 4, "kitty", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority")
|
||||||
|
aProcess(t, proc, 5, "i3", "DISPLAY=:1.0", "XAUTHORITY=/home/op/.Xauthority")
|
||||||
|
aProcess(t, proc, 6, "sshd", "PATH=/bin")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Display != ":1.0" || s.XAuthority != "/home/op/.Xauthority" || !strings.Contains(s.FoundBy, "i3") {
|
||||||
|
t.Fatalf("%+v: a display without a socket (:9) is skipped, and the window manager's is preferred", s)
|
||||||
|
}
|
||||||
|
if got := strings.Join(s.Env(), " "); got != "DISPLAY=:1.0 XAUTHORITY=/home/op/.Xauthority" {
|
||||||
|
t.Fatalf("env %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheOnlySocketWithTheHomesCookieIsTheFallback(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
_ = os.WriteFile(filepath.Join(home, ".Xauthority"), []byte("c"), 0o600)
|
||||||
|
_, sockets := aMachine(t, map[string]string{"MESH_OPERATOR_HOME": home})
|
||||||
|
aSocket(t, sockets, "X0")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil || s.Display != ":0" || s.XAuthority != filepath.Join(home, ".Xauthority") || s.FoundBy != "socket" {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionNoSessionIsSaidNotGuessed(t *testing.T) {
|
||||||
|
_, sockets := aMachine(t, map[string]string{})
|
||||||
|
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("none: %v", err)
|
||||||
|
}
|
||||||
|
aSocket(t, sockets, "X0")
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
if _, err := findSession(); err == nil || !strings.Contains(err.Error(), "2 X sockets") {
|
||||||
|
t.Fatalf("two: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionTheProcessesOwnDisplayComesFirst(t *testing.T) {
|
||||||
|
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":2", "XAUTHORITY": "/a"})
|
||||||
|
aSocket(t, sockets, "X2")
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil || s.Display != ":2" || s.FoundBy != "environment" {
|
||||||
|
t.Fatalf("%+v %v", s, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selections are the X selections xclip reaches.
|
||||||
|
var Selections = []string{"clipboard", "primary", "secondary"}
|
||||||
|
|
||||||
|
// MostCopy bounds what a caller may put on the clipboard.
|
||||||
|
const MostCopy = 1 << 20
|
||||||
|
|
||||||
|
var targetName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.+/;=-]{0,127}$`)
|
||||||
|
|
||||||
|
func checkTarget(t string) error {
|
||||||
|
if t != "" && !targetName.MatchString(t) {
|
||||||
|
return fmt.Errorf("%q is not a type a selection can be offered as", t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// xclip runs xclip in the session, naming an unreachable display as such.
|
||||||
|
func xclip(s Session, c Cmd) (Result, error) {
|
||||||
|
c.Name, c.Env = "xclip", s.Env()
|
||||||
|
r := run(c)
|
||||||
|
said := r.Stderr + r.Stdout
|
||||||
|
switch {
|
||||||
|
case r.Error != "":
|
||||||
|
return r, failure(c, r)
|
||||||
|
case strings.Contains(said, "Can't open display"):
|
||||||
|
return r, fmt.Errorf("the X session at %s (found by %s) refused the connection: the display is gone, or the cookie in %s is not the server's", s.Display, s.FoundBy, orNone(s.XAuthority))
|
||||||
|
case r.Status != 0:
|
||||||
|
return r, failure(c, r)
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func orNone(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return "(no authority file)"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyAnswer is what xclip_copy answers.
|
||||||
|
type CopyAnswer struct {
|
||||||
|
Selection string `json:"selection"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
Session Session `json:"session"`
|
||||||
|
Note string `json:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy puts text on a selection. xclip forks a process that holds the selection until another
|
||||||
|
// program takes it, so the command runs detached.
|
||||||
|
func Copy(text, selection, typ string) (CopyAnswer, error) {
|
||||||
|
if len(text) > MostCopy {
|
||||||
|
return CopyAnswer{}, fmt.Errorf("text is %d bytes; at most %d are copied", len(text), MostCopy)
|
||||||
|
}
|
||||||
|
if err := checkTarget(typ); err != nil {
|
||||||
|
return CopyAnswer{}, err
|
||||||
|
}
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
return CopyAnswer{}, err
|
||||||
|
}
|
||||||
|
args := []string{"-selection", selection, "-in"}
|
||||||
|
if typ != "" {
|
||||||
|
args = append(args, "-t", typ)
|
||||||
|
}
|
||||||
|
if _, err := xclip(s, Cmd{Args: args, Stdin: text, Detached: true}); err != nil {
|
||||||
|
return CopyAnswer{}, err
|
||||||
|
}
|
||||||
|
if typ == "" {
|
||||||
|
typ = "UTF8_STRING"
|
||||||
|
}
|
||||||
|
return CopyAnswer{Selection: selection, Type: typ, Bytes: len(text), Session: s,
|
||||||
|
Note: "xclip holds the selection until another program takes it; a clipboard manager may copy it into its history"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasteAnswer is what xclip_paste answers.
|
||||||
|
type PasteAnswer struct {
|
||||||
|
Selection string `json:"selection"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Empty bool `json:"empty"`
|
||||||
|
Text string `json:"text,omitempty"`
|
||||||
|
Base64 string `json:"base64,omitempty"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste reads a selection.
|
||||||
|
func Paste(selection, typ string) (PasteAnswer, error) {
|
||||||
|
if err := checkTarget(typ); err != nil {
|
||||||
|
return PasteAnswer{}, err
|
||||||
|
}
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
return PasteAnswer{}, err
|
||||||
|
}
|
||||||
|
args := []string{"-selection", selection, "-out"}
|
||||||
|
if typ != "" {
|
||||||
|
args = append(args, "-t", typ)
|
||||||
|
} else {
|
||||||
|
typ = "UTF8_STRING"
|
||||||
|
}
|
||||||
|
out := PasteAnswer{Selection: selection, Type: typ}
|
||||||
|
r, err := xclip(s, Cmd{Args: args})
|
||||||
|
if err != nil {
|
||||||
|
// "Error: target … not available": the selection is empty, or not offered as that type.
|
||||||
|
if strings.Contains(r.Stderr, "not available") {
|
||||||
|
out.Empty = true
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return PasteAnswer{}, err
|
||||||
|
}
|
||||||
|
out.Bytes, out.Truncated = len(r.Stdout), r.Truncated
|
||||||
|
if utf8.ValidString(r.Stdout) {
|
||||||
|
out.Text = r.Stdout
|
||||||
|
} else {
|
||||||
|
out.Base64 = base64.StdEncoding.EncodeToString([]byte(r.Stdout))
|
||||||
|
}
|
||||||
|
out.Empty = out.Bytes == 0
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Targets answers the types a selection is offered as.
|
||||||
|
func Targets(selection string) (map[string]any, error) {
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r, err := xclip(s, Cmd{Args: []string{"-selection", selection, "-out", "-t", "TARGETS"}})
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(r.Stderr, "not available") {
|
||||||
|
return map[string]any{"selection": selection, "targets": []string{}, "empty": true}, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return map[string]any{"selection": selection, "targets": lines(r.Stdout), "empty": strings.TrimSpace(r.Stdout) == ""}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionCheck answers the session and whether its X server answers.
|
||||||
|
func SessionCheck() (map[string]any, error) {
|
||||||
|
s, err := findSession()
|
||||||
|
if err != nil {
|
||||||
|
return map[string]any{"found": false, "why": err.Error()}, nil
|
||||||
|
}
|
||||||
|
out := map[string]any{"found": true, "session": s}
|
||||||
|
if _, err := Targets("clipboard"); err != nil {
|
||||||
|
out["answers"], out["why"] = false, err.Error()
|
||||||
|
} else {
|
||||||
|
out["answers"] = true
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheManifestIsThePackageAndNothingElse(t *testing.T) {
|
||||||
|
m := readManifest(t)
|
||||||
|
holdsTheBundle(t, m, "xclip")
|
||||||
|
if got := strings.Join(m.packages(), ","); got != "xclip" || len(m.Resources) != 1 {
|
||||||
|
t.Errorf("packages %s, resources %v", got, m.Resources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// aSession gives the tools an X session on :1 with the account's cookie.
|
||||||
|
func aSession(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
_, sockets := aMachine(t, map[string]string{"DISPLAY": ":1", "XAUTHORITY": "/home/op/.Xauthority"})
|
||||||
|
aSocket(t, sockets, "X1")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCopyRunsDetachedInTheSessionWithTheTextOnItsInput(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
got, err := Copy("hello", "clipboard", "")
|
||||||
|
if err != nil || got.Bytes != 5 || got.Type != "UTF8_STRING" || got.Session.Display != ":1" {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
c := f.asked[0]
|
||||||
|
if f.lines()[0] != "xclip -selection clipboard -in" || c.Stdin != "hello" || !c.Detached {
|
||||||
|
t.Errorf("%v %+v", f.lines(), c)
|
||||||
|
}
|
||||||
|
if strings.Join(c.Env, " ") != "DISPLAY=:1 XAUTHORITY=/home/op/.Xauthority" {
|
||||||
|
t.Errorf("env %v", c.Env)
|
||||||
|
}
|
||||||
|
if _, err := Copy("<b>x</b>", "primary", "text/html"); err != nil || f.lines()[1] != "xclip -selection primary -in -t text/html" {
|
||||||
|
t.Errorf("%v %v", f.lines(), err)
|
||||||
|
}
|
||||||
|
if _, err := Copy(strings.Repeat("a", MostCopy+1), "clipboard", ""); err == nil {
|
||||||
|
t.Error("more than 1 MiB")
|
||||||
|
}
|
||||||
|
if _, err := Copy("x", "clipboard", "-o"); err == nil {
|
||||||
|
t.Error("an option as a type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasteAnswersTextBase64OrEmpty(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
answer := Result{Stdout: "some text"}
|
||||||
|
f := using(t, func(string, Cmd) Result { return answer })
|
||||||
|
got, err := Paste("clipboard", "")
|
||||||
|
if err != nil || got.Text != "some text" || got.Bytes != 9 || got.Empty {
|
||||||
|
t.Fatalf("%+v %v", got, err)
|
||||||
|
}
|
||||||
|
if f.lines()[0] != "xclip -selection clipboard -out" || f.asked[0].Detached {
|
||||||
|
t.Errorf("%v", f.lines())
|
||||||
|
}
|
||||||
|
answer = Result{Stdout: "\x89PNG\r\n\x1a\n\xff"}
|
||||||
|
got, _ = Paste("clipboard", "image/png")
|
||||||
|
if got.Text != "" || got.Base64 == "" {
|
||||||
|
t.Errorf("binary: %+v", got)
|
||||||
|
}
|
||||||
|
answer = Result{Status: 1, Stderr: "Error: target UTF8_STRING not available\n"}
|
||||||
|
got, err = Paste("clipboard", "")
|
||||||
|
if err != nil || !got.Empty {
|
||||||
|
t.Errorf("an empty clipboard is an answer: %+v %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestADisplayThatRefusesIsSaidWithHowItWasFound(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
using(t, func(string, Cmd) Result { return Result{Status: 1, Stderr: "Error: Can't open display: :1\n"} })
|
||||||
|
_, err := Paste("clipboard", "")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "refused the connection") || !strings.Contains(err.Error(), "environment") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithoutASessionNothingRunsAndTheToolSaysWhy(t *testing.T) {
|
||||||
|
aMachine(t, map[string]string{})
|
||||||
|
f := using(t, func(string, Cmd) Result { return ok("") })
|
||||||
|
if _, err := Copy("x", "clipboard", ""); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||||
|
t.Fatalf("%v", err)
|
||||||
|
}
|
||||||
|
got, err := SessionCheck()
|
||||||
|
if err != nil || got["found"] != false || !strings.Contains(got["why"].(string), "logged in") {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
if len(f.asked) != 0 {
|
||||||
|
t.Errorf("nothing runs without a session: %v", f.lines())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTargetsListWhatASelectionOffers(t *testing.T) {
|
||||||
|
aSession(t)
|
||||||
|
using(t, func(string, Cmd) Result { return ok("TIMESTAMP\nTARGETS\nUTF8_STRING\ntext/html\n") })
|
||||||
|
got, err := Targets("clipboard")
|
||||||
|
if err != nil || len(got["targets"].([]string)) != 4 {
|
||||||
|
t.Fatalf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
s, _ := SessionCheck()
|
||||||
|
if s["answers"] != true {
|
||||||
|
t.Errorf("%v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module xclip
|
||||||
|
|
||||||
|
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,35 @@
|
|||||||
|
{
|
||||||
|
"module": "xclip",
|
||||||
|
"version": "1",
|
||||||
|
"capabilities": [
|
||||||
|
"package-manager"
|
||||||
|
],
|
||||||
|
"tools": [
|
||||||
|
"xclip_copy",
|
||||||
|
"xclip_paste",
|
||||||
|
"xclip_targets",
|
||||||
|
"xclip_session"
|
||||||
|
],
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "package",
|
||||||
|
"type": "package",
|
||||||
|
"package": "xclip"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"build": {
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"name": "tools",
|
||||||
|
"kind": "bundle",
|
||||||
|
"language": "go",
|
||||||
|
"system": "arch",
|
||||||
|
"from": "cmd/xclip-tools",
|
||||||
|
"binary": "xclip-tools",
|
||||||
|
"loads": [
|
||||||
|
"xclip-tools"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user