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.
This commit is contained in:
jochen
2026-10-04 13:02:23 +02:00
parent 45befbbd02
commit db297e8bdd
10 changed files with 1508 additions and 0 deletions
+71
View File
@@ -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.
+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")
}
}
+153
View File
@@ -0,0 +1,153 @@
// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their
// revisions and the space they take, the store's pending updates, snapd's changes, and installing,
// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188,
// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`.
//
// The module installs nothing: snapd is not in the distribution's official repositories (README).
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
var providedBy = map[string]string{
"snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)",
"systemctl": "the systemd package",
}
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var nameArg = map[string]any{"type": "string", "description": "the snap's name"}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "snapd_status",
Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " +
"whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " +
"for classic snaps. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Status() },
},
{
Name: "snapd_list",
Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " +
"whether the revision is disabled (kept for rollback, taking space). (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return List() },
},
{
Name: "snapd_info",
Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)",
Input: map[string]any{"name": nameArg},
Run: func(args map[string]any) (any, error) {
name, err := snapName(args)
if err != nil {
return nil, err
}
return Info(name)
},
},
{
Name: "snapd_updates",
Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Updates() },
},
{
Name: "snapd_disk_usage",
Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " +
"kept for rollback, and the total. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return DiskUsage() },
},
{
Name: "snapd_services",
Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)",
Input: map[string]any{},
Run: func(map[string]any) (any, error) { return Services() },
},
{
Name: "snapd_changes",
Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " +
"one change's tasks. An act answers the change id to follow here. (r)",
Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}},
Run: func(args map[string]any) (any, error) {
id, err := optText(args, "id", "")
if err != nil {
return nil, err
}
return Changes(id)
},
},
{
Name: "snapd_install",
Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " +
"carries it out in the background; the answer is the change id to follow with snapd_changes. (a)",
Input: map[string]any{
"name": nameArg,
"channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"},
"classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"},
},
Run: func(args map[string]any) (any, error) {
name, err := snapName(args)
if err != nil {
return nil, err
}
channel, err := optText(args, "channel", "")
if err != nil {
return nil, err
}
classic, err := optFlag(args, "classic", false)
if err != nil {
return nil, err
}
return Install(name, channel, classic)
},
},
{
Name: "snapd_remove",
Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)",
Input: map[string]any{
"name": nameArg,
"purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"},
},
Run: func(args map[string]any) (any, error) {
name, err := snapName(args)
if err != nil {
return nil, err
}
purge, err := optFlag(args, "purge", false)
if err != nil {
return nil, err
}
return Remove(name, purge)
},
},
{
Name: "snapd_refresh",
Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)",
Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}},
Run: func(args map[string]any) (any, error) {
name, err := optText(args, "name", "")
if err != nil {
return nil, err
}
if name != "" {
if err := checkSnapName(name); err != nil {
return nil, err
}
}
return Refresh(name)
},
},
}
}
@@ -0,0 +1,107 @@
package main
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
// definition so the module's own tests can hold it to what it says.
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
type manifest struct {
Module string `json:"module"`
Capabilities []string `json:"capabilities"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m
}
func (m manifest) resource(id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
return nil
}
// packages are the packages the module installs, sorted.
func (m manifest) packages() []string {
out := []string{}
for _, r := range m.Resources {
if r["type"] == "package" && r["absent"] != true {
out = append(out, r["package"].(string))
}
}
sort.Strings(out)
return out
}
// services are the units the module declares, by unit name.
func (m manifest) services() map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range m.Resources {
if r["type"] == "service" {
out[r["unit"].(string)] = r
}
}
return out
}
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
t.Helper()
registered := []string{}
for _, tool := range tools() {
registered = append(registered, tool.Name)
if !strings.HasPrefix(tool.Name, prefix+"_") {
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
}
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
}
}
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
t.Errorf("registered %v, listed %v", registered, m.Tools)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
}
cwd, _ := os.Getwd()
binary := filepath.Base(cwd)
a := m.Build.Artifacts[0]
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
for k, v := range want {
if a[k] != v {
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
}
}
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
}
if m.Claims != nil || m.Seats != nil {
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
}
}
+437
View File
@@ -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)
}
+203
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
module snapd
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=
+31
View File
@@ -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"
]
}
]
}
}