Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d06648bc1 | ||
|
|
e810afb3eb | ||
|
|
838e1c2616 | ||
|
|
91c6fa6fce | ||
|
|
c42cd285e2 | ||
|
|
0fa90e5cf2 | ||
|
|
8bbea4a2ad | ||
|
|
f0f0a79623 | ||
|
|
04894e6618 | ||
|
|
b8982b4a7c | ||
|
|
838a6e510b | ||
|
|
b1b7e58e4b | ||
|
|
3b43a1ef4d | ||
|
|
b352f3920e | ||
|
|
152ef9621d | ||
|
|
db297e8bdd | ||
|
|
45befbbd02 | ||
|
|
86cf6d438d | ||
|
|
4d028d40c5 | ||
|
|
5c212531da | ||
|
|
2e082d1680 | ||
|
|
d9336d11d0 | ||
|
|
21d8a7f6b4 | ||
|
|
a8d308d440 | ||
|
|
f015aba34a | ||
|
|
44aafc9b1c | ||
|
|
0d72c3f29a | ||
|
|
3ac7c0289e | ||
|
|
dde9c264f5 | ||
|
|
24f11f2138 | ||
|
|
af63f12129 | ||
|
|
a72df57214 |
@@ -0,0 +1,42 @@
|
||||
# avahi
|
||||
|
||||
The local network's name and service discovery (mDNS/DNS-SD) as a module (novox/hq to-be 42 Phase 1,
|
||||
research 027).
|
||||
|
||||
## What it owns
|
||||
|
||||
- The `avahi` package.
|
||||
- `avahi-daemon.service`, running and enabled.
|
||||
|
||||
## What it improves
|
||||
|
||||
It was on all four machines and owned by none. It is now declared, and its tools show why discovery
|
||||
does not work today:
|
||||
|
||||
- **The packet filter drops mDNS.** The mesh's filter has no rule for inbound UDP 5353 on any of the
|
||||
four machines, so avahi announces this machine but hears no other machine's answers. A browse
|
||||
finds nothing, and resolving even the machine's own `.local` name times out. A module's `listens`
|
||||
can reach the private network, this machine or anywhere, but not the local link. Opening the port
|
||||
to anywhere would answer the internet on a public machine, so the module opens nothing. This needs
|
||||
a decision in novox/hq: a local-link source scope for `listens`. Until then, `avahi_status` reports
|
||||
`inbound_mdns_accepted: false`, and browse and resolve say so whenever they hear nothing.
|
||||
|
||||
## What it leaves found
|
||||
|
||||
- **`nss-mdns` and `/etc/nsswitch.conf`.** An ordinary lookup reaches avahi only through the
|
||||
`hosts:` line. That line is one ordered list shared by every name source: containers, files, DNS,
|
||||
mDNS and the resolver daemon. The host can write a marked block into a file, but it cannot add a
|
||||
member to a line. Owning the whole file would make this module the owner of every machine's name
|
||||
resolution. On 2026-10-04 all four machines had the same file, with `mdns4_minimal` wired by hand
|
||||
and nss-mdns installed. Both are left as found, and `avahi_status` reports the wiring.
|
||||
- `/etc/avahi/avahi-daemon.conf`, including each workstation's hand-set `allow-interfaces`, which
|
||||
names that machine's own network interface.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | | answers |
|
||||
|---|---|---|
|
||||
| `avahi_status` | r | the daemon, its version and configuration, the `hosts:` line and whether mdns is on it, nss-mdns, whether the filter accepts inbound 5353, systemd-resolved beside it, and notes |
|
||||
| `avahi_browse` | r | every service announced in a few seconds (`avahi-browse -prt`), resolved where possible, narrowed to a type |
|
||||
| `avahi_resolve` | r | a `.local` name through avahi and through the name service side by side, or an address to its name |
|
||||
| `avahi_services` | r | what this machine publishes from `/etc/avahi/services` |
|
||||
@@ -0,0 +1,353 @@
|
||||
package main
|
||||
|
||||
// Avahi, the local network's name and service discovery (mDNS/DNS-SD), as a module (novox/hq to-be 42
|
||||
// Phase 1, research 027: "on all four, owned by none"). The module declares the package and the
|
||||
// daemon. Two things it does not declare, and these tools report instead:
|
||||
//
|
||||
// - **The name service switch.** nss-mdns is what lets an ordinary lookup answer `<host>.local`, and
|
||||
// it works only through the `hosts:` line of /etc/nsswitch.conf. That line is one ordered list
|
||||
// shared by every name source on the machine (containers, files, DNS, mDNS, the resolver daemon),
|
||||
// the host can write a marked block into a file but not a member into a line, and owning the whole
|
||||
// file would make this module the owner of every machine's name resolution. So both stay as found
|
||||
// (wired by hand, identically, on all four machines on 2026-10-04) and `avahi_status` says whether
|
||||
// the wiring is there.
|
||||
// - **The packet filter.** mDNS is multicast to UDP 5353 on the local link. The mesh's filter has no
|
||||
// source scope for "the local link" — a module's `listens` reach the private network, this machine
|
||||
// or anywhere — so it drops what other machines announce, and a browse hears nothing. Opening it to
|
||||
// anywhere would answer the internet on a public machine. `avahi_status` reports whether inbound
|
||||
// 5353 is accepted; browse and resolve say so when they hear nothing.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The files avahi and the name service read.
|
||||
const (
|
||||
DaemonConf = "/etc/avahi/avahi-daemon.conf"
|
||||
ServicesDir = "/etc/avahi/services"
|
||||
NSSwitch = "/etc/nsswitch.conf"
|
||||
Daemon = "avahi-daemon.service"
|
||||
)
|
||||
|
||||
// Status is the daemon, its configuration, the name service's wiring and the filter.
|
||||
type Status struct {
|
||||
Daemon map[string]string `json:"daemon"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Config map[string]map[string]string `json:"config"`
|
||||
HostsLine string `json:"nsswitch_hosts"`
|
||||
MDNSWired bool `json:"nss_mdns_wired"`
|
||||
NSSMDNS string `json:"nss_mdns_package,omitempty"`
|
||||
InboundMDNS *bool `json:"inbound_mdns_accepted"`
|
||||
FilterError string `json:"filter_error,omitempty"`
|
||||
ResolvedOn bool `json:"systemd_resolved_active"`
|
||||
Notes []string `json:"notes"`
|
||||
}
|
||||
|
||||
// ParseINI reads avahi-daemon.conf's sections and their set keys; commented keys are defaults.
|
||||
func ParseINI(text string) map[string]map[string]string {
|
||||
out := map[string]map[string]string{}
|
||||
section := ""
|
||||
for _, l := range lines(text) {
|
||||
l = strings.TrimSpace(l)
|
||||
switch {
|
||||
case strings.HasPrefix(l, "#") || strings.HasPrefix(l, ";"):
|
||||
case strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]"):
|
||||
section = strings.Trim(l, "[]")
|
||||
out[section] = map[string]string{}
|
||||
default:
|
||||
if k, v, ok := strings.Cut(l, "="); ok && section != "" {
|
||||
out[section][strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HostsLine is the `hosts:` line of nsswitch.conf, and whether an mdns source is on it.
|
||||
func HostsLine(text string) (string, bool) {
|
||||
for _, l := range lines(text) {
|
||||
l = strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(l, "hosts:") {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(strings.TrimPrefix(l, "hosts:")) {
|
||||
if strings.HasPrefix(f, "mdns") {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return l, false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
var mdnsAccept = regexp.MustCompile(`(?m)\budp dport (?:\{[^}\n]*\b(?:5353|mdns)\b[^}\n]*\}|(?:5353|mdns)\b)[^\n]*\baccept\b`)
|
||||
|
||||
// InboundMDNS is whether a ruleset accepts UDP 5353 coming in.
|
||||
func InboundMDNS(ruleset string) bool { return mdnsAccept.MatchString(ruleset) }
|
||||
|
||||
// GetStatus reads the daemon, its configuration, the name service and the packet filter.
|
||||
func (m *Machine) GetStatus() (Status, error) {
|
||||
s := Status{Config: map[string]map[string]string{}, Notes: []string{}}
|
||||
d, err := m.unitProps(Daemon, "LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID")
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Daemon = d
|
||||
if v, err := m.Out("avahi-daemon", "--version"); err == nil {
|
||||
s.Version = strings.TrimSpace(v)
|
||||
}
|
||||
if text, err := m.ReadFile(DaemonConf); err == nil {
|
||||
s.Config = ParseINI(string(text))
|
||||
}
|
||||
if text, err := m.ReadFile(NSSwitch); err == nil {
|
||||
s.HostsLine, s.MDNSWired = HostsLine(string(text))
|
||||
}
|
||||
if r := m.Run(bg(), "pacman", "-Q", "nss-mdns"); r.Status == 0 && r.Err == "" {
|
||||
s.NSSMDNS = strings.TrimSpace(r.Stdout)
|
||||
}
|
||||
if rs, err := m.Root("nft", "list", "ruleset"); err == nil {
|
||||
open := InboundMDNS(rs)
|
||||
s.InboundMDNS = &open
|
||||
if !open {
|
||||
s.Notes = append(s.Notes, "the packet filter drops inbound UDP 5353: this machine announces itself but hears no other machine's mDNS")
|
||||
}
|
||||
} else {
|
||||
s.FilterError = err.Error()
|
||||
}
|
||||
if p, err := m.unitProps("systemd-resolved.service", "ActiveState"); err == nil {
|
||||
s.ResolvedOn = p["ActiveState"] == "active"
|
||||
}
|
||||
if s.MDNSWired && s.NSSMDNS == "" {
|
||||
s.Notes = append(s.Notes, "nsswitch names mdns and nss-mdns is not installed: those lookups fail")
|
||||
}
|
||||
if !s.MDNSWired {
|
||||
s.Notes = append(s.Notes, "nsswitch does not name mdns: ordinary lookups never ask avahi")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Service is one service a browse found.
|
||||
type Service struct {
|
||||
Interface string `json:"interface"`
|
||||
Protocol string `json:"protocol"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Domain string `json:"domain"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Address string `json:"address,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
TXT []string `json:"txt,omitempty"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
// unescape undoes avahi-browse -p's escaping: a special byte as a backslash and three decimals, any
|
||||
// other character after a backslash as itself. Decoded as bytes, so a name in UTF-8 stays whole.
|
||||
func unescape(s string) string {
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\\' {
|
||||
if d := s[i+1 : min(i+4, len(s))]; len(d) == 3 && isDigits(d) {
|
||||
n, _ := strconv.Atoi(d)
|
||||
out = append(out, byte(n))
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if i+1 < len(s) {
|
||||
out = append(out, s[i+1])
|
||||
i++
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, s[i])
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func isDigits(s string) bool {
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var txtItem = regexp.MustCompile(`"((?:[^"\\]|\\.)*)"`)
|
||||
|
||||
// ParseBrowse reads `avahi-browse -p -r`: `+` lines found, `=` lines resolved; a found service
|
||||
// that resolved is answered once, resolved.
|
||||
func ParseBrowse(out string) []Service {
|
||||
byKey := map[string]int{}
|
||||
services := []Service{}
|
||||
for _, l := range lines(out) {
|
||||
f := strings.Split(l, ";")
|
||||
if len(f) < 6 || (f[0] != "+" && f[0] != "=") {
|
||||
continue
|
||||
}
|
||||
s := Service{Interface: f[1], Protocol: f[2], Name: unescape(f[3]), Type: f[4], Domain: f[5]}
|
||||
if f[0] == "=" && len(f) >= 9 {
|
||||
s.Resolved, s.Host, s.Address = true, f[6], f[7]
|
||||
s.Port, _ = strconv.Atoi(f[8])
|
||||
if len(f) >= 10 {
|
||||
for _, t := range txtItem.FindAllStringSubmatch(strings.Join(f[9:], ";"), -1) {
|
||||
s.TXT = append(s.TXT, t[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
key := strings.Join([]string{s.Interface, s.Protocol, s.Name, s.Type, s.Domain}, "\x00")
|
||||
if i, seen := byKey[key]; seen {
|
||||
if s.Resolved {
|
||||
services[i] = s
|
||||
}
|
||||
continue
|
||||
}
|
||||
byKey[key] = len(services)
|
||||
services = append(services, s)
|
||||
}
|
||||
sort.SliceStable(services, func(i, j int) bool {
|
||||
if services[i].Type != services[j].Type {
|
||||
return services[i].Type < services[j].Type
|
||||
}
|
||||
return services[i].Name < services[j].Name
|
||||
})
|
||||
return services
|
||||
}
|
||||
|
||||
var serviceType = regexp.MustCompile(`^_[A-Za-z0-9-]+\._(tcp|udp)$`)
|
||||
|
||||
// Browse listens for a few seconds and answers every service announced, resolved where it could be.
|
||||
func (m *Machine) Browse(seconds int, kind string) (map[string]any, error) {
|
||||
args := []string{strconv.Itoa(seconds), "avahi-browse", "-p", "-r", "-t"}
|
||||
if kind == "" {
|
||||
args = append(args, "-a")
|
||||
} else {
|
||||
if !serviceType.MatchString(kind) {
|
||||
return nil, fmt.Errorf("%q is not a service type such as _ssh._tcp", kind)
|
||||
}
|
||||
args = append(args, kind)
|
||||
}
|
||||
r := m.Run(bg(), "timeout", args...)
|
||||
// timeout's 124 is the listening time ending, which is how a browse that keeps hearing ends.
|
||||
if r.Err != "" || (r.Status != 0 && r.Status != 124) {
|
||||
return nil, failure("avahi-browse", "avahi-browse", r)
|
||||
}
|
||||
services := ParseBrowse(r.Stdout)
|
||||
out := map[string]any{"seconds": seconds, "count": len(services), "services": services}
|
||||
if len(services) == 0 {
|
||||
out["note"] = m.silenceNote()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// silenceNote says why nothing may have been heard, from the packet filter when it can be read.
|
||||
func (m *Machine) silenceNote() string {
|
||||
if rs, err := m.Root("nft", "list", "ruleset"); err == nil && !InboundMDNS(rs) {
|
||||
return "nothing was heard, and this machine's packet filter drops inbound UDP 5353 (mDNS): other machines' answers do not reach avahi"
|
||||
}
|
||||
return "nothing was heard on the local network"
|
||||
}
|
||||
|
||||
// Resolve asks avahi for a name's address (or an address's name), and the name service the same,
|
||||
// so an answer avahi has and an ordinary lookup does not shows the switch unwired.
|
||||
func (m *Machine) Resolve(name, address string) (map[string]any, error) {
|
||||
if (name == "") == (address == "") {
|
||||
return nil, fmt.Errorf("give a name or an address")
|
||||
}
|
||||
out := map[string]any{}
|
||||
var r Ran
|
||||
if name != "" {
|
||||
if !strings.HasSuffix(name, ".local") {
|
||||
name += ".local"
|
||||
}
|
||||
out["name"] = name
|
||||
r = m.Run(bg(), "avahi-resolve", "-n", name)
|
||||
} else {
|
||||
if net.ParseIP(address) == nil {
|
||||
return nil, fmt.Errorf("%q is not an address", address)
|
||||
}
|
||||
out["address"] = address
|
||||
r = m.Run(bg(), "avahi-resolve", "-a", address)
|
||||
}
|
||||
if r.Err != "" {
|
||||
return nil, failure("avahi-resolve", "avahi-resolve", r)
|
||||
}
|
||||
// avahi-resolve says a failure on stderr and exits 0.
|
||||
avahi := map[string]any{"answers": []string{}}
|
||||
for _, l := range lines(r.Stdout) {
|
||||
if f := strings.Fields(l); len(f) >= 2 {
|
||||
avahi["answers"] = append(avahi["answers"].([]string), f[1])
|
||||
}
|
||||
}
|
||||
if said := firstLine(r.Stderr); said != "" {
|
||||
avahi["error"] = said
|
||||
}
|
||||
avahi["resolved"] = len(avahi["answers"].([]string)) > 0
|
||||
out["avahi"] = avahi
|
||||
if name != "" {
|
||||
nss := map[string]any{"answers": []string{}}
|
||||
g := m.Run(bg(), "getent", "hosts", name)
|
||||
for _, l := range lines(g.Stdout) {
|
||||
if f := strings.Fields(l); len(f) >= 1 {
|
||||
nss["answers"] = append(nss["answers"].([]string), f[0])
|
||||
}
|
||||
}
|
||||
nss["resolved"] = len(nss["answers"].([]string)) > 0
|
||||
out["name_service"] = nss
|
||||
}
|
||||
if avahi["resolved"] == false {
|
||||
out["note"] = m.silenceNote()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Published is one service this machine announces from a file of /etc/avahi/services.
|
||||
type Published struct {
|
||||
File string `json:"file"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Types []string `json:"types"`
|
||||
Ports []int `json:"ports"`
|
||||
}
|
||||
|
||||
var (
|
||||
xmlName = regexp.MustCompile(`<name[^>]*>([^<]*)</name>`)
|
||||
xmlType = regexp.MustCompile(`<type>([^<]*)</type>`)
|
||||
xmlPort = regexp.MustCompile(`<port>(\d+)</port>`)
|
||||
)
|
||||
|
||||
// Services is what this machine publishes from its service files.
|
||||
func (m *Machine) Services() (map[string]any, error) {
|
||||
r := m.Run(bg(), "find", ServicesDir, "-mindepth", "1", "-maxdepth", "1", "-name", "*.service", "-printf", "%f\n")
|
||||
if r.Err != "" || r.Status != 0 {
|
||||
if strings.Contains(r.Stderr, "No such file") {
|
||||
return map[string]any{"directory": ServicesDir, "published": []Published{}}, nil
|
||||
}
|
||||
return nil, failure("find", "find", r)
|
||||
}
|
||||
pub := []Published{}
|
||||
names := lines(r.Stdout)
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
text, err := m.ReadFile(ServicesDir + "/" + n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := Published{File: n, Types: []string{}, Ports: []int{}}
|
||||
if x := xmlName.FindStringSubmatch(string(text)); x != nil {
|
||||
p.Name = x[1]
|
||||
}
|
||||
for _, t := range xmlType.FindAllStringSubmatch(string(text), -1) {
|
||||
p.Types = append(p.Types, t[1])
|
||||
}
|
||||
for _, x := range xmlPort.FindAllStringSubmatch(string(text), -1) {
|
||||
port, _ := strconv.Atoi(x[1])
|
||||
p.Ports = append(p.Ports, port)
|
||||
}
|
||||
pub = append(pub, p)
|
||||
}
|
||||
return map[string]any{"directory": ServicesDir, "published": pub}, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const browse = `+;enp6s0;IPv4;home\032server;_ssh._tcp;local
|
||||
+;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local
|
||||
=;enp6s0;IPv4;home\032server;_ssh._tcp;local;home-server.local;192.168.1.10;22;
|
||||
=;enp6s0;IPv4;Printer\046Co;_ipp._tcp;local;printer.local;192.168.1.20;631;"txtvers=1" "rp=ipp/print"
|
||||
+;enp6s0;IPv6;Kitchen;_spotify-connect._tcp;local
|
||||
`
|
||||
|
||||
func TestABrowseIsReadResolvedOnceAndUnescaped(t *testing.T) {
|
||||
s := ParseBrowse(browse)
|
||||
if len(s) != 3 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
by := map[string]Service{}
|
||||
for _, x := range s {
|
||||
by[x.Name] = x
|
||||
}
|
||||
ssh := by["home server"]
|
||||
if !ssh.Resolved || ssh.Address != "192.168.1.10" || ssh.Port != 22 || ssh.Host != "home-server.local" {
|
||||
t.Fatalf("%+v", ssh)
|
||||
}
|
||||
ipp := by["Printer.Co"]
|
||||
if strings.Join(ipp.TXT, ",") != "txtvers=1,rp=ipp/print" {
|
||||
t.Fatalf("%+v", ipp)
|
||||
}
|
||||
if k := by["Kitchen"]; k.Resolved || k.Type != "_spotify-connect._tcp" {
|
||||
t.Fatalf("%+v", k)
|
||||
}
|
||||
if unescape(`caf\195\169`) != "café" || unescape(`a\.b`) != "a.b" {
|
||||
t.Fatal("unescape")
|
||||
}
|
||||
}
|
||||
|
||||
func TestABrowseThatHearsNothingSaysTheFilterDropsMDNS(t *testing.T) {
|
||||
var calls []call
|
||||
m := machine(fake(func(c call) Ran {
|
||||
switch c.String() {
|
||||
case "timeout 5 avahi-browse -p -r -t -a":
|
||||
return Ran{Status: 124}
|
||||
case "sudo -n nft list ruleset":
|
||||
return Ran{Stdout: "table inet mesh {\n chain input {\n type filter hook input priority filter; policy drop;\n tcp dport 22 accept\n }\n}\n"}
|
||||
}
|
||||
return Ran{Status: 99}
|
||||
}, &calls), 1000)
|
||||
r, err := m.Browse(5, "")
|
||||
if err != nil || r["count"] != 0 || !strings.Contains(r["note"].(string), "drops inbound UDP 5353") {
|
||||
t.Fatalf("%v %v", r, err)
|
||||
}
|
||||
if _, err := m.Browse(5, "ssh; rm"); err == nil {
|
||||
t.Fatal("not a service type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFilterIsReadForAnAcceptedInboundMDNS(t *testing.T) {
|
||||
for rs, want := range map[string]bool{
|
||||
"\t\tudp dport 5353 accept\n": true,
|
||||
"\t\tiifname \"enp6s0\" udp dport { 53, 5353 } accept\n": true,
|
||||
"\t\tudp dport mdns accept\n": true,
|
||||
"\t\tudp dport 53 accept\n": false,
|
||||
"\t\tudp dport 5353 drop\n": false,
|
||||
"\t\tip saddr 10.0.0.0/8 udp dport 15353 accept\n": false,
|
||||
} {
|
||||
if InboundMDNS(rs) != want {
|
||||
t.Errorf("%q: %v", rs, !want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNamesTheSwitchTheFilterAndTheDaemon(t *testing.T) {
|
||||
m := machine(fake(func(c call) Ran {
|
||||
switch {
|
||||
case c.name == "systemctl" && c.args[1] == Daemon:
|
||||
return Ran{Stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\n"}
|
||||
case c.name == "systemctl":
|
||||
return Ran{Stdout: "ActiveState=inactive\n"}
|
||||
case c.String() == "avahi-daemon --version":
|
||||
return Ran{Stdout: "avahi-daemon 0.9-rc5\n"}
|
||||
case c.String() == "pacman -Q nss-mdns":
|
||||
return Ran{Stdout: "nss-mdns 0.15.1-2\n"}
|
||||
case c.String() == "sudo -n nft list ruleset":
|
||||
return Ran{Stdout: "udp dport 53 accept\n"}
|
||||
}
|
||||
return Ran{Status: 99}
|
||||
}, nil), 1000)
|
||||
files := map[string]string{
|
||||
DaemonConf: "[server]\nuse-ipv4=yes\n#host-name=foo\nallow-interfaces=enp6s0\n[publish]\npublish-hinfo=no\n",
|
||||
NSSwitch: "passwd: files\nhosts: mymachines files dns mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return]\n",
|
||||
}
|
||||
m.ReadFile = func(p string) ([]byte, error) {
|
||||
if s, ok := files[p]; ok {
|
||||
return []byte(s), nil
|
||||
}
|
||||
return nil, errNoFile
|
||||
}
|
||||
s, err := m.GetStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !s.MDNSWired || s.NSSMDNS != "nss-mdns 0.15.1-2" || s.InboundMDNS == nil || *s.InboundMDNS || s.Version != "avahi-daemon 0.9-rc5" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if s.Config["server"]["allow-interfaces"] != "enp6s0" || s.Config["server"]["host-name"] != "" || s.Daemon["ActiveState"] != "active" {
|
||||
t.Fatalf("%+v", s.Config)
|
||||
}
|
||||
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "drops inbound UDP 5353") {
|
||||
t.Fatalf("%v", s.Notes)
|
||||
}
|
||||
if _, wired := HostsLine("hosts: files dns\n"); wired {
|
||||
t.Fatal("no mdns on the line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAsksAvahiAndTheNameServiceAndReadsAFailureFromStderr(t *testing.T) {
|
||||
m := machine(byLine(map[string]Ran{
|
||||
"avahi-resolve -n printer.local": {Stdout: "printer.local\t192.168.1.20\n"},
|
||||
"getent hosts printer.local": {Status: 2},
|
||||
"avahi-resolve -n nowhere.local": {Stderr: "Failed to resolve host name 'nowhere.local': Timeout reached\n"},
|
||||
"getent hosts nowhere.local": {Status: 2},
|
||||
"sudo -n nft list ruleset": {Stdout: "udp dport 5353 accept\n"},
|
||||
"avahi-resolve -a 192.168.1.20": {Stdout: "192.168.1.20\tprinter.local\n"},
|
||||
}, nil), 1000)
|
||||
r, err := m.Resolve("printer", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["avahi"].(map[string]any)["resolved"] != true || r["name_service"].(map[string]any)["resolved"] != false {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
r, _ = m.Resolve("nowhere.local", "")
|
||||
if a := r["avahi"].(map[string]any); a["resolved"] != false || !strings.Contains(a["error"].(string), "Timeout reached") || r["note"] != "nothing was heard on the local network" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
r, _ = m.Resolve("", "192.168.1.20")
|
||||
if r["avahi"].(map[string]any)["answers"].([]string)[0] != "printer.local" {
|
||||
t.Fatalf("%v", r)
|
||||
}
|
||||
for _, bad := range [][2]string{{"", ""}, {"a", "1.2.3.4"}, {"", "not-an-ip"}} {
|
||||
if _, err := m.Resolve(bad[0], bad[1]); err == nil {
|
||||
t.Errorf("%v accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishedServicesAreReadFromTheirFiles(t *testing.T) {
|
||||
m := machine(byLine(map[string]Ran{
|
||||
"find /etc/avahi/services -mindepth 1 -maxdepth 1 -name *.service -printf %f\n": {Stdout: "ssh.service\n"},
|
||||
}, nil), 1000)
|
||||
m.ReadFile = func(string) ([]byte, error) {
|
||||
return []byte(`<service-group><name replace-wildcards="yes">%h</name><service><type>_ssh._tcp</type><port>22</port></service></service-group>`), nil
|
||||
}
|
||||
r, err := m.Services()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := r["published"].([]Published)
|
||||
if len(p) != 1 || p[0].Name != "%h" || p[0].Types[0] != "_ssh._tcp" || p[0].Ports[0] != 22 {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package main
|
||||
|
||||
// The commands this bundle runs on its machine, and who runs them.
|
||||
//
|
||||
// Who asks. The node's tool runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
|
||||
// and launches this binary as a process of its own (ADR 0188, ADR 0193) with the runtime's words —
|
||||
// HOME, a PATH, MESH_OPERATOR_ACCOUNT — and no session words. Reading needs nothing more; what only
|
||||
// root may do goes through `sudo -n`, as the packet filter's, the service manager's and the
|
||||
// intrusion prevention's tools do (to-be 38 WP4), and the `sudo` module is what declares that the
|
||||
// account may (to-be 42, research 027). A refusal is named by how it failed, never read as an
|
||||
// empty answer.
|
||||
//
|
||||
// The runner is injected, so every tool is tested over a fake one without the machine.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ran is what one command did: its output, its exit status, and why it never ran to an answer.
|
||||
type Ran struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Status int
|
||||
// Err is "ENOENT" when the program is not there, or that it was ended for taking too long.
|
||||
Err string
|
||||
}
|
||||
|
||||
// Runner runs one command, so the tools can be tested without the machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) Ran
|
||||
|
||||
// CallTimeout is how long one command may take: below the runtime's thirty-second call limit, so a
|
||||
// command that hangs is answered as such rather than as a call the runtime gave up on.
|
||||
const CallTimeout = 20 * time.Second
|
||||
|
||||
// outputLimit bounds what one command may hand back, so a runaway listing cannot exhaust the
|
||||
// process; well above anything a tool answers.
|
||||
const outputLimit = 16 << 20
|
||||
|
||||
type bounded struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (b *bounded) Write(p []byte) (int, error) {
|
||||
if room := outputLimit - b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
b.Buffer.Write(p[:room])
|
||||
}
|
||||
b.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// ExecRunner runs a command on this machine, in the C locale so what is parsed is one language.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
|
||||
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||
var out, errb bounded
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
r := Ran{Stdout: out.String(), Stderr: errb.String()}
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout.Seconds()))
|
||||
return r
|
||||
}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, fs.ErrNotExist):
|
||||
r.Status, r.Err = 127, "ENOENT"
|
||||
default:
|
||||
r.Status, r.Err = 126, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func Escalated(uid int, name string, args ...string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
// Machine is this machine as the tools see it: a runner, who this process is, and its files.
|
||||
type Machine struct {
|
||||
Run Runner
|
||||
UID int
|
||||
User string
|
||||
Account string
|
||||
ReadFile func(path string) ([]byte, error)
|
||||
Now func() time.Time
|
||||
Sleep func(time.Duration)
|
||||
}
|
||||
|
||||
// ThisMachine is the machine the runtime launched this bundle on.
|
||||
func ThisMachine() *Machine {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = os.Getenv("LOGNAME")
|
||||
}
|
||||
account := strings.TrimSpace(os.Getenv("MESH_OPERATOR_ACCOUNT"))
|
||||
if account == "" {
|
||||
account = user
|
||||
}
|
||||
return &Machine{Run: ExecRunner, UID: os.Getuid(), User: user, Account: account, ReadFile: os.ReadFile, Now: time.Now, Sleep: time.Sleep}
|
||||
}
|
||||
|
||||
// Out runs a command that only reads, and fails with what went wrong named.
|
||||
func (m *Machine) Out(name string, args ...string) (string, error) {
|
||||
r := m.Run(context.Background(), name, args...)
|
||||
if r.Status == 0 && r.Err == "" {
|
||||
return r.Stdout, nil
|
||||
}
|
||||
return r.Stdout, failure(name, name, r)
|
||||
}
|
||||
|
||||
// Root runs a command that needs root, escalated when this process is not.
|
||||
func (m *Machine) Root(name string, args ...string) (string, error) {
|
||||
program, argv := Escalated(m.UID, name, args...)
|
||||
r := m.Run(context.Background(), program, argv...)
|
||||
if r.Status == 0 && r.Err == "" {
|
||||
return r.Stdout, nil
|
||||
}
|
||||
return r.Stdout, failure(name, program, r)
|
||||
}
|
||||
|
||||
// RootRan is Root's raw answer, for a command whose non-zero status is itself an answer.
|
||||
func (m *Machine) RootRan(name string, args ...string) (Ran, error) {
|
||||
program, argv := Escalated(m.UID, name, args...)
|
||||
r := m.Run(context.Background(), program, argv...)
|
||||
if r.Err != "" || (program == "sudo" && sudoRefused(r)) {
|
||||
return r, failure(name, program, r)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func sudoRefused(r Ran) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:")
|
||||
}
|
||||
|
||||
// failure names what failed by how it failed: the program missing is a spawn error, sudo missing
|
||||
// or refusing speaks for itself, and the rest is the command's own first line.
|
||||
func failure(cmd, program string, r Ran) error {
|
||||
said := strings.TrimSpace(r.Stderr + "\n" + r.Stdout)
|
||||
if r.Err == "ENOENT" {
|
||||
if program == "sudo" {
|
||||
return fmt.Errorf("%s needs root for this, and sudo is not installed here for the runtime's account to escalate with", cmd)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", cmd)
|
||||
}
|
||||
if r.Err != "" {
|
||||
return fmt.Errorf("%s did not answer: %s", cmd, r.Err)
|
||||
}
|
||||
if program == "sudo" && sudoRefused(r) {
|
||||
if strings.Contains(said, "command not found") {
|
||||
return fmt.Errorf("%s is not installed on this machine", cmd)
|
||||
}
|
||||
return fmt.Errorf("%s needs root for this and the runtime's account may not run it without a prompt: %s", cmd, firstLine(said))
|
||||
}
|
||||
if line := firstLine(said); line != "" {
|
||||
return fmt.Errorf("%s failed (%d): %s", cmd, r.Status, line)
|
||||
}
|
||||
return fmt.Errorf("%s failed with status %d", cmd, r.Status)
|
||||
}
|
||||
|
||||
func firstLine(text string) string {
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func lines(text string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(text, "\n") {
|
||||
if l = strings.TrimRight(l, "\r"); strings.TrimSpace(l) != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// text is a string argument; required says whether it may be absent. It is never something a
|
||||
// command would read as an option, which under sudo would be root's option.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if required && s == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
if strings.HasPrefix(s, "-") || strings.ContainsRune(s, 0) || strings.ContainsAny(s, "\n\r") {
|
||||
return "", fmt.Errorf("%s %q is not a value this tool passes on", key, s)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument with a default, kept within bounds.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := raw.(float64)
|
||||
if !ok || 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
|
||||
}
|
||||
|
||||
// flag is a boolean argument, false when absent.
|
||||
func flag(args map[string]any, key string) (bool, error) {
|
||||
raw, present := args[key]
|
||||
if !present || raw == nil {
|
||||
return false, nil
|
||||
}
|
||||
b, ok := raw.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// schema is a tool's input: its properties and the ones it requires.
|
||||
func schema(properties map[string]any, required ...string) map[string]any {
|
||||
s := map[string]any{"type": "object", "properties": properties}
|
||||
if len(required) > 0 {
|
||||
s["required"] = required
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// unitProps reads a unit's properties as systemctl shows them.
|
||||
func (m *Machine) unitProps(unit string, props ...string) (map[string]string, error) {
|
||||
args := []string{"show", unit, "--no-pager"}
|
||||
for _, p := range props {
|
||||
args = append(args, "--property="+p)
|
||||
}
|
||||
out, err := m.Out("systemctl", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return keyValues(out, "="), nil
|
||||
}
|
||||
|
||||
// keyValues reads `key<sep>value` lines; a line without the separator is skipped.
|
||||
func keyValues(out, sep string) map[string]string {
|
||||
kv := map[string]string{}
|
||||
for _, l := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(l, sep)
|
||||
if ok {
|
||||
kv[strings.TrimSpace(k)] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return kv
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// call is one command a fake runner was asked to run.
|
||||
type call struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
func (c call) String() string {
|
||||
if len(c.args) == 0 {
|
||||
return c.name
|
||||
}
|
||||
return c.name + " " + strings.Join(c.args, " ")
|
||||
}
|
||||
|
||||
// fake is a runner answering by the command line it is given, recording every call.
|
||||
func fake(answer func(c call) Ran, calls *[]call) Runner {
|
||||
return func(_ context.Context, name string, args ...string) Ran {
|
||||
c := call{name, append([]string(nil), args...)}
|
||||
if calls != nil {
|
||||
*calls = append(*calls, c)
|
||||
}
|
||||
return answer(c)
|
||||
}
|
||||
}
|
||||
|
||||
// byLine answers from a table keyed by the whole command line, and refuses anything else as a
|
||||
// command the test did not expect.
|
||||
func byLine(table map[string]Ran, calls *[]call) Runner {
|
||||
return fake(func(c call) Ran {
|
||||
if r, ok := table[c.String()]; ok {
|
||||
return r
|
||||
}
|
||||
return Ran{Status: 99, Stderr: "unexpected command: " + c.String()}
|
||||
}, calls)
|
||||
}
|
||||
|
||||
func machine(run Runner, uid int) *Machine {
|
||||
return &Machine{Run: run, UID: uid, User: "operator", Account: "operator",
|
||||
ReadFile: func(string) ([]byte, error) { return nil, errNoFile },
|
||||
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) },
|
||||
Sleep: func(time.Duration) {}}
|
||||
}
|
||||
|
||||
type noFile struct{}
|
||||
|
||||
func (noFile) Error() string { return "no such file" }
|
||||
|
||||
var errNoFile = noFile{}
|
||||
|
||||
func TestAnActNeedingRootGoesThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
|
||||
if p, a := Escalated(1000, "visudo", "-c"); p != "sudo" || strings.Join(a, " ") != "-n visudo -c" {
|
||||
t.Fatalf("not root: %s %v", p, a)
|
||||
}
|
||||
if p, a := Escalated(0, "visudo", "-c"); p != "visudo" || strings.Join(a, " ") != "-c" {
|
||||
t.Fatalf("root: %s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailuresAreNamedNeverReadAsEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
r Ran
|
||||
want string
|
||||
}{
|
||||
{Ran{Status: 127, Err: "ENOENT"}, "sudo is not installed here"},
|
||||
{Ran{Status: 1, Stderr: "sudo: a password is required\n"}, "may not run it without a prompt: sudo: a password is required"},
|
||||
{Ran{Status: 124, Err: "no answer within 20 s"}, "did not answer: no answer within 20 s"},
|
||||
{Ran{Status: 2, Stderr: "boom\nmore"}, "failed (2): boom"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
m := machine(fake(func(call) Ran { return c.r }, nil), 1000)
|
||||
if _, err := m.Root("thing"); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%+v: %v, want %q", c.r, err, c.want)
|
||||
}
|
||||
}
|
||||
m := machine(fake(func(call) Ran { return Ran{Status: 127, Err: "ENOENT"} }, nil), 1000)
|
||||
if _, err := m.Out("thing"); err == nil || !strings.Contains(err.Error(), "thing is not installed") {
|
||||
t.Errorf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArgumentIsNeverAnOption(t *testing.T) {
|
||||
for _, bad := range []any{"-rf", "a\nb", 3.0} {
|
||||
if _, err := text(map[string]any{"x": bad}, "x", true); err == nil {
|
||||
t.Errorf("%v was accepted", bad)
|
||||
}
|
||||
}
|
||||
if s, err := text(map[string]any{"x": " ok "}, "x", true); err != nil || s != "ok" {
|
||||
t.Errorf("a plain value: %q %v", s, err)
|
||||
}
|
||||
if _, err := text(map[string]any{}, "x", true); err == nil {
|
||||
t.Error("a missing required value was accepted")
|
||||
}
|
||||
if n, _ := whole(map[string]any{"n": 10000.0}, "n", 5, 1, 100); n != 100 {
|
||||
t.Errorf("not bounded: %d", n)
|
||||
}
|
||||
if _, err := whole(map[string]any{"n": 0.0}, "n", 5, 1, 100); err == nil {
|
||||
t.Error("below the least was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// avahi's tools bundle (novox/hq to-be 42 Phase 1, research 026/05): a process the node's runtime
|
||||
// launches and speaks MCP over stdio to, through the Go SDK (ADR 0188, ADR 0193). It reads the
|
||||
// daemon, the name service's wiring and the packet filter's view of mDNS, browses the local network
|
||||
// for services, resolves a name, and lists what the machine publishes. It changes nothing.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// binaryName is what the build names this bundle's executable: the manifest's `binary`.
|
||||
const binaryName = "avahi-tools"
|
||||
|
||||
func bg() context.Context { return context.Background() }
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): avahi.
|
||||
if err := stdio.Serve("", tools(ThisMachine())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func tools(m *Machine) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "avahi_status",
|
||||
Description: "The daemon's state and version, its configuration as set, the name service switch's hosts line and whether mdns is on it, " +
|
||||
"whether nss-mdns is installed, whether the packet filter accepts inbound mDNS (UDP 5353), whether systemd-resolved runs beside it, " +
|
||||
"and notes naming what keeps discovery from working.",
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.GetStatus() },
|
||||
},
|
||||
{
|
||||
Name: "avahi_browse",
|
||||
Description: "Listen on the local network for a few seconds (avahi-browse -prt) and answer every service announced, with interface, " +
|
||||
"protocol, name, type, host, address, port and TXT where it resolved; narrowed to one service type when given. Hearing nothing says why it may be.",
|
||||
Input: schema(map[string]any{
|
||||
"seconds": map[string]any{"type": "integer", "description": "how long to listen (default 5, at most 15)"},
|
||||
"type": map[string]any{"type": "string", "description": "one service type, e.g. _ssh._tcp (optional)"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "seconds", 5, 1, 15)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kind, err := text(args, "type", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Browse(n, kind)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "avahi_resolve",
|
||||
Description: "Resolve a .local name to its addresses through avahi, and through the name service (getent) beside it, or an address to its name. " +
|
||||
"An answer from avahi that the name service lacks shows nsswitch unwired; no answer says why it may be.",
|
||||
Input: schema(map[string]any{
|
||||
"name": map[string]any{"type": "string", "description": "a host name; .local is added when missing"},
|
||||
"address": map[string]any{"type": "string", "description": "an address to name instead"},
|
||||
}),
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := text(args, "name", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
address, err := text(args, "address", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.Resolve(name, address)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "avahi_services",
|
||||
Description: "What this machine publishes from /etc/avahi/services: each file with the service's name, types and ports.",
|
||||
Input: schema(map[string]any{}),
|
||||
Run: func(map[string]any) (any, error) { return m.Services() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
// The module's shape (novox/hq to-be 42 Phase 1, research 027): the package and the daemon, and
|
||||
// nothing written into the name service switch or opened in the packet filter — avahi.go says why
|
||||
// neither can be declared safely today, and the tools report both instead.
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestItDeclaresThePackageAndTheDaemonOnly(t *testing.T) {
|
||||
m := manifest(t)
|
||||
if p := m.resource(t, "package"); p["package"] != "avahi" {
|
||||
t.Fatalf("%v", p)
|
||||
}
|
||||
d := m.resource(t, "daemon")
|
||||
if d["unit"] != Daemon || d["state"] != "running" || d["boot"] != "enabled" {
|
||||
t.Fatalf("%v", d)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["path"] == NSSwitch || r["package"] == "nss-mdns" {
|
||||
t.Fatalf("%v: the name service switch is left as found", r["id"])
|
||||
}
|
||||
}
|
||||
if len(m.Resources) != 2 {
|
||||
t.Fatalf("%v", m.Resources)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type resource map[string]any
|
||||
|
||||
type manifestShape struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []map[string]any `json:"claims"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []resource `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func manifest(t *testing.T) manifestShape {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifestShape
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifestShape) resource(t *testing.T, id string) resource {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestToolsAreTheManifests holds the served tools and the manifest's list to one another, and the
|
||||
// bundle to the shape the builder compiles and the runtime loads.
|
||||
func TestToolsAreTheManifests(t *testing.T) {
|
||||
m := manifest(t)
|
||||
names := map[string]bool{}
|
||||
for _, tool := range tools(machine(nil, 1000)) {
|
||||
if names[tool.Name] {
|
||||
t.Errorf("%s is served twice", tool.Name)
|
||||
}
|
||||
names[tool.Name] = true
|
||||
}
|
||||
for _, want := range m.Tools {
|
||||
if !names[want] {
|
||||
t.Errorf("the manifest lists %s and the bundle does not serve it", want)
|
||||
}
|
||||
delete(names, want)
|
||||
}
|
||||
if len(names) != 0 {
|
||||
t.Errorf("served and not listed: %v", names)
|
||||
}
|
||||
var tools map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["name"] == "tools" {
|
||||
tools = a
|
||||
}
|
||||
}
|
||||
if tools == nil || tools["kind"] != "bundle" || tools["language"] != "go" || tools["system"] != "arch" ||
|
||||
tools["from"] != "cmd/"+binaryName || tools["binary"] != binaryName {
|
||||
t.Fatalf("the tools artifact: %v", tools)
|
||||
}
|
||||
if loads, _ := tools["loads"].([]any); len(loads) != 1 || loads[0] != binaryName {
|
||||
t.Fatalf("loads: %v", tools["loads"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module avahi
|
||||
|
||||
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,43 @@
|
||||
{
|
||||
"module": "avahi",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager"
|
||||
],
|
||||
"tools": [
|
||||
"avahi_status",
|
||||
"avahi_browse",
|
||||
"avahi_resolve",
|
||||
"avahi_services"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "avahi"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "service",
|
||||
"unit": "avahi-daemon.service",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/avahi-tools",
|
||||
"binary": "avahi-tools",
|
||||
"loads": [
|
||||
"avahi-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,12 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
||||
accounts = append(accounts, a)
|
||||
}
|
||||
sort.Strings(accounts)
|
||||
// A node reporting an account the manager already holds, and bound to nothing, is bound to it
|
||||
// (ADR 0206 §7) — whenever its report arrives, not only when the licence is adopted: a node whose own
|
||||
// login is older than the one adopted is never a candidate, and would otherwise never be bound.
|
||||
if err := m.bindReporters(ctx, reports); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var adopted []string
|
||||
for _, account := range accounts {
|
||||
list := byAccount[account]
|
||||
@@ -225,6 +231,38 @@ func (m *Manager) Consider(ctx context.Context, reports []Holdings) ([]string, e
|
||||
return adopted, nil
|
||||
}
|
||||
|
||||
// bindReporters binds each reporting node that is bound to nothing to the licence its account already has.
|
||||
func (m *Manager) bindReporters(ctx context.Context, reports []Holdings) error {
|
||||
for _, rep := range reports {
|
||||
if rep.Identity == nil || rep.Identity.AccountUUID == "" {
|
||||
continue
|
||||
}
|
||||
if b, err := m.Store.Binding(ctx, rep.Node); err != nil || b != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
l, err := m.Store.LicenceForAccount(ctx, rep.Identity.AccountUUID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if l == nil {
|
||||
continue
|
||||
}
|
||||
b, err := m.Store.Bind(ctx, rep.Node, l.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.Store.Audit(ctx, "bound", map[string]any{"consumer": rep.Node, "licence": l.Name, "by": "its account's report"})
|
||||
if err := m.PutBinding(ctx, rep.Node, BindingState{Licence: l.Name, Kind: l.Kind, Generation: b.Generation}); err != nil {
|
||||
return err
|
||||
}
|
||||
m.Log("bound %s to %s, the licence its account already has", rep.Node, l.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) adoptOne(ctx context.Context, c Candidate, reports []Holdings) (string, error) {
|
||||
answer, err := m.AskGrant(ctx, c.Node, m.Keys.PublicKey)
|
||||
if err != nil {
|
||||
|
||||
@@ -336,3 +336,24 @@ func TestAReportIsReadAsTheAgentModuleWritesIt(t *testing.T) {
|
||||
t.Fatalf("the report's time does not parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A node whose report arrives after its account was adopted — with an older login, so never a candidate —
|
||||
// is still bound to that account's licence, once.
|
||||
func TestANodeReportingAnAdoptedAccountLaterIsBoundToIt(t *testing.T) {
|
||||
mm := newMesh(t)
|
||||
ctx := context.Background()
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{mm.login("novox", "rt-new", true, t0)})
|
||||
late := mm.login("laptop", "rt-older", true, t0.Add(-24*time.Hour))
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Licence != licence1 {
|
||||
t.Fatalf("a node reporting the adopted account later was not bound: %v", mm.state)
|
||||
}
|
||||
if strings.Contains(strings.Join(mm.vendor.exchanged, ","), "rt-older") {
|
||||
t.Fatal("the older login was exchanged")
|
||||
}
|
||||
g := mm.state["laptop"].Generation
|
||||
_, _ = mm.m.Consider(ctx, []Holdings{late})
|
||||
if mm.state["laptop"].Generation != g {
|
||||
t.Fatal("a node already bound was bound again")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# clipmenu
|
||||
|
||||
The clipboard manager as a module (novox/hq ADR 0208, research 026/04).
|
||||
|
||||
- Installs `clipmenu` from the official repositories. It brings `clipnotify`, `xsel`, `xdotool` and
|
||||
`dmenu` as its own dependencies.
|
||||
- Claims the mesh's `node-clipboard` seat and serves its verbs `history` and `copy`. Requires
|
||||
`x11-display` on its own machine.
|
||||
- **Declares `rofi-greenclip` absent** (ADR 0180). This module replaces it.
|
||||
- Starts `clipmenud` **once per session**, from the session's start (the `xinitrc` slot `normal`).
|
||||
It is not a user unit as well. Its packaged unit needs user-scoped units (mesh-host #72, not
|
||||
merged), and the session start alone is one starter.
|
||||
- Binds `$mod+period` to `clipmenu`, as its own i3 drop-in (`50-clipmenu.conf`). clipmenu shows the
|
||||
history through `dmenu`, the seat command of `node-launcher`, so it looks like every other menu.
|
||||
- Its settings are environment contributions (ADR 0203), read by the daemon, the menu and the tools
|
||||
alike:
|
||||
- `CM_SELECTIONS=clipboard`: what was copied, not every highlighted word. The found greenclip did
|
||||
the same.
|
||||
- `CM_MAX_CLIPS=500`: how many clips are kept.
|
||||
- `CM_HISTLENGTH=15`: how many lines the menu shows.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `node-clipboard.history` | the history, newest first, each entry once with its id, first line, time, size and text (cut) |
|
||||
| `node-clipboard.copy` | put text on the clipboard; it enters the history like any copy |
|
||||
| `clipmenu_paste` | what the clipboard holds now |
|
||||
| `clipmenu_clear` | forget the history; the daemon's locks stay |
|
||||
| `clipmenu_delete` | forget one entry, by id or first line |
|
||||
|
||||
The history is read from clipmenu's own store, in the account's runtime directory, under clipmenu's
|
||||
own lock, so a copy arriving meanwhile is neither lost nor half-written. `copy` hands the text to an
|
||||
owner (`xsel`) under the account's service manager. As a child of the tools runtime, the clipboard
|
||||
would empty whenever the runtime restarted.
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
- **No AUR package.** greenclip came from the user repository. clipmenu is in the official one.
|
||||
- **Started once.** greenclip was started by the window manager on both workstations, and on the
|
||||
desktop by an enabled user unit as well.
|
||||
- **No absolute home path** in any configuration. greenclip's named one.
|
||||
- **The history does not outlive a reboot.** greenclip kept it in `~/.cache`, so every password ever
|
||||
copied stayed on disk. clipmenu keeps it in the runtime directory, which is memory.
|
||||
- **One menu.** The history appears in the launcher's own menu, through the seat's `dmenu` command,
|
||||
instead of a theme from a cloned theme repository.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- `~/.config/greenclip.toml` and greenclip's history, `~/.cache/greenclip.history`.
|
||||
- On the desktop: greenclip's enabled user unit link
|
||||
(`~/.config/systemd/user/default.target.wants/greenclip.service`). It dangles once the package is
|
||||
gone.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
1. After the first push, delete `~/.config/greenclip.toml` and `~/.cache/greenclip.history`.
|
||||
2. On the desktop: `systemctl --user disable greenclip.service`, before the push if you can. The
|
||||
package's removal takes the unit file with it.
|
||||
3. Until the `i3` module carries the main configuration, the found `exec --no-startup-id greenclip
|
||||
daemon` and `$mod+period` lines stay in `~/.config/i3/config`. i3 reports `$mod+period` as bound
|
||||
twice. The `i3` module's configuration carries neither.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `node-clipboard`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows
|
||||
them, `mctl` reads them as unknown.
|
||||
- `CM_*` reach the session through `node-env` (ADR 0203), so the account's environment module must
|
||||
be assigned too. Without it clipmenu runs on its defaults: both selections, 1000 clips, 8 lines.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || 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 is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
mostCopy = 1 << 20
|
||||
mostPaste = 64 << 10
|
||||
// majorVersion is clipmenu's store layout: <dir>/clipmenu.<major>.<user>/.
|
||||
majorVersion = 6
|
||||
)
|
||||
|
||||
// account is the user clipmenu's store is named for.
|
||||
func account() string {
|
||||
for _, k := range []string{"USER", "MESH_OPERATOR_ACCOUNT", "LOGNAME"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
if u, err := user.Current(); err == nil {
|
||||
return u.Username
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// storeDir is where clipmenud keeps the history: CM_DIR, else the account's runtime directory.
|
||||
func storeDir(s Session) (string, error) {
|
||||
base := os.Getenv("CM_DIR")
|
||||
if base == "" {
|
||||
base = s.RuntimeDir
|
||||
}
|
||||
if base == "" {
|
||||
return "", fmt.Errorf("%w: the account's runtime directory, where the clipboard history lives, is missing (the account is not logged in)", ErrNoBus)
|
||||
}
|
||||
return filepath.Join(base, fmt.Sprintf("clipmenu.%d.%s", majorVersion, account())), nil
|
||||
}
|
||||
|
||||
// cksum is POSIX cksum(1) of data: clipmenu names each entry's file by the cksum of its first line
|
||||
// followed by a newline, as "<crc> <length>".
|
||||
func cksum(data []byte) string {
|
||||
var crc uint32
|
||||
step := func(b byte) {
|
||||
crc ^= uint32(b) << 24
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&0x80000000 != 0 {
|
||||
crc = crc<<1 ^ 0x04C11DB7
|
||||
} else {
|
||||
crc <<= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, b := range data {
|
||||
step(b)
|
||||
}
|
||||
for n := len(data); n != 0; n >>= 8 {
|
||||
step(byte(n))
|
||||
}
|
||||
return fmt.Sprintf("%d %d", ^crc, len(data))
|
||||
}
|
||||
|
||||
func entryID(line string) string { return cksum([]byte(line + "\n")) }
|
||||
|
||||
// Entry is one clip in the history.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Line string `json:"line"`
|
||||
At string `json:"at"`
|
||||
Bytes int `json:"bytes"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
at int64
|
||||
}
|
||||
|
||||
// HistoryResult is what node-clipboard.history answers.
|
||||
type HistoryResult struct {
|
||||
Collecting bool `json:"collecting"`
|
||||
Store string `json:"store"`
|
||||
Total int `json:"total"`
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// readStore reads clipmenu's line cache: one "<nanoseconds> <first line>" per copy, oldest first, a
|
||||
// line repeated when the same thing was copied again. The newest copy of each line wins.
|
||||
func readStore(dir string) ([]Entry, error) {
|
||||
f, err := os.Open(filepath.Join(dir, "line_cache"))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Entry{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
latest := map[string]int64{}
|
||||
scan := bufio.NewScanner(f)
|
||||
scan.Buffer(make([]byte, 64<<10), 1<<20)
|
||||
for scan.Scan() {
|
||||
stamp, line, ok := strings.Cut(scan.Text(), " ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ns, err := strconv.ParseInt(stamp, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if ns >= latest[line] {
|
||||
latest[line] = ns
|
||||
}
|
||||
}
|
||||
out := make([]Entry, 0, len(latest))
|
||||
for line, ns := range latest {
|
||||
out = append(out, Entry{ID: entryID(line), Line: line, at: ns, At: time.Unix(0, ns).Format(time.RFC3339)})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].at > out[j].at })
|
||||
return out, scan.Err()
|
||||
}
|
||||
|
||||
// History is the clipboard's history, newest first.
|
||||
func History(limit, maxBytes int) (HistoryResult, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
out := HistoryResult{Collecting: len(processesOf("clipmenud")) > 0, Store: dir, Total: len(entries), Entries: []Entry{}}
|
||||
for i, e := range entries {
|
||||
if i == limit {
|
||||
break
|
||||
}
|
||||
if info, err := os.Stat(filepath.Join(dir, e.ID)); err == nil {
|
||||
e.Bytes = int(info.Size())
|
||||
if maxBytes > 0 {
|
||||
raw, _ := os.ReadFile(filepath.Join(dir, e.ID))
|
||||
if len(raw) > maxBytes {
|
||||
raw, e.Truncated = raw[:maxBytes], true
|
||||
}
|
||||
e.Text = string(raw)
|
||||
}
|
||||
}
|
||||
out.Entries = append(out.Entries, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CopyResult is what node-clipboard.copy answers.
|
||||
type CopyResult struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Unit string `json:"unit"`
|
||||
}
|
||||
|
||||
// Copy puts text on the clipboard. The clipboard is owned by a process until another copies, so the
|
||||
// owner (xsel) runs under the account's service manager, not as a child of this tool.
|
||||
func Copy(text string) (CopyResult, error) {
|
||||
if len(text) == 0 {
|
||||
return CopyResult{}, errors.New("text is empty; to empty the clipboard's history, clipmenu_clear")
|
||||
}
|
||||
if len(text) > mostCopy {
|
||||
return CopyResult{}, fmt.Errorf("%d bytes; the clipboard takes at most %d here", len(text), mostCopy)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
if s.RuntimeDir == "" {
|
||||
return CopyResult{}, fmt.Errorf("%w: no runtime directory to hand the text over in", ErrNoBus)
|
||||
}
|
||||
// Handed over in a file only the account can read, which the owner reads and removes.
|
||||
f, err := os.CreateTemp(s.RuntimeDir, "clipmenu-copy-")
|
||||
if err != nil {
|
||||
return CopyResult{}, err
|
||||
}
|
||||
if _, err := f.WriteString(text); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
f.Close()
|
||||
unit := uniqueUnit("clipmenu-copy")
|
||||
script := `xsel --nodetach --input --clipboard < "$0" & sleep 1; rm -f "$0"; wait`
|
||||
if err := s.detach(unit, "/bin/sh", "-c", script, f.Name()); err != nil {
|
||||
os.Remove(f.Name())
|
||||
return CopyResult{}, err
|
||||
}
|
||||
return CopyResult{Bytes: len(text), Unit: unit + ".service"}, nil
|
||||
}
|
||||
|
||||
// PasteResult is what clipmenu_paste answers.
|
||||
type PasteResult struct {
|
||||
Text string `json:"text"`
|
||||
Bytes int `json:"bytes"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Paste reads the clipboard now.
|
||||
func Paste() (PasteResult, error) {
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
r, err := s.run(5*time.Second, "", "xsel", "--output", "--clipboard")
|
||||
if err != nil {
|
||||
return PasteResult{}, err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return PasteResult{}, fmt.Errorf("xsel: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
out := PasteResult{Text: r.Stdout, Bytes: len(r.Stdout), Truncated: r.Truncated}
|
||||
if len(out.Text) > mostPaste {
|
||||
out.Text, out.Truncated = out.Text[:mostPaste], true
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ChangeResult is what clear and delete answer.
|
||||
type ChangeResult struct {
|
||||
Removed int `json:"removed"`
|
||||
Remaining int `json:"remaining"`
|
||||
}
|
||||
|
||||
// withStoreLock holds clipmenu's own lock on its store, the one clipmenud and clipdel take, while
|
||||
// change runs, so a copy arriving meanwhile is neither lost nor half-written.
|
||||
func withStoreLock(dir string, change func() error) error {
|
||||
lock, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer lock.Close()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX|syscall.LOCK_NB)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("the clipboard store is locked by clipmenud and did not come free within 2s")
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN)
|
||||
return change()
|
||||
}
|
||||
|
||||
func storeOf() (string, error) {
|
||||
dir, err := storeDir(findEnvironment())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := os.Stat(dir); errors.Is(err, os.ErrNotExist) {
|
||||
return "", fmt.Errorf("there is no clipboard history at %s: clipmenud has not run in this login", dir)
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// Clear forgets every entry and its text, keeping the store and its locks (clipdel's own clear
|
||||
// removes the directory, the daemon's lock with it).
|
||||
func Clear() (ChangeResult, error) {
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
entries, err := readStore(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Removed = len(entries)
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
switch f.Name() {
|
||||
case "lock", "session_lock", "line_cache":
|
||||
continue
|
||||
}
|
||||
if f.Type().IsRegular() {
|
||||
if err := os.Remove(filepath.Join(dir, f.Name())); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.WriteFile(filepath.Join(dir, "line_cache"), nil, 0o600)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// Delete forgets one entry, by id or by its first line.
|
||||
func Delete(id, line string) (ChangeResult, error) {
|
||||
if (id == "") == (line == "") {
|
||||
return ChangeResult{}, errors.New("give the entry's id or its line, one of the two")
|
||||
}
|
||||
dir, err := storeOf()
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
var out ChangeResult
|
||||
err = withStoreLock(dir, func() error {
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
var kept []string
|
||||
for _, l := range strings.Split(strings.TrimRight(string(raw), "\n"), "\n") {
|
||||
if l == "" {
|
||||
continue
|
||||
}
|
||||
_, text, _ := strings.Cut(l, " ")
|
||||
if text == line || (id != "" && entryID(text) == id) {
|
||||
out.Removed++
|
||||
_ = os.Remove(filepath.Join(dir, entryID(text)))
|
||||
continue
|
||||
}
|
||||
kept = append(kept, l)
|
||||
}
|
||||
if out.Removed == 0 {
|
||||
return fmt.Errorf("no entry %s%s in the clipboard history", id, line)
|
||||
}
|
||||
content := strings.Join(kept, "\n")
|
||||
if content != "" {
|
||||
content += "\n"
|
||||
}
|
||||
tmp := filepath.Join(dir, ".line_cache.mesh")
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, filepath.Join(dir, "line_cache"))
|
||||
})
|
||||
if err != nil {
|
||||
return ChangeResult{}, err
|
||||
}
|
||||
entries, _ := readStore(dir)
|
||||
out.Remaining = len(entries)
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const nobody = 4194400
|
||||
|
||||
func TestEntryIdsAreWhatClipmenuNamesItsFiles(t *testing.T) {
|
||||
for _, line := range []string{"hello", "", "two words (3 lines)", "ünïcode ✓", strings.Repeat("x", 300)} {
|
||||
out, err := exec.Command("bash", "-c", `cksum <<< "$1"`, "_", line).Output()
|
||||
if err != nil {
|
||||
t.Skip("bash or cksum is missing here")
|
||||
}
|
||||
if got, want := entryID(line), strings.TrimSpace(string(out)); got != want {
|
||||
t.Errorf("%q: %s, cksum says %s", line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store makes clipmenu's store as clipmenud leaves it, for the account the tools run as.
|
||||
func store(t *testing.T, clips map[string]string, order ...string) string {
|
||||
t.Helper()
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
t.Setenv("USER", "op")
|
||||
t.Setenv("CM_DIR", "")
|
||||
dir := filepath.Join(runtime, "clipmenu.6.op")
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cache strings.Builder
|
||||
for i, line := range order {
|
||||
cache.WriteString(strconv.FormatInt(1_700_000_000_000_000_000+int64(i)*1_000_000_000, 10) + " " + line + "\n")
|
||||
if err := os.WriteFile(filepath.Join(dir, entryID(line)), []byte(clips[line]), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "line_cache"), []byte(cache.String()), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestTheHistoryIsNewestFirstOnceEachWithItsText(t *testing.T) {
|
||||
store(t, map[string]string{"first": "first", "second (2 lines)": "second\nline two"}, "first", "second (2 lines)", "first")
|
||||
fakeProcess(t, nobody, "clipmenud")
|
||||
h, err := History(10, 4096)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !h.Collecting || h.Total != 2 || h.Entries[0].Line != "first" || h.Entries[1].Text != "second\nline two" || h.Entries[1].Bytes != 15 {
|
||||
t.Fatalf("%+v", h)
|
||||
}
|
||||
if h, _ := History(1, 3); len(h.Entries) != 1 || h.Entries[0].Text != "fir" || !h.Entries[0].Truncated {
|
||||
t.Fatalf("limited and cut: %+v", h)
|
||||
}
|
||||
if h, _ := History(10, 0); h.Entries[0].Text != "" || h.Entries[0].Bytes != 5 {
|
||||
t.Fatalf("without text: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryIsDeletedByIdOrLineWithItsText(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b", "c": "c"}, "a", "b", "c", "a")
|
||||
r, err := Delete(entryID("a"), "")
|
||||
if err != nil || r.Removed != 2 || r.Remaining != 2 {
|
||||
t.Fatalf("by id, both copies: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, entryID("a"))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatal("the text stayed")
|
||||
}
|
||||
if r, err := Delete("", "b"); err != nil || r.Removed != 1 || r.Remaining != 1 {
|
||||
t.Fatalf("by line: %+v, %v", r, err)
|
||||
}
|
||||
if _, err := Delete("", "zzz"); err == nil {
|
||||
t.Fatal("a missing entry was reported deleted")
|
||||
}
|
||||
if _, err := Delete("x", "y"); err == nil {
|
||||
t.Fatal("both an id and a line were accepted")
|
||||
}
|
||||
cache, _ := os.ReadFile(filepath.Join(dir, "line_cache"))
|
||||
if !strings.HasSuffix(string(cache), " c\n") || strings.Count(string(cache), "\n") != 1 {
|
||||
t.Fatalf("line cache: %q", cache)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClearForgetsEverythingButKeepsTheDaemonsLocks(t *testing.T) {
|
||||
dir := store(t, map[string]string{"a": "a", "b": "b"}, "a", "b")
|
||||
if err := os.WriteFile(filepath.Join(dir, "session_lock"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Clear()
|
||||
if err != nil || r.Removed != 2 {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
left, _ := os.ReadDir(dir)
|
||||
var names []string
|
||||
for _, f := range left {
|
||||
names = append(names, f.Name())
|
||||
}
|
||||
if strings.Join(names, ",") != "line_cache,lock,session_lock" {
|
||||
t.Fatalf("left: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyHandsTheTextToAnOwnerUnderTheAccountsServiceManager(t *testing.T) {
|
||||
store(t, nil)
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
bin := fakeBinaries(t, map[string]string{"systemctl": "true", "systemd-run": `echo "$*" > "$LOG"; for last; do :; done; cat "$last" > "$LOG.text"`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
r, err := Copy("secret-free text")
|
||||
if err != nil || r.Bytes != 16 || !strings.HasPrefix(r.Unit, "clipmenu-copy-") {
|
||||
t.Fatalf("%+v, %v", r, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
if !strings.Contains(string(asked), "--setenv=DISPLAY=:1 -- /bin/sh -c xsel --nodetach --input --clipboard") {
|
||||
t.Fatalf("asked: %s", asked)
|
||||
}
|
||||
handed, _ := os.ReadFile(filepath.Join(bin, "log.text"))
|
||||
if string(handed) != "secret-free text" {
|
||||
t.Fatalf("handed over: %q", handed)
|
||||
}
|
||||
if _, err := Copy(""); err == nil {
|
||||
t.Fatal("empty text was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasteReadsTheClipboardOrSaysThereIsNoSession(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Paste(); !errors.Is(err, ErrNoSession) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
|
||||
fakeBinaries(t, map[string]string{"xsel": `[ "$*" = "--output --clipboard" ] && printf 'on the clipboard'`})
|
||||
p, err := Paste()
|
||||
if err != nil || p.Text != "on the clipboard" || p.Bytes != 16 {
|
||||
t.Fatalf("%+v, %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutAStoreTheChangesSayWhy(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("USER", "op")
|
||||
if _, err := Clear(); err == nil || !strings.Contains(err.Error(), "clipmenud has not run") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h, err := History(5, 0); err != nil || h.Total != 0 || h.Collecting {
|
||||
t.Fatalf("an empty history: %+v, %v", h, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// clipmenu's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
|
||||
// node-clipboard's verbs `history` and `copy`, and its own tools, served by the node's runtime as the
|
||||
// operator account. The history is read from clipmenu's own store in the account's runtime directory;
|
||||
// the clipboard itself is the X session's.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
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: "node-clipboard.history",
|
||||
Description: "What the operator copied, newest first: each entry's id, its first line, when, its size " +
|
||||
"and its text (each cut at max_bytes). Whether the clipboard daemon is collecting.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many entries (default 20, at most 500)"},
|
||||
"max_bytes": map[string]any{"type": "integer", "description": "cut each entry's text at this many bytes; 0 leaves the text out (default 4096, at most 65536)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := whole(args, "limit", 20, 1, 500)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
most, err := whole(args, "max_bytes", 4096, 0, 65536)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return History(limit, most)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "node-clipboard.copy",
|
||||
Description: "Put text on the operator's clipboard, as if they had copied it; it enters the history " +
|
||||
"like any copy. Answers how many bytes.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": fmt.Sprintf("the text (at most %d bytes)", mostCopy)},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
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")
|
||||
}
|
||||
return Copy(t)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_paste",
|
||||
Description: "What the operator's clipboard holds right now, as text (cut at 64 KiB, said in truncated).",
|
||||
Run: func(map[string]any) (any, error) { return Paste() },
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_clear",
|
||||
Description: "Forget the whole clipboard history. What is on the clipboard now stays there.",
|
||||
Run: func(map[string]any) (any, error) { return Clear() },
|
||||
},
|
||||
{
|
||||
Name: "clipmenu_delete",
|
||||
Description: "Forget one entry of the clipboard history, by its id as the history answers it, or by " +
|
||||
"its first line exactly.",
|
||||
Input: map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the entry's id"},
|
||||
"line": map[string]any{"type": "string", "description": "the entry's first line, exactly"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := text(args, "id", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, _ := args["line"].(string)
|
||||
return Delete(id, line)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// clipmenu's shape (novox/hq ADR 0208, research 026/04): it claims node-clipboard serving history
|
||||
// and copy, requires the X display on its own machine, replaces greenclip, starts its daemon once
|
||||
// from the session's start, and binds its menu as an i3 drop-in through the launcher's dmenu command.
|
||||
|
||||
func TestItClaimsTheClipboardSeatServingHistoryAndCopy(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "clipmenu" || m.Seats != nil {
|
||||
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-clipboard", Scope: "node", Serves: []string{"history", "copy"}}}) {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("requires: %v", m.Requires)
|
||||
}
|
||||
present, absent := m.packages()
|
||||
if !reflect.DeepEqual(present, []string{"clipmenu"}) || !reflect.DeepEqual(absent, []string{"rofi-greenclip"}) {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDaemonStartsOnceFromTheSessionsStart(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
code := m.Shell[0].Code
|
||||
if strings.Count(code, "\nclipmenud &\n") != 1 || strings.Contains(code, "greenclip") {
|
||||
t.Fatalf("%q", code)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" || r["type"] == "process" {
|
||||
t.Fatalf("a second start: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItsSettingsAreEnvironmentAndItsMenuIsTheLaunchersDmenu(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if !reflect.DeepEqual(m.Environment.Variables, map[string]string{"CM_SELECTIONS": "clipboard", "CM_MAX_CLIPS": "500", "CM_HISTLENGTH": "15"}) {
|
||||
t.Fatalf("%v", m.Environment.Variables)
|
||||
}
|
||||
if _, set := m.Environment.Variables["CM_LAUNCHER"]; set {
|
||||
t.Fatal("the launcher is clipmenu's default, dmenu: the seat's command")
|
||||
}
|
||||
m.sameAsSource(t, "i3-bindings", "files/i3/50-clipmenu.conf")
|
||||
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+period exec --no-startup-id clipmenu") {
|
||||
t.Fatalf("%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at
|
||||
# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which
|
||||
# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the
|
||||
# clipboard.
|
||||
bindsym $mod+period exec --no-startup-id clipmenu -p Clipboard
|
||||
@@ -0,0 +1,5 @@
|
||||
module clipmenu
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"module": "clipmenu",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-clipboard",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"history",
|
||||
"copy"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"clipmenu_paste",
|
||||
"clipmenu_clear",
|
||||
"clipmenu_delete"
|
||||
],
|
||||
"environment": {
|
||||
"variables": {
|
||||
"CM_SELECTIONS": "clipboard",
|
||||
"CM_MAX_CLIPS": "500",
|
||||
"CM_HISTLENGTH": "15"
|
||||
}
|
||||
},
|
||||
"shell": [
|
||||
{
|
||||
"for": "xinitrc",
|
||||
"slot": "normal",
|
||||
"code": "# The clipboard's history (module clipmenu, novox/hq ADR 0208): clipmenud collects every copy from\n# here on, once per session. It keeps the history in the account's runtime directory, so a reboot\n# forgets it, and with it every password that was ever copied.\nclipmenud &\n"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "clipmenu"
|
||||
},
|
||||
{
|
||||
"id": "greenclip",
|
||||
"type": "package",
|
||||
"package": "rofi-greenclip",
|
||||
"absent": true
|
||||
},
|
||||
{
|
||||
"id": "i3-bindings",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/i3/config.d/50-clipmenu.conf",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/clipmenu-tools",
|
||||
"binary": "clipmenu-tools",
|
||||
"loads": [
|
||||
"clipmenu-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,166 @@
|
||||
# docker
|
||||
|
||||
The container runtime as a module (novox/hq to-be 42 phase 1, item 8; research 027/01–02; ADR 0166,
|
||||
ADR 0207). It claims the node seat `node-container-runtime`. That seat carries no verbs yet: its verbs,
|
||||
and the host creating containers through its holder, wait on ADR 0166's acceptance. Until then the
|
||||
tools below are the module's own.
|
||||
|
||||
## What it declares
|
||||
|
||||
| resource | what | the host's rule |
|
||||
|---|---|---|
|
||||
| `package` | `docker` | installed if absent; never uninstalled when the module goes |
|
||||
| `buildx` | `docker-buildx` | the same. Only the build machine has it today; `docker build` needs it for BuildKit everywhere |
|
||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
||||
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
||||
|
||||
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
||||
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
||||
holds. It runs at idle priority, at a random point in the hour after the weekly mark. A run missed
|
||||
while the machine was off happens at the next boot.
|
||||
|
||||
**Capabilities:** `package-manager`, `service-manager`, `privileged`. It does not declare
|
||||
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
||||
the module that installs the daemon cannot require it.
|
||||
|
||||
## What it does not declare yet, and why
|
||||
|
||||
Three things this module should own are already declared by other modules on every machine. The
|
||||
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
|
||||
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
|
||||
make the module unassignable everywhere. The refusals were checked against the controller's own
|
||||
check:
|
||||
|
||||
```
|
||||
zsh and docker both declare the name "${machine:account}"
|
||||
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
|
||||
dnsmasq and docker both declare the unit "docker.service"
|
||||
```
|
||||
|
||||
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
|
||||
|
||||
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
|
||||
service:
|
||||
|
||||
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
|
||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
||||
`insecure-registries`. The collision check does not see generated resources.
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
|
||||
|
||||
**The change proposed, in one merge:**
|
||||
|
||||
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
|
||||
2. `docker` adds the two resources below:
|
||||
|
||||
```json
|
||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
|
||||
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
|
||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
|
||||
```
|
||||
|
||||
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
|
||||
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
|
||||
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
|
||||
start keeps every container running.
|
||||
|
||||
**Why one merge, and only after this module is on every machine:**
|
||||
|
||||
- In one apply, the host first gives back the resources that are no longer declared, then applies
|
||||
the new ones (mesh-host `apply.go`).
|
||||
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
|
||||
again in the same apply. The daemon is reloaded once, after both steps.
|
||||
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
|
||||
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
|
||||
every container.
|
||||
|
||||
**Later:** the controller hands the registry to this module as a value, and the overlay stops
|
||||
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
|
||||
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
|
||||
per resource.
|
||||
|
||||
### 2. The operator account's membership of the `docker` group
|
||||
|
||||
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
||||
`usermod --append` and never takes a group away.
|
||||
|
||||
```json
|
||||
{"id": "group", "type": "user", "name": "${machine:account}", "groups": ["docker"]}
|
||||
```
|
||||
|
||||
`zsh` already declares a `user` resource for the same account (its login shell). The controller
|
||||
compares `name` across modules, so the two collide.
|
||||
|
||||
**The change proposed (mesh-controller, `checkResources`):** judge a `user` resource by the fields it
|
||||
sets, not by its name:
|
||||
|
||||
- `shell` and `home` stay single-owner;
|
||||
- `groups` may be declared by any number of modules, because the host only adds them.
|
||||
|
||||
Then this module declares the resource above, and no module has to carry another's group.
|
||||
|
||||
Today the operator account is in the group on every machine, by hand. Nothing is lost while it waits.
|
||||
|
||||
## The bootstrap's runtime
|
||||
|
||||
On the machine the mesh was first installed on, the foundation bundle declared `package docker`
|
||||
(`container-runtime`) and `docker.service` running and enabled (`container-runtime-running`). ADR 0207
|
||||
§5 exempts them.
|
||||
|
||||
- The host records them under their bare ids, with origin *carried*. A mesh declaration's orphan pass
|
||||
never sees them (mesh-host `store.go`).
|
||||
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
||||
installed", and neither record ever uninstalls it.
|
||||
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
|
||||
1 would add a second record of that unit. Its found state is *running*, because genesis started
|
||||
it, so undeclaring this module would leave the daemon running.
|
||||
|
||||
## Tools
|
||||
|
||||
The tools run as the operator account. If the daemon's socket refuses that account, a call is asked
|
||||
again through `sudo -n` (a process keeps the groups it started with). Every call has a 20 s bound.
|
||||
A failure is an error naming how it failed, never an empty answer.
|
||||
|
||||
**Every container on the machine is in scope.** A container the mesh holds carries the host's label
|
||||
`mesh-host.id` (its value names the assignment), and every answer says `mesh_held`.
|
||||
|
||||
| tool | | what |
|
||||
|---|---|---|
|
||||
| `docker_list` | r | every container: image, state, health, restarts, ports, mounts, compose project, `mesh_held`; filter by owner, state or name |
|
||||
| `docker_inspect` | r | one container whole, **environment values left out** (names kept) |
|
||||
| `docker_logs` | r | the last lines of both streams, merged in order, with timestamps (default 200, at most 2000) |
|
||||
| `docker_stats` | r | CPU, memory, I/O and process count per running container, heaviest first |
|
||||
| `docker_start` / `docker_stop` / `docker_restart` | a | one container. On a mesh-held one, the answer says the host restores its declared state at its next apply |
|
||||
| `docker_top` | r | the processes inside one container |
|
||||
| `docker_images` | r | images, largest first, with the containers using each; `dangling`, `unused` or `used` |
|
||||
| `docker_prune` | a | dangling images and build cache, and stopped containers the mesh does not hold if `containers` is true. **A dry run unless `dry_run` is false. Never a volume** |
|
||||
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
|
||||
| `docker_networks` | r | networks, subnets, and the containers on each |
|
||||
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
|
||||
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise |
|
||||
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
|
||||
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
|
||||
| `docker_problems` | r | unhealthy, restarting, dead, killed for memory, failed, or restarted five times or more |
|
||||
| `docker_ports` | r | every published port, and the containers on the host's network |
|
||||
|
||||
## Tests
|
||||
|
||||
```
|
||||
go test ./...
|
||||
```
|
||||
|
||||
The tests run against a fake runner and cover:
|
||||
|
||||
- escalation through `sudo -n` on a refused socket, and never as root;
|
||||
- each failure named by its cause;
|
||||
- a name or id never read as an option;
|
||||
- mesh-held marking;
|
||||
- the environment left out of `inspect`;
|
||||
- the restore note on a mesh-held act;
|
||||
- prune being a dry run by default and never reaching a volume, a mesh container or `--volumes`;
|
||||
- the log merge;
|
||||
- size parsing;
|
||||
- what the daemon has not yet taken;
|
||||
- event filtering;
|
||||
- volume ownership;
|
||||
- that the tools served are exactly the manifest's `tools`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type call struct {
|
||||
name string
|
||||
args []string
|
||||
}
|
||||
|
||||
// fake answers each command by the first rule whose prefix matches "name arg arg…".
|
||||
type fake struct {
|
||||
rules []rule
|
||||
calls []call
|
||||
}
|
||||
|
||||
type rule struct {
|
||||
prefix string
|
||||
ran Ran
|
||||
}
|
||||
|
||||
func (f *fake) on(prefix string, r Ran) *fake { f.rules = append(f.rules, rule{prefix, r}); return f }
|
||||
|
||||
func (f *fake) run(_ context.Context, name string, args ...string) Ran {
|
||||
f.calls = append(f.calls, call{name, args})
|
||||
line := strings.Join(append([]string{name}, args...), " ")
|
||||
for _, r := range f.rules {
|
||||
if strings.HasPrefix(line, r.prefix) {
|
||||
return r.ran
|
||||
}
|
||||
}
|
||||
return Ran{Status: 1, Stderr: "unexpected: " + line}
|
||||
}
|
||||
|
||||
func (f *fake) ran(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(strings.Join(append([]string{c.name}, c.args...), " "), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func client(f *fake, uid int) *Client {
|
||||
return &Client{Run: f.run, UID: uid, ReadFile: func(string) ([]byte, error) { return nil, fs.ErrNotExist },
|
||||
Now: func() time.Time { return time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC) }}
|
||||
}
|
||||
|
||||
const held = `{"Id":"aaaaaaaaaaaaaaaa","Name":"/mesh-web","Created":"2026-10-01T00:00:00Z","Image":"sha256:img1",
|
||||
"Config":{"Image":"web:1","Labels":{"mesh-host.id":"hello-web.server","mesh-host.spec":"x"},"Env":["PASSWORD=hunter2","PATH=/bin"]},
|
||||
"State":{"Status":"running","Running":true,"StartedAt":"2026-10-01T00:00:01Z","FinishedAt":"0001-01-01T00:00:00Z","Health":{"Status":"healthy"}},
|
||||
"HostConfig":{"RestartPolicy":{"Name":"unless-stopped"},"NetworkMode":"bridge"},
|
||||
"NetworkSettings":{"Ports":{"80/tcp":[{"HostIp":"0.0.0.0","HostPort":"8080"}]}},
|
||||
"Mounts":[{"Type":"volume","Name":"webdata","Destination":"/data","RW":true}]}`
|
||||
|
||||
const stray = `{"Id":"bbbbbbbbbbbbbbbb","Name":"/dev-db","Created":"2026-09-01T00:00:00Z","Image":"sha256:img2",
|
||||
"Config":{"Image":"postgres:16","Labels":{"com.docker.compose.project":"dev","com.docker.compose.project.working_dir":"/home/op/dev"}},
|
||||
"State":{"Status":"exited","ExitCode":1,"FinishedAt":"2026-09-02T00:00:00Z"},
|
||||
"HostConfig":{"RestartPolicy":{"Name":"no"}},"NetworkSettings":{"Ports":{}},
|
||||
"Mounts":[{"Type":"volume","Name":"dbdata","Destination":"/var/lib/postgresql/data","RW":true}]}`
|
||||
|
||||
func machine() *fake {
|
||||
return (&fake{}).
|
||||
on("docker ps --all --quiet --no-trunc", Ran{Stdout: "aaaaaaaaaaaaaaaa\nbbbbbbbbbbbbbbbb\n"}).
|
||||
on("docker container inspect aaaaaaaaaaaaaaaa bbbbbbbbbbbbbbbb", Ran{Stdout: "[" + held + "," + stray + "]"}).
|
||||
on("docker container inspect mesh-web", Ran{Stdout: "[" + held + "]"}).
|
||||
on("docker container inspect dev-db", Ran{Stdout: "[" + stray + "]"})
|
||||
}
|
||||
|
||||
func TestARefusedSocketIsAskedAgainThroughSudoWithoutAPromptUnlessThisIsRoot(t *testing.T) {
|
||||
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Get ...: dial unix /var/run/docker.sock: connect: permission denied\n"}
|
||||
f := (&fake{}).on("docker ", denied).on("sudo -n docker info", Ran{Stdout: "{}"})
|
||||
if _, err := client(f, 1000).docker(context.Background(), "info", "--format", "{{json .}}"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.ran("sudo -n docker info --format") {
|
||||
t.Fatalf("not escalated: %+v", f.calls)
|
||||
}
|
||||
f = (&fake{}).on("docker ", denied)
|
||||
if _, err := client(f, 0).docker(context.Background(), "info"); err == nil || f.ran("sudo") {
|
||||
t.Fatalf("root escalated or answered: %v %+v", err, f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailuresAreNamedByHowTheyFailed(t *testing.T) {
|
||||
denied := Ran{Status: 1, Stderr: "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock\n"}
|
||||
cases := map[string]*fake{
|
||||
"may not escalate without a prompt": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 1, Stderr: "sudo: a password is required\n"}),
|
||||
"sudo is not installed": (&fake{}).on("docker ", denied).on("sudo ", Ran{Status: 127, Err: "ENOENT"}),
|
||||
"docker is not installed": (&fake{}).on("docker ", Ran{Status: 127, Err: "ENOENT"}),
|
||||
"daemon is not answering": (&fake{}).on("docker ", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?\n"}),
|
||||
"did not answer: no answer within": (&fake{}).on("docker ", Ran{Status: 124, Err: "no answer within 20 s"}),
|
||||
"docker info failed (3): boom": (&fake{}).on("docker ", Ran{Status: 3, Stderr: "boom\n"}),
|
||||
}
|
||||
for want, f := range cases {
|
||||
_, err := client(f, 1000).docker(context.Background(), "info")
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("want %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameIsNeverAnOption(t *testing.T) {
|
||||
for _, bad := range []string{"--help", "-v", "", "a b", "x;y"} {
|
||||
if _, err := Ref(bad); err == nil {
|
||||
t.Errorf("%q accepted", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"mesh-web", "aaaaaaaaaaaa", "registry.mesh.internal:5100/x@sha256:abc", "dev_db.1"} {
|
||||
if _, err := Ref(good); err != nil {
|
||||
t.Errorf("%q refused: %v", good, err)
|
||||
}
|
||||
}
|
||||
f := machine()
|
||||
for _, verb := range []string{"start", "stop", "restart"} {
|
||||
if _, err := client(f, 1000).Act(context.Background(), verb, "--rm"); err == nil {
|
||||
t.Errorf("%s took an option", verb)
|
||||
}
|
||||
}
|
||||
if len(f.calls) != 0 {
|
||||
t.Fatalf("docker was called: %+v", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryContainerIsListedAndTheMeshsAreMarked(t *testing.T) {
|
||||
c := client(machine(), 1000)
|
||||
all, err := c.Containers(context.Background(), "", "", "")
|
||||
if err != nil || len(all) != 2 {
|
||||
t.Fatalf("%v %+v", err, all)
|
||||
}
|
||||
web, db := all[1], all[0]
|
||||
if !web.MeshHeld || web.HeldBy != "hello-web.server" || web.Module != "hello-web" || web.Health != "healthy" {
|
||||
t.Errorf("held: %+v", web)
|
||||
}
|
||||
if !reflect.DeepEqual(web.Ports, []string{"0.0.0.0:8080->80/tcp"}) || web.Mounts[0].Name != "webdata" {
|
||||
t.Errorf("ports/mounts: %+v", web)
|
||||
}
|
||||
if db.MeshHeld || db.Compose != "dev" || db.ComposeDir != "/home/op/dev" || db.FinishedAt == "" {
|
||||
t.Errorf("stray: %+v", db)
|
||||
}
|
||||
mesh, _ := c.Containers(context.Background(), "mesh", "", "")
|
||||
other, _ := c.Containers(context.Background(), "other", "", "")
|
||||
if len(mesh) != 1 || mesh[0].Name != "mesh-web" || len(other) != 1 || other[0].Name != "dev-db" {
|
||||
t.Errorf("held filter: %+v / %+v", mesh, other)
|
||||
}
|
||||
if _, err := c.Containers(context.Background(), "mine", "", ""); err == nil {
|
||||
t.Error("an unknown held filter was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContainersIsAnEmptyListAndAFailureIsAnError(t *testing.T) {
|
||||
got, err := client((&fake{}).on("docker ps", Ran{}), 1000).Containers(context.Background(), "", "", "")
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("%v %v", got, err)
|
||||
}
|
||||
if _, err := client((&fake{}).on("docker ps", Ran{Status: 1, Stderr: "Cannot connect to the Docker daemon\n"}), 1000).Containers(context.Background(), "", "", ""); err == nil {
|
||||
t.Fatal("a daemon that does not answer read as no containers")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectLeavesTheEnvironmentsValuesOut(t *testing.T) {
|
||||
got, err := client(machine(), 1000).Inspect(context.Background(), "mesh-web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(got)
|
||||
if strings.Contains(string(b), "hunter2") || !strings.Contains(string(b), `"PASSWORD"`) || got["mesh_held"] != true {
|
||||
t.Fatalf("%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActingOnAMeshContainerSaysTheHostRestoresIt(t *testing.T) {
|
||||
f := machine().on("docker stop", Ran{}).on("docker start", Ran{})
|
||||
got, err := client(f, 1000).Act(context.Background(), "stop", "mesh-web")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.ran("docker stop --time 10 mesh-web") || got["mesh_held"] != true || !strings.Contains(got["note"].(string), "host restores") {
|
||||
t.Fatalf("%v %+v", got, f.calls)
|
||||
}
|
||||
got, _ = client(f, 1000).Act(context.Background(), "start", "dev-db")
|
||||
if _, noted := got["note"]; noted || got["mesh_held"] != false {
|
||||
t.Fatalf("a stray was noted: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneIsADryRunByDefaultAndNeverTouchesAVolumeOrAMeshContainer(t *testing.T) {
|
||||
f := machine().
|
||||
on("docker image ls --no-trunc --filter dangling=true", Ran{Stdout: `{"ID":"sha256:dead","Size":"1.5GB"}` + "\n"}).
|
||||
on("docker system df --format", Ran{Stdout: `{"Type":"Build Cache","TotalCount":"3","Size":"2GB","Reclaimable":"1GB"}` + "\n"}).
|
||||
on("docker image prune", Ran{Stdout: "Deleted Images:\nx\n\nTotal reclaimed space: 1.5GB\n"}).
|
||||
on("docker builder prune", Ran{Stdout: "Total:\t1GB\n"}).
|
||||
on("docker container rm", Ran{})
|
||||
c := client(f, 1000)
|
||||
got, err := c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, DryRun: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.ran("docker image prune") || f.ran("docker builder prune") || f.ran("docker container rm") {
|
||||
t.Fatalf("a dry run removed something: %+v", f.calls)
|
||||
}
|
||||
if got["images"].(map[string]any)["dangling"] != 1 || !reflect.DeepEqual(got["containers"].(map[string]any)["stopped_not_held"], []string{"dev-db"}) {
|
||||
t.Fatalf("%v", got)
|
||||
}
|
||||
got, err = c.Prune(context.Background(), PruneAsk{Images: true, BuildCache: true, Containers: true, OlderThanH: 24})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !f.ran("docker image prune --force --filter until=24h") || !f.ran("docker builder prune --force --filter until=24h") || !f.ran("docker container rm dev-db") {
|
||||
t.Fatalf("not pruned: %+v", f.calls)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
line := strings.Join(c.args, " ")
|
||||
if strings.Contains(line, "volume") || strings.Contains(line, "mesh-web") && c.args[0] != "container" || strings.Contains(line, "--volumes") || strings.Contains(line, "--all") && c.args[0] != "ps" {
|
||||
t.Errorf("prune reached too far: %s", line)
|
||||
}
|
||||
}
|
||||
if got["images"].(map[string]any)["reclaimed"] != "1.5GB" || got["build_cache"].(map[string]any)["reclaimed"] != "1GB" {
|
||||
t.Errorf("reclaimed: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogsMergeBothStreamsInOrderAndKeepTheTail(t *testing.T) {
|
||||
f := (&fake{}).on("docker logs", Ran{Stdout: "2026-10-04T10:00:01Z out one\n2026-10-04T10:00:03Z out two\n", Stderr: "2026-10-04T10:00:02Z err one\n"})
|
||||
got, err := client(f, 1000).Logs(context.Background(), "web", 2, "30m")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got["lines"], []string{"2026-10-04T10:00:02Z err one", "2026-10-04T10:00:03Z out two"}) {
|
||||
t.Fatalf("%v", got["lines"])
|
||||
}
|
||||
if !f.ran("docker logs --timestamps --tail 2 --since 30m web") {
|
||||
t.Fatalf("%+v", f.calls)
|
||||
}
|
||||
if _, err := client(f, 1000).Logs(context.Background(), "web", 2, "--follow"); err == nil {
|
||||
t.Fatal("since took an option")
|
||||
}
|
||||
f = (&fake{}).on("docker logs", Ran{Status: 1, Stderr: "Error response from daemon: No such container: nope\n"})
|
||||
if _, err := client(f, 1000).Logs(context.Background(), "nope", 2, ""); err == nil {
|
||||
t.Fatal("a missing container read as no lines")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizesAreReadAsDockerPrintsThem(t *testing.T) {
|
||||
for in, want := range map[string]int64{"0B": 0, "55.63GB": 55630000000, "33.2MiB": 34812723, "1.5kB": 1500, "12MB (34%)": 12000000, "N/A": -1} {
|
||||
if got := Bytes(in); got != want {
|
||||
t.Errorf("%s: %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemonConfigSaysWhatTheDaemonHasNotTakenYet(t *testing.T) {
|
||||
f := (&fake{}).on("docker info", Ran{Stdout: `{"ServerVersion":"29.8.2","LiveRestoreEnabled":false,"LoggingDriver":"json-file","RegistryConfig":{"IndexConfigs":{"docker.io":{"Secure":true},"registry.mesh.internal:5100":{"Secure":false}}}}`})
|
||||
c := client(f, 1000)
|
||||
c.ReadFile = func(string) ([]byte, error) {
|
||||
return []byte(`{"live-restore": true, "dns": ["10.0.0.1"], "log-driver": "local"}`), nil
|
||||
}
|
||||
got, err := c.DaemonConfig(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pending := strings.Join(got["pending"].([]string), "\n")
|
||||
if !strings.Contains(pending, "live-restore is true in the file and false") || !strings.Contains(pending, "log-driver is local") {
|
||||
t.Errorf("pending: %s", pending)
|
||||
}
|
||||
if !reflect.DeepEqual(got["daemon"].(map[string]any)["InsecureRegistries"], []string{"registry.mesh.internal:5100"}) {
|
||||
t.Errorf("registries: %v", got["daemon"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["read_only_at_start"], []string{"dns", "log-driver"}) {
|
||||
t.Errorf("start-only: %v", got["read_only_at_start"])
|
||||
}
|
||||
c.ReadFile = func(string) ([]byte, error) { return nil, os.ErrNotExist }
|
||||
got, _ = c.DaemonConfig(context.Background())
|
||||
if !strings.HasPrefix(got["file_state"].(string), "absent") {
|
||||
t.Errorf("absent: %v", got["file_state"])
|
||||
}
|
||||
c.ReadFile = func(string) ([]byte, error) { return nil, errors.New("permission denied") }
|
||||
got, _ = c.DaemonConfig(context.Background())
|
||||
if !strings.HasPrefix(got["file_state"].(string), "unreadable") {
|
||||
t.Errorf("unreadable: %v", got["file_state"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventsAreABoundedWindowWithoutExecNoise(t *testing.T) {
|
||||
out := `{"Type":"container","Action":"exec_start: pg_isready","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"db"}},"timeNano":1}
|
||||
{"Type":"container","Action":"die","Actor":{"ID":"aaaaaaaaaaaaaaaa","Attributes":{"name":"web","mesh-host.id":"hello-web.server","exitCode":"137"}},"timeNano":2}
|
||||
`
|
||||
f := (&fake{}).on("docker events", Ran{Stdout: out})
|
||||
got, err := client(f, 1000).Events(context.Background(), 30, "container", 10, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evs := got["events"].([]map[string]any)
|
||||
if len(evs) != 1 || evs[0]["action"] != "die" || evs[0]["mesh_held"] != true || evs[0]["exit_code"] != "137" {
|
||||
t.Fatalf("%v", evs)
|
||||
}
|
||||
if !f.ran("docker events --since 30m --until 0s --format {{json .}} --filter type=container") {
|
||||
t.Fatalf("%+v", f.calls)
|
||||
}
|
||||
if _, err := client(f, 1000).Events(context.Background(), 30, "secret", 10, false); err == nil {
|
||||
t.Fatal("an unknown type was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVolumesSayWhoMountsThemAndWhetherTheMeshDoes(t *testing.T) {
|
||||
f := machine().
|
||||
on("docker volume ls --quiet", Ran{Stdout: "webdata\ndbdata\nloose\n"}).
|
||||
on("docker volume inspect", Ran{Stdout: `[{"Name":"webdata","Driver":"local"},{"Name":"dbdata","Driver":"local"},{"Name":"loose","Driver":"local","Labels":{"com.docker.volume.anonymous":""}}]`})
|
||||
got, err := client(f, 1000).Volumes(context.Background(), false, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vols := got["volumes"].([]map[string]any)
|
||||
if vols[0]["mesh_held"] != true || vols[1]["mesh_held"] != false || len(vols[2]["mounted_by"].([]map[string]any)) != 0 || vols[2]["anonymous"] != true {
|
||||
t.Fatalf("%v", vols)
|
||||
}
|
||||
got, _ = client(f, 1000).Volumes(context.Background(), true, false)
|
||||
if got["count"] != 1 {
|
||||
t.Fatalf("unmounted: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImagesNameTheirUsers(t *testing.T) {
|
||||
f := machine().on("docker image ls", Ran{Stdout: `{"ID":"sha256:img1","Repository":"web","Tag":"1","Size":"100MB"}
|
||||
{"ID":"sha256:img3","Repository":"<none>","Tag":"<none>","Size":"2GB"}
|
||||
`})
|
||||
got, err := client(f, 1000).Images(context.Background(), "", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imgs := got["images"].([]Image)
|
||||
if imgs[0].ID != "sha256:img3" || !imgs[0].Dangling || imgs[1].UsedBy[0] != "mesh-web" || !imgs[1].MeshUsed {
|
||||
t.Fatalf("%+v", imgs)
|
||||
}
|
||||
got, _ = client(f, 1000).Images(context.Background(), "unused", "", 10)
|
||||
if got["count"] != 1 {
|
||||
t.Fatalf("unused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemsNameWhyAndUnlabelledIsTheCleanupList(t *testing.T) {
|
||||
c := client(machine(), 1000)
|
||||
p, err := c.Problems(context.Background())
|
||||
if err != nil || len(p) != 1 || p[0]["name"] != "dev-db" || p[0]["why"].([]string)[0] != "exited 1" {
|
||||
t.Fatalf("%v %v", p, err)
|
||||
}
|
||||
u, err := c.Unlabelled(context.Background())
|
||||
if err != nil || u["count"] != 1 {
|
||||
t.Fatalf("%v %v", u, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
// docker's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's tool runtime launches
|
||||
// and speaks MCP over stdio to, through the Go SDK. It answers for every container on this machine —
|
||||
// the mesh's and every other — and for the runtime's images, networks, volumes, events and
|
||||
// configuration. It runs as the operator account (ADR 0175 §4); docker.go says how it reaches the
|
||||
// daemon's socket. The host applies the module's resources; these tools answer about the runtime.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): docker.
|
||||
if err := stdio.Serve("", tools(NewClient())); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var containerArg = map[string]any{"type": "string", "description": "the container's name or id"}
|
||||
|
||||
func tools(c *Client) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
act := func(verb, description string) stdio.Tool {
|
||||
return stdio.Tool{
|
||||
Name: "docker_" + verb, Description: description,
|
||||
Input: map[string]any{"container": containerArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := text(args, "container")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Act(ctx, verb, ref)
|
||||
},
|
||||
}
|
||||
}
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "docker_list",
|
||||
Description: "Every container on this machine — the mesh's and every other — with its image, state, health, restarts, " +
|
||||
"published ports, mounts, compose project, and mesh_held/held_by (the assignment that holds it).",
|
||||
Input: map[string]any{
|
||||
"held": map[string]any{"type": "string", "enum": []string{"all", "mesh", "other"}, "description": "whose: all (default), the mesh's, or the others"},
|
||||
"state": map[string]any{"type": "string", "description": "only containers in this state (running, exited, created, restarting, paused, dead)"},
|
||||
"match": map[string]any{"type": "string", "description": "only containers whose name or image contains this"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
list, err := c.Containers(ctx, optional(args, "held"), optional(args, "state"), optional(args, "match"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"count": len(list), "containers": list}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_inspect",
|
||||
Description: "One container whole, as docker inspects it, with mesh_held; its environment's values are left out (names kept), because that is where a container's secrets are.",
|
||||
Input: map[string]any{"container": containerArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := text(args, "container")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Inspect(ctx, ref)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_logs",
|
||||
Description: "The last lines one container wrote, both streams merged in order, each with its timestamp (default 200, at most 2000 lines; a line is cut at 4 KiB).",
|
||||
Input: map[string]any{
|
||||
"container": containerArg,
|
||||
"lines": map[string]any{"type": "integer", "description": "how many lines from the end (default 200, at most 2000)"},
|
||||
"since": map[string]any{"type": "string", "description": "only lines since then: a duration such as 30m or 2h, or a time"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := text(args, "container")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n, err := bounded(args, "lines", 200, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Logs(ctx, ref, n, optional(args, "since"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_stats",
|
||||
Description: "What the running containers use now — CPU, memory, network and disk I/O, processes — the heaviest by memory first; or one container's.",
|
||||
Input: map[string]any{"container": map[string]any{"type": "string", "description": "one container (optional)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
stats, err := c.Stats(ctx, optional(args, "container"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"count": len(stats), "containers": stats}, nil
|
||||
},
|
||||
},
|
||||
act("start", "Start one container. A container the mesh holds is started too, and the answer says the host restores what its declaration says at its next apply."),
|
||||
act("stop", "Stop one container (ten seconds, then killed). For a container the mesh holds, the answer says the host will start it again at its next apply if its declaration says running."),
|
||||
act("restart", "Restart one container (ten seconds to stop, then killed); the answer says whether the mesh holds it."),
|
||||
{
|
||||
Name: "docker_top",
|
||||
Description: "The processes running inside one container: pid, user, elapsed time, CPU, resident memory and command.",
|
||||
Input: map[string]any{"container": containerArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
ref, err := text(args, "container")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Top(ctx, ref)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_images",
|
||||
Description: "The images on this machine, the largest first, each with its size and the containers using it (and whether one of them is the mesh's). " +
|
||||
"filter: all, dangling, unused or used.",
|
||||
Input: map[string]any{
|
||||
"filter": map[string]any{"type": "string", "enum": []string{"all", "dangling", "unused", "used"}, "description": "which images (default all)"},
|
||||
"match": map[string]any{"type": "string", "description": "only images whose repository:tag contains this"},
|
||||
"limit": map[string]any{"type": "integer", "description": "how many to show (default 100, at most 1000); count says how many matched"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := bounded(args, "limit", 100, 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Images(ctx, optional(args, "filter"), optional(args, "match"), n)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_prune",
|
||||
Description: "Reclaim space: dangling images and unused build cache, and — only when containers is true — stopped containers the mesh does not hold. " +
|
||||
"Never a volume, never a container the mesh holds, never an image a container uses. A dry run by default: it lists what would go; dry_run false removes it.",
|
||||
Input: map[string]any{
|
||||
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
|
||||
"images": map[string]any{"type": "boolean", "description": "dangling images (default true)"},
|
||||
"build_cache": map[string]any{"type": "boolean", "description": "build cache nothing refers to (default true)"},
|
||||
"containers": map[string]any{"type": "boolean", "description": "stopped containers the mesh does not hold (default false); what they mounted is kept"},
|
||||
"older_than_hours": map[string]any{"type": "integer", "description": "only what is older than this many hours (default 0: any age)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
older := 0
|
||||
if v, ok := args["older_than_hours"]; ok && v != nil && v != float64(0) {
|
||||
n, err := bounded(args, "older_than_hours", 0, 24*365)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
older = n
|
||||
}
|
||||
return c.Prune(ctx, PruneAsk{
|
||||
DryRun: flag(args, "dry_run", true), Images: flag(args, "images", true), BuildCache: flag(args, "build_cache", true),
|
||||
Containers: flag(args, "containers", false), OlderThanH: older,
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_disk_usage",
|
||||
Description: "What the runtime takes on disk (docker system df -v): per kind — images, containers, volumes, build cache — the total, the active and the reclaimable, and the largest of each.",
|
||||
Input: map[string]any{"top": map[string]any{"type": "integer", "description": "how many of the largest per kind (default 10, at most 100)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := bounded(args, "top", 10, 100)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.DiskUsage(ctx, n)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_networks",
|
||||
Description: "Every network the runtime has: driver, scope, subnets and gateway, and the running containers on it with their addresses and whether the mesh holds them.",
|
||||
Run: func(map[string]any) (any, error) { return c.Networks(ctx) },
|
||||
},
|
||||
{
|
||||
Name: "docker_volumes",
|
||||
Description: "Every volume with the containers mounting it, whether the mesh holds any of them, whether it is anonymous, its compose project, and — when sizes is true (slower) — its size.",
|
||||
Input: map[string]any{
|
||||
"unmounted": map[string]any{"type": "boolean", "description": "only volumes no container mounts (default false)"},
|
||||
"sizes": map[string]any{"type": "boolean", "description": "measure each volume (default false: it walks every volume)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_events",
|
||||
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked.",
|
||||
Input: map[string]any{
|
||||
"minutes": map[string]any{"type": "integer", "description": "how far back (default 60, at most 1440)"},
|
||||
"type": map[string]any{"type": "string", "description": "only one kind: container, image, network, volume, daemon, plugin or builder"},
|
||||
"limit": map[string]any{"type": "integer", "description": "the latest this many (default 200, at most 2000)"},
|
||||
"execs": map[string]any{"type": "boolean", "description": "include exec_* events (default false: health checks make many)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
minutes, err := bounded(args, "minutes", 60, 1440)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limit, err := bounded(args, "limit", 200, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Events(ctx, minutes, optional(args, "type"), limit, flag(args, "execs", false))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_daemon_config",
|
||||
Description: "The runtime's configuration: /etc/docker/daemon.json as it is on disk, the daemon's essentials as it runs now (docker info: version, storage and logging drivers, " +
|
||||
"live restore, root directory, insecure registries, warnings), and where the two differ — keys a reload or only a restart would take.",
|
||||
Run: func(map[string]any) (any, error) { return c.DaemonConfig(ctx) },
|
||||
},
|
||||
{
|
||||
Name: "docker_unlabelled",
|
||||
Description: "The containers the mesh does not hold — the cleanup list — each with its image, state, compose project and directory, ports and mounts.",
|
||||
Run: func(map[string]any) (any, error) { return c.Unlabelled(ctx) },
|
||||
},
|
||||
{
|
||||
Name: "docker_problems",
|
||||
Description: "Every container that is not well: unhealthy, restarting, dead, killed for memory, exited with a failure, or restarted five times or more — with whether the mesh holds it.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
p, err := c.Problems(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"count": len(p), "containers": p}, nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "docker_ports",
|
||||
Description: "Every port the containers publish on this machine (address:port -> container port), and the containers on the host's network, which publish whatever they listen on.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
p, err := c.Ports(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"count": len(p), "ports": p}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
s, _ := args[key].(string)
|
||||
if s = strings.TrimSpace(s); s == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optional(args map[string]any, key string) string {
|
||||
s, _ := args[key].(string)
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func flag(args map[string]any, key string, def bool) bool {
|
||||
if b, ok := args[key].(bool); ok {
|
||||
return b
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// bounded is a whole number argument, defaulted when absent and held to a ceiling.
|
||||
func bounded(args map[string]any, key string, def, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok || f != math.Trunc(f) || f < 1 {
|
||||
return 0, fmt.Errorf("%s must be a whole number of at least 1", key)
|
||||
}
|
||||
return int(math.Min(f, float64(most))), nil
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Ran is what a command did: its output, its exit status, and why it never ran to an answer.
|
||||
type Ran struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Status int
|
||||
// Err is "ENOENT" when the program is not installed, or says it was ended for taking too long.
|
||||
Err string
|
||||
}
|
||||
|
||||
// Runner runs one command, so every tool can be tested without a daemon.
|
||||
type Runner func(ctx context.Context, name string, args ...string) Ran
|
||||
|
||||
// CallTimeout is how long one docker command may take: below the runtime's thirty-second call
|
||||
// limit, so a daemon that hangs is answered as such rather than as a call the runtime gave up on.
|
||||
const CallTimeout = 20 * time.Second
|
||||
|
||||
// ExecRunner runs a command on this machine, bounded by CallTimeout.
|
||||
func ExecRunner(ctx context.Context, name string, args ...string) Ran {
|
||||
ctx, cancel := context.WithTimeout(ctx, CallTimeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
r := Ran{Stdout: out.String(), Stderr: errb.String()}
|
||||
var exitErr *exec.ExitError
|
||||
switch {
|
||||
case errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
r.Status, r.Err = 124, fmt.Sprintf("no answer within %d s", int(CallTimeout/time.Second))
|
||||
case errors.Is(err, exec.ErrNotFound):
|
||||
r.Status, r.Err = 127, "ENOENT"
|
||||
case errors.As(err, &exitErr):
|
||||
r.Status = exitErr.ExitCode()
|
||||
case err != nil:
|
||||
r.Status, r.Err = 1, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Tools []string `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := []string{}
|
||||
for _, tool := range tools(client(&fake{}, 1000)) {
|
||||
if !strings.HasPrefix(tool.Name, "docker_") || tool.Description == "" || tool.Run == nil {
|
||||
t.Errorf("tool %q", tool.Name)
|
||||
}
|
||||
served = append(served, tool.Name)
|
||||
}
|
||||
sort.Strings(served)
|
||||
listed := append([]string{}, m.Tools...)
|
||||
sort.Strings(listed)
|
||||
if !reflect.DeepEqual(served, listed) {
|
||||
t.Fatalf("served %v, manifest %v", served, listed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumbersAreDefaultedAndBounded(t *testing.T) {
|
||||
if n, _ := bounded(map[string]any{}, "lines", 200, 2000); n != 200 {
|
||||
t.Error(n)
|
||||
}
|
||||
if n, _ := bounded(map[string]any{"lines": float64(99999)}, "lines", 200, 2000); n != 2000 {
|
||||
t.Error(n)
|
||||
}
|
||||
for _, bad := range []any{float64(0), float64(-1), float64(1.5), "10"} {
|
||||
if _, err := bounded(map[string]any{"lines": bad}, "lines", 200, 2000); err == nil {
|
||||
t.Errorf("%v accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStopFromTheToolNeedsAContainer(t *testing.T) {
|
||||
for _, tool := range tools(client(&fake{}, 1000)) {
|
||||
if tool.Name == "docker_stop" {
|
||||
if _, err := tool.Run(map[string]any{}); err == nil {
|
||||
t.Fatal("a stop without a container was accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module docker
|
||||
|
||||
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,94 @@
|
||||
{
|
||||
"module": "docker",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager",
|
||||
"service-manager",
|
||||
"privileged"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-container-runtime",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"docker_list",
|
||||
"docker_inspect",
|
||||
"docker_logs",
|
||||
"docker_stats",
|
||||
"docker_start",
|
||||
"docker_stop",
|
||||
"docker_restart",
|
||||
"docker_top",
|
||||
"docker_images",
|
||||
"docker_prune",
|
||||
"docker_disk_usage",
|
||||
"docker_networks",
|
||||
"docker_volumes",
|
||||
"docker_events",
|
||||
"docker_daemon_config",
|
||||
"docker_unlabelled",
|
||||
"docker_problems",
|
||||
"docker_ports"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "docker"
|
||||
},
|
||||
{
|
||||
"id": "buildx",
|
||||
"type": "package",
|
||||
"package": "docker-buildx"
|
||||
},
|
||||
{
|
||||
"id": "socket",
|
||||
"type": "service",
|
||||
"unit": "docker.socket",
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "prune-service",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/docker-prune.service",
|
||||
"mode": "0644",
|
||||
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Prune dangling images and unused build cache (the mesh's docker module)\n# Never volumes, never a container, never an image a container uses: dangling\n# images and build cache nothing refers to, unused for a week. What a person\n# prunes beyond that is docker_prune's, by hand.\nAfter=docker.service\nConditionPathExists=/run/docker.sock\n\n[Service]\nType=oneshot\nNice=19\nIOSchedulingClass=idle\nExecStart=/usr/bin/docker image prune --force --filter until=168h\nExecStart=/usr/bin/docker builder prune --force --filter until=168h\n"
|
||||
},
|
||||
{
|
||||
"id": "prune-timer",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/docker-prune.timer",
|
||||
"mode": "0644",
|
||||
"content": "# Generated by the mesh. Do not edit — module docker writes this file and replaces it at every push.\n[Unit]\nDescription=Weekly prune of dangling images and unused build cache (the mesh's docker module)\n\n[Timer]\nOnCalendar=weekly\nRandomizedDelaySec=1h\nPersistent=true\n\n[Install]\nWantedBy=timers.target\n"
|
||||
},
|
||||
{
|
||||
"id": "prune",
|
||||
"type": "service",
|
||||
"unit": "docker-prune.timer",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"prune-service",
|
||||
"prune-timer"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/docker-tools",
|
||||
"binary": "docker-tools",
|
||||
"loads": [
|
||||
"docker-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# dunst
|
||||
|
||||
The notifier as a module (novox/hq ADR 0208, research 026/05).
|
||||
|
||||
- Installs `dunst`, and `libnotify` for `notify-send`, the client every program and these tools use.
|
||||
- Claims the mesh's `node-notifier` seat and serves its verbs `send` and `history`.
|
||||
- Owns `~/.config/dunst/dunstrc` and the directory `~/.config/dunst/dunstrc.d/`. Another module's
|
||||
rule is that module's own file in the directory (ADR 0208 §4). dunst reads the directory after
|
||||
`dunstrc`, so a drop-in outranks it.
|
||||
- **Starts nothing.** The package registers dunst with D-Bus, which starts it on the first
|
||||
notification, inside the account's service manager. There is no autostart line, no unit and no
|
||||
session-start contribution.
|
||||
- **Requires no display of its own.** dunst speaks both X11 and Wayland and picks the one the session
|
||||
has, so it serves an X session and a later sway one alike.
|
||||
|
||||
## Tools
|
||||
|
||||
Every tool goes over the account's session bus. None needs the screen, and each answers clearly when
|
||||
the account is not logged in.
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `node-notifier.send` | a notification: title, body, urgency, sender, icon, how long; answers its id |
|
||||
| `node-notifier.history` | what was shown, newest first, with how long ago |
|
||||
| `dunst_pause` / `dunst_resume` | do not disturb: notifications are held back, not lost |
|
||||
| `dunst_close_all` | clear the screen; the history keeps them |
|
||||
| `dunst_rules` | the rules the running notifier holds, and the files they come from |
|
||||
| `dunst_count` | shown, waiting, in history, and whether paused |
|
||||
|
||||
## What it chose, and what it improves
|
||||
|
||||
The workstations' files differed: one had the notifications bottom-right, 15 % transparent and with
|
||||
rounded corners; the other top-right, opaque and square. This module takes the second, because the
|
||||
rest of the desktop is square and opaque, and the top-right corner sits under the bar that shows the
|
||||
count. The file keeps only the settings that differ from dunst's defaults.
|
||||
|
||||
- **The context menu works.** It called `/usr/bin/dmenu`, installed on neither machine. It now calls
|
||||
`dmenu`, the seat command of whichever module holds `node-launcher` (`rofi` on the workstations).
|
||||
- **The face is the interface one,** Inter (research 026/04), instead of a monospace Nerd font.
|
||||
- `icon_path`, which named two directories of an icon theme that is not installed, is gone. The icon
|
||||
theme is looked up recursively.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- `~/.config/dunst/dunstrc.d/50-slack.conf`, the Slack rule. It becomes the Slack module's own drop-in
|
||||
when there is one, and until then it is the operator's file in a directory this module owns.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
- The first push keeps the found `dunstrc` once, then writes the module's.
|
||||
- **The desktop runs two notification daemons** because its session began before the session bus
|
||||
fix (research 026/01). That ends at the next login, and nothing here starts a second one.
|
||||
`dunst_count` after logging in again shows the one daemon's counts.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `node-notifier` is ADR 0208's seat. Until the controller knows it, `mctl` reads the claim as
|
||||
unknown.
|
||||
- `dunst_rules` and the counts ask the running notifier. When none runs, the bus starts one, which
|
||||
needs a session to draw on.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || 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 is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var urgencies = []string{"low", "normal", "critical"}
|
||||
|
||||
const busTimeout = 10 * time.Second
|
||||
|
||||
// Notification is what node-notifier.send shows.
|
||||
type Notification struct {
|
||||
Summary string
|
||||
Body string
|
||||
Urgency string
|
||||
AppName string
|
||||
Icon string
|
||||
ExpireMS int
|
||||
Category string
|
||||
ReplaceID int
|
||||
}
|
||||
|
||||
func notificationOf(args map[string]any) (Notification, error) {
|
||||
var n Notification
|
||||
var err error
|
||||
if n.Summary, err = text(args, "summary", true); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Body, err = text(args, "body", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Urgency, err = text(args, "urgency", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Urgency == "" {
|
||||
n.Urgency = "normal"
|
||||
}
|
||||
known := false
|
||||
for _, u := range urgencies {
|
||||
known = known || u == n.Urgency
|
||||
}
|
||||
if !known {
|
||||
return n, fmt.Errorf("urgency %q is low, normal or critical", n.Urgency)
|
||||
}
|
||||
if n.AppName, err = text(args, "app_name", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.AppName == "" {
|
||||
n.AppName = "mesh"
|
||||
}
|
||||
if n.Icon, err = text(args, "icon", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.ExpireMS, err = whole(args, "expire_ms", -1, 0, 24*3600*1000); err != nil {
|
||||
return n, err
|
||||
}
|
||||
if n.Category, err = text(args, "category", false); err != nil {
|
||||
return n, err
|
||||
}
|
||||
n.ReplaceID, err = whole(args, "replace_id", 0, 0, 1<<31-1)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// SendResult is what node-notifier.send answers.
|
||||
type SendResult struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// Send shows a notification through the desktop's notification service, whichever runs it.
|
||||
func Send(n Notification) (SendResult, error) {
|
||||
s, err := findBus()
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
args := []string{"--print-id", "--urgency=" + n.Urgency, "--app-name=" + n.AppName}
|
||||
if n.Icon != "" {
|
||||
args = append(args, "--icon="+n.Icon)
|
||||
}
|
||||
if n.ExpireMS >= 0 {
|
||||
args = append(args, "--expire-time="+strconv.Itoa(n.ExpireMS))
|
||||
}
|
||||
if n.Category != "" {
|
||||
args = append(args, "--category="+n.Category)
|
||||
}
|
||||
if n.ReplaceID > 0 {
|
||||
args = append(args, "--replace-id="+strconv.Itoa(n.ReplaceID))
|
||||
}
|
||||
// "--" so a title that starts with a dash is a title.
|
||||
args = append(args, "--", n.Summary)
|
||||
if n.Body != "" {
|
||||
args = append(args, n.Body)
|
||||
}
|
||||
r, err := s.run(busTimeout, "", "notify-send", args...)
|
||||
if err != nil {
|
||||
return SendResult{}, err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return SendResult{}, fmt.Errorf("notify-send: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
id, err := strconv.Atoi(strings.TrimSpace(r.Stdout))
|
||||
if err != nil {
|
||||
return SendResult{}, fmt.Errorf("notify-send answered no id: %q", r.Stdout)
|
||||
}
|
||||
return SendResult{ID: id}, nil
|
||||
}
|
||||
|
||||
// dunstctl runs one dunstctl command over the session bus and answers what it printed.
|
||||
func dunstctl(args ...string) (string, error) {
|
||||
s, err := findBus()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
r, err := s.run(busTimeout, "", "dunstctl", args...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return "", fmt.Errorf("dunstctl %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr+r.Stdout))
|
||||
}
|
||||
return r.Stdout, nil
|
||||
}
|
||||
|
||||
// variantMaps reads busctl's JSON form of an array of dictionaries (aa{sv}), which is how dunstctl
|
||||
// answers history and rules, into plain maps.
|
||||
func variantMaps(raw string) ([]map[string]any, error) {
|
||||
var doc struct {
|
||||
Type string `json:"type"`
|
||||
Data [][]map[string]struct {
|
||||
Data any `json:"data"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
|
||||
return nil, fmt.Errorf("dunstctl's answer is not the bus's JSON: %w", err)
|
||||
}
|
||||
if doc.Type != "aa{sv}" {
|
||||
return nil, fmt.Errorf("dunstctl answered %s, not aa{sv}", doc.Type)
|
||||
}
|
||||
out := []map[string]any{}
|
||||
for _, group := range doc.Data {
|
||||
for _, entry := range group {
|
||||
m := map[string]any{}
|
||||
for k, v := range entry {
|
||||
m[k] = v.Data
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Shown is one notification in the history.
|
||||
type Shown struct {
|
||||
ID int `json:"id"`
|
||||
AppName string `json:"app_name"`
|
||||
Summary string `json:"summary"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Urgency string `json:"urgency"`
|
||||
Category string `json:"category,omitempty"`
|
||||
AgeSeconds int64 `json:"age_seconds"`
|
||||
}
|
||||
|
||||
// HistoryResult is what node-notifier.history answers.
|
||||
type HistoryResult struct {
|
||||
Total int `json:"total"`
|
||||
Notifications []Shown `json:"notifications"`
|
||||
}
|
||||
|
||||
// History is dunst's history, newest first.
|
||||
func History(limit int) (HistoryResult, error) {
|
||||
raw, err := dunstctl("history")
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
return parseHistory(raw, monotonicMicros(), limit)
|
||||
}
|
||||
|
||||
func parseHistory(raw string, nowMicros int64, limit int) (HistoryResult, error) {
|
||||
entries, err := variantMaps(raw)
|
||||
if err != nil {
|
||||
return HistoryResult{}, err
|
||||
}
|
||||
out := HistoryResult{Total: len(entries), Notifications: []Shown{}}
|
||||
type stamped struct {
|
||||
Shown
|
||||
at int64
|
||||
}
|
||||
var all []stamped
|
||||
for _, e := range entries {
|
||||
at := number(e["timestamp"])
|
||||
all = append(all, stamped{Shown{
|
||||
ID: int(number(e["id"])), AppName: str(e["appname"]), Summary: str(e["summary"]), Body: str(e["body"]),
|
||||
Urgency: strings.ToLower(str(e["urgency"])), Category: str(e["category"]),
|
||||
AgeSeconds: max(0, (nowMicros-at)/1_000_000),
|
||||
}, at})
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool { return all[i].at > all[j].at })
|
||||
for i, s := range all {
|
||||
if i == limit {
|
||||
break
|
||||
}
|
||||
out.Notifications = append(out.Notifications, s.Shown)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func number(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case json.Number:
|
||||
i, _ := n.Int64()
|
||||
return i
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func str(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// monotonicMicros is the clock dunst stamps its notifications with (CLOCK_MONOTONIC, microseconds).
|
||||
func monotonicMicros() int64 {
|
||||
var ts syscall.Timespec
|
||||
const clockMonotonic = 1
|
||||
if _, _, errno := syscall.Syscall(syscall.SYS_CLOCK_GETTIME, clockMonotonic, uintptr(unsafe.Pointer(&ts)), 0); errno != 0 {
|
||||
return 0
|
||||
}
|
||||
return ts.Sec*1_000_000 + ts.Nsec/1000
|
||||
}
|
||||
|
||||
// PauseResult is what dunst_pause and dunst_resume answer.
|
||||
type PauseResult struct {
|
||||
Paused bool `json:"paused"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// SetPaused turns do-not-disturb on or off.
|
||||
func SetPaused(on bool) (PauseResult, error) {
|
||||
if _, err := dunstctl("set-paused", strconv.FormatBool(on)); err != nil {
|
||||
return PauseResult{}, err
|
||||
}
|
||||
out, err := dunstctl("is-paused")
|
||||
if err != nil {
|
||||
return PauseResult{}, err
|
||||
}
|
||||
paused := strings.TrimSpace(out) == "true"
|
||||
note := "notifications are shown"
|
||||
if paused {
|
||||
note = "notifications are held back until dunst_resume; the next login starts unpaused"
|
||||
}
|
||||
return PauseResult{Paused: paused, Note: note}, nil
|
||||
}
|
||||
|
||||
// CloseAll closes what is on screen.
|
||||
func CloseAll() (CountResult, error) {
|
||||
if _, err := dunstctl("close-all"); err != nil {
|
||||
return CountResult{}, err
|
||||
}
|
||||
return Count()
|
||||
}
|
||||
|
||||
// CountResult is what dunst_count answers.
|
||||
type CountResult struct {
|
||||
Displayed int `json:"displayed"`
|
||||
Waiting int `json:"waiting"`
|
||||
History int `json:"history"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
// Count is how many notifications are where.
|
||||
func Count() (CountResult, error) {
|
||||
var c CountResult
|
||||
for _, part := range []struct {
|
||||
which string
|
||||
into *int
|
||||
}{{"displayed", &c.Displayed}, {"waiting", &c.Waiting}, {"history", &c.History}} {
|
||||
out, err := dunstctl("count", part.which)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(out))
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("dunstctl count %s answered %q", part.which, out)
|
||||
}
|
||||
*part.into = n
|
||||
}
|
||||
out, err := dunstctl("is-paused")
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Paused = strings.TrimSpace(out) == "true"
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Rule is one notifier rule in force.
|
||||
type Rule struct {
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Sets map[string]any `json:"sets"`
|
||||
}
|
||||
|
||||
// RulesResult is what dunst_rules answers.
|
||||
type RulesResult struct {
|
||||
Files []string `json:"files"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
// Rules are the rules the running notifier holds, and the files it reads them from.
|
||||
func Rules() (RulesResult, error) {
|
||||
raw, err := dunstctl("rules", "--json")
|
||||
if err != nil {
|
||||
return RulesResult{}, err
|
||||
}
|
||||
return parseRules(raw, configFiles(filepath.Join(operatorHome(), ".config", "dunst")))
|
||||
}
|
||||
|
||||
func parseRules(raw string, files []string) (RulesResult, error) {
|
||||
entries, err := variantMaps(raw)
|
||||
if err != nil {
|
||||
return RulesResult{}, err
|
||||
}
|
||||
out := RulesResult{Files: files, Rules: []Rule{}}
|
||||
for _, e := range entries {
|
||||
r := Rule{Name: str(e["name"]), Enabled: e["enabled"] == true, Sets: map[string]any{}}
|
||||
for k, v := range e {
|
||||
if k != "name" && k != "enabled" {
|
||||
r.Sets[k] = v
|
||||
}
|
||||
}
|
||||
out.Rules = append(out.Rules, r)
|
||||
}
|
||||
sort.SliceStable(out.Rules, func(i, j int) bool { return out.Rules[i].Name < out.Rules[j].Name })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// configFiles are dunstrc and its drop-ins in the order dunst reads them.
|
||||
func configFiles(dir string) []string {
|
||||
files := []string{}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dunstrc")); err == nil {
|
||||
files = append(files, filepath.Join(dir, "dunstrc"))
|
||||
}
|
||||
dropins, _ := filepath.Glob(filepath.Join(dir, "dunstrc.d", "*.conf"))
|
||||
sort.Strings(dropins)
|
||||
return append(files, dropins...)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// withBus gives the fake machine the account's runtime directory and bus socket.
|
||||
func withBus(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
// A history as dunstctl answers it (busctl's JSON), two entries out of order.
|
||||
const history = `{"type":"aa{sv}","data":[[
|
||||
{"body":{"type":"s","data":"the build is green"},"summary":{"type":"s","data":"CI"},"appname":{"type":"s","data":"mesh"},
|
||||
"category":{"type":"s","data":""},"id":{"type":"i","data":7},"timestamp":{"type":"x","data":100000000},"urgency":{"type":"s","data":"NORMAL"}},
|
||||
{"body":{"type":"s","data":""},"summary":{"type":"s","data":"Battery low"},"appname":{"type":"s","data":"upower"},
|
||||
"category":{"type":"s","data":"device"},"id":{"type":"i","data":9},"timestamp":{"type":"x","data":160000000},"urgency":{"type":"s","data":"CRITICAL"}}
|
||||
]]}`
|
||||
|
||||
func TestTheHistoryIsNewestFirstWithAgesFromDunstsOwnClock(t *testing.T) {
|
||||
got, err := parseHistory(history, 200_000_000, 20)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []Shown{
|
||||
{ID: 9, AppName: "upower", Summary: "Battery low", Urgency: "critical", Category: "device", AgeSeconds: 40},
|
||||
{ID: 7, AppName: "mesh", Summary: "CI", Body: "the build is green", Urgency: "normal", AgeSeconds: 100},
|
||||
}
|
||||
if got.Total != 2 || !reflect.DeepEqual(got.Notifications, want) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got, _ := parseHistory(history, 200_000_000, 1); len(got.Notifications) != 1 || got.Total != 2 {
|
||||
t.Fatalf("limited: %+v", got)
|
||||
}
|
||||
if _, err := parseHistory(`{"type":"as","data":[]}`, 0, 1); err == nil {
|
||||
t.Fatal("an answer of another type was accepted")
|
||||
}
|
||||
if monotonicMicros() <= 0 {
|
||||
t.Fatal("the monotonic clock")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAsksNotifySendOverTheAccountsBusAndAnswersTheId(t *testing.T) {
|
||||
withBus(t)
|
||||
bin := fakeBinaries(t, map[string]string{"notify-send": `for a in "$@"; do printf '[%s]' "$a"; done > "$LOG"; echo >> "$LOG"; echo "bus=$DBUS_SESSION_BUS_ADDRESS" >> "$LOG"; echo 42`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
n, err := notificationOf(map[string]any{"summary": "-dash title", "body": "hello", "urgency": "critical", "expire_ms": float64(0)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Send(n)
|
||||
if err != nil || got.ID != 42 {
|
||||
t.Fatalf("%+v, %v", got, err)
|
||||
}
|
||||
asked, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
want := "[--print-id][--urgency=critical][--app-name=mesh][--expire-time=0][--][-dash title][hello]\nbus=unix:path=" +
|
||||
filepath.Join(runUserDir, strconv.Itoa(os.Getuid()), "bus") + "\n"
|
||||
if string(asked) != want {
|
||||
t.Fatalf("notify-send was asked:\n%s\nwant:\n%s", asked, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANotificationIsRefusedForWhatItCannotBe(t *testing.T) {
|
||||
for _, bad := range []map[string]any{{}, {"summary": " "}, {"summary": "x", "urgency": "urgent"}, {"summary": "x", "expire_ms": float64(-5)}} {
|
||||
if _, err := notificationOf(bad); err == nil {
|
||||
t.Errorf("accepted %v", bad)
|
||||
}
|
||||
}
|
||||
n, _ := notificationOf(map[string]any{"summary": "x"})
|
||||
if n.Urgency != "normal" || n.AppName != "mesh" || n.ExpireMS != -1 {
|
||||
t.Fatalf("defaults: %+v", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutABusTheToolsSaySo(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
if _, err := Send(Notification{Summary: "x"}); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Count(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountPauseAndCloseAllAreDunstctlsAnswers(t *testing.T) {
|
||||
withBus(t)
|
||||
bin := fakeBinaries(t, map[string]string{"dunstctl": `echo "$*" >> "$LOG"
|
||||
case "$*" in
|
||||
"count displayed") echo 1 ;;
|
||||
"count waiting") echo 0 ;;
|
||||
"count history") echo 12 ;;
|
||||
is-paused) cat "$STATE" 2>/dev/null || echo false ;;
|
||||
"set-paused true") echo true > "$STATE" ;;
|
||||
"set-paused false") echo false > "$STATE" ;;
|
||||
esac`})
|
||||
t.Setenv("LOG", filepath.Join(bin, "log"))
|
||||
t.Setenv("STATE", filepath.Join(bin, "paused"))
|
||||
c, err := Count()
|
||||
if err != nil || c != (CountResult{Displayed: 1, History: 12}) {
|
||||
t.Fatalf("%+v, %v", c, err)
|
||||
}
|
||||
p, err := SetPaused(true)
|
||||
if err != nil || !p.Paused || !strings.Contains(p.Note, "held back") {
|
||||
t.Fatalf("%+v, %v", p, err)
|
||||
}
|
||||
if c, _ := CloseAll(); !c.Paused {
|
||||
t.Fatalf("close-all answers the counts: %+v", c)
|
||||
}
|
||||
if p, _ := SetPaused(false); p.Paused {
|
||||
t.Fatalf("resumed: %+v", p)
|
||||
}
|
||||
log, _ := os.ReadFile(filepath.Join(bin, "log"))
|
||||
if !strings.Contains(string(log), "close-all\n") {
|
||||
t.Fatalf("dunstctl was asked:\n%s", log)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRulesAreTheRunningNotifiersWithTheFilesTheyComeFrom(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, f := range []string{"dunstrc", "dunstrc.d/50-slack.conf", "dunstrc.d/10-mail.conf", "dunstrc.d/notes.txt"} {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, f)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, f), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
raw := `{"type":"aa{sv}","data":[[{"enabled":{"type":"b","data":true},"appname":{"type":"s","data":"Slack"},
|
||||
"fc":{"type":"s","data":"#6715ebff"},"name":{"type":"s","data":"slack"},"timeout":{"type":"x","data":10000000}}]]}`
|
||||
got, err := parseRules(raw, configFiles(root))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Rules) != 1 || got.Rules[0].Name != "slack" || !got.Rules[0].Enabled || got.Rules[0].Sets["appname"] != "Slack" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
var names []string
|
||||
for _, f := range got.Files {
|
||||
rel, _ := filepath.Rel(root, f)
|
||||
names = append(names, rel)
|
||||
}
|
||||
if !reflect.DeepEqual(names, []string{"dunstrc", "dunstrc.d/10-mail.conf", "dunstrc.d/50-slack.conf"}) {
|
||||
t.Fatalf("files: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// dunst's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
|
||||
// node-notifier's verbs `send` and `history`, and its own tools, served by the node's runtime as the
|
||||
// operator account. Everything here goes over the account's session bus; none of it needs the screen.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
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: "node-notifier.send",
|
||||
Description: "Show a notification on the operator's desktop: a title, a body, an urgency (low, " +
|
||||
"normal, critical), and optionally the sending application's name, an icon and how long it stays. " +
|
||||
"Answers the notification's id.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"summary": map[string]any{"type": "string", "description": "the title"},
|
||||
"body": map[string]any{"type": "string", "description": "the text; simple markup (<b>, <i>, <u>, <a href>) is shown"},
|
||||
"urgency": map[string]any{"type": "string", "enum": urgencies, "description": "default normal"},
|
||||
"app_name": map[string]any{"type": "string", "description": "who it is from (default: mesh); a notifier rule can match it"},
|
||||
"icon": map[string]any{"type": "string", "description": "an icon name from the icon theme, or a file"},
|
||||
"expire_ms": map[string]any{"type": "integer", "description": "how long it stays; 0 until dismissed (default: the urgency's own)"},
|
||||
"category": map[string]any{"type": "string", "description": "a notification category, e.g. email.arrived"},
|
||||
"replace_id": map[string]any{"type": "integer", "description": "replace the notification with this id instead of adding one"},
|
||||
},
|
||||
"required": []string{"summary"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := notificationOf(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Send(n)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "node-notifier.history",
|
||||
Description: "The notifications the operator was shown, newest first: id, application, title, " +
|
||||
"body, urgency and how long ago.",
|
||||
Input: map[string]any{
|
||||
"limit": map[string]any{"type": "integer", "description": "at most this many (default 20, at most 200)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
limit, err := whole(args, "limit", 20, 1, 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return History(limit)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "dunst_pause",
|
||||
Description: "Do not disturb: hold every new notification back until resumed. They are shown then, not lost.",
|
||||
Run: func(map[string]any) (any, error) { return SetPaused(true) },
|
||||
},
|
||||
{
|
||||
Name: "dunst_resume",
|
||||
Description: "End do-not-disturb: notifications held back are shown.",
|
||||
Run: func(map[string]any) (any, error) { return SetPaused(false) },
|
||||
},
|
||||
{
|
||||
Name: "dunst_close_all",
|
||||
Description: "Close every notification on screen. They stay in the history.",
|
||||
Run: func(map[string]any) (any, error) { return CloseAll() },
|
||||
},
|
||||
{
|
||||
Name: "dunst_rules",
|
||||
Description: "The notifier's rules in force — each rule's name, whether it is enabled, what it " +
|
||||
"matches and what it sets — and the files they come from (the mesh's dunstrc, then dunstrc.d).",
|
||||
Run: func(map[string]any) (any, error) { return Rules() },
|
||||
},
|
||||
{
|
||||
Name: "dunst_count",
|
||||
Description: "How many notifications are shown, waiting and in the history, and whether do-not-disturb is on.",
|
||||
Run: func(map[string]any) (any, error) { return Count() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// dunst's shape (novox/hq ADR 0208): it claims node-notifier serving send and history, owns its
|
||||
// dunstrc and the drop-in directory other modules' rules go in, and starts nothing — D-Bus starts it
|
||||
// on the first notification. It draws only once a notification arrives, through the bus's
|
||||
// activation, so it requires no display of its own.
|
||||
|
||||
func TestItClaimsTheNotifierSeatServingSendAndHistory(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "dunst" || m.Seats != nil {
|
||||
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-notifier", Scope: "node", Serves: []string{"send", "history"}}}) {
|
||||
t.Fatalf("claims: %+v", m.Claims)
|
||||
}
|
||||
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"dunst", "libnotify"}) || absent != nil {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItOwnsItsFileAndTheDropInDirectory(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
m.sameAsSource(t, "configuration", "files/dunstrc")
|
||||
if p := m.resource(t, "configuration")["path"]; p != "${machine:account-home}/.config/dunst/dunstrc" {
|
||||
t.Fatalf("path: %v", p)
|
||||
}
|
||||
if d := m.resource(t, "dropins"); d["type"] != "directory" || d["path"] != "${machine:account-home}/.config/dunst/dunstrc.d" {
|
||||
t.Fatalf("drop-ins: %v", d)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if p, _ := r["path"].(string); strings.Contains(p, "dunstrc.d/") {
|
||||
t.Fatalf("a rule of another module's: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileIsTheDecidedOneAndCallsTheSeatsMenu(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
c := m.resource(t, "configuration")["content"].(string)
|
||||
for _, want := range []string{"origin = top-right", "transparency = 0", "corner_radius = 0", "font = Inter 10", "dmenu = dmenu -p dunst"} {
|
||||
if !strings.Contains(c, " "+want+"\n") {
|
||||
t.Errorf("lacks %q", want)
|
||||
}
|
||||
}
|
||||
for _, never := range []string{"/usr/bin/dmenu", "Hack", "icon_path", "/home/"} {
|
||||
if strings.Contains(c, never) {
|
||||
t.Errorf("names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingStartsItButTheBus(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Shell != nil {
|
||||
t.Fatalf("a session start: %+v", m.Shell)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" || r["type"] == "process" {
|
||||
t.Fatalf("a unit: %v", r)
|
||||
}
|
||||
if p, _ := r["path"].(string); strings.Contains(p, "autostart") || strings.Contains(p, "i3/config.d") {
|
||||
t.Fatalf("a start: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced
|
||||
# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in
|
||||
# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the
|
||||
# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.
|
||||
#
|
||||
# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,
|
||||
# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.
|
||||
|
||||
[global]
|
||||
monitor = 0
|
||||
follow = none
|
||||
|
||||
# Geometry
|
||||
width = 250
|
||||
height = (0, 300)
|
||||
origin = top-right
|
||||
offset = (10, 50)
|
||||
notification_limit = 20
|
||||
|
||||
progress_bar = true
|
||||
progress_bar_height = 10
|
||||
progress_bar_frame_width = 1
|
||||
progress_bar_min_width = 150
|
||||
progress_bar_max_width = 300
|
||||
|
||||
indicate_hidden = yes
|
||||
transparency = 0
|
||||
separator_height = 2
|
||||
padding = 8
|
||||
horizontal_padding = 8
|
||||
text_icon_padding = 0
|
||||
frame_width = 3
|
||||
frame_color = "#de5200"
|
||||
gap_size = 0
|
||||
separator_color = frame
|
||||
sort = yes
|
||||
corner_radius = 0
|
||||
|
||||
# Text: the interface face (research 026/04)
|
||||
font = Inter 10
|
||||
line_height = 0
|
||||
markup = full
|
||||
format = "<b>%s</b>\n%b"
|
||||
alignment = left
|
||||
vertical_alignment = center
|
||||
show_age_threshold = 60
|
||||
ellipsize = middle
|
||||
ignore_newline = no
|
||||
stack_duplicates = true
|
||||
hide_duplicate_count = false
|
||||
show_indicators = yes
|
||||
|
||||
# Icons, from the desktop's icon theme
|
||||
enable_recursive_icon_lookup = true
|
||||
icon_theme = Adwaita
|
||||
icon_position = left
|
||||
min_icon_size = 32
|
||||
max_icon_size = 128
|
||||
|
||||
# History
|
||||
sticky_history = yes
|
||||
history_length = 20
|
||||
|
||||
# The context menu is the node's dmenu-compatible command, which the holder of node-launcher
|
||||
# answers (rofi on the workstations). Links open in the desktop's default browser.
|
||||
dmenu = dmenu -p dunst
|
||||
browser = /usr/bin/xdg-open
|
||||
always_run_script = true
|
||||
|
||||
title = Dunst
|
||||
class = Dunst
|
||||
ignore_dbusclose = false
|
||||
|
||||
mouse_left_click = close_current
|
||||
mouse_middle_click = do_action, close_current
|
||||
mouse_right_click = close_all
|
||||
|
||||
[urgency_low]
|
||||
background = "#000000"
|
||||
foreground = "#ffffff"
|
||||
timeout = 10
|
||||
|
||||
[urgency_normal]
|
||||
background = "#000000"
|
||||
foreground = "#ffffff"
|
||||
timeout = 10
|
||||
|
||||
[urgency_critical]
|
||||
background = "#000000"
|
||||
foreground = "#ffffff"
|
||||
frame_color = "#ff0000"
|
||||
timeout = 0
|
||||
@@ -0,0 +1,5 @@
|
||||
module dunst
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"module": "dunst",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-notifier",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"send",
|
||||
"history"
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
"dunst_pause",
|
||||
"dunst_resume",
|
||||
"dunst_close_all",
|
||||
"dunst_rules",
|
||||
"dunst_count"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "package",
|
||||
"type": "package",
|
||||
"package": "dunst"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "package",
|
||||
"package": "libnotify"
|
||||
},
|
||||
{
|
||||
"id": "configuration-dir",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/dunst",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "dropins",
|
||||
"type": "directory",
|
||||
"path": "${machine:account-home}/.config/dunst/dunstrc.d",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "configuration",
|
||||
"type": "file",
|
||||
"path": "${machine:account-home}/.config/dunst/dunstrc",
|
||||
"owner": "${machine:account}",
|
||||
"mode": "0644",
|
||||
"content": "# dunst, the notifier (module dunst, novox/hq ADR 0208). Owned by the mesh: this file is replaced\n# at every push. Adopted from the laptop's file of 2026-10-04 (the two workstations differed in\n# position, transparency and corner radius; the laptop's square, opaque, top-right one matches the\n# rest of the desktop). Only what differs from dunst's defaults, and what the desktop relies on.\n#\n# Other modules' rules go in ~/.config/dunst/dunstrc.d/*.conf, which dunst reads after this file,\n# so a drop-in outranks it. dunst is started by D-Bus on the first notification: nothing starts it.\n\n[global]\n monitor = 0\n follow = none\n\n # Geometry\n width = 250\n height = (0, 300)\n origin = top-right\n offset = (10, 50)\n notification_limit = 20\n\n progress_bar = true\n progress_bar_height = 10\n progress_bar_frame_width = 1\n progress_bar_min_width = 150\n progress_bar_max_width = 300\n\n indicate_hidden = yes\n transparency = 0\n separator_height = 2\n padding = 8\n horizontal_padding = 8\n text_icon_padding = 0\n frame_width = 3\n frame_color = \"#de5200\"\n gap_size = 0\n separator_color = frame\n sort = yes\n corner_radius = 0\n\n # Text: the interface face (research 026/04)\n font = Inter 10\n line_height = 0\n markup = full\n format = \"<b>%s</b>\\n%b\"\n alignment = left\n vertical_alignment = center\n show_age_threshold = 60\n ellipsize = middle\n ignore_newline = no\n stack_duplicates = true\n hide_duplicate_count = false\n show_indicators = yes\n\n # Icons, from the desktop's icon theme\n enable_recursive_icon_lookup = true\n icon_theme = Adwaita\n icon_position = left\n min_icon_size = 32\n max_icon_size = 128\n\n # History\n sticky_history = yes\n history_length = 20\n\n # The context menu is the node's dmenu-compatible command, which the holder of node-launcher\n # answers (rofi on the workstations). Links open in the desktop's default browser.\n dmenu = dmenu -p dunst\n browser = /usr/bin/xdg-open\n always_run_script = true\n\n title = Dunst\n class = Dunst\n ignore_dbusclose = false\n\n mouse_left_click = close_current\n mouse_middle_click = do_action, close_current\n mouse_right_click = close_all\n\n[urgency_low]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_normal]\n background = \"#000000\"\n foreground = \"#ffffff\"\n timeout = 10\n\n[urgency_critical]\n background = \"#000000\"\n foreground = \"#ffffff\"\n frame_color = \"#ff0000\"\n timeout = 0\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/dunst-tools",
|
||||
"binary": "dunst-tools",
|
||||
"loads": [
|
||||
"dunst-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# feh
|
||||
|
||||
The wallpaper as a module (novox/hq ADR 0208, research 026/05).
|
||||
|
||||
- Installs `feh` and requires `x11-display` on its own machine. It holds no seat: a wallpaper is not a
|
||||
role anything else calls.
|
||||
- **Carries the wallpaper itself.** `wallpaper/default.jpg` is built into an archive of the module
|
||||
(ADR 0205) and unpacked into `~/.local/share/feh/wallpapers/`, which the module owns.
|
||||
- Owns `~/.fehbg`, which sets that image, filled, on every monitor, without rewriting itself
|
||||
(`--no-fehbg`).
|
||||
- Runs `~/.fehbg` once per session, from the session's start (the `xinitrc` slot `normal`).
|
||||
- Binds `$mod+Shift+b` to the same file, as its own i3 drop-in (`50-feh.conf`): the declared wallpaper
|
||||
back, after a monitor change.
|
||||
|
||||
## Tools
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `feh_set` | set images (one for all monitors, or one each) in a mode: fill, center, max, scale, tile. For this session; the declared wallpaper returns at the next login |
|
||||
| `feh_current` | the declared wallpaper (from `~/.fehbg`) and the one `feh_set` put up in this session |
|
||||
|
||||
`feh_set` never writes `~/.fehbg`. A wallpaper that should stay is a change to this module, or a
|
||||
setting once issue 168 closes, not a file the next push would overwrite.
|
||||
|
||||
## What it improves on what was found
|
||||
|
||||
- **The wallpaper no longer lives in the predecessor's tree.** `~/.fehbg` pointed into a directory
|
||||
of the retired predecessor's. Deleting that directory would have left the desktop black, silently.
|
||||
- **One image file, the same on both workstations.** The image was byte-identical on both. It is now
|
||||
the module's own.
|
||||
|
||||
## What it leaves as found
|
||||
|
||||
- The predecessor's wallpaper directory. It is part of the predecessor's tree, which goes as a whole.
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
- The first push keeps the found `~/.fehbg` once, then writes the module's.
|
||||
- Once the `xorg` module writes the session's start, delete the `~/.fehbg &` line from your own part
|
||||
of `~/.xinitrc`.
|
||||
- The image's origin is the predecessor's desktop module. Check that it may be redistributed before
|
||||
this catalogue is published anywhere public.
|
||||
|
||||
## Blockers
|
||||
|
||||
- `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows them, `mctl` reads
|
||||
them as unknown.
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || 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 is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// feh's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the wallpaper's tools, served by the
|
||||
// node's runtime as the operator account.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
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: "feh_set",
|
||||
Description: "Set the wallpaper in the operator's session: one image for every monitor, or one per " +
|
||||
"monitor in the X screen order, filled, centred, scaled to fit, stretched or tiled. Lasts until the " +
|
||||
"next session start, when the declared wallpaper returns; the declared one is untouched.",
|
||||
Input: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"images": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "image files on this machine, absolute or under the account's home; one per monitor, or one for all"},
|
||||
"mode": map[string]any{"type": "string", "enum": modes, "description": "default fill"},
|
||||
},
|
||||
"required": []string{"images"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
images, err := texts(args, "images")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mode, err := text(args, "mode", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Set(images, mode)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "feh_current",
|
||||
Description: "The wallpaper: the declared one the session start sets (images and mode, read from " +
|
||||
"~/.fehbg), and the one feh_set put up in this session, if any.",
|
||||
Run: func(map[string]any) (any, error) { return Current() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
|
||||
// every desktop module that carries it.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Claims []claim `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Environment *environment `json:"environment"`
|
||||
Shell []shellCode `json:"shell"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
type claim struct {
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
Serves []string `json:"serves"`
|
||||
}
|
||||
|
||||
type environment struct {
|
||||
Variables map[string]string `json:"variables"`
|
||||
Path []map[string]any `json:"path"`
|
||||
}
|
||||
|
||||
type shellCode struct {
|
||||
For string `json:"for"`
|
||||
Slot string `json:"slot"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(t *testing.T, id string) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %q", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m manifest) packages() (present, absent []string) {
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" {
|
||||
if r["absent"] == true {
|
||||
absent = append(absent, r["package"].(string))
|
||||
} else {
|
||||
present = append(present, r["package"].(string))
|
||||
}
|
||||
}
|
||||
}
|
||||
return present, absent
|
||||
}
|
||||
|
||||
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
|
||||
// the readable file in the repository is what the machine gets.
|
||||
func (m manifest) sameAsSource(t *testing.T, id, source string) {
|
||||
t.Helper()
|
||||
want, err := os.ReadFile(filepath.Join("..", "..", source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := m.resource(t, id)
|
||||
if r["type"] != "file" {
|
||||
t.Fatalf("%s is a %v, not a file", id, r["type"])
|
||||
}
|
||||
if got, _ := r["content"].(string); got != string(want) {
|
||||
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
|
||||
}
|
||||
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
|
||||
t.Fatalf("%s under the home is the account's", id)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
|
||||
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
|
||||
func checkTheToolsAgree(t *testing.T, m manifest) {
|
||||
t.Helper()
|
||||
own, seat := map[string]bool{}, map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
if strings.Contains(tool.Name, ".") {
|
||||
seat[tool.Name] = true
|
||||
} else {
|
||||
own[tool.Name] = true
|
||||
}
|
||||
if strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s has no description", tool.Name)
|
||||
}
|
||||
}
|
||||
listed := map[string]bool{}
|
||||
for _, name := range m.Tools {
|
||||
listed[name] = true
|
||||
if !own[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
}
|
||||
for name := range own {
|
||||
if !listed[name] {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
|
||||
t.Errorf("%s is not prefixed with the module's name", name)
|
||||
}
|
||||
}
|
||||
promised := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
for _, verb := range c.Serves {
|
||||
promised[c.Name+"."+verb] = true
|
||||
if !seat[c.Name+"."+verb] {
|
||||
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range seat {
|
||||
if !promised[name] {
|
||||
t.Errorf("the bundle serves %s, which no claim promises", name)
|
||||
}
|
||||
}
|
||||
var bundle map[string]any
|
||||
for _, a := range m.Build.Artifacts {
|
||||
if a["kind"] == "bundle" {
|
||||
bundle = a
|
||||
}
|
||||
}
|
||||
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
|
||||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", bundle)
|
||||
}
|
||||
}
|
||||
|
||||
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
|
||||
func checkNoSecretsOrInstallationNames(t *testing.T) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := strings.ToLower(string(raw))
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// feh's shape (novox/hq ADR 0208): no seat; it requires the X display on its own machine, carries
|
||||
// the wallpaper as its own archive (ADR 0205), owns ~/.fehbg pointing at it, and sets it once from
|
||||
// the session's start.
|
||||
|
||||
func TestItRequiresTheXDisplayAndClaimsNothing(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
if m.Module != "feh" || m.Seats != nil || m.Claims != nil {
|
||||
t.Fatalf("module %q, seats %v, claims %v", m.Module, m.Seats, m.Claims)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("requires: %v", m.Requires)
|
||||
}
|
||||
if present, absent := m.packages(); !reflect.DeepEqual(present, []string{"feh"}) || absent != nil {
|
||||
t.Fatalf("packages: %v, absent %v", present, absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWallpaperIsTheModulesOwnArchive(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
a := m.resource(t, "wallpapers")
|
||||
if a["type"] != "archive" || a["artifact"] != "wallpapers" || a["path"] != "${machine:account-home}/.local/share/feh/wallpapers" || a["owner"] != "${machine:account}" {
|
||||
t.Fatalf("%v", a)
|
||||
}
|
||||
found := false
|
||||
for _, art := range m.Build.Artifacts {
|
||||
if art["name"] == "wallpapers" && art["kind"] == "archive" && art["from"] == "wallpaper" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("no archive artifact built from wallpaper/")
|
||||
}
|
||||
info, err := os.Stat(filepath.Join("..", "..", "wallpaper", "default.jpg"))
|
||||
if err != nil || info.Size() == 0 {
|
||||
t.Fatalf("the image: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFehbgIsOwnedAndTheSessionStartRunsItOnce(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
m.sameAsSource(t, "fehbg", "files/fehbg")
|
||||
f := m.resource(t, "fehbg")
|
||||
if f["path"] != "${machine:account-home}/.fehbg" || f["mode"] != "0755" {
|
||||
t.Fatalf("%v", f)
|
||||
}
|
||||
if c := f["content"].(string); !strings.Contains(c, "$HOME/.local/share/feh/wallpapers/default.jpg") || strings.Contains(c, ".hal") {
|
||||
t.Fatalf("%s", c)
|
||||
}
|
||||
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" || strings.Count(m.Shell[0].Code, `"$HOME/.fehbg"`) != 1 {
|
||||
t.Fatalf("%+v", m.Shell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheKeyThatRestoresTheWallpaperIsAnI3DropIn(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
m.sameAsSource(t, "i3-bindings", "files/i3/50-feh.conf")
|
||||
if p := m.resource(t, "i3-bindings")["path"]; p != "${machine:account-home}/.config/i3/config.d/50-feh.conf" {
|
||||
t.Fatalf("path: %v", p)
|
||||
}
|
||||
if c := m.resource(t, "i3-bindings")["content"].(string); !strings.Contains(c, "bindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n") {
|
||||
t.Fatalf("%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
checkTheToolsAgree(t, m)
|
||||
checkNoSecretsOrInstallationNames(t)
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
// The operator's graphical session, as a tool the node's runtime runs finds it (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment — no DISPLAY, no XAUTHORITY, no session bus. A tool that
|
||||
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
|
||||
// the account that is part of the session (the window manager first), the same thing `loginctl` and
|
||||
// a person's own shell would point at, and says where it found them.
|
||||
//
|
||||
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
|
||||
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
|
||||
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
|
||||
// die with it.
|
||||
//
|
||||
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
|
||||
// second consumer outside the desktop wants it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where the session is looked for. Variables so a test can point them at a fake tree.
|
||||
var (
|
||||
procRoot = "/proc"
|
||||
runUserDir = "/run/user"
|
||||
x11Sockets = "/tmp/.X11-unix"
|
||||
)
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first: the window
|
||||
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
|
||||
|
||||
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
|
||||
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
|
||||
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
|
||||
|
||||
// Session is what a tool needs to reach the operator's desktop.
|
||||
type Session struct {
|
||||
UID int `json:"uid"`
|
||||
Display string `json:"display,omitempty"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Wayland string `json:"wayland_display,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
I3Sock string `json:"i3sock,omitempty"`
|
||||
// From says where the values were found: the tool's own environment, a process, or the socket.
|
||||
From string `json:"from"`
|
||||
}
|
||||
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
var ErrNoSession = errors.New("no graphical session")
|
||||
|
||||
// ErrTimedOut is what run answers for a command ended because it ran past its time.
|
||||
var ErrTimedOut = errors.New("timed out")
|
||||
|
||||
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
|
||||
var ErrNoBus = errors.New("no session bus")
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
// findSession finds the graphical session of the account this tool runs as, or answers
|
||||
// ErrNoSession with what it looked at.
|
||||
func findSession() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Display == "" && s.Wayland == "" {
|
||||
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
|
||||
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
|
||||
// is running.
|
||||
func findBus() (Session, error) {
|
||||
s := findEnvironment()
|
||||
if s.Bus == "" {
|
||||
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
|
||||
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func findEnvironment() Session {
|
||||
uid := os.Getuid()
|
||||
s := Session{UID: uid}
|
||||
own := map[string]string{}
|
||||
for _, k := range sessionKeys {
|
||||
own[k] = os.Getenv(k)
|
||||
}
|
||||
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
|
||||
s.fill(own)
|
||||
s.From = "the tool's own environment"
|
||||
} else if pid, comm, env, ok := sessionProcess(uid); ok {
|
||||
s.fill(env)
|
||||
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
} else if display, ok := lonelyX11Socket(); ok {
|
||||
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
|
||||
s.Display, s.XAuthority = display, a
|
||||
s.From = "the X server socket and the account's ~/.Xauthority"
|
||||
}
|
||||
s.fill(own)
|
||||
} else {
|
||||
s.fill(own)
|
||||
s.From = "nothing: no session found"
|
||||
}
|
||||
// The bus and the runtime directory are the account's, whether or not the process named them.
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
|
||||
if s.RuntimeDir == "" && exists(runtime) {
|
||||
s.RuntimeDir = runtime
|
||||
}
|
||||
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Session) fill(env map[string]string) {
|
||||
set := func(dst *string, key string) {
|
||||
if *dst == "" {
|
||||
*dst = env[key]
|
||||
}
|
||||
}
|
||||
set(&s.Display, "DISPLAY")
|
||||
set(&s.XAuthority, "XAUTHORITY")
|
||||
set(&s.Wayland, "WAYLAND_DISPLAY")
|
||||
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
|
||||
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
|
||||
set(&s.SessionID, "XDG_SESSION_ID")
|
||||
set(&s.I3Sock, "I3SOCK")
|
||||
}
|
||||
|
||||
// sessionProcess is the best process of this uid whose environment names a display.
|
||||
func sessionProcess(uid int) (int, string, map[string]string, bool) {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
type candidate struct {
|
||||
pid int
|
||||
comm string
|
||||
env map[string]string
|
||||
rank int
|
||||
}
|
||||
var found []candidate
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
env := parseEnviron(raw)
|
||||
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
break
|
||||
}
|
||||
}
|
||||
found = append(found, candidate{pid, comm, env, rank})
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return 0, "", nil, false
|
||||
}
|
||||
sort.Slice(found, func(i, j int) bool {
|
||||
if found[i].rank != found[j].rank {
|
||||
return found[i].rank < found[j].rank
|
||||
}
|
||||
return found[i].pid > found[j].pid // the newer of two equals
|
||||
})
|
||||
best := found[0]
|
||||
return best.pid, best.comm, best.env, true
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func ownerOf(path string) (int, bool) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(st.Uid), true
|
||||
}
|
||||
|
||||
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
|
||||
func lonelyX11Socket() (string, bool) {
|
||||
entries, err := os.ReadDir(x11Sockets)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
var displays []string
|
||||
for _, e := range entries {
|
||||
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
|
||||
if _, err := strconv.Atoi(n); err == nil {
|
||||
displays = append(displays, ":"+n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(displays) != 1 {
|
||||
return "", false
|
||||
}
|
||||
return displays[0], true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Env is this process's environment with the session's variables in place of its own.
|
||||
func (s Session) Env() []string {
|
||||
drop := map[string]bool{}
|
||||
for _, k := range sessionKeys {
|
||||
drop[k] = true
|
||||
}
|
||||
var env []string
|
||||
for _, kv := range os.Environ() {
|
||||
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
|
||||
continue
|
||||
}
|
||||
env = append(env, kv)
|
||||
}
|
||||
add := func(k, v string) {
|
||||
if v != "" {
|
||||
env = append(env, k+"="+v)
|
||||
}
|
||||
}
|
||||
add("DISPLAY", s.Display)
|
||||
add("XAUTHORITY", s.XAuthority)
|
||||
add("WAYLAND_DISPLAY", s.Wayland)
|
||||
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
|
||||
add("XDG_RUNTIME_DIR", s.RuntimeDir)
|
||||
add("XDG_SESSION_ID", s.SessionID)
|
||||
add("I3SOCK", s.I3Sock)
|
||||
return env
|
||||
}
|
||||
|
||||
// mostOutput bounds what a command may answer with, per stream.
|
||||
const mostOutput = 256 << 10
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr,omitempty"`
|
||||
Code int `json:"code"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// run runs a command in the session's environment, its input given, ended with everything it
|
||||
// started after timeout. A command that is not installed is an error naming it; one that exits
|
||||
// non-zero is a Result with its code, for the caller to judge.
|
||||
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
|
||||
}
|
||||
cmd := exec.Command(path, args...)
|
||||
cmd.Env = s.Env()
|
||||
if home := operatorHome(); exists(home) {
|
||||
cmd.Dir = home
|
||||
}
|
||||
if stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(stdin)
|
||||
}
|
||||
var out, errOut capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return Result{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- cmd.Wait() }()
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(timeout):
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
<-done
|
||||
return Result{Stdout: out.String(), Stderr: errOut.String()},
|
||||
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
|
||||
}
|
||||
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
|
||||
var exit *exec.ExitError
|
||||
if errors.As(err, &exit) {
|
||||
r.Code = exit.ExitCode()
|
||||
} else if err != nil {
|
||||
return r, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's own service manager, as a transient unit
|
||||
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
|
||||
// running under the same name is stopped first, so a fixed name means "at most one".
|
||||
func (s Session) detach(unit string, args ...string) error {
|
||||
if s.RuntimeDir == "" {
|
||||
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
|
||||
"cannot be reached", ErrNoBus)
|
||||
}
|
||||
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(call, "--")
|
||||
call = append(call, args...)
|
||||
r, err := s.run(10*time.Second, "", "systemd-run", call...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uniqueUnit is a transient unit name that will not collide with an earlier one.
|
||||
func uniqueUnit(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := mostOutput - c.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.Buffer.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.Buffer.Write(p)
|
||||
}
|
||||
|
||||
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
|
||||
func processesOf(comm string) []int {
|
||||
entries, err := os.ReadDir(procRoot)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
uid := os.Getuid()
|
||||
var pids []int
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(procRoot, e.Name())
|
||||
if owner, ok := ownerOf(dir); !ok || owner != uid {
|
||||
continue
|
||||
}
|
||||
if readTrimmed(filepath.Join(dir, "comm")) == comm {
|
||||
pids = append(pids, pid)
|
||||
}
|
||||
}
|
||||
sort.Ints(pids)
|
||||
return pids
|
||||
}
|
||||
|
||||
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
|
||||
func signalAll(comm string, sig syscall.Signal) []int {
|
||||
var reached []int
|
||||
for _, pid := range processesOf(comm) {
|
||||
if syscall.Kill(pid, sig) == nil {
|
||||
reached = append(reached, pid)
|
||||
}
|
||||
}
|
||||
return reached
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
|
||||
// none of the test process's own session variables, and gives back the root.
|
||||
func fakeMachine(t *testing.T) string {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
|
||||
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, k := range sessionKeys {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
|
||||
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
|
||||
return root
|
||||
}
|
||||
|
||||
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(procRoot, strconv.Itoa(pid))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
|
||||
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
|
||||
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
|
||||
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
|
||||
t.Fatalf("the window manager's environment: %+v", s)
|
||||
}
|
||||
for _, kv := range s.Env() {
|
||||
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
|
||||
t.Fatal("a variable of the session process that is not a session variable was handed on")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
|
||||
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":2" {
|
||||
t.Fatalf("the newest of two equals: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
|
||||
_, err := findSession()
|
||||
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
|
||||
t.Fatalf("no session: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
|
||||
root := fakeMachine(t)
|
||||
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
|
||||
t.Fatalf("socket and authority: %+v, %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
|
||||
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory is no bus: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(runtime, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := findBus()
|
||||
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
|
||||
t.Fatalf("bus: %+v, %v", s, err)
|
||||
}
|
||||
env := strings.Join(s.Env(), "\n")
|
||||
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
|
||||
t.Fatalf("the bus is handed on: %s", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
s := Session{}
|
||||
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
|
||||
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("result: %+v, %v", r, err)
|
||||
}
|
||||
start := time.Now()
|
||||
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
|
||||
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
|
||||
}
|
||||
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
|
||||
t.Fatalf("a missing program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
|
||||
fakeMachine(t)
|
||||
bin := fakeBinaries(t, map[string]string{
|
||||
"systemctl": `echo "systemctl $*" >> "$LOG"`,
|
||||
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
|
||||
})
|
||||
log := filepath.Join(bin, "log")
|
||||
t.Setenv("LOG", log)
|
||||
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
|
||||
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := os.ReadFile(log)
|
||||
want := "systemctl --user stop picom-session.service\n" +
|
||||
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
|
||||
if string(got) != want {
|
||||
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
|
||||
}
|
||||
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
|
||||
t.Fatalf("no runtime directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
|
||||
func fakeBinaries(t *testing.T, scripts map[string]string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range scripts {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return dir
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// modes are feh's background modes, by the word feh_set takes.
|
||||
var modes = []string{"fill", "center", "max", "scale", "tile"}
|
||||
|
||||
// Wallpaper is images and how they are laid on the screens.
|
||||
type Wallpaper struct {
|
||||
Images []string `json:"images"`
|
||||
Mode string `json:"mode"`
|
||||
At string `json:"at,omitempty"`
|
||||
}
|
||||
|
||||
func fehbg() string { return filepath.Join(operatorHome(), ".fehbg") }
|
||||
|
||||
// sessionRecord is where feh_set notes what it put up, for feh_current: in the runtime directory,
|
||||
// so it lasts exactly as long as the login, like the wallpaper itself.
|
||||
func sessionRecord(s Session) string {
|
||||
if s.RuntimeDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(s.RuntimeDir, "feh", "current.json")
|
||||
}
|
||||
|
||||
// SetResult is what feh_set answers.
|
||||
type SetResult struct {
|
||||
Wallpaper
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// Set puts images up as the wallpaper for this session.
|
||||
func Set(images []string, mode string) (SetResult, error) {
|
||||
if mode == "" {
|
||||
mode = "fill"
|
||||
}
|
||||
known := false
|
||||
for _, m := range modes {
|
||||
known = known || m == mode
|
||||
}
|
||||
if !known {
|
||||
return SetResult{}, fmt.Errorf("mode %q is one of %s", mode, strings.Join(modes, ", "))
|
||||
}
|
||||
if len(images) == 0 {
|
||||
return SetResult{}, errors.New("images is required: at least one image")
|
||||
}
|
||||
var paths []string
|
||||
for _, img := range images {
|
||||
p := img
|
||||
if strings.HasPrefix(p, "~/") {
|
||||
p = filepath.Join(operatorHome(), p[2:])
|
||||
}
|
||||
if !filepath.IsAbs(p) {
|
||||
p = filepath.Join(operatorHome(), p)
|
||||
}
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
return SetResult{}, fmt.Errorf("image %s: %w", img, err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
return SetResult{}, fmt.Errorf("image %s is a directory", img)
|
||||
}
|
||||
paths = append(paths, p)
|
||||
}
|
||||
s, err := findSession()
|
||||
if err != nil {
|
||||
return SetResult{}, err
|
||||
}
|
||||
args := append([]string{"--no-fehbg", "--bg-" + mode}, paths...)
|
||||
r, err := s.run(15*time.Second, "", "feh", args...)
|
||||
if err != nil {
|
||||
return SetResult{}, err
|
||||
}
|
||||
if r.Code != 0 {
|
||||
return SetResult{}, fmt.Errorf("feh: %s", strings.TrimSpace(r.Stderr))
|
||||
}
|
||||
w := Wallpaper{Images: paths, Mode: mode, At: time.Now().Format(time.RFC3339)}
|
||||
if rec := sessionRecord(s); rec != "" {
|
||||
if err := os.MkdirAll(filepath.Dir(rec), 0o700); err == nil {
|
||||
raw, _ := json.Marshal(w)
|
||||
_ = os.WriteFile(rec, raw, 0o600)
|
||||
}
|
||||
}
|
||||
return SetResult{Wallpaper: w, Note: "for this session; the declared wallpaper returns at the next login"}, nil
|
||||
}
|
||||
|
||||
// CurrentResult is what feh_current answers.
|
||||
type CurrentResult struct {
|
||||
Declared *Wallpaper `json:"declared"`
|
||||
Session *Wallpaper `json:"session,omitempty"`
|
||||
}
|
||||
|
||||
var bgMode = regexp.MustCompile(`--bg-(fill|center|max|scale|tile)\b`)
|
||||
|
||||
// Current is the declared wallpaper and the one set in this session.
|
||||
func Current() (CurrentResult, error) {
|
||||
var out CurrentResult
|
||||
if raw, err := os.ReadFile(fehbg()); err == nil {
|
||||
out.Declared = parseFehbg(string(raw), operatorHome())
|
||||
}
|
||||
if rec := sessionRecord(findEnvironment()); rec != "" {
|
||||
if raw, err := os.ReadFile(rec); err == nil {
|
||||
var w Wallpaper
|
||||
if json.Unmarshal(raw, &w) == nil {
|
||||
out.Session = &w
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// parseFehbg reads the feh line of a ~/.fehbg: its mode and its images, with $HOME expanded.
|
||||
func parseFehbg(script, home string) *Wallpaper {
|
||||
for _, line := range strings.Split(script, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "feh ") {
|
||||
continue
|
||||
}
|
||||
w := &Wallpaper{Images: []string{}}
|
||||
if m := bgMode.FindStringSubmatch(line); m != nil {
|
||||
w.Mode = m[1]
|
||||
}
|
||||
for _, word := range shellWords(line)[1:] {
|
||||
if strings.HasPrefix(word, "-") {
|
||||
continue
|
||||
}
|
||||
word = strings.ReplaceAll(strings.ReplaceAll(word, "${HOME}", home), "$HOME", home)
|
||||
w.Images = append(w.Images, word)
|
||||
}
|
||||
return w
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shellWords splits a simple command line on blanks, honouring single and double quotes.
|
||||
func shellWords(line string) []string {
|
||||
var words []string
|
||||
var cur strings.Builder
|
||||
var quote byte
|
||||
in := false
|
||||
for i := 0; i < len(line); i++ {
|
||||
c := line[i]
|
||||
switch {
|
||||
case quote != 0 && c == quote:
|
||||
quote = 0
|
||||
case quote != 0:
|
||||
cur.WriteByte(c)
|
||||
case c == '\'' || c == '"':
|
||||
quote, in = c, true
|
||||
case c == ' ' || c == '\t':
|
||||
if in {
|
||||
words = append(words, cur.String())
|
||||
cur.Reset()
|
||||
in = false
|
||||
}
|
||||
default:
|
||||
cur.WriteByte(c)
|
||||
in = true
|
||||
}
|
||||
}
|
||||
if in {
|
||||
words = append(words, cur.String())
|
||||
}
|
||||
return words
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user