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:
co-authored by
Claude Fable 5.1
parent
41c2f05535
commit
acb11193b9
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user