From db297e8bdd8c8194322b7dc1c0300d5172b35d89 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 13:01:25 +0200 Subject: [PATCH] snapd: tools for the snaps, the package blocked on the mesh's AUR repository (hq to-be 42 phase 2.9) snapd is not in the official repositories, and ADR 0205's archive does not fit a daemon with setuid helpers, so the module declares nothing until research 027 question 1 (P2) builds it into the mesh's own repository. Not even its units: on the laptop they do not exist, and the module would fail there. Ten Go tools that work wherever snapd is installed and say so where it is not: status (with AppArmor's absence from the kernel named), list, info, updates, disk usage with the disabled revisions' share, services, changes, and install, remove and refresh through sudo -n with --no-wait, answering snapd's change id. --- modules/snapd/README.md | 71 +++ modules/snapd/cmd/snapd-tools/kit.go | 352 ++++++++++++++ modules/snapd/cmd/snapd-tools/kit_test.go | 147 ++++++ modules/snapd/cmd/snapd-tools/main.go | 153 ++++++ .../cmd/snapd-tools/manifest_kit_test.go | 107 +++++ modules/snapd/cmd/snapd-tools/snapd.go | 437 ++++++++++++++++++ modules/snapd/cmd/snapd-tools/snapd_test.go | 203 ++++++++ modules/snapd/go.mod | 5 + modules/snapd/go.sum | 2 + modules/snapd/module.json | 31 ++ 10 files changed, 1508 insertions(+) create mode 100644 modules/snapd/README.md create mode 100644 modules/snapd/cmd/snapd-tools/kit.go create mode 100644 modules/snapd/cmd/snapd-tools/kit_test.go create mode 100644 modules/snapd/cmd/snapd-tools/main.go create mode 100644 modules/snapd/cmd/snapd-tools/manifest_kit_test.go create mode 100644 modules/snapd/cmd/snapd-tools/snapd.go create mode 100644 modules/snapd/cmd/snapd-tools/snapd_test.go create mode 100644 modules/snapd/go.mod create mode 100644 modules/snapd/go.sum create mode 100644 modules/snapd/module.json diff --git a/modules/snapd/README.md b/modules/snapd/README.md new file mode 100644 index 0000000..c6c6345 --- /dev/null +++ b/modules/snapd/README.md @@ -0,0 +1,71 @@ +# snapd + +Snaps on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are modules, on the +two workstations only"; to-be 42 phase 2 step 9). + +## Status: tools only, the package blocked + +**snapd is not in the distribution's official repositories.** It is a user-repository (AUR) package: +on the desktop it is installed as a foreign package, and `pacman -Si snapd` finds nothing. The host's +`package` shape installs from the official repositories only, so this module cannot declare it. + +Neither form of ADR 0205 fits either. snapd is a daemon in compiled code with setuid helpers, a socket, +services and a system mount, so it is not a pinned archive of plain files. The way out is research 027 +question 1, option P2: the build machine builds user-repository packages into a package repository the +mesh serves. Until that exists: + +- **The module declares no resources.** It does not even declare the units snapd brings + (`snapd.socket`, `snapd.apparmor.service`). On a workstation without the package, the laptop today, + those units do not exist, and the host would fail the module there. A declaration that cannot hold + on every machine the module is assigned to is not written. +- **The tools are written and work wherever snapd is installed.** Where it is not, every tool says + that, rather than answering an empty list. + +When the repository exists, the module gains, in one change: + +1. the package `snapd`; +2. `snapd.socket` enabled and running; +3. `snapd.apparmor.service` enabled only if the kernel runs AppArmor (below); +4. its tests. + +## Improves (once it owns the package) + +- **The disabled revisions become visible.** snapd keeps old revisions for rollback. On the desktop on + 2026-10-04 that was 1.7 GB of snaps, of which about 0.7 GB were disabled revisions: the previous + `code`, `core18`, `core20` and `snapd`. `snapd_disk_usage` answers it. +- **A unit that does nothing is named.** On the desktop `snapd.apparmor.service` is enabled, but the + kernel's security modules are `capability,landlock,lockdown,yama,bpf`. There is no AppArmor, so the + profiles it would load are enforced by nothing, and strict snaps run unconfined. `snapd_status` says + so. Turning AppArmor on is a kernel command-line change, which is the `kernel` module's, and is the + operator's choice. + +## Tools + +All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account; acts go through `sudo -n`. +Acts use `--no-wait`: snapd carries them out in the background, and the answer is snapd's change id, +followed with `snapd_changes`. No act outlasts the 20 s a call has. + +| tool | what | +|---|---| +| `snapd_status` (r) | installed or not, version, the four units' enabled and active states, AppArmor in the kernel, `/snap` present, and findings | +| `snapd_list` (r) | every revision: version, revision, tracking, publisher, notes, disabled | +| `snapd_info` (r) | one snap: fields, commands, channels | +| `snapd_updates` (r) | what a refresh would change | +| `snapd_disk_usage` (r) | bytes per snap and revision, the disabled revisions' share, the total | +| `snapd_services` (r) | the services snaps provide | +| `snapd_changes` (r) | recent changes, or one change's tasks | +| `snapd_install` (a) | install, optionally from a channel and in classic confinement | +| `snapd_remove` (a) | remove, keeping a snapshot unless `purge` | +| `snapd_refresh` (a) | refresh one snap, or all | + +## What changes when it is assigned + +Nothing on disk, on either workstation: the module declares nothing. + +- **desktop:** the tools answer for its snaps: `code` (classic), its bases, `gtk-common-themes`, + `gnome-3-28-1804`, `snapd`. +- **laptop:** snapd is not installed, and every tool says so. + +## Leaves as found + +Everything: the package, its units, `/snap`, the installed snaps and their data. diff --git a/modules/snapd/cmd/snapd-tools/kit.go b/modules/snapd/cmd/snapd-tools/kit.go new file mode 100644 index 0000000..adc5aac --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/kit.go @@ -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 +} diff --git a/modules/snapd/cmd/snapd-tools/kit_test.go b/modules/snapd/cmd/snapd-tools/kit_test.go new file mode 100644 index 0000000..c5d3557 --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/kit_test.go @@ -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") + } +} diff --git a/modules/snapd/cmd/snapd-tools/main.go b/modules/snapd/cmd/snapd-tools/main.go new file mode 100644 index 0000000..be6a51b --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/main.go @@ -0,0 +1,153 @@ +// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their +// revisions and the space they take, the store's pending updates, snapd's changes, and installing, +// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188, +// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`. +// +// The module installs nothing: snapd is not in the distribution's official repositories (README). +package main + +import ( + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +var providedBy = map[string]string{ + "snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)", + "systemctl": "the systemd package", +} + +func main() { + if err := stdio.Serve("", tools()); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +var nameArg = map[string]any{"type": "string", "description": "the snap's name"} + +func tools() []stdio.Tool { + return []stdio.Tool{ + { + Name: "snapd_status", + Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " + + "whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " + + "for classic snaps. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Status() }, + }, + { + Name: "snapd_list", + Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " + + "whether the revision is disabled (kept for rollback, taking space). (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return List() }, + }, + { + Name: "snapd_info", + Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)", + Input: map[string]any{"name": nameArg}, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + return Info(name) + }, + }, + { + Name: "snapd_updates", + Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Updates() }, + }, + { + Name: "snapd_disk_usage", + Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " + + "kept for rollback, and the total. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return DiskUsage() }, + }, + { + Name: "snapd_services", + Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)", + Input: map[string]any{}, + Run: func(map[string]any) (any, error) { return Services() }, + }, + { + Name: "snapd_changes", + Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " + + "one change's tasks. An act answers the change id to follow here. (r)", + Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}}, + Run: func(args map[string]any) (any, error) { + id, err := optText(args, "id", "") + if err != nil { + return nil, err + } + return Changes(id) + }, + }, + { + Name: "snapd_install", + Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " + + "carries it out in the background; the answer is the change id to follow with snapd_changes. (a)", + Input: map[string]any{ + "name": nameArg, + "channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"}, + "classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"}, + }, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + channel, err := optText(args, "channel", "") + if err != nil { + return nil, err + } + classic, err := optFlag(args, "classic", false) + if err != nil { + return nil, err + } + return Install(name, channel, classic) + }, + }, + { + Name: "snapd_remove", + Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)", + Input: map[string]any{ + "name": nameArg, + "purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"}, + }, + Run: func(args map[string]any) (any, error) { + name, err := snapName(args) + if err != nil { + return nil, err + } + purge, err := optFlag(args, "purge", false) + if err != nil { + return nil, err + } + return Remove(name, purge) + }, + }, + { + Name: "snapd_refresh", + Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)", + Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}}, + Run: func(args map[string]any) (any, error) { + name, err := optText(args, "name", "") + if err != nil { + return nil, err + } + if name != "" { + if err := checkSnapName(name); err != nil { + return nil, err + } + } + return Refresh(name) + }, + }, + } +} diff --git a/modules/snapd/cmd/snapd-tools/manifest_kit_test.go b/modules/snapd/cmd/snapd-tools/manifest_kit_test.go new file mode 100644 index 0000000..3e675b4 --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/manifest_kit_test.go @@ -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 _…, 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) + } +} diff --git a/modules/snapd/cmd/snapd-tools/snapd.go b/modules/snapd/cmd/snapd-tools/snapd.go new file mode 100644 index 0000000..9b0ef1b --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/snapd.go @@ -0,0 +1,437 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// snapNames are what the store accepts as a snap's name, optionally with an instance key. +var snapNames = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}(_[a-z0-9]{1,10})?$`) + +func checkSnapName(name string) error { + if !snapNames.MatchString(name) { + return fmt.Errorf("%q is not a snap name", name) + } + return nil +} + +func snapName(args map[string]any) (string, error) { + name, err := text(args, "name") + if err != nil { + return "", err + } + return name, checkSnapName(name) +} + +var plain = []string{"--unicode=never", "--color=never"} + +// Where snapd keeps what it knows. Tests point these elsewhere. +var ( + snapsDir = "/var/lib/snapd/snaps" + lsmFile = "/sys/kernel/security/lsm" + snapRoot = "/snap" +) + +// UnitState is one unit's state. +type UnitState struct { + Unit string `json:"unit"` + Enabled string `json:"enabled"` + Active string `json:"active"` +} + +// StatusAnswer is what snapd_status answers. +type StatusAnswer struct { + Installed bool `json:"installed"` + Version string `json:"version,omitempty"` + Units []UnitState `json:"units"` + AppArmor bool `json:"kernel_apparmor"` + ClassicRoot bool `json:"classic_root"` + Findings []string `json:"findings"` +} + +// Status says whether snapd is here and working. +func Status() (StatusAnswer, error) { + out := StatusAnswer{Units: []UnitState{}, Findings: []string{}} + r := run(Cmd{Name: "snap", Args: []string{"version"}}) + if r.Error == "not-found" { + out.Findings = append(out.Findings, "snapd is not installed on this machine") + return out, nil + } + if r.Status != 0 || r.Error != "" { + return out, failure(Cmd{Name: "snap", Args: []string{"version"}}, r) + } + out.Installed = true + for _, l := range lines(r.Stdout) { + if f := strings.Fields(l); len(f) >= 2 && f[0] == "snapd" { + out.Version = f[1] + } + } + for _, u := range []string{"snapd.socket", "snapd.service", "snapd.apparmor.service", "apparmor.service"} { + // is-enabled and is-active answer on stdout and exit non-zero for "disabled" and "inactive": + // a state, not a failure. + en := run(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}) + ac := run(Cmd{Name: "systemctl", Args: []string{"is-active", u}}) + if en.Error != "" || ac.Error != "" { + return out, failure(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}, en) + } + out.Units = append(out.Units, UnitState{Unit: u, Enabled: firstLine(en.Stdout), Active: firstLine(ac.Stdout)}) + } + if b, err := os.ReadFile(lsmFile); err == nil { + for _, m := range strings.Split(strings.TrimSpace(string(b)), ",") { + out.AppArmor = out.AppArmor || m == "apparmor" + } + } + _, err := os.Stat(snapRoot) + out.ClassicRoot = err == nil + if out.Units[0].Enabled != "enabled" { + out.Findings = append(out.Findings, "snapd.socket is not enabled: snapd does not start on demand") + } + if !out.AppArmor { + out.Findings = append(out.Findings, "the kernel does not run AppArmor: strict snaps run without their confinement") + } + if out.Units[2].Enabled == "enabled" && !out.AppArmor { + out.Findings = append(out.Findings, "snapd.apparmor.service is enabled with no AppArmor in the kernel: it loads profiles nothing enforces") + } + if !out.ClassicRoot { + out.Findings = append(out.Findings, "/snap does not exist: classic snaps cannot run") + } + return out, nil +} + +// Snap is one installed revision. +type Snap struct { + Name string `json:"name"` + Version string `json:"version"` + Revision string `json:"revision"` + Tracking string `json:"tracking"` + Publisher string `json:"publisher"` + Notes []string `json:"notes"` + Disabled bool `json:"disabled"` +} + +// ListAnswer is what snapd_list answers. +type ListAnswer struct { + Snaps []Snap `json:"snaps"` + Active int `json:"active"` + Disabled int `json:"disabled_revisions"` +} + +// columns reads a table snap prints: a header line, then whitespace-separated columns, the last +// taking the rest of the line. +func columns(s string, n int) [][]string { + out := [][]string{} + for i, l := range lines(s) { + if i == 0 { + continue + } + f := strings.Fields(l) + if len(f) < n { + continue + } + if len(f) > n { + f = append(f[:n-1], strings.Join(f[n-1:], " ")) + } + out = append(out, f) + } + return out +} + +// List answers every installed snap and revision. +func List() (ListAnswer, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"list", "--all"}, plain...)}) + if err != nil { + return ListAnswer{}, err + } + out := ListAnswer{Snaps: []Snap{}} + for _, f := range columns(r.Stdout, 6) { + s := Snap{Name: f[0], Version: f[1], Revision: f[2], Tracking: f[3], Publisher: strings.TrimRight(f[4], "*"), Notes: []string{}} + if f[5] != "-" { + s.Notes = strings.Split(f[5], ",") + } + for _, n := range s.Notes { + s.Disabled = s.Disabled || n == "disabled" + } + if s.Disabled { + out.Disabled++ + } else { + out.Active++ + } + out.Snaps = append(out.Snaps, s) + } + return out, nil +} + +// InfoAnswer is what snapd_info answers. +type InfoAnswer struct { + Name string `json:"name"` + Fields map[string]string `json:"fields"` + Commands []string `json:"commands"` + Channels map[string]string `json:"channels"` +} + +// Info reads snap info's YAML-like answer: top-level key: value lines, and the commands and +// channels blocks. +func Info(name string) (InfoAnswer, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"info"}, append(plain, name)...)}) + if err != nil { + return InfoAnswer{}, err + } + out := InfoAnswer{Name: name, Fields: map[string]string{}, Commands: []string{}, Channels: map[string]string{}} + block := "" + for _, l := range strings.Split(r.Stdout, "\n") { + if strings.TrimSpace(l) == "" { + continue + } + if !strings.HasPrefix(l, " ") { + block = "" + k, v, found := strings.Cut(l, ":") + if !found { + continue + } + v = strings.TrimSpace(v) + switch { + case v == "" || v == "|": + block = k + default: + out.Fields[k] = v + } + continue + } + t := strings.TrimSpace(l) + switch block { + case "commands": + out.Commands = append(out.Commands, strings.TrimPrefix(t, "- ")) + case "channels": + if k, v, found := strings.Cut(t, ":"); found { + out.Channels[k] = strings.Join(strings.Fields(v), " ") + } + case "description": + out.Fields["description"] = strings.TrimSpace(out.Fields["description"] + " " + t) + } + } + return out, nil +} + +// Update is one pending refresh. +type Update struct { + Name string `json:"name"` + Version string `json:"version"` + Revision string `json:"revision"` + Size string `json:"size"` + Publisher string `json:"publisher"` +} + +// Updates answers what a refresh would change. +func Updates() (map[string]any, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"refresh", "--list"}, plain...)}) + if err != nil { + return nil, err + } + out := []Update{} + if !strings.Contains(r.Stdout+r.Stderr, "All snaps up to date") { + for _, f := range columns(r.Stdout, 6) { + out = append(out, Update{Name: f[0], Version: f[1], Revision: f[2], Size: f[3], Publisher: strings.TrimRight(f[4], "*")}) + } + } + return map[string]any{"updates": out, "count": len(out)}, nil +} + +// Revision is one revision's file. +type Revision struct { + Revision string `json:"revision"` + Bytes int64 `json:"bytes"` + Disabled bool `json:"disabled"` +} + +// SnapUsage is the space one snap's revisions take. +type SnapUsage struct { + Name string `json:"name"` + Bytes int64 `json:"bytes"` + Revisions []Revision `json:"revisions"` +} + +// DiskAnswer is what snapd_disk_usage answers. +type DiskAnswer struct { + Dir string `json:"dir"` + TotalBytes int64 `json:"total_bytes"` + Reclaimable int64 `json:"disabled_revisions_bytes"` + Snaps []SnapUsage `json:"snaps"` + Note string `json:"note"` +} + +// DiskUsage measures the snap files and marks the disabled revisions. +func DiskUsage() (DiskAnswer, error) { + listed, err := List() + if err != nil { + return DiskAnswer{}, err + } + disabled := map[string]bool{} + for _, s := range listed.Snaps { + if s.Disabled { + disabled[s.Name+"_"+s.Revision] = true + } + } + files, err := filepath.Glob(filepath.Join(snapsDir, "*.snap")) + if err != nil { + return DiskAnswer{}, err + } + out := DiskAnswer{Dir: snapsDir, Snaps: []SnapUsage{}, + Note: "A disabled revision is kept by snapd for rollback (refresh.retain); removing one is `snap remove --revision`, which no tool here does."} + by := map[string]*SnapUsage{} + for _, f := range files { + info, err := os.Stat(f) + if err != nil { + return DiskAnswer{}, err + } + base := strings.TrimSuffix(filepath.Base(f), ".snap") + i := strings.LastIndex(base, "_") + if i <= 0 { + continue + } + name, rev := base[:i], base[i+1:] + if by[name] == nil { + by[name] = &SnapUsage{Name: name, Revisions: []Revision{}} + } + d := disabled[base] + by[name].Revisions = append(by[name].Revisions, Revision{Revision: rev, Bytes: info.Size(), Disabled: d}) + by[name].Bytes += info.Size() + out.TotalBytes += info.Size() + if d { + out.Reclaimable += info.Size() + } + } + for _, u := range by { + sort.Slice(u.Revisions, func(i, k int) bool { + a, _ := strconv.Atoi(u.Revisions[i].Revision) + b, _ := strconv.Atoi(u.Revisions[k].Revision) + return a < b + }) + out.Snaps = append(out.Snaps, *u) + } + sort.Slice(out.Snaps, func(i, k int) bool { return out.Snaps[i].Bytes > out.Snaps[k].Bytes }) + return out, nil +} + +// Services answers the snaps' services. +func Services() (map[string]any, error) { + r, err := call(Cmd{Name: "snap", Args: append([]string{"services"}, plain...)}) + if err != nil { + return nil, err + } + out := []map[string]string{} + if !strings.Contains(r.Stdout+r.Stderr, "no services") { + for _, f := range columns(r.Stdout, 4) { + out = append(out, map[string]string{"service": f[0], "startup": f[1], "current": f[2], "notes": f[3]}) + } + } + return map[string]any{"services": out}, nil +} + +// Change is one of snapd's changes, or one task of a change. +type Change struct { + ID string `json:"id,omitempty"` + Status string `json:"status"` + Spawn string `json:"spawn"` + Ready string `json:"ready,omitempty"` + Summary string `json:"summary"` +} + +var changeID = regexp.MustCompile(`^[0-9]+$`) + +// Changes answers snapd's recent changes, or one change's tasks. +func Changes(id string) (map[string]any, error) { + args := append([]string{"changes", "--abs-time"}, plain...) + n := 5 + if id != "" { + if !changeID.MatchString(id) { + return nil, fmt.Errorf("%q is not a change id", id) + } + args, n = append([]string{"tasks", "--abs-time"}, append(plain, id)...), 4 + } + r := run(Cmd{Name: "snap", Args: args}) + if strings.Contains(r.Stdout+r.Stderr, "no changes found") { + return map[string]any{"changes": []Change{}}, nil + } + if r.Status != 0 || r.Error != "" { + return nil, failure(Cmd{Name: "snap", Args: args}, r) + } + out := []Change{} + for _, f := range columns(r.Stdout, n) { + c := Change{} + if n == 5 { + c.ID, f = f[0], f[1:] + } + c.Status, c.Spawn, c.Ready, c.Summary = f[0], f[1], f[2], f[3] + if c.Ready == "-" { + c.Ready = "" + } + out = append(out, c) + } + if id != "" { + return map[string]any{"id": id, "tasks": out}, nil + } + return map[string]any{"changes": out}, nil +} + +// ActAnswer is what an act answers: the change snapd carries it out in. +type ActAnswer struct { + Act string `json:"act"` + Snap string `json:"snap,omitempty"` + Change string `json:"change,omitempty"` + Said string `json:"said,omitempty"` + Follow string `json:"follow,omitempty"` +} + +// act runs one snap act with --no-wait, which answers snapd's change id at once. +func act(verb, name string, extra ...string) (ActAnswer, error) { + args := append([]string{verb, "--no-wait"}, extra...) + if name != "" { + args = append(args, name) + } + r, err := call(Cmd{Name: "snap", Args: args, Root: true}) + if err != nil { + return ActAnswer{}, err + } + out := ActAnswer{Act: verb, Snap: name} + if id := strings.TrimSpace(r.Stdout); changeID.MatchString(id) { + out.Change, out.Follow = id, "snapd_changes with id "+id + } else { + out.Said = strings.TrimSpace(r.Stdout + "\n" + r.Stderr) + } + return out, nil +} + +var channelName = regexp.MustCompile(`^[a-z0-9][a-z0-9./_-]*$`) + +// Install installs a snap. +func Install(name, channel string, classic bool) (ActAnswer, error) { + extra := []string{} + if channel != "" { + if !channelName.MatchString(channel) { + return ActAnswer{}, fmt.Errorf("%q is not a channel", channel) + } + extra = append(extra, "--channel="+channel) + } + if classic { + extra = append(extra, "--classic") + } + return act("install", name, extra...) +} + +// Remove removes a snap. +func Remove(name string, purge bool) (ActAnswer, error) { + if purge { + return act("remove", name, "--purge") + } + return act("remove", name) +} + +// Refresh refreshes one snap, or all. +func Refresh(name string) (ActAnswer, error) { + return act("refresh", name) +} diff --git a/modules/snapd/cmd/snapd-tools/snapd_test.go b/modules/snapd/cmd/snapd-tools/snapd_test.go new file mode 100644 index 0000000..98b523a --- /dev/null +++ b/modules/snapd/cmd/snapd-tools/snapd_test.go @@ -0,0 +1,203 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTheManifestDeclaresNothingTheHostCouldNotInstall(t *testing.T) { + m := readManifest(t) + holdsTheBundle(t, m, "snapd") + // snapd is not in the official repositories: no package, and no unit that only that package + // brings, until the mesh's package repository builds it (research 027 question 1). + if len(m.Resources) != 0 { + t.Errorf("resources %v", m.Resources) + } +} + +const listAll = `Name Version Rev Tracking Publisher Notes +bare 1.0 5 latest/stable canonical** base +code 04c0d99f 266 latest/stable vscode** disabled,classic +code 07f806f9 267 latest/stable vscode** classic +gtk-common-themes 0.1-81-g442e511 1535 latest/stable canonical** - +` + +func TestListReadsEachRevisionAndMarksTheDisabledOnes(t *testing.T) { + f := using(t, func(string, Cmd) Result { return ok(listAll) }) + got, err := List() + if err != nil || len(got.Snaps) != 4 || got.Disabled != 1 || got.Active != 3 { + t.Fatalf("%+v %v", got, err) + } + if s := got.Snaps[1]; s.Name != "code" || s.Revision != "266" || !s.Disabled || s.Publisher != "vscode" || strings.Join(s.Notes, ",") != "disabled,classic" { + t.Errorf("%+v", s) + } + if s := got.Snaps[3]; len(s.Notes) != 0 || s.Disabled { + t.Errorf("%+v", s) + } + if f.lines()[0] != "snap list --all --unicode=never --color=never" { + t.Errorf("%v", f.lines()) + } +} + +func TestToolsSayWhenSnapdIsNotInstalled(t *testing.T) { + using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} }) + if _, err := List(); err == nil || !strings.Contains(err.Error(), "not in the official repositories") { + t.Fatalf("%v", err) + } + got, err := Status() + if err != nil || got.Installed || !strings.Contains(strings.Join(got.Findings, ";"), "not installed") { + t.Fatalf("%+v %v", got, err) + } +} + +func TestStatusNamesWhatIsWrongWithTheInstallation(t *testing.T) { + dir := t.TempDir() + wasLSM, wasRoot := lsmFile, snapRoot + lsmFile, snapRoot = filepath.Join(dir, "lsm"), filepath.Join(dir, "snap") + defer func() { lsmFile, snapRoot = wasLSM, wasRoot }() + _ = os.WriteFile(lsmFile, []byte("capability,landlock,lockdown,yama,bpf\n"), 0o644) + _ = os.Mkdir(snapRoot, 0o755) + using(t, func(line string, c Cmd) Result { + switch { + case line == "snap version": + return ok("snap 2.76.2-2\nsnapd 2.76.2-2\nseries 16\n") + case strings.HasSuffix(line, "is-enabled snapd.service"), strings.HasSuffix(line, "is-enabled apparmor.service"): + return Result{Status: 1, Stdout: "disabled\n"} + case strings.Contains(line, "is-enabled"): + return ok("enabled\n") + case strings.Contains(line, "is-active snapd.service"): + return ok("active\n") + } + return Result{Status: 3, Stdout: "inactive\n"} + }) + got, err := Status() + if err != nil || !got.Installed || got.Version != "2.76.2-2" || got.AppArmor || !got.ClassicRoot || len(got.Units) != 4 { + t.Fatalf("%+v %v", got, err) + } + if got.Units[1].Enabled != "disabled" || got.Units[1].Active != "active" { + t.Errorf("socket-activated service: %+v", got.Units[1]) + } + all := strings.Join(got.Findings, ";") + if !strings.Contains(all, "without their confinement") || !strings.Contains(all, "nothing enforces") || strings.Contains(all, "/snap does not exist") { + t.Errorf("%s", all) + } +} + +func TestInfoReadsFieldsCommandsAndChannels(t *testing.T) { + using(t, func(string, Cmd) Result { + return ok(`name: code +summary: Code editing. Redefined. +publisher: Visual Studio Code (vscode**) +license: unset +description: | + Visual Studio Code is a new choice + of tool. +commands: + - code + - code.url-handler +tracking: latest/stable +channels: + latest/stable: 07f806f9 2026-09-30 (267) 543MB classic + latest/candidate: ^ +`) + }) + got, err := Info("code") + if err != nil || got.Fields["summary"] != "Code editing. Redefined." || len(got.Commands) != 2 || got.Fields["tracking"] != "latest/stable" { + t.Fatalf("%+v %v", got, err) + } + if got.Channels["latest/stable"] != "07f806f9 2026-09-30 (267) 543MB classic" || got.Fields["description"] != "Visual Studio Code is a new choice of tool." { + t.Errorf("%+v", got) + } +} + +func TestUpdatesAndChangesReadNothingToDoAsEmpty(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "refresh --list") { + return Result{Stderr: "All snaps up to date.\n"} + } + return Result{Status: 1, Stderr: "error: no changes found\n"} + }) + u, err := Updates() + if err != nil || u["count"] != 0 { + t.Fatalf("%v %v", u, err) + } + c, err := Changes("") + if err != nil || len(c["changes"].([]Change)) != 0 { + t.Fatalf("%v %v", c, err) + } + if _, err := Changes("12; reboot"); err == nil { + t.Error("a change id that is not a number") + } +} + +func TestChangesAndTasksAreReadByColumn(t *testing.T) { + using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "tasks") { + return ok("Status Spawn Ready Summary\nDone 2026-10-01T18:57:00+02:00 2026-10-01T18:57:10+02:00 Download snap \"code\" (267)\n") + } + return ok("ID Status Spawn Ready Summary\n42 Doing 2026-10-04T10:00:00+02:00 - Install \"hello\" snap\n") + }) + c, err := Changes("") + ch := c["changes"].([]Change) + if err != nil || len(ch) != 1 || ch[0].ID != "42" || ch[0].Ready != "" || ch[0].Summary != `Install "hello" snap` { + t.Fatalf("%+v %v", c, err) + } + c, _ = Changes("42") + if tasks := c["tasks"].([]Change); len(tasks) != 1 || tasks[0].Status != "Done" || tasks[0].Summary != `Download snap "code" (267)` { + t.Errorf("%+v", c) + } +} + +func TestActsGoThroughSudoWithoutWaitingAndAnswerTheChange(t *testing.T) { + f := using(t, func(line string, c Cmd) Result { + if strings.Contains(line, "refresh") { + return Result{Stderr: "All snaps up to date.\n"} + } + return ok("57\n") + }) + got, err := Install("hello-world", "latest/edge", true) + if err != nil || got.Change != "57" || !strings.Contains(got.Follow, "57") { + t.Fatalf("%+v %v", got, err) + } + if _, err := Remove("hello-world", true); err != nil { + t.Fatal(err) + } + r, err := Refresh("") + if err != nil || r.Change != "" || !strings.Contains(r.Said, "up to date") { + t.Fatalf("%+v %v", r, err) + } + want := "sudo -n snap install --no-wait --channel=latest/edge --classic hello-world\n" + + "sudo -n snap remove --no-wait --purge hello-world\n" + + "sudo -n snap refresh --no-wait" + if got := strings.Join(f.lines(), "\n"); got != want { + t.Errorf("asked\n%s", got) + } + for _, bad := range []string{"--classic", "Hello", "a b", "../x"} { + if err := checkSnapName(bad); err == nil { + t.Errorf("%q accepted as a snap name", bad) + } + } + if _, err := Install("x", "--dangerous", false); err == nil { + t.Error("an option as a channel") + } +} + +func TestDiskUsageMeasuresEachRevisionAndWhatDisabledOnesTake(t *testing.T) { + dir := t.TempDir() + was := snapsDir + snapsDir = dir + defer func() { snapsDir = was }() + for name, size := range map[string]int{"code_266.snap": 500, "code_267.snap": 510, "bare_5.snap": 4} { + _ = os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o600) + } + using(t, func(string, Cmd) Result { return ok(listAll) }) + got, err := DiskUsage() + if err != nil || got.TotalBytes != 1014 || got.Reclaimable != 500 || len(got.Snaps) != 2 { + t.Fatalf("%+v %v", got, err) + } + if c := got.Snaps[0]; c.Name != "code" || c.Bytes != 1010 || !c.Revisions[0].Disabled || c.Revisions[1].Disabled { + t.Errorf("%+v", c) + } +} diff --git a/modules/snapd/go.mod b/modules/snapd/go.mod new file mode 100644 index 0000000..fdb3da4 --- /dev/null +++ b/modules/snapd/go.mod @@ -0,0 +1,5 @@ +module snapd + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/snapd/go.sum b/modules/snapd/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/snapd/go.sum @@ -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= diff --git a/modules/snapd/module.json b/modules/snapd/module.json new file mode 100644 index 0000000..93ad730 --- /dev/null +++ b/modules/snapd/module.json @@ -0,0 +1,31 @@ +{ + "module": "snapd", + "version": "1", + "tools": [ + "snapd_status", + "snapd_list", + "snapd_info", + "snapd_updates", + "snapd_disk_usage", + "snapd_services", + "snapd_changes", + "snapd_install", + "snapd_remove", + "snapd_refresh" + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/snapd-tools", + "binary": "snapd-tools", + "loads": [ + "snapd-tools" + ] + } + ] + } +}