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.
438 lines
13 KiB
Go
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)
|
|
}
|