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:
@@ -0,0 +1,71 @@
|
||||
# snapd
|
||||
|
||||
Snaps on the two workstations (novox/hq research 027/02: "`snapd` and `flatpak` are modules, on the
|
||||
two workstations only"; to-be 42 phase 2 step 9).
|
||||
|
||||
## Status: tools only, the package blocked
|
||||
|
||||
**snapd is not in the distribution's official repositories.** It is a user-repository (AUR) package:
|
||||
on the desktop it is installed as a foreign package, and `pacman -Si snapd` finds nothing. The host's
|
||||
`package` shape installs from the official repositories only, so this module cannot declare it.
|
||||
|
||||
Neither form of ADR 0205 fits either. snapd is a daemon in compiled code with setuid helpers, a socket,
|
||||
services and a system mount, so it is not a pinned archive of plain files. The way out is research 027
|
||||
question 1, option P2: the build machine builds user-repository packages into a package repository the
|
||||
mesh serves. Until that exists:
|
||||
|
||||
- **The module declares no resources.** It does not even declare the units snapd brings
|
||||
(`snapd.socket`, `snapd.apparmor.service`). On a workstation without the package, the laptop today,
|
||||
those units do not exist, and the host would fail the module there. A declaration that cannot hold
|
||||
on every machine the module is assigned to is not written.
|
||||
- **The tools are written and work wherever snapd is installed.** Where it is not, every tool says
|
||||
that, rather than answering an empty list.
|
||||
|
||||
When the repository exists, the module gains, in one change:
|
||||
|
||||
1. the package `snapd`;
|
||||
2. `snapd.socket` enabled and running;
|
||||
3. `snapd.apparmor.service` enabled only if the kernel runs AppArmor (below);
|
||||
4. its tests.
|
||||
|
||||
## Improves (once it owns the package)
|
||||
|
||||
- **The disabled revisions become visible.** snapd keeps old revisions for rollback. On the desktop on
|
||||
2026-10-04 that was 1.7 GB of snaps, of which about 0.7 GB were disabled revisions: the previous
|
||||
`code`, `core18`, `core20` and `snapd`. `snapd_disk_usage` answers it.
|
||||
- **A unit that does nothing is named.** On the desktop `snapd.apparmor.service` is enabled, but the
|
||||
kernel's security modules are `capability,landlock,lockdown,yama,bpf`. There is no AppArmor, so the
|
||||
profiles it would load are enforced by nothing, and strict snaps run unconfined. `snapd_status` says
|
||||
so. Turning AppArmor on is a kernel command-line change, which is the `kernel` module's, and is the
|
||||
operator's choice.
|
||||
|
||||
## Tools
|
||||
|
||||
All answer JSON; `(r)` reads, `(a)` acts. Reads run as the operator account; acts go through `sudo -n`.
|
||||
Acts use `--no-wait`: snapd carries them out in the background, and the answer is snapd's change id,
|
||||
followed with `snapd_changes`. No act outlasts the 20 s a call has.
|
||||
|
||||
| tool | what |
|
||||
|---|---|
|
||||
| `snapd_status` (r) | installed or not, version, the four units' enabled and active states, AppArmor in the kernel, `/snap` present, and findings |
|
||||
| `snapd_list` (r) | every revision: version, revision, tracking, publisher, notes, disabled |
|
||||
| `snapd_info` (r) | one snap: fields, commands, channels |
|
||||
| `snapd_updates` (r) | what a refresh would change |
|
||||
| `snapd_disk_usage` (r) | bytes per snap and revision, the disabled revisions' share, the total |
|
||||
| `snapd_services` (r) | the services snaps provide |
|
||||
| `snapd_changes` (r) | recent changes, or one change's tasks |
|
||||
| `snapd_install` (a) | install, optionally from a channel and in classic confinement |
|
||||
| `snapd_remove` (a) | remove, keeping a snapshot unless `purge` |
|
||||
| `snapd_refresh` (a) | refresh one snap, or all |
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
Nothing on disk, on either workstation: the module declares nothing.
|
||||
|
||||
- **desktop:** the tools answer for its snaps: `code` (classic), its bases, `gtk-common-themes`,
|
||||
`gnome-3-28-1804`, `snapd`.
|
||||
- **laptop:** snapd is not installed, and every tool says so.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
Everything: the package, its units, `/snap`, the installed snaps and their data.
|
||||
@@ -0,0 +1,352 @@
|
||||
package main
|
||||
|
||||
// kit.go is the same file in each of the workstations' tool bundles (fonts, docker-compose, snapd,
|
||||
// flatpak, cups, bluetooth, xclip, dmenu): how a tool runs a command, escalates, bounds what it
|
||||
// keeps, and names a failure. A module is built from its own directory, so the file is copied rather
|
||||
// than shared; a change to one copy is made to all eight.
|
||||
//
|
||||
// The rules it holds (novox/hq research 026/05, to-be 38 WP4):
|
||||
// - the node's tool runtime runs as the operator account, not root (ADR 0175 §4); a command that
|
||||
// needs root goes through `sudo -n`, never a prompt, and a refusal is named as such;
|
||||
// - one command gets 20 s, below the runtime's 30 s call limit, and is ended with everything it
|
||||
// started when it takes longer;
|
||||
// - each stream is kept to 256 KiB, and the answer says when it was cut;
|
||||
// - a failure is an error with what went wrong in it, never an empty answer.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command is held to.
|
||||
const (
|
||||
CallTimeout = 20 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
)
|
||||
|
||||
// Cmd is one command a tool runs.
|
||||
type Cmd struct {
|
||||
Name string
|
||||
Args []string
|
||||
// Stdin is written to the command's standard input when not empty.
|
||||
Stdin string
|
||||
// Env is added to this process's own environment.
|
||||
Env []string
|
||||
// Root says the command needs root: it is run through `sudo -n` when this process is not root.
|
||||
Root bool
|
||||
// Timeout replaces CallTimeout; only a background job (jobs.go) asks for longer.
|
||||
Timeout time.Duration
|
||||
// Detached is for a program that forks a child which outlives it, as xclip does to keep the
|
||||
// selection: its streams go to files, because a pipe the child inherits would hold the call open
|
||||
// until the child exits.
|
||||
Detached bool
|
||||
}
|
||||
|
||||
// Result is what a command did.
|
||||
type Result struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Status int `json:"status"`
|
||||
// Error is why it did not run to an answer: "not-found" when the program is not there,
|
||||
// "timeout" when it was ended for taking too long, else the spawn error.
|
||||
Error string `json:"error,omitempty"`
|
||||
Truncated bool `json:"truncated,omitempty"`
|
||||
}
|
||||
|
||||
// Runner runs a command. Tests replace it; nothing else does.
|
||||
type Runner func(Cmd) Result
|
||||
|
||||
var (
|
||||
run Runner = execRun
|
||||
euid = os.Geteuid
|
||||
)
|
||||
|
||||
// argv is the command as it is run: through sudo without a prompt when it needs root and this
|
||||
// process is not root.
|
||||
func argv(c Cmd) (string, []string) {
|
||||
if c.Root && euid() != 0 {
|
||||
return "sudo", append([]string{"-n", c.Name}, c.Args...)
|
||||
}
|
||||
return c.Name, c.Args
|
||||
}
|
||||
|
||||
// bounded keeps the first MostOutput bytes written to it and notes that more came.
|
||||
type bounded struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (w *bounded) Write(p []byte) (int, error) {
|
||||
room := MostOutput - w.b.Len()
|
||||
if room <= 0 {
|
||||
w.cut = w.cut || len(p) > 0
|
||||
return len(p), nil
|
||||
}
|
||||
if len(p) > room {
|
||||
w.b.Write(p[:room])
|
||||
w.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return w.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(c Cmd) Result {
|
||||
timeout := c.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = CallTimeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
name, args := argv(c)
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), c.Env...)
|
||||
if !c.Detached {
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
if c.Stdin != "" {
|
||||
cmd.Stdin = strings.NewReader(c.Stdin)
|
||||
}
|
||||
var out, errs bounded
|
||||
var outFile, errFile *os.File
|
||||
if c.Detached {
|
||||
var err error
|
||||
if outFile, err = os.CreateTemp("", "mesh-tool-out-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(outFile.Name())
|
||||
defer outFile.Close()
|
||||
if errFile, err = os.CreateTemp("", "mesh-tool-err-*"); err != nil {
|
||||
return Result{Status: 127, Error: err.Error()}
|
||||
}
|
||||
defer os.Remove(errFile.Name())
|
||||
defer errFile.Close()
|
||||
cmd.Stdout, cmd.Stderr = outFile, errFile
|
||||
} else {
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
}
|
||||
err := cmd.Run()
|
||||
if c.Detached {
|
||||
for _, f := range []struct {
|
||||
file *os.File
|
||||
into *bounded
|
||||
}{{outFile, &out}, {errFile, &errs}} {
|
||||
if _, e := f.file.Seek(0, io.SeekStart); e == nil {
|
||||
_, _ = io.Copy(f.into, f.file)
|
||||
}
|
||||
}
|
||||
}
|
||||
r := Result{Stdout: out.b.String(), Stderr: errs.b.String(), Truncated: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
r.Status, r.Error = 124, "timeout"
|
||||
case errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist):
|
||||
r.Status, r.Error = 127, "not-found"
|
||||
case errors.As(err, &exit):
|
||||
r.Status = exit.ExitCode()
|
||||
default:
|
||||
r.Status, r.Error = 127, err.Error()
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// call runs a command and answers its result, or an error naming what went wrong.
|
||||
func call(c Cmd) (Result, error) {
|
||||
r := run(c)
|
||||
if r.Status == 0 && r.Error == "" {
|
||||
return r, nil
|
||||
}
|
||||
return r, failure(c, r)
|
||||
}
|
||||
|
||||
// failure names how a command failed: not installed, refused escalation, too slow, or its exit
|
||||
// status with the end of what it said.
|
||||
func failure(c Cmd, r Result) error {
|
||||
program, _ := argv(c)
|
||||
switch {
|
||||
case r.Error == "not-found" && program == "sudo":
|
||||
return fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", c.Name)
|
||||
case r.Error == "not-found":
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case r.Error == "timeout":
|
||||
limit := c.Timeout
|
||||
if limit <= 0 {
|
||||
limit = CallTimeout
|
||||
}
|
||||
return fmt.Errorf("%s gave no answer within %s and was ended", c.Name, limit)
|
||||
case r.Error != "":
|
||||
return fmt.Errorf("%s did not run: %s", c.Name, r.Error)
|
||||
case program == "sudo" && strings.Contains(r.Stderr, "command not found"):
|
||||
if hint, ok := providedBy[c.Name]; ok {
|
||||
return fmt.Errorf("%s is not installed on this machine (%s)", c.Name, hint)
|
||||
}
|
||||
return fmt.Errorf("%s is not installed on this machine", c.Name)
|
||||
case program == "sudo" && strings.HasPrefix(strings.TrimSpace(r.Stderr), "sudo:"):
|
||||
return fmt.Errorf("%s needs root, and sudo -n refused the runtime's account: %s (the escalation is the sudo module's to declare)",
|
||||
c.Name, firstLine(r.Stderr))
|
||||
}
|
||||
said := tail(strings.TrimSpace(r.Stderr), 2000)
|
||||
if said == "" {
|
||||
said = tail(strings.TrimSpace(r.Stdout), 2000)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", c.Name, strings.Join(c.Args, " "), r.Status, said)
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// lines are a command's output lines, blank ones dropped.
|
||||
func lines(s string) []string {
|
||||
out := []string{}
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(l) != "" {
|
||||
out = append(out, strings.TrimRight(l, "\r"))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Arguments, read the way a tool's JSON arguments arrive.
|
||||
|
||||
func text(args map[string]any, key string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return "", fmt.Errorf("%s must not be empty", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func optText(args map[string]any, key, def string) (string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s must be a string", key)
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// optWhole reads a whole number, defaulted, refused below least and held to most.
|
||||
func optWhole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s must be a number", key)
|
||||
}
|
||||
}
|
||||
if f != float64(int(f)) {
|
||||
return 0, fmt.Errorf("%s must be a whole number", key)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least {
|
||||
return 0, fmt.Errorf("%s must be at least %d", key, least)
|
||||
}
|
||||
if n > most {
|
||||
n = most
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func optFlag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s must be true or false", key)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func optList(args map[string]any, key string) ([]string, error) {
|
||||
v, ok := args[key]
|
||||
if !ok || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s must be a list of strings", key)
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
s, ok := it.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return nil, fmt.Errorf("%s must be a list of non-empty strings", key)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// oneOf refuses a value outside a closed set.
|
||||
func oneOf(key, value string, allowed ...string) error {
|
||||
for _, a := range allowed {
|
||||
if value == a {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s must be one of %s, not %q", key, strings.Join(allowed, ", "), value)
|
||||
}
|
||||
|
||||
// plainName refuses a name that could be read as an option or carries a path or a space: package,
|
||||
// snap, application and printer names never do.
|
||||
func plainName(key, value string) error {
|
||||
if strings.HasPrefix(value, "-") || strings.ContainsAny(value, " \t\n/\\") {
|
||||
return fmt.Errorf("%s %q is not a plain name", key, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// Tests of kit.go, the same in each workstation module.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fake records the commands asked and answers each from a function of the command line.
|
||||
type fake struct {
|
||||
asked []Cmd
|
||||
answer func(line string, c Cmd) Result
|
||||
}
|
||||
|
||||
func (f *fake) runner() Runner {
|
||||
return func(c Cmd) Result {
|
||||
f.asked = append(f.asked, c)
|
||||
name, args := argv(c)
|
||||
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
|
||||
if f.answer == nil {
|
||||
return Result{}
|
||||
}
|
||||
return f.answer(line, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) lines() []string {
|
||||
out := []string{}
|
||||
for _, c := range f.asked {
|
||||
name, args := argv(c)
|
||||
out = append(out, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// using installs a fake runner and a non-root uid for one test.
|
||||
func using(t *testing.T, answer func(line string, c Cmd) Result) *fake {
|
||||
t.Helper()
|
||||
f := &fake{answer: answer}
|
||||
wasRun, wasUID := run, euid
|
||||
run, euid = f.runner(), func() int { return 1000 }
|
||||
t.Cleanup(func() { run, euid = wasRun, wasUID })
|
||||
return f
|
||||
}
|
||||
|
||||
func ok(stdout string) Result { return Result{Stdout: stdout} }
|
||||
|
||||
func TestKitAnActThatNeedsRootGoesThroughSudoWithoutAPromptUnlessAlreadyRoot(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
if name, args := argv(Cmd{Name: "x", Args: []string{"a"}, Root: true}); name != "sudo" || strings.Join(args, " ") != "-n x a" {
|
||||
t.Fatalf("not root: %s %v", name, args)
|
||||
}
|
||||
if name, _ := argv(Cmd{Name: "x"}); name != "x" {
|
||||
t.Fatalf("a read is run as the account: %s", name)
|
||||
}
|
||||
euid = func() int { return 0 }
|
||||
if name, _ := argv(Cmd{Name: "x", Root: true}); name != "x" {
|
||||
t.Fatalf("as root no sudo: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitAFailureIsNamedByHowItFailed(t *testing.T) {
|
||||
was := euid
|
||||
defer func() { euid = was }()
|
||||
euid = func() int { return 1000 }
|
||||
cases := []struct {
|
||||
c Cmd
|
||||
r Result
|
||||
want string
|
||||
}{
|
||||
{Cmd{Name: "nothere"}, Result{Status: 127, Error: "not-found"}, "not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 127, Error: "not-found"}, "sudo is not installed"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: a password is required\n"}, "sudo -n refused"},
|
||||
{Cmd{Name: "x", Root: true}, Result{Status: 1, Stderr: "sudo: x: command not found\n"}, "x is not installed"},
|
||||
{Cmd{Name: "x"}, Result{Status: 124, Error: "timeout"}, "within 20s"},
|
||||
{Cmd{Name: "x", Args: []string{"y"}}, Result{Status: 3, Stderr: "boom\n"}, "x y exited 3: boom"},
|
||||
{Cmd{Name: "x"}, Result{Status: 3}, "said nothing"},
|
||||
}
|
||||
for _, k := range cases {
|
||||
err := failure(k.c, k.r)
|
||||
if err == nil || !strings.Contains(err.Error(), k.want) {
|
||||
t.Errorf("%+v: %v, want %q", k.r, err, k.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitOutputIsBoundedAndSaysSo(t *testing.T) {
|
||||
var w bounded
|
||||
big := strings.Repeat("a", MostOutput+10)
|
||||
n, _ := w.Write([]byte(big))
|
||||
if n != len(big) || w.b.Len() != MostOutput || !w.cut {
|
||||
t.Fatalf("kept %d of %d, cut %v", w.b.Len(), len(big), w.cut)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitTheRealRunnerRunsEndsAndReportsAMissingProgram(t *testing.T) {
|
||||
r := execRun(Cmd{Name: "sh", Args: []string{"-c", "echo out; echo err >&2; exit 3"}})
|
||||
if r.Status != 3 || strings.TrimSpace(r.Stdout) != "out" || strings.TrimSpace(r.Stderr) != "err" {
|
||||
t.Fatalf("%+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "sleep 5 & sleep 5"}, Timeout: 200 * time.Millisecond})
|
||||
if r.Error != "timeout" {
|
||||
t.Fatalf("a slow command: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "no-such-program-anywhere"})
|
||||
if r.Error != "not-found" {
|
||||
t.Fatalf("a missing program: %+v", r)
|
||||
}
|
||||
r = execRun(Cmd{Name: "cat", Stdin: "given"})
|
||||
if r.Stdout != "given" {
|
||||
t.Fatalf("stdin: %+v", r)
|
||||
}
|
||||
start := time.Now()
|
||||
r = execRun(Cmd{Name: "sh", Args: []string{"-c", "echo kept; (sleep 3 &) ; exit 0"}, Detached: true})
|
||||
if r.Status != 0 || strings.TrimSpace(r.Stdout) != "kept" || time.Since(start) > 2*time.Second {
|
||||
t.Fatalf("a detached command returns when it exits, not when its child does: %+v after %s", r, time.Since(start))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKitArgumentsAreReadStrictly(t *testing.T) {
|
||||
args := map[string]any{"s": "x", "n": float64(5), "f": 1.5, "b": true, "l": []any{"a", "b"}}
|
||||
if _, err := text(args, "missing"); err == nil {
|
||||
t.Error("a missing required string")
|
||||
}
|
||||
if n, _ := optWhole(args, "n", 1, 1, 3); n != 3 {
|
||||
t.Errorf("held to most: %d", n)
|
||||
}
|
||||
if _, err := optWhole(args, "n", 1, 6, 9); err == nil {
|
||||
t.Error("below least")
|
||||
}
|
||||
if _, err := optWhole(args, "f", 1, 0, 9); err == nil {
|
||||
t.Error("a fraction")
|
||||
}
|
||||
if l, _ := optList(args, "l"); len(l) != 2 {
|
||||
t.Errorf("list: %v", l)
|
||||
}
|
||||
if b, _ := optFlag(args, "b", false); !b {
|
||||
t.Error("flag")
|
||||
}
|
||||
if err := plainName("name", "--all"); err == nil {
|
||||
t.Error("an option as a name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// The snapd module's tools (novox/hq research 027/02, 026/05): the snaps on this machine, their
|
||||
// revisions and the space they take, the store's pending updates, snapd's changes, and installing,
|
||||
// removing and refreshing a snap. A Go bundle the node's runtime launches over stdio (ADR 0188,
|
||||
// ADR 0193); it runs as the operator account, and an act goes through `sudo -n`.
|
||||
//
|
||||
// The module installs nothing: snapd is not in the distribution's official repositories (README).
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
var providedBy = map[string]string{
|
||||
"snap": "snapd is not installed; it is not in the official repositories, and this module does not install it (see its README)",
|
||||
"systemctl": "the systemd package",
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var nameArg = map[string]any{"type": "string", "description": "the snap's name"}
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "snapd_status",
|
||||
Description: "Whether snapd is here and working: its version, its units (socket, service, AppArmor loader), " +
|
||||
"whether the kernel runs AppArmor (without it strict snaps are not confined), and whether /snap exists " +
|
||||
"for classic snaps. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Status() },
|
||||
},
|
||||
{
|
||||
Name: "snapd_list",
|
||||
Description: "Every installed snap and revision: version, revision, channel tracked, publisher, notes, and " +
|
||||
"whether the revision is disabled (kept for rollback, taking space). (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return List() },
|
||||
},
|
||||
{
|
||||
Name: "snapd_info",
|
||||
Description: "One snap as the store and snapd describe it: summary, publisher, licence, commands, tracking, and the channels with their versions. (r)",
|
||||
Input: map[string]any{"name": nameArg},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := snapName(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Info(name)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "snapd_updates",
|
||||
Description: "What a refresh would change: each snap with an update, its new version, revision and size. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Updates() },
|
||||
},
|
||||
{
|
||||
Name: "snapd_disk_usage",
|
||||
Description: "The space each snap's revisions take in /var/lib/snapd/snaps, which of it is disabled revisions " +
|
||||
"kept for rollback, and the total. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return DiskUsage() },
|
||||
},
|
||||
{
|
||||
Name: "snapd_services",
|
||||
Description: "The services installed snaps provide, with whether each starts at boot and runs now. (r)",
|
||||
Input: map[string]any{},
|
||||
Run: func(map[string]any) (any, error) { return Services() },
|
||||
},
|
||||
{
|
||||
Name: "snapd_changes",
|
||||
Description: "snapd's recent changes (installs, refreshes, removals): id, status, when, summary; or, with id, " +
|
||||
"one change's tasks. An act answers the change id to follow here. (r)",
|
||||
Input: map[string]any{"id": map[string]any{"type": "string", "description": "one change's id, for its tasks"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
id, err := optText(args, "id", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Changes(id)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "snapd_install",
|
||||
Description: "Install a snap from the store, optionally from a channel and in classic confinement. snapd " +
|
||||
"carries it out in the background; the answer is the change id to follow with snapd_changes. (a)",
|
||||
Input: map[string]any{
|
||||
"name": nameArg,
|
||||
"channel": map[string]any{"type": "string", "description": "a channel such as latest/stable or 3.x/edge"},
|
||||
"classic": map[string]any{"type": "boolean", "description": "classic confinement, for a snap that asks for it"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := snapName(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
channel, err := optText(args, "channel", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
classic, err := optFlag(args, "classic", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Install(name, channel, classic)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "snapd_remove",
|
||||
Description: "Remove a snap, keeping a snapshot of its data unless purge is set. Answers the change id. (a)",
|
||||
Input: map[string]any{
|
||||
"name": nameArg,
|
||||
"purge": map[string]any{"type": "boolean", "description": "remove its data too, without a snapshot"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := snapName(args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
purge, err := optFlag(args, "purge", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Remove(name, purge)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "snapd_refresh",
|
||||
Description: "Refresh one snap, or every snap when no name is given. Answers the change id, or that nothing needed it. (a)",
|
||||
Input: map[string]any{"name": map[string]any{"type": "string", "description": "the snap to refresh (default all)"}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
name, err := optText(args, "name", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name != "" {
|
||||
if err := checkSnapName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return Refresh(name)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
// manifest_kit_test.go is the same file in each workstation module: it reads the module's
|
||||
// definition so the module's own tests can hold it to what it says.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) manifest {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m manifest) resource(id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// packages are the packages the module installs, sorted.
|
||||
func (m manifest) packages() []string {
|
||||
out := []string{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "package" && r["absent"] != true {
|
||||
out = append(out, r["package"].(string))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// services are the units the module declares, by unit name.
|
||||
func (m manifest) services() map[string]map[string]any {
|
||||
out := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "service" {
|
||||
out[r["unit"].(string)] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// holdsTheBundle holds the manifest to the Go bundle this directory builds: every tool registered
|
||||
// is listed and nothing else, each named <prefix>_…, and the artifact builds this command.
|
||||
func holdsTheBundle(t *testing.T, m manifest, prefix string) {
|
||||
t.Helper()
|
||||
registered := []string{}
|
||||
for _, tool := range tools() {
|
||||
registered = append(registered, tool.Name)
|
||||
if !strings.HasPrefix(tool.Name, prefix+"_") {
|
||||
t.Errorf("tool %s is not named %s_…", tool.Name, prefix)
|
||||
}
|
||||
if tool.Description == "" || tool.Run == nil || tool.Input == nil {
|
||||
t.Errorf("tool %s is not described, runnable and given an input schema", tool.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(registered, ",") != strings.Join(m.Tools, ",") {
|
||||
t.Errorf("registered %v, listed %v", registered, m.Tools)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("one artifact, got %d", len(m.Build.Artifacts))
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
binary := filepath.Base(cwd)
|
||||
a := m.Build.Artifacts[0]
|
||||
want := map[string]any{"kind": "bundle", "language": "go", "system": "arch", "from": "cmd/" + binary, "binary": binary}
|
||||
for k, v := range want {
|
||||
if a[k] != v {
|
||||
t.Errorf("artifact %s = %v, want %v", k, a[k], v)
|
||||
}
|
||||
}
|
||||
if loads, _ := a["loads"].([]any); len(loads) != 1 || loads[0] != binary {
|
||||
t.Errorf("artifact loads %v, want [%s]", a["loads"], binary)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil {
|
||||
t.Errorf("claims %v, seats %v: this module holds no seat", m.Claims, m.Seats)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// snapNames are what the store accepts as a snap's name, optionally with an instance key.
|
||||
var snapNames = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,39}(_[a-z0-9]{1,10})?$`)
|
||||
|
||||
func checkSnapName(name string) error {
|
||||
if !snapNames.MatchString(name) {
|
||||
return fmt.Errorf("%q is not a snap name", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapName(args map[string]any) (string, error) {
|
||||
name, err := text(args, "name")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, checkSnapName(name)
|
||||
}
|
||||
|
||||
var plain = []string{"--unicode=never", "--color=never"}
|
||||
|
||||
// Where snapd keeps what it knows. Tests point these elsewhere.
|
||||
var (
|
||||
snapsDir = "/var/lib/snapd/snaps"
|
||||
lsmFile = "/sys/kernel/security/lsm"
|
||||
snapRoot = "/snap"
|
||||
)
|
||||
|
||||
// UnitState is one unit's state.
|
||||
type UnitState struct {
|
||||
Unit string `json:"unit"`
|
||||
Enabled string `json:"enabled"`
|
||||
Active string `json:"active"`
|
||||
}
|
||||
|
||||
// StatusAnswer is what snapd_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed bool `json:"installed"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Units []UnitState `json:"units"`
|
||||
AppArmor bool `json:"kernel_apparmor"`
|
||||
ClassicRoot bool `json:"classic_root"`
|
||||
Findings []string `json:"findings"`
|
||||
}
|
||||
|
||||
// Status says whether snapd is here and working.
|
||||
func Status() (StatusAnswer, error) {
|
||||
out := StatusAnswer{Units: []UnitState{}, Findings: []string{}}
|
||||
r := run(Cmd{Name: "snap", Args: []string{"version"}})
|
||||
if r.Error == "not-found" {
|
||||
out.Findings = append(out.Findings, "snapd is not installed on this machine")
|
||||
return out, nil
|
||||
}
|
||||
if r.Status != 0 || r.Error != "" {
|
||||
return out, failure(Cmd{Name: "snap", Args: []string{"version"}}, r)
|
||||
}
|
||||
out.Installed = true
|
||||
for _, l := range lines(r.Stdout) {
|
||||
if f := strings.Fields(l); len(f) >= 2 && f[0] == "snapd" {
|
||||
out.Version = f[1]
|
||||
}
|
||||
}
|
||||
for _, u := range []string{"snapd.socket", "snapd.service", "snapd.apparmor.service", "apparmor.service"} {
|
||||
// is-enabled and is-active answer on stdout and exit non-zero for "disabled" and "inactive":
|
||||
// a state, not a failure.
|
||||
en := run(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}})
|
||||
ac := run(Cmd{Name: "systemctl", Args: []string{"is-active", u}})
|
||||
if en.Error != "" || ac.Error != "" {
|
||||
return out, failure(Cmd{Name: "systemctl", Args: []string{"is-enabled", u}}, en)
|
||||
}
|
||||
out.Units = append(out.Units, UnitState{Unit: u, Enabled: firstLine(en.Stdout), Active: firstLine(ac.Stdout)})
|
||||
}
|
||||
if b, err := os.ReadFile(lsmFile); err == nil {
|
||||
for _, m := range strings.Split(strings.TrimSpace(string(b)), ",") {
|
||||
out.AppArmor = out.AppArmor || m == "apparmor"
|
||||
}
|
||||
}
|
||||
_, err := os.Stat(snapRoot)
|
||||
out.ClassicRoot = err == nil
|
||||
if out.Units[0].Enabled != "enabled" {
|
||||
out.Findings = append(out.Findings, "snapd.socket is not enabled: snapd does not start on demand")
|
||||
}
|
||||
if !out.AppArmor {
|
||||
out.Findings = append(out.Findings, "the kernel does not run AppArmor: strict snaps run without their confinement")
|
||||
}
|
||||
if out.Units[2].Enabled == "enabled" && !out.AppArmor {
|
||||
out.Findings = append(out.Findings, "snapd.apparmor.service is enabled with no AppArmor in the kernel: it loads profiles nothing enforces")
|
||||
}
|
||||
if !out.ClassicRoot {
|
||||
out.Findings = append(out.Findings, "/snap does not exist: classic snaps cannot run")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Snap is one installed revision.
|
||||
type Snap struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Revision string `json:"revision"`
|
||||
Tracking string `json:"tracking"`
|
||||
Publisher string `json:"publisher"`
|
||||
Notes []string `json:"notes"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// ListAnswer is what snapd_list answers.
|
||||
type ListAnswer struct {
|
||||
Snaps []Snap `json:"snaps"`
|
||||
Active int `json:"active"`
|
||||
Disabled int `json:"disabled_revisions"`
|
||||
}
|
||||
|
||||
// columns reads a table snap prints: a header line, then whitespace-separated columns, the last
|
||||
// taking the rest of the line.
|
||||
func columns(s string, n int) [][]string {
|
||||
out := [][]string{}
|
||||
for i, l := range lines(s) {
|
||||
if i == 0 {
|
||||
continue
|
||||
}
|
||||
f := strings.Fields(l)
|
||||
if len(f) < n {
|
||||
continue
|
||||
}
|
||||
if len(f) > n {
|
||||
f = append(f[:n-1], strings.Join(f[n-1:], " "))
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// List answers every installed snap and revision.
|
||||
func List() (ListAnswer, error) {
|
||||
r, err := call(Cmd{Name: "snap", Args: append([]string{"list", "--all"}, plain...)})
|
||||
if err != nil {
|
||||
return ListAnswer{}, err
|
||||
}
|
||||
out := ListAnswer{Snaps: []Snap{}}
|
||||
for _, f := range columns(r.Stdout, 6) {
|
||||
s := Snap{Name: f[0], Version: f[1], Revision: f[2], Tracking: f[3], Publisher: strings.TrimRight(f[4], "*"), Notes: []string{}}
|
||||
if f[5] != "-" {
|
||||
s.Notes = strings.Split(f[5], ",")
|
||||
}
|
||||
for _, n := range s.Notes {
|
||||
s.Disabled = s.Disabled || n == "disabled"
|
||||
}
|
||||
if s.Disabled {
|
||||
out.Disabled++
|
||||
} else {
|
||||
out.Active++
|
||||
}
|
||||
out.Snaps = append(out.Snaps, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InfoAnswer is what snapd_info answers.
|
||||
type InfoAnswer struct {
|
||||
Name string `json:"name"`
|
||||
Fields map[string]string `json:"fields"`
|
||||
Commands []string `json:"commands"`
|
||||
Channels map[string]string `json:"channels"`
|
||||
}
|
||||
|
||||
// Info reads snap info's YAML-like answer: top-level key: value lines, and the commands and
|
||||
// channels blocks.
|
||||
func Info(name string) (InfoAnswer, error) {
|
||||
r, err := call(Cmd{Name: "snap", Args: append([]string{"info"}, append(plain, name)...)})
|
||||
if err != nil {
|
||||
return InfoAnswer{}, err
|
||||
}
|
||||
out := InfoAnswer{Name: name, Fields: map[string]string{}, Commands: []string{}, Channels: map[string]string{}}
|
||||
block := ""
|
||||
for _, l := range strings.Split(r.Stdout, "\n") {
|
||||
if strings.TrimSpace(l) == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(l, " ") {
|
||||
block = ""
|
||||
k, v, found := strings.Cut(l, ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
switch {
|
||||
case v == "" || v == "|":
|
||||
block = k
|
||||
default:
|
||||
out.Fields[k] = v
|
||||
}
|
||||
continue
|
||||
}
|
||||
t := strings.TrimSpace(l)
|
||||
switch block {
|
||||
case "commands":
|
||||
out.Commands = append(out.Commands, strings.TrimPrefix(t, "- "))
|
||||
case "channels":
|
||||
if k, v, found := strings.Cut(t, ":"); found {
|
||||
out.Channels[k] = strings.Join(strings.Fields(v), " ")
|
||||
}
|
||||
case "description":
|
||||
out.Fields["description"] = strings.TrimSpace(out.Fields["description"] + " " + t)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Update is one pending refresh.
|
||||
type Update struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Revision string `json:"revision"`
|
||||
Size string `json:"size"`
|
||||
Publisher string `json:"publisher"`
|
||||
}
|
||||
|
||||
// Updates answers what a refresh would change.
|
||||
func Updates() (map[string]any, error) {
|
||||
r, err := call(Cmd{Name: "snap", Args: append([]string{"refresh", "--list"}, plain...)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Update{}
|
||||
if !strings.Contains(r.Stdout+r.Stderr, "All snaps up to date") {
|
||||
for _, f := range columns(r.Stdout, 6) {
|
||||
out = append(out, Update{Name: f[0], Version: f[1], Revision: f[2], Size: f[3], Publisher: strings.TrimRight(f[4], "*")})
|
||||
}
|
||||
}
|
||||
return map[string]any{"updates": out, "count": len(out)}, nil
|
||||
}
|
||||
|
||||
// Revision is one revision's file.
|
||||
type Revision struct {
|
||||
Revision string `json:"revision"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// SnapUsage is the space one snap's revisions take.
|
||||
type SnapUsage struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Revisions []Revision `json:"revisions"`
|
||||
}
|
||||
|
||||
// DiskAnswer is what snapd_disk_usage answers.
|
||||
type DiskAnswer struct {
|
||||
Dir string `json:"dir"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
Reclaimable int64 `json:"disabled_revisions_bytes"`
|
||||
Snaps []SnapUsage `json:"snaps"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
||||
// DiskUsage measures the snap files and marks the disabled revisions.
|
||||
func DiskUsage() (DiskAnswer, error) {
|
||||
listed, err := List()
|
||||
if err != nil {
|
||||
return DiskAnswer{}, err
|
||||
}
|
||||
disabled := map[string]bool{}
|
||||
for _, s := range listed.Snaps {
|
||||
if s.Disabled {
|
||||
disabled[s.Name+"_"+s.Revision] = true
|
||||
}
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(snapsDir, "*.snap"))
|
||||
if err != nil {
|
||||
return DiskAnswer{}, err
|
||||
}
|
||||
out := DiskAnswer{Dir: snapsDir, Snaps: []SnapUsage{},
|
||||
Note: "A disabled revision is kept by snapd for rollback (refresh.retain); removing one is `snap remove --revision`, which no tool here does."}
|
||||
by := map[string]*SnapUsage{}
|
||||
for _, f := range files {
|
||||
info, err := os.Stat(f)
|
||||
if err != nil {
|
||||
return DiskAnswer{}, err
|
||||
}
|
||||
base := strings.TrimSuffix(filepath.Base(f), ".snap")
|
||||
i := strings.LastIndex(base, "_")
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
name, rev := base[:i], base[i+1:]
|
||||
if by[name] == nil {
|
||||
by[name] = &SnapUsage{Name: name, Revisions: []Revision{}}
|
||||
}
|
||||
d := disabled[base]
|
||||
by[name].Revisions = append(by[name].Revisions, Revision{Revision: rev, Bytes: info.Size(), Disabled: d})
|
||||
by[name].Bytes += info.Size()
|
||||
out.TotalBytes += info.Size()
|
||||
if d {
|
||||
out.Reclaimable += info.Size()
|
||||
}
|
||||
}
|
||||
for _, u := range by {
|
||||
sort.Slice(u.Revisions, func(i, k int) bool {
|
||||
a, _ := strconv.Atoi(u.Revisions[i].Revision)
|
||||
b, _ := strconv.Atoi(u.Revisions[k].Revision)
|
||||
return a < b
|
||||
})
|
||||
out.Snaps = append(out.Snaps, *u)
|
||||
}
|
||||
sort.Slice(out.Snaps, func(i, k int) bool { return out.Snaps[i].Bytes > out.Snaps[k].Bytes })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Services answers the snaps' services.
|
||||
func Services() (map[string]any, error) {
|
||||
r, err := call(Cmd{Name: "snap", Args: append([]string{"services"}, plain...)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []map[string]string{}
|
||||
if !strings.Contains(r.Stdout+r.Stderr, "no services") {
|
||||
for _, f := range columns(r.Stdout, 4) {
|
||||
out = append(out, map[string]string{"service": f[0], "startup": f[1], "current": f[2], "notes": f[3]})
|
||||
}
|
||||
}
|
||||
return map[string]any{"services": out}, nil
|
||||
}
|
||||
|
||||
// Change is one of snapd's changes, or one task of a change.
|
||||
type Change struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Spawn string `json:"spawn"`
|
||||
Ready string `json:"ready,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
var changeID = regexp.MustCompile(`^[0-9]+$`)
|
||||
|
||||
// Changes answers snapd's recent changes, or one change's tasks.
|
||||
func Changes(id string) (map[string]any, error) {
|
||||
args := append([]string{"changes", "--abs-time"}, plain...)
|
||||
n := 5
|
||||
if id != "" {
|
||||
if !changeID.MatchString(id) {
|
||||
return nil, fmt.Errorf("%q is not a change id", id)
|
||||
}
|
||||
args, n = append([]string{"tasks", "--abs-time"}, append(plain, id)...), 4
|
||||
}
|
||||
r := run(Cmd{Name: "snap", Args: args})
|
||||
if strings.Contains(r.Stdout+r.Stderr, "no changes found") {
|
||||
return map[string]any{"changes": []Change{}}, nil
|
||||
}
|
||||
if r.Status != 0 || r.Error != "" {
|
||||
return nil, failure(Cmd{Name: "snap", Args: args}, r)
|
||||
}
|
||||
out := []Change{}
|
||||
for _, f := range columns(r.Stdout, n) {
|
||||
c := Change{}
|
||||
if n == 5 {
|
||||
c.ID, f = f[0], f[1:]
|
||||
}
|
||||
c.Status, c.Spawn, c.Ready, c.Summary = f[0], f[1], f[2], f[3]
|
||||
if c.Ready == "-" {
|
||||
c.Ready = ""
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
if id != "" {
|
||||
return map[string]any{"id": id, "tasks": out}, nil
|
||||
}
|
||||
return map[string]any{"changes": out}, nil
|
||||
}
|
||||
|
||||
// ActAnswer is what an act answers: the change snapd carries it out in.
|
||||
type ActAnswer struct {
|
||||
Act string `json:"act"`
|
||||
Snap string `json:"snap,omitempty"`
|
||||
Change string `json:"change,omitempty"`
|
||||
Said string `json:"said,omitempty"`
|
||||
Follow string `json:"follow,omitempty"`
|
||||
}
|
||||
|
||||
// act runs one snap act with --no-wait, which answers snapd's change id at once.
|
||||
func act(verb, name string, extra ...string) (ActAnswer, error) {
|
||||
args := append([]string{verb, "--no-wait"}, extra...)
|
||||
if name != "" {
|
||||
args = append(args, name)
|
||||
}
|
||||
r, err := call(Cmd{Name: "snap", Args: args, Root: true})
|
||||
if err != nil {
|
||||
return ActAnswer{}, err
|
||||
}
|
||||
out := ActAnswer{Act: verb, Snap: name}
|
||||
if id := strings.TrimSpace(r.Stdout); changeID.MatchString(id) {
|
||||
out.Change, out.Follow = id, "snapd_changes with id "+id
|
||||
} else {
|
||||
out.Said = strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var channelName = regexp.MustCompile(`^[a-z0-9][a-z0-9./_-]*$`)
|
||||
|
||||
// Install installs a snap.
|
||||
func Install(name, channel string, classic bool) (ActAnswer, error) {
|
||||
extra := []string{}
|
||||
if channel != "" {
|
||||
if !channelName.MatchString(channel) {
|
||||
return ActAnswer{}, fmt.Errorf("%q is not a channel", channel)
|
||||
}
|
||||
extra = append(extra, "--channel="+channel)
|
||||
}
|
||||
if classic {
|
||||
extra = append(extra, "--classic")
|
||||
}
|
||||
return act("install", name, extra...)
|
||||
}
|
||||
|
||||
// Remove removes a snap.
|
||||
func Remove(name string, purge bool) (ActAnswer, error) {
|
||||
if purge {
|
||||
return act("remove", name, "--purge")
|
||||
}
|
||||
return act("remove", name)
|
||||
}
|
||||
|
||||
// Refresh refreshes one snap, or all.
|
||||
func Refresh(name string) (ActAnswer, error) {
|
||||
return act("refresh", name)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTheManifestDeclaresNothingTheHostCouldNotInstall(t *testing.T) {
|
||||
m := readManifest(t)
|
||||
holdsTheBundle(t, m, "snapd")
|
||||
// snapd is not in the official repositories: no package, and no unit that only that package
|
||||
// brings, until the mesh's package repository builds it (research 027 question 1).
|
||||
if len(m.Resources) != 0 {
|
||||
t.Errorf("resources %v", m.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
const listAll = `Name Version Rev Tracking Publisher Notes
|
||||
bare 1.0 5 latest/stable canonical** base
|
||||
code 04c0d99f 266 latest/stable vscode** disabled,classic
|
||||
code 07f806f9 267 latest/stable vscode** classic
|
||||
gtk-common-themes 0.1-81-g442e511 1535 latest/stable canonical** -
|
||||
`
|
||||
|
||||
func TestListReadsEachRevisionAndMarksTheDisabledOnes(t *testing.T) {
|
||||
f := using(t, func(string, Cmd) Result { return ok(listAll) })
|
||||
got, err := List()
|
||||
if err != nil || len(got.Snaps) != 4 || got.Disabled != 1 || got.Active != 3 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if s := got.Snaps[1]; s.Name != "code" || s.Revision != "266" || !s.Disabled || s.Publisher != "vscode" || strings.Join(s.Notes, ",") != "disabled,classic" {
|
||||
t.Errorf("%+v", s)
|
||||
}
|
||||
if s := got.Snaps[3]; len(s.Notes) != 0 || s.Disabled {
|
||||
t.Errorf("%+v", s)
|
||||
}
|
||||
if f.lines()[0] != "snap list --all --unicode=never --color=never" {
|
||||
t.Errorf("%v", f.lines())
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolsSayWhenSnapdIsNotInstalled(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result { return Result{Status: 127, Error: "not-found"} })
|
||||
if _, err := List(); err == nil || !strings.Contains(err.Error(), "not in the official repositories") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
got, err := Status()
|
||||
if err != nil || got.Installed || !strings.Contains(strings.Join(got.Findings, ";"), "not installed") {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusNamesWhatIsWrongWithTheInstallation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
wasLSM, wasRoot := lsmFile, snapRoot
|
||||
lsmFile, snapRoot = filepath.Join(dir, "lsm"), filepath.Join(dir, "snap")
|
||||
defer func() { lsmFile, snapRoot = wasLSM, wasRoot }()
|
||||
_ = os.WriteFile(lsmFile, []byte("capability,landlock,lockdown,yama,bpf\n"), 0o644)
|
||||
_ = os.Mkdir(snapRoot, 0o755)
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
switch {
|
||||
case line == "snap version":
|
||||
return ok("snap 2.76.2-2\nsnapd 2.76.2-2\nseries 16\n")
|
||||
case strings.HasSuffix(line, "is-enabled snapd.service"), strings.HasSuffix(line, "is-enabled apparmor.service"):
|
||||
return Result{Status: 1, Stdout: "disabled\n"}
|
||||
case strings.Contains(line, "is-enabled"):
|
||||
return ok("enabled\n")
|
||||
case strings.Contains(line, "is-active snapd.service"):
|
||||
return ok("active\n")
|
||||
}
|
||||
return Result{Status: 3, Stdout: "inactive\n"}
|
||||
})
|
||||
got, err := Status()
|
||||
if err != nil || !got.Installed || got.Version != "2.76.2-2" || got.AppArmor || !got.ClassicRoot || len(got.Units) != 4 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if got.Units[1].Enabled != "disabled" || got.Units[1].Active != "active" {
|
||||
t.Errorf("socket-activated service: %+v", got.Units[1])
|
||||
}
|
||||
all := strings.Join(got.Findings, ";")
|
||||
if !strings.Contains(all, "without their confinement") || !strings.Contains(all, "nothing enforces") || strings.Contains(all, "/snap does not exist") {
|
||||
t.Errorf("%s", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInfoReadsFieldsCommandsAndChannels(t *testing.T) {
|
||||
using(t, func(string, Cmd) Result {
|
||||
return ok(`name: code
|
||||
summary: Code editing. Redefined.
|
||||
publisher: Visual Studio Code (vscode**)
|
||||
license: unset
|
||||
description: |
|
||||
Visual Studio Code is a new choice
|
||||
of tool.
|
||||
commands:
|
||||
- code
|
||||
- code.url-handler
|
||||
tracking: latest/stable
|
||||
channels:
|
||||
latest/stable: 07f806f9 2026-09-30 (267) 543MB classic
|
||||
latest/candidate: ^
|
||||
`)
|
||||
})
|
||||
got, err := Info("code")
|
||||
if err != nil || got.Fields["summary"] != "Code editing. Redefined." || len(got.Commands) != 2 || got.Fields["tracking"] != "latest/stable" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if got.Channels["latest/stable"] != "07f806f9 2026-09-30 (267) 543MB classic" || got.Fields["description"] != "Visual Studio Code is a new choice of tool." {
|
||||
t.Errorf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatesAndChangesReadNothingToDoAsEmpty(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
if strings.Contains(line, "refresh --list") {
|
||||
return Result{Stderr: "All snaps up to date.\n"}
|
||||
}
|
||||
return Result{Status: 1, Stderr: "error: no changes found\n"}
|
||||
})
|
||||
u, err := Updates()
|
||||
if err != nil || u["count"] != 0 {
|
||||
t.Fatalf("%v %v", u, err)
|
||||
}
|
||||
c, err := Changes("")
|
||||
if err != nil || len(c["changes"].([]Change)) != 0 {
|
||||
t.Fatalf("%v %v", c, err)
|
||||
}
|
||||
if _, err := Changes("12; reboot"); err == nil {
|
||||
t.Error("a change id that is not a number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangesAndTasksAreReadByColumn(t *testing.T) {
|
||||
using(t, func(line string, c Cmd) Result {
|
||||
if strings.Contains(line, "tasks") {
|
||||
return ok("Status Spawn Ready Summary\nDone 2026-10-01T18:57:00+02:00 2026-10-01T18:57:10+02:00 Download snap \"code\" (267)\n")
|
||||
}
|
||||
return ok("ID Status Spawn Ready Summary\n42 Doing 2026-10-04T10:00:00+02:00 - Install \"hello\" snap\n")
|
||||
})
|
||||
c, err := Changes("")
|
||||
ch := c["changes"].([]Change)
|
||||
if err != nil || len(ch) != 1 || ch[0].ID != "42" || ch[0].Ready != "" || ch[0].Summary != `Install "hello" snap` {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
c, _ = Changes("42")
|
||||
if tasks := c["tasks"].([]Change); len(tasks) != 1 || tasks[0].Status != "Done" || tasks[0].Summary != `Download snap "code" (267)` {
|
||||
t.Errorf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActsGoThroughSudoWithoutWaitingAndAnswerTheChange(t *testing.T) {
|
||||
f := using(t, func(line string, c Cmd) Result {
|
||||
if strings.Contains(line, "refresh") {
|
||||
return Result{Stderr: "All snaps up to date.\n"}
|
||||
}
|
||||
return ok("57\n")
|
||||
})
|
||||
got, err := Install("hello-world", "latest/edge", true)
|
||||
if err != nil || got.Change != "57" || !strings.Contains(got.Follow, "57") {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if _, err := Remove("hello-world", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Refresh("")
|
||||
if err != nil || r.Change != "" || !strings.Contains(r.Said, "up to date") {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
want := "sudo -n snap install --no-wait --channel=latest/edge --classic hello-world\n" +
|
||||
"sudo -n snap remove --no-wait --purge hello-world\n" +
|
||||
"sudo -n snap refresh --no-wait"
|
||||
if got := strings.Join(f.lines(), "\n"); got != want {
|
||||
t.Errorf("asked\n%s", got)
|
||||
}
|
||||
for _, bad := range []string{"--classic", "Hello", "a b", "../x"} {
|
||||
if err := checkSnapName(bad); err == nil {
|
||||
t.Errorf("%q accepted as a snap name", bad)
|
||||
}
|
||||
}
|
||||
if _, err := Install("x", "--dangerous", false); err == nil {
|
||||
t.Error("an option as a channel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskUsageMeasuresEachRevisionAndWhatDisabledOnesTake(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
was := snapsDir
|
||||
snapsDir = dir
|
||||
defer func() { snapsDir = was }()
|
||||
for name, size := range map[string]int{"code_266.snap": 500, "code_267.snap": 510, "bare_5.snap": 4} {
|
||||
_ = os.WriteFile(filepath.Join(dir, name), make([]byte, size), 0o600)
|
||||
}
|
||||
using(t, func(string, Cmd) Result { return ok(listAll) })
|
||||
got, err := DiskUsage()
|
||||
if err != nil || got.TotalBytes != 1014 || got.Reclaimable != 500 || len(got.Snaps) != 2 {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if c := got.Snaps[0]; c.Name != "code" || c.Bytes != 1010 || !c.Revisions[0].Disabled || c.Revisions[1].Disabled {
|
||||
t.Errorf("%+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module snapd
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"module": "snapd",
|
||||
"version": "1",
|
||||
"tools": [
|
||||
"snapd_status",
|
||||
"snapd_list",
|
||||
"snapd_info",
|
||||
"snapd_updates",
|
||||
"snapd_disk_usage",
|
||||
"snapd_services",
|
||||
"snapd_changes",
|
||||
"snapd_install",
|
||||
"snapd_remove",
|
||||
"snapd_refresh"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/snapd-tools",
|
||||
"binary": "snapd-tools",
|
||||
"loads": [
|
||||
"snapd-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user