Files
mesh-catalog/modules/snapd/cmd/snapd-tools/snapd.go
T
jochen db297e8bdd 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.
2026-10-04 13:02:23 +02:00

438 lines
13 KiB
Go

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)
}