cups: the scheduler and driverless printing, and the printers as tools (hq to-be 42 phase 2.9)

Research 027 asked for cups with the printer's driver. Measured: both
Brother queues already print through IPP Everywhere, so cups and
cups-filters are the whole driver, and the AUR vendor packages beside them
serve no queue. The desktop's Canon is the exception: its 2012 driver is
AUR-only and waits for the mesh's package repository; it stays as found.

Seven Go tools: printers (state, device, driverless or not, supply levels),
queue, cancel (the account first, sudo -n when CUPS refuses it), print,
default, resume, and drivers (which packages bring drivers, which are
foreign, which no queue uses).
This commit is contained in:
jochen
2026-10-04 13:02:23 +02:00
parent 152ef9621d
commit b352f3920e
10 changed files with 1726 additions and 0 deletions
+84
View File
@@ -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`.
+560
View File
@@ -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
}
+234
View File
@@ -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)
}
}
+352
View File
@@ -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
}
+147
View File
@@ -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")
}
}
+177
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module cups
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -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=
+58
View File
@@ -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"
]
}
]
}
}