- hamlab install / uninstall (internal/setup): systemd or OpenRC, elevates through sudo or doas, runs as the invoking user, sets or generates the password, names only journal groups that exist, waits for /healthz, reports an active firewalld/ufw. --purge and the data chown never touch a directory that isn't Ham Lab's alone. - install.sh: curl-able bootstrap (arch detection, SHA256SUMS, offline beside the binaries, noexec /tmp fallback). make release builds dist/ for amd64, arm64, arm (ARMv6), 386, riscv64. Every build is CGO_ENABLED=0; the native one used to link glibc. - internal/priv replaces every literal `sudo -n`: uid 0, sudo or doas. - Updates acts on dnf, zypper, pacman and apk as well as apt; dnf shows installed versions. - Services falls back to the plain systemctl table before systemd 246 and says so on hosts without systemd. Disks falls back to /sys/block without lsblk. - Listener binds with IP_FREEBIND, so a LAN address that isn't up at boot is no failure. - Self-update: the new build must pass `check` against the live config, is put in place by atomic rename, and the daemon re-execs itself (same under any supervisor). - State and config writes are fsynced; a corrupt state.json is set aside, not fatal. - Terminal shutdown no longer empties the saved list, so names come back after a restart by design rather than by winning a race. - Config default is ~/.config/hamlab; an existing ~/apps/hamlab is still found. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
545 lines
16 KiB
Go
545 lines
16 KiB
Go
// Package updates is the package-manager view: what can be upgraded, when the index
|
|
// was last refreshed, whether a reboot is pending, and the refresh/upgrade actions,
|
|
// which run as background tasks so their output can be followed live.
|
|
//
|
|
// Supported: apt (Debian, Ubuntu), dnf (Fedora, RHEL family), zypper (openSUSE, SLES),
|
|
// pacman (Arch) and apk (Alpine). All of them list, refresh, upgrade and install; pacman
|
|
// upgrades everything or nothing, because Arch doesn't support partial upgrades.
|
|
package updates
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlab/internal/priv"
|
|
"hamlab/internal/tasks"
|
|
)
|
|
|
|
type Package struct {
|
|
Name string `json:"name"`
|
|
Current string `json:"current"`
|
|
Candidate string `json:"candidate"`
|
|
Suite string `json:"suite,omitempty"`
|
|
Security bool `json:"security"`
|
|
Description string `json:"description,omitempty"`
|
|
}
|
|
|
|
type Status struct {
|
|
Manager string `json:"manager"` // apt | dnf | zypper | pacman | apk | ""
|
|
CanUpgrade bool `json:"can_upgrade"`
|
|
Selective bool `json:"selective"` // individual packages can be upgraded
|
|
Packages []Package `json:"packages"`
|
|
SecurityCount int `json:"security_count"`
|
|
LastRefresh int64 `json:"last_refresh,omitempty"` // unix ms
|
|
LastUpgrade int64 `json:"last_upgrade,omitempty"`
|
|
RebootNeeded bool `json:"reboot_needed"`
|
|
RebootPkgs []string `json:"reboot_pkgs"`
|
|
AutoUpgrades bool `json:"auto_upgrades"`
|
|
RunningTask string `json:"running_task,omitempty"`
|
|
Sudo bool `json:"sudo"`
|
|
}
|
|
|
|
type Manager struct {
|
|
sudo bool
|
|
tasks *tasks.Manager
|
|
pm string
|
|
}
|
|
|
|
func New(sudo bool, tm *tasks.Manager) *Manager {
|
|
m := &Manager{sudo: sudo, tasks: tm}
|
|
for _, pm := range []string{"apt-get", "dnf", "zypper", "pacman", "apk"} {
|
|
if _, err := exec.LookPath(pm); err == nil {
|
|
m.pm = strings.TrimSuffix(pm, "-get")
|
|
break
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *Manager) sudoArgs(args ...string) []string {
|
|
if m.sudo {
|
|
return priv.Wrap(args...)
|
|
}
|
|
return args
|
|
}
|
|
|
|
func out(ctx context.Context, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
|
cmd.Env = append(os.Environ(), "LC_ALL=C", "DEBIAN_FRONTEND=noninteractive")
|
|
b, err := cmd.Output()
|
|
if err != nil {
|
|
var ee *exec.ExitError
|
|
if errors.As(err, &ee) && len(ee.Stderr) > 0 {
|
|
return string(b), errors.New(strings.TrimSpace(string(ee.Stderr)))
|
|
}
|
|
return string(b), err
|
|
}
|
|
return string(b), nil
|
|
}
|
|
|
|
// newestMtime: the newest mtime among the paths matching any of the globs.
|
|
func newestMtime(globs ...string) int64 {
|
|
var best int64
|
|
for _, g := range globs {
|
|
matches, _ := filepath.Glob(g)
|
|
for _, m := range matches {
|
|
if t := mtimeMs(m); t > best {
|
|
best = t
|
|
}
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// dnfLastUpgrade: newest transaction in `dnf history` whose action includes an upgrade,
|
|
// else the newest transaction of any kind.
|
|
func dnfLastUpgrade(ctx context.Context) int64 {
|
|
text, err := out(ctx, "dnf", "history", "list")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
re := regexp.MustCompile(`(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(\S+)`)
|
|
var newest, upgrade int64
|
|
for _, line := range strings.Split(text, "\n") {
|
|
m := re.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
t, err := time.ParseInLocation("2006-01-02 15:04:05", m[1], time.Local)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
ms := t.UnixMilli()
|
|
if ms > newest {
|
|
newest = ms
|
|
}
|
|
if strings.Contains(m[2], "U") && ms > upgrade {
|
|
upgrade = ms
|
|
}
|
|
}
|
|
if upgrade > 0 {
|
|
return upgrade
|
|
}
|
|
return newest
|
|
}
|
|
|
|
func mtimeMs(p string) int64 {
|
|
st, err := os.Stat(p)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return st.ModTime().UnixMilli()
|
|
}
|
|
|
|
// ---- status ----
|
|
|
|
func (m *Manager) Status(ctx context.Context) Status {
|
|
s := Status{Manager: m.pm, Packages: []Package{}, RebootPkgs: []string{}, Sudo: m.sudo}
|
|
if t, ok := m.tasks.RunningOf("updates"); ok {
|
|
s.RunningTask = t.ID
|
|
}
|
|
s.CanUpgrade = m.sudo && m.pm != ""
|
|
s.Selective = m.pm != "pacman"
|
|
switch m.pm {
|
|
case "apt":
|
|
s.Packages = aptUpgradable(ctx)
|
|
s.LastRefresh = mtimeMs("/var/lib/apt/periodic/update-success-stamp")
|
|
if s.LastRefresh == 0 {
|
|
s.LastRefresh = mtimeMs("/var/lib/apt/lists/partial")
|
|
}
|
|
if s.LastRefresh == 0 {
|
|
s.LastRefresh = mtimeMs("/var/lib/apt/lists")
|
|
}
|
|
s.LastUpgrade = aptLastUpgrade()
|
|
s.AutoUpgrades = aptAutoUpgrades(ctx)
|
|
case "dnf":
|
|
s.Packages = dnfUpgradable(ctx)
|
|
s.LastRefresh = newestMtime("/var/cache/libdnf5/*/repodata", "/var/cache/dnf/*/repodata", "/var/cache/libdnf5", "/var/cache/dnf")
|
|
s.LastUpgrade = dnfLastUpgrade(ctx)
|
|
case "zypper":
|
|
s.Packages = zypperUpgradable(ctx)
|
|
s.LastRefresh = newestMtime("/var/cache/zypp/raw/*", "/var/cache/zypp/raw")
|
|
s.LastUpgrade = mtimeMs("/var/log/zypp/history")
|
|
case "pacman":
|
|
s.Packages = pacmanUpgradable(ctx)
|
|
s.LastRefresh = newestMtime("/var/lib/pacman/sync/*.db", "/var/lib/pacman/sync")
|
|
s.LastUpgrade = pacmanLastUpgrade()
|
|
case "apk":
|
|
s.Packages = apkUpgradable(ctx)
|
|
s.LastRefresh = newestMtime("/var/cache/apk/APKINDEX.*", "/var/lib/apk/APKINDEX.*")
|
|
s.LastUpgrade = mtimeMs("/lib/apk/db/installed")
|
|
}
|
|
for _, p := range s.Packages {
|
|
if p.Security {
|
|
s.SecurityCount++
|
|
}
|
|
}
|
|
if _, err := os.Stat("/run/reboot-required"); err == nil {
|
|
s.RebootNeeded = true
|
|
if b, err := os.ReadFile("/run/reboot-required.pkgs"); err == nil {
|
|
for _, l := range strings.Split(strings.TrimSpace(string(b)), "\n") {
|
|
if l = strings.TrimSpace(l); l != "" {
|
|
s.RebootPkgs = append(s.RebootPkgs, l)
|
|
}
|
|
}
|
|
}
|
|
} else if m.pm != "apt" {
|
|
s.RebootNeeded = kernelChanged()
|
|
}
|
|
return s
|
|
}
|
|
|
|
// kernelChanged: the running kernel isn't the newest installed one (dnf/pacman hosts).
|
|
func kernelChanged() bool {
|
|
b, err := os.ReadFile("/proc/sys/kernel/osrelease")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
running := strings.TrimSpace(string(b))
|
|
ents, err := os.ReadDir("/lib/modules")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
newest := ""
|
|
for _, e := range ents {
|
|
if e.Name() > newest {
|
|
newest = e.Name()
|
|
}
|
|
}
|
|
return newest != "" && newest != running
|
|
}
|
|
|
|
// ---- apt ----
|
|
|
|
var aptListRe = regexp.MustCompile(`^(\S+)/(\S+)\s+(\S+)\s+\S+\s+\[upgradable from: (\S+)\]`)
|
|
|
|
func aptUpgradable(ctx context.Context) []Package {
|
|
text, _ := out(ctx, "apt", "list", "--upgradable")
|
|
var pkgs []Package
|
|
for _, line := range strings.Split(text, "\n") {
|
|
m := aptListRe.FindStringSubmatch(line)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
pkgs = append(pkgs, Package{Name: m[1], Suite: m[2], Candidate: m[3], Current: m[4],
|
|
Security: strings.Contains(m[2], "security")})
|
|
}
|
|
if len(pkgs) == 0 {
|
|
return []Package{}
|
|
}
|
|
// One apt-cache call for all the descriptions.
|
|
args := append([]string{"apt-cache", "show", "--no-all-versions"}, names(pkgs)...)
|
|
if text, err := out(ctx, args...); err == nil {
|
|
desc := map[string]string{}
|
|
cur := ""
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if strings.HasPrefix(line, "Package: ") {
|
|
cur = strings.TrimPrefix(line, "Package: ")
|
|
} else if strings.HasPrefix(line, "Description-en: ") || strings.HasPrefix(line, "Description: ") {
|
|
if _, ok := desc[cur]; !ok {
|
|
_, v, _ := strings.Cut(line, ": ")
|
|
desc[cur] = v
|
|
}
|
|
}
|
|
}
|
|
for i := range pkgs {
|
|
pkgs[i].Description = desc[pkgs[i].Name]
|
|
}
|
|
}
|
|
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Name < pkgs[j].Name })
|
|
return pkgs
|
|
}
|
|
|
|
func names(pkgs []Package) []string {
|
|
n := make([]string, len(pkgs))
|
|
for i, p := range pkgs {
|
|
n[i] = p.Name
|
|
}
|
|
return n
|
|
}
|
|
|
|
// aptLastUpgrade: the Start-Date of the last history.log entry that upgraded something.
|
|
func aptLastUpgrade() int64 {
|
|
f, err := os.Open("/var/log/apt/history.log")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer f.Close()
|
|
var last, start string
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 0, 64<<10), 8<<20)
|
|
for sc.Scan() {
|
|
l := sc.Text()
|
|
switch {
|
|
case strings.HasPrefix(l, "Start-Date: "):
|
|
start = strings.TrimPrefix(l, "Start-Date: ")
|
|
case strings.HasPrefix(l, "Upgrade: "):
|
|
last = start
|
|
}
|
|
}
|
|
if last == "" {
|
|
return 0
|
|
}
|
|
t, err := time.ParseInLocation("2006-01-02 15:04:05", last, time.Local)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return t.UnixMilli()
|
|
}
|
|
|
|
func aptAutoUpgrades(ctx context.Context) bool {
|
|
text, _ := out(ctx, "apt-config", "dump", "APT::Periodic::Unattended-Upgrade")
|
|
return strings.Contains(text, `"1"`)
|
|
}
|
|
|
|
// ---- dnf / zypper / pacman / apk ----
|
|
|
|
func dnfUpgradable(ctx context.Context) []Package {
|
|
text, _ := out(ctx, "dnf", "-q", "check-update") // exit 100 when updates exist; output still parses
|
|
var pkgs []Package
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if len(f) != 3 || !strings.Contains(f[0], ".") {
|
|
continue
|
|
}
|
|
name := f[0][:strings.LastIndex(f[0], ".")]
|
|
pkgs = append(pkgs, Package{Name: name, Candidate: f[1], Suite: f[2], Security: strings.Contains(f[2], "security")})
|
|
}
|
|
if pkgs == nil {
|
|
return []Package{}
|
|
}
|
|
// check-update only names the candidate; one rpm query fills in what's installed.
|
|
args := append([]string{"rpm", "-q", "--qf", `%{NAME} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`}, names(pkgs)...)
|
|
if text, _ := out(ctx, args...); text != "" { // exits non-zero when any name isn't installed (new dependencies)
|
|
cur := map[string]string{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if f := strings.Fields(line); len(f) == 2 {
|
|
cur[f[0]] = f[1] // several installed (kernels): the last listed is the newest
|
|
}
|
|
}
|
|
for i := range pkgs {
|
|
pkgs[i].Current = cur[pkgs[i].Name]
|
|
}
|
|
}
|
|
return pkgs
|
|
}
|
|
|
|
func pacmanUpgradable(ctx context.Context) []Package {
|
|
text, _ := out(ctx, "pacman", "-Qu")
|
|
var pkgs []Package
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line) // name old -> new
|
|
if len(f) == 4 && f[2] == "->" {
|
|
pkgs = append(pkgs, Package{Name: f[0], Current: f[1], Candidate: f[3]})
|
|
}
|
|
}
|
|
if pkgs == nil {
|
|
return []Package{}
|
|
}
|
|
return pkgs
|
|
}
|
|
|
|
// zypperUpgradable parses the table `zypper list-updates` prints:
|
|
// S | Repository | Name | Current Version | Available Version | Arch
|
|
func zypperUpgradable(ctx context.Context) []Package {
|
|
text, _ := out(ctx, "zypper", "--non-interactive", "--quiet", "list-updates")
|
|
pkgs := []Package{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
f := strings.Split(line, "|")
|
|
if len(f) < 5 || strings.TrimSpace(f[0]) != "v" {
|
|
continue
|
|
}
|
|
repo := strings.TrimSpace(f[1])
|
|
pkgs = append(pkgs, Package{Name: strings.TrimSpace(f[2]), Current: strings.TrimSpace(f[3]), Candidate: strings.TrimSpace(f[4]),
|
|
Suite: repo, Security: strings.Contains(strings.ToLower(repo), "security")})
|
|
}
|
|
return pkgs
|
|
}
|
|
|
|
var apkVerRe = regexp.MustCompile(`^(.+)-([^-]+-r\d+)\s+<\s+(\S+)`)
|
|
|
|
// apkUpgradable parses `apk version -l '<'`: "musl-1.2.4-r2 < 1.2.5-r0".
|
|
func apkUpgradable(ctx context.Context) []Package {
|
|
text, _ := out(ctx, "apk", "version", "-l", "<")
|
|
pkgs := []Package{}
|
|
for _, line := range strings.Split(text, "\n") {
|
|
if m := apkVerRe.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
|
|
pkgs = append(pkgs, Package{Name: m[1], Current: m[2], Candidate: m[3]})
|
|
}
|
|
}
|
|
return pkgs
|
|
}
|
|
|
|
// pacmanLastUpgrade: the timestamp of the last "upgraded" line in pacman.log.
|
|
func pacmanLastUpgrade() int64 {
|
|
f, err := os.Open("/var/log/pacman.log")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
defer f.Close()
|
|
last := ""
|
|
sc := bufio.NewScanner(f)
|
|
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
|
for sc.Scan() {
|
|
if l := sc.Text(); strings.Contains(l, "[ALPM] upgraded ") && strings.HasPrefix(l, "[") {
|
|
if end := strings.IndexByte(l, ']'); end > 1 {
|
|
last = l[1:end]
|
|
}
|
|
}
|
|
}
|
|
for _, layout := range []string{"2006-01-02T15:04:05-0700", "2006-01-02 15:04"} {
|
|
if t, err := time.ParseInLocation(layout, last, time.Local); err == nil {
|
|
return t.UnixMilli()
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// ---- actions ----
|
|
|
|
// Never starts with '-', so a name can't be read as an option by any of the managers.
|
|
var pkgNameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9+._-]{0,127}$`)
|
|
|
|
func checkNames(pkgs []string) error {
|
|
for _, p := range pkgs {
|
|
if !pkgNameRe.MatchString(p) {
|
|
return errors.New("bad package name: " + p)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) start(args ...string) (*tasks.Task, error) {
|
|
if m.pm == "" {
|
|
return nil, errors.New("no supported package manager on this host")
|
|
}
|
|
if !m.sudo {
|
|
return nil, errors.New("needs root: run Ham Lab as root, or give its user passwordless sudo or doas")
|
|
}
|
|
if t, ok := m.tasks.RunningOf("updates"); ok {
|
|
return nil, errors.New("another package task is running: " + t.Title)
|
|
}
|
|
full := priv.Wrap(append([]string{"env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)...)
|
|
cmd := exec.Command(full[0], full[1:]...)
|
|
return m.tasks.Start("updates", strings.Join(args, " "), cmd)
|
|
}
|
|
|
|
func (m *Manager) Refresh() (*tasks.Task, error) {
|
|
switch m.pm {
|
|
case "apt":
|
|
return m.start("apt-get", "update")
|
|
case "dnf":
|
|
return m.start("dnf", "-y", "makecache", "--refresh")
|
|
case "zypper":
|
|
return m.start("zypper", "--non-interactive", "refresh")
|
|
case "pacman":
|
|
return m.start("pacman", "-Sy", "--noconfirm")
|
|
case "apk":
|
|
return m.start("apk", "update")
|
|
}
|
|
return m.start()
|
|
}
|
|
|
|
// rolling reports a distro whose supported upgrade path is a full distribution upgrade.
|
|
func rolling() bool {
|
|
b, _ := os.ReadFile("/etc/os-release")
|
|
return strings.Contains(string(b), "tumbleweed") || strings.Contains(string(b), "microos")
|
|
}
|
|
|
|
// Upgrade upgrades everything, or only the named packages. Nothing here can block on a
|
|
// question: apt keeps the current config file on a conflict, the rest run non-interactive.
|
|
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
|
|
if err := checkNames(pkgs); err != nil {
|
|
return nil, err
|
|
}
|
|
all := len(pkgs) == 0
|
|
var args []string
|
|
switch m.pm {
|
|
case "apt":
|
|
args = []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
|
|
if all {
|
|
args = append(args, "dist-upgrade")
|
|
} else {
|
|
args = append(args, "install", "--only-upgrade")
|
|
}
|
|
case "dnf":
|
|
args = []string{"dnf", "-y", "upgrade"}
|
|
case "zypper":
|
|
args = []string{"zypper", "--non-interactive", "update"}
|
|
if all && rolling() {
|
|
args = []string{"zypper", "--non-interactive", "dist-upgrade"}
|
|
}
|
|
case "pacman":
|
|
if !all {
|
|
return nil, errors.New("pacman upgrades everything or nothing: Arch doesn't support partial upgrades")
|
|
}
|
|
args = []string{"pacman", "-Syu", "--noconfirm"}
|
|
case "apk":
|
|
args = []string{"apk", "upgrade"}
|
|
if all {
|
|
args = []string{"apk", "upgrade", "--update-cache"}
|
|
}
|
|
}
|
|
return m.start(append(args, pkgs...)...)
|
|
}
|
|
|
|
// Install installs packages (used by other tabs for optional tooling like smartmontools).
|
|
func (m *Manager) Install(pkgs []string) (*tasks.Task, error) {
|
|
if len(pkgs) == 0 {
|
|
return nil, errors.New("no packages")
|
|
}
|
|
if err := checkNames(pkgs); err != nil {
|
|
return nil, err
|
|
}
|
|
var args []string
|
|
switch m.pm {
|
|
case "apt":
|
|
args = []string{"apt-get", "-y", "--no-install-recommends", "install"}
|
|
case "dnf":
|
|
args = []string{"dnf", "-y", "install"}
|
|
case "zypper":
|
|
args = []string{"zypper", "--non-interactive", "install", "--no-recommends"}
|
|
case "pacman":
|
|
args = []string{"pacman", "-S", "--noconfirm", "--needed"}
|
|
case "apk":
|
|
args = []string{"apk", "add"}
|
|
}
|
|
return m.start(append(args, pkgs...)...)
|
|
}
|
|
|
|
func (m *Manager) Changelog(ctx context.Context, pkg string) (string, error) {
|
|
if !pkgNameRe.MatchString(pkg) {
|
|
return "", errors.New("bad package name")
|
|
}
|
|
switch m.pm {
|
|
case "apt":
|
|
text, err := out(ctx, "apt-get", "changelog", "-qq", pkg)
|
|
if len(text) > 64<<10 {
|
|
text = text[:64<<10] + "\n…"
|
|
}
|
|
return text, err
|
|
case "dnf":
|
|
return out(ctx, "dnf", "-q", "changelog", pkg)
|
|
}
|
|
return "", errors.New("changelogs not available for " + m.pm)
|
|
}
|
|
|
|
// Power runs systemctl reboot / poweroff.
|
|
func (m *Manager) Power(ctx context.Context, action string) error {
|
|
if action != "reboot" && action != "poweroff" {
|
|
return errors.New("unknown power action")
|
|
}
|
|
_, err := out(ctx, m.sudoArgs("systemctl", action)...)
|
|
return err
|
|
}
|