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 was merged in pull request #269.
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