Updates tab (apt list/refresh/upgrade/changelog, reboot flag) on a new background tasks package with live WS streaming; header power button with hostname confirmation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 17:40:29 -07:00
co-authored by Claude Fable 5.1
parent 41c2f05535
commit acb11193b9
11 changed files with 975 additions and 6 deletions
+155 -2
View File
@@ -23,7 +23,9 @@ import (
"hamlab/internal/metrics"
"hamlab/internal/services"
"hamlab/internal/store"
"hamlab/internal/tasks"
"hamlab/internal/term"
"hamlab/internal/updates"
)
const cookieName = "hamlab_session"
@@ -37,6 +39,8 @@ type Server struct {
store *store.Store
svc *services.Manager
cron *cron.Manager
tasks *tasks.Manager
updates *updates.Manager
version string
started time.Time
user string
@@ -45,9 +49,9 @@ type Server struct {
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, ui fs.FS, version string) *Server {
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, ui: ui, version: version,
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username
@@ -86,6 +90,15 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("POST /api/cron/jobs/{id}/{action}", s.auth(s.cronJobAction))
m.HandleFunc("GET /api/cron/runs/{id}", s.auth(s.cronRun))
m.HandleFunc("POST /api/cron/timers/{unit}/{action}", s.auth(s.cronTimerAction))
m.HandleFunc("GET /api/tasks", s.auth(s.listTasks))
m.HandleFunc("GET /api/tasks/{id}", s.auth(s.getTask))
m.HandleFunc("GET /api/tasks/{id}/stream", s.auth(s.streamTask))
m.HandleFunc("POST /api/tasks/{id}/cancel", s.auth(s.cancelTask))
m.HandleFunc("GET /api/updates", s.auth(s.updatesStatus))
m.HandleFunc("POST /api/updates/refresh", s.auth(s.updatesRefresh))
m.HandleFunc("POST /api/updates/upgrade", s.auth(s.updatesUpgrade))
m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog))
m.HandleFunc("POST /api/power/{action}", s.auth(s.power))
m.HandleFunc("GET /api/prefs/dashboard", s.auth(s.getDashPrefs))
m.HandleFunc("PUT /api/prefs/dashboard", s.auth(s.putDashPrefs))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
@@ -192,9 +205,11 @@ func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
uptime, _ = json.Number(f[0]).Float64()
}
}
_, rebootErr := os.Stat("/run/reboot-required")
jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
"reboot_needed": rebootErr == nil,
})
}
@@ -435,6 +450,144 @@ func (s *Server) cronTimerAction(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// ---- tasks (background commands with followable output) ----
func (s *Server) listTasks(w http.ResponseWriter, _ *http.Request) { jsonOK(w, 200, s.tasks.List()) }
func (s *Server) getTask(w http.ResponseWriter, r *http.Request) {
t, ok := s.tasks.Get(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such task")
return
}
jsonOK(w, 200, t.Snapshot(true))
}
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
t, ok := s.tasks.Get(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such task")
return
}
t.Cancel()
log.Printf("task %s cancelled (from %s)", t.ID, clientIP(r))
w.WriteHeader(204)
}
// streamTask sends the output so far, then live chunks as binary frames, then {"type":"exit"}.
func (s *Server) streamTask(w http.ResponseWriter, r *http.Request) {
t, ok := s.tasks.Get(r.PathValue("id"))
if !ok {
jsonErr(w, 404, "no such task")
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
return
}
defer conn.Close(websocket.StatusNormalClosure, "")
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
go func() {
defer cancel()
for {
if _, _, err := conn.Read(ctx); err != nil {
return
}
}
}()
backlog, ch, unsub := t.Subscribe()
defer unsub()
if len(backlog) > 0 {
if err := conn.Write(ctx, websocket.MessageBinary, backlog); err != nil {
return
}
}
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pc()
if err != nil {
return
}
case chunk, ok := <-ch:
if !ok {
snap := t.Snapshot(false)
b, _ := json.Marshal(map[string]any{"type": "exit", "code": snap.ExitCode, "error": snap.Err})
_ = conn.Write(ctx, websocket.MessageText, b)
return
}
if err := conn.Write(ctx, websocket.MessageBinary, chunk); err != nil {
return
}
}
}
}
// ---- updates ----
func (s *Server) updatesStatus(w http.ResponseWriter, r *http.Request) {
jsonOK(w, 200, s.updates.Status(r.Context()))
}
func (s *Server) updatesRefresh(w http.ResponseWriter, r *http.Request) {
t, err := s.updates.Refresh()
if err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("updates: refresh started as task %s (from %s)", t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) updatesUpgrade(w http.ResponseWriter, r *http.Request) {
var body struct {
Packages []string `json:"packages"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body)
t, err := s.updates.Upgrade(body.Packages)
if err != nil {
jsonErr(w, 400, err.Error())
return
}
log.Printf("updates: %s started as task %s (from %s)", t.Title, t.ID, clientIP(r))
jsonOK(w, 202, t.Snapshot(false))
}
func (s *Server) updatesChangelog(w http.ResponseWriter, r *http.Request) {
text, err := s.updates.Changelog(r.Context(), r.URL.Query().Get("pkg"))
if err != nil && text == "" {
jsonErr(w, 400, err.Error())
return
}
jsonOK(w, 200, map[string]string{"text": text})
}
// power: reboot/poweroff, gated on the hostname being typed back.
func (s *Server) power(w http.ResponseWriter, r *http.Request) {
var body struct {
Confirm string `json:"confirm"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
if body.Confirm != s.host {
jsonErr(w, 400, "confirmation must be the hostname")
return
}
action := r.PathValue("action")
log.Printf("POWER: %s requested from %s", action, clientIP(r))
if err := s.updates.Power(r.Context(), action); err != nil {
jsonErr(w, 400, err.Error())
return
}
w.WriteHeader(204)
}
// ---- dashboard layout prefs (opaque JSON object, owned by the UI) ----
func (s *Server) getDashPrefs(w http.ResponseWriter, _ *http.Request) {
+229
View File
@@ -0,0 +1,229 @@
// Package tasks runs long commands (apt upgrades, and later anything else the UI kicks
// off) in the background, keeps their output, and lets any number of clients follow it
// live. It is also the task history: what ran, when, and how it ended.
package tasks
import (
"bytes"
"context"
"crypto/rand"
"encoding/hex"
"errors"
"os/exec"
"sort"
"sync"
"time"
)
const (
maxOutput = 1 << 20 // bytes kept per task
maxHistory = 200
)
type Task struct {
ID string
Kind string // "updates", "cron", …
Title string
Command string
Started time.Time
Finished time.Time
Running bool
ExitCode int
Err string
mu sync.Mutex
buf bytes.Buffer
subs map[chan []byte]struct{}
cancel context.CancelFunc
}
type Snapshot struct {
ID string `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Command string `json:"command"`
Started time.Time `json:"started"`
Finished *time.Time `json:"finished,omitempty"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Err string `json:"error,omitempty"`
Output string `json:"output,omitempty"`
}
type Manager struct {
mu sync.Mutex
tasks map[string]*Task
}
func New() *Manager { return &Manager{tasks: map[string]*Task{}} }
func newID() string {
b := make([]byte, 6)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Start launches cmd with its output captured into a new task. cmd must not have
// Stdout/Stderr set. The task ends when the process exits or Cancel is called.
func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) {
ctx, cancel := context.WithCancel(context.Background())
t := &Task{ID: newID(), Kind: kind, Title: title, Command: cmd.String(), Started: time.Now(), Running: true,
subs: map[chan []byte]struct{}{}, cancel: cancel}
w := &writer{t: t}
cmd.Stdout, cmd.Stderr = w, w
if err := cmd.Start(); err != nil {
cancel()
return nil, err
}
m.mu.Lock()
m.tasks[t.ID] = t
m.prune()
m.mu.Unlock()
go func() {
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = cmd.Process.Kill()
err = <-done
t.mu.Lock()
t.Err = "cancelled"
t.mu.Unlock()
}
t.mu.Lock()
t.Running, t.Finished = false, time.Now()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
t.ExitCode = ee.ExitCode()
} else {
t.ExitCode = -1
if t.Err == "" {
t.Err = err.Error()
}
}
}
for ch := range t.subs {
close(ch)
}
t.subs = map[chan []byte]struct{}{}
t.mu.Unlock()
cancel()
}()
return t, nil
}
// prune keeps the newest maxHistory finished tasks. Caller holds m.mu.
func (m *Manager) prune() {
if len(m.tasks) <= maxHistory {
return
}
var finished []*Task
for _, t := range m.tasks {
if !t.Running {
finished = append(finished, t)
}
}
sort.Slice(finished, func(i, j int) bool { return finished[i].Started.Before(finished[j].Started) })
for i := 0; i < len(finished) && len(m.tasks) > maxHistory; i++ {
delete(m.tasks, finished[i].ID)
}
}
func (m *Manager) Get(id string) (*Task, bool) {
m.mu.Lock()
defer m.mu.Unlock()
t, ok := m.tasks[id]
return t, ok
}
// List returns every task newest first, without output.
func (m *Manager) List() []Snapshot {
m.mu.Lock()
all := make([]*Task, 0, len(m.tasks))
for _, t := range m.tasks {
all = append(all, t)
}
m.mu.Unlock()
sort.Slice(all, func(i, j int) bool { return all[i].Started.After(all[j].Started) })
out := make([]Snapshot, 0, len(all))
for _, t := range all {
out = append(out, t.Snapshot(false))
}
return out
}
// RunningOf reports whether a task of the given kind is in flight.
func (m *Manager) RunningOf(kind string) (*Task, bool) {
m.mu.Lock()
defer m.mu.Unlock()
for _, t := range m.tasks {
if t.Kind == kind && t.Running {
return t, true
}
}
return nil, false
}
func (t *Task) Snapshot(withOutput bool) Snapshot {
t.mu.Lock()
defer t.mu.Unlock()
s := Snapshot{ID: t.ID, Kind: t.Kind, Title: t.Title, Command: t.Command, Started: t.Started, Running: t.Running, ExitCode: t.ExitCode, Err: t.Err}
if !t.Running {
f := t.Finished
s.Finished = &f
}
if withOutput {
s.Output = t.buf.String()
}
return s
}
// Subscribe returns the output so far plus a channel of further chunks, closed when the
// task ends. Call unsub when done following.
func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) {
t.mu.Lock()
defer t.mu.Unlock()
backlog = append([]byte(nil), t.buf.Bytes()...)
if !t.Running {
c := make(chan []byte)
close(c)
return backlog, c, func() {}
}
ch = make(chan []byte, 256)
t.subs[ch] = struct{}{}
return backlog, ch, func() {
t.mu.Lock()
if _, ok := t.subs[ch]; ok {
delete(t.subs, ch)
close(ch)
}
t.mu.Unlock()
}
}
func (t *Task) Cancel() { t.cancel() }
type writer struct{ t *Task }
func (w *writer) Write(p []byte) (int, error) {
t := w.t
t.mu.Lock()
defer t.mu.Unlock()
t.buf.Write(p)
if t.buf.Len() > maxOutput {
b := t.buf.Bytes()
t.buf = *bytes.NewBuffer(append([]byte("…\n"), b[len(b)-maxOutput*3/4:]...))
}
chunk := append([]byte(nil), p...)
for ch := range t.subs {
select {
case ch <- chunk:
default: // slow follower: drop the chunk rather than stall the process
}
}
return len(p), nil
}
+342
View File
@@ -0,0 +1,342 @@
// 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.
//
// apt is fully supported. dnf and pacman get a read-only list so the tab still says
// something useful on those hosts; upgrades there are a later addition.
package updates
import (
"bufio"
"context"
"errors"
"os"
"os/exec"
"regexp"
"sort"
"strings"
"time"
"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 | pacman | ""
CanUpgrade bool `json:"can_upgrade"`
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", "pacman"} {
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 append([]string{"sudo", "-n"}, 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
}
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
}
switch m.pm {
case "apt":
s.CanUpgrade = m.sudo
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 = mtimeMs("/var/cache/dnf")
case "pacman":
s.Packages = pacmanUpgradable(ctx)
s.LastRefresh = mtimeMs("/var/lib/pacman/sync")
}
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 / pacman (read-only) ----
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{}
}
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
}
// ---- actions (apt only) ----
var pkgNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9+.-]{1,127}$`)
func (m *Manager) start(title string, args ...string) (*tasks.Task, error) {
if m.pm != "apt" {
return nil, errors.New("upgrades are only supported for apt so far")
}
if !m.sudo {
return nil, errors.New("needs passwordless sudo")
}
if t, ok := m.tasks.RunningOf("updates"); ok {
return nil, errors.New("another package task is running: " + t.Title)
}
full := append([]string{"sudo", "-n", "env", "DEBIAN_FRONTEND=noninteractive", "APT_LISTCHANGES_FRONTEND=none", "LC_ALL=C"}, args...)
cmd := exec.Command(full[0], full[1:]...)
return m.tasks.Start("updates", title, cmd)
}
func (m *Manager) Refresh() (*tasks.Task, error) {
return m.start("apt-get update", "apt-get", "update")
}
// Upgrade runs a full dist-upgrade, or only the named packages. Config-file prompts are
// answered "keep the current one" so the run can never block on a question.
func (m *Manager) Upgrade(pkgs []string) (*tasks.Task, error) {
base := []string{"apt-get", "-y", "-o", "Dpkg::Options::=--force-confdef", "-o", "Dpkg::Options::=--force-confold"}
if len(pkgs) == 0 {
return m.start("apt-get dist-upgrade", append(base, "dist-upgrade")...)
}
for _, p := range pkgs {
if !pkgNameRe.MatchString(p) {
return nil, errors.New("bad package name: " + p)
}
}
args := append(base, "install", "--only-upgrade")
args = append(args, pkgs...)
return m.start("apt-get upgrade "+strings.Join(pkgs, " "), args...)
}
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
}