Files
hamlab/internal/updates/updates.go
T
Mitchell-JohnsonandClaude Fable 5.1 f8c9479e93 Files: previews — a toggle that shows a thumbnail per image and video, made by ffmpeg on the machine's GPU where it has one, cached under the data dir
A previews button on the Files tab turns the icon column into a 256 px thumbnail for
every image and video row, loaded as rows scroll near. internal/thumbs probes what the
machine can do rather than assuming it: ffmpeg and ffprobe, every /dev/dri/renderD*
opened and exercised, vainfo's decode profiles per node where it exists. A video goes to
the first GPU that lists its codec, with ffmpeg's own software fallback behind it and a
CPU retry behind that; images decode on the CPU, JPEGs at a reduced DCT size. Previews
are kept under <data dir>/thumbs/ keyed by path, size and mtime, with undecodable files
remembered too. Root mode makes them through the same root helper the file reads use.
Where ffmpeg is missing the tab offers to install it; where a render node exists but is
closed to the daemon's user, it offers a drop-in adding the group and a restart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-11 15:20:27 -07:00

548 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
}
// PM is the package manager found: apt | dnf | zypper | pacman | apk, or "".
func (m *Manager) PM() string { return m.pm }
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
}