feat: list pending package updates on the system page (#2427)

This commit is contained in:
Anand Hegde
2026-09-27 11:27:13 -04:00
committed by GitHub
parent c1505804bd
commit 8613cfe548
16 changed files with 1275 additions and 91 deletions
+16
View File
@@ -9,6 +9,7 @@ import (
"github.com/henrygd/beszel/internal/common"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/smart"
"github.com/henrygd/beszel/internal/entities/system"
"log/slog"
)
@@ -54,6 +55,7 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
return registry
}
@@ -198,6 +200,20 @@ func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetPackageUpdatesHandler returns the pending package updates found by the
// last background check. It never runs a check itself.
type GetPackageUpdatesHandler struct{}
func (h *GetPackageUpdatesHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.packageUpdates == nil {
return hctx.SendResponse(system.PackageUpdates{}, hctx.RequestID)
}
return hctx.SendResponse(hctx.Agent.packageUpdates.list(), hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
+265 -67
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
@@ -25,16 +26,25 @@ const (
pacmanSyncInterval = 12 * time.Hour
)
// packageUpdatesCheck returns [total] or [total, security] pending package updates.
type packageUpdatesCheck func(ctx context.Context) ([]uint16, error)
// packageUpdatesResult is the outcome of one package manager check.
type packageUpdatesResult struct {
// counts is [total] or [total, security] pending package updates.
counts []uint16
packages []system.PackageUpdate
// securityKnown is true if packages carry per-package security flags.
securityKnown bool
}
type packageUpdatesCheck func(ctx context.Context) (packageUpdatesResult, error)
// packageUpdatesManager periodically checks the host package manager for pending
// updates in the background and caches the result, so checks never delay metrics.
type packageUpdatesManager struct {
sync.Mutex
name string
check packageUpdatesCheck
interval time.Duration
counts []uint16
result packageUpdatesResult
checkedAt time.Time
running bool
}
@@ -46,24 +56,37 @@ func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
if runtime.GOOS != "linux" || runningInContainer() {
return nil
}
interval := defaultPackageUpdatesInterval
if env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL"); exists {
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
return nil
case err == nil && duration > 0:
interval = duration
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
}
interval, enabled := packageUpdatesInterval()
if !enabled {
return nil
}
name, check := detectPackageManager(dataDir)
if check == nil {
return nil
}
slog.Debug("Package updates", "manager", name, "interval", interval)
return &packageUpdatesManager{check: check, interval: interval}
return &packageUpdatesManager{name: name, check: check, interval: interval}
}
// packageUpdatesInterval reads PACKAGE_UPDATES_INTERVAL as a Go duration such as
// "30m" or "6h". "0" disables checks. Invalid or negative values keep the default.
func packageUpdatesInterval() (interval time.Duration, enabled bool) {
env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL")
if !exists {
return defaultPackageUpdatesInterval, true
}
duration, err := time.ParseDuration(env)
switch {
case err == nil && duration == 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", "disabled")
return 0, false
case err == nil && duration > 0:
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", duration)
return duration, true
default:
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
return defaultPackageUpdatesInterval, true
}
}
// get returns the last cached counts and starts a background check if they are stale.
@@ -74,19 +97,34 @@ func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
pm.running = true
go pm.refresh()
}
return pm.counts
return pm.result.counts
}
// list returns the per-package details of the last check. It never starts a check.
func (pm *packageUpdatesManager) list() system.PackageUpdates {
pm.Lock()
defer pm.Unlock()
data := system.PackageUpdates{
Manager: pm.name,
SecurityKnown: pm.result.securityKnown,
Packages: pm.result.packages,
}
if !pm.checkedAt.IsZero() {
data.CheckedAt = pm.checkedAt.Unix()
}
return data
}
func (pm *packageUpdatesManager) refresh() {
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
defer cancel()
counts, err := pm.check(ctx)
result, err := pm.check(ctx)
if err != nil {
slog.Debug("Package updates check failed", "err", err)
counts = nil
result = packageUpdatesResult{}
}
pm.Lock()
pm.counts = counts
pm.result = result
pm.checkedAt = time.Now()
pm.running = false
pm.Unlock()
@@ -143,42 +181,92 @@ func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name
return string(out), err
}
// countSecurity returns the number of packages flagged as security updates.
func countSecurity(packages []system.PackageUpdate) (count uint16) {
for _, pkg := range packages {
if pkg.Security {
count++
}
}
return count
}
// checkApt simulates a full upgrade against the current package lists.
// It never refreshes the lists; apt-daily or the user does that.
func checkApt(ctx context.Context) ([]uint16, error) {
func checkApt(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
if err != nil {
return nil, err
return packageUpdatesResult{}, err
}
total, security := parseAptSimulate(out)
return []uint16{total, security}, nil
packages := parseAptSimulate(out)
return packageUpdatesResult{
counts: []uint16{uint16(len(packages)), countSecurity(packages)},
packages: packages,
securityKnown: true,
}, nil
}
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
func checkDnf(ctx context.Context) ([]uint16, error) {
// check-update lists only available versions, so installed versions come from rpm.
func checkDnf(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
if err != nil {
return nil, err
return packageUpdatesResult{}, err
}
total := parseDnfCheckUpdate(out)
packages := parseDnfCheckUpdate(out)
result := packageUpdatesResult{packages: packages}
if len(packages) > 0 {
args := []string{"-q", "--qf", rpmInstalledQueryFormat}
for _, pkg := range packages {
args = append(args, pkg.Name)
}
// rpm exits non-zero if any package is not installed; keep what it printed
out, _ = runPackageCommand(ctx, nil, "rpm", args...)
installed := parseRpmInstalled(out)
for i := range packages {
packages[i].Current = installed[packages[i].Name]
}
}
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
if err != nil {
return []uint16{total}, nil
if err == nil {
// --security lists the lowest version that fixes an advisory, which may be
// older than the version check-update offers, so match on name.arch only
security := make(map[string]struct{})
for _, pkg := range parseDnfCheckUpdate(out) {
security[pkg.Name] = struct{}{}
}
for i := range packages {
_, packages[i].Security = security[packages[i].Name]
}
result.securityKnown = true
}
return []uint16{total, parseDnfCheckUpdate(out)}, nil
for i := range packages {
packages[i].Name = trimRpmArch(packages[i].Name)
}
result.counts = []uint16{uint16(len(packages))}
if result.securityKnown {
result.counts = append(result.counts, countSecurity(packages))
}
return result, nil
}
func checkZypper(ctx context.Context) ([]uint16, error) {
// checkZypper lists package updates. Security updates come from patches, which
// zypper does not map to packages here, so only the security count is known.
func checkZypper(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
if err != nil {
return nil, err
return packageUpdatesResult{}, err
}
total := parseZypperTable(out)
packages := parseZypperListUpdates(out)
result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}}
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
if err != nil {
return []uint16{total}, nil
if err == nil {
result.counts = append(result.counts, parseZypperTable(out))
}
return []uint16{total, parseZypperTable(out)}, nil
return result, nil
}
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
@@ -197,7 +285,7 @@ func newPacmanCheck(dataDir string) packageUpdatesCheck {
}
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
return func(ctx context.Context) ([]uint16, error) {
return func(ctx context.Context) (packageUpdatesResult, error) {
// -n with a missing database reports no updates rather than failing,
// so always sync first and whenever the private copy is missing
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
@@ -212,47 +300,55 @@ func newPacmanCheck(dataDir string) packageUpdatesCheck {
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return nil, err
return packageUpdatesResult{}, err
}
if sync {
lastSync = time.Now()
}
return []uint16{parsePacmanCheckUpdates(out)}, nil
packages := parsePacmanCheckUpdates(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
}
func checkApk(ctx context.Context) ([]uint16, error) {
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
if err != nil {
return nil, err
return packageUpdatesResult{}, err
}
return []uint16{parseApkUpgradable(out)}, nil
packages := parseApkUpgradable(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
// parseAptSimulate counts upgrades in `apt-get -s` output. Upgrade lines look like
// parseAptSimulate parses upgrades in `apt-get -s` output. Upgrade lines look like
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
// New dependencies have no "[old version]" and are not counted.
func parseAptSimulate(out string) (total, security uint16) {
// New dependencies have no "[old version]" and are skipped.
func parseAptSimulate(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") {
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") || !strings.HasPrefix(fields[3], "(") {
continue
}
total++
pkg := system.PackageUpdate{
Name: fields[1],
Current: strings.Trim(fields[2], "[]"),
Available: strings.TrimPrefix(fields[3], "("),
}
start := strings.IndexByte(line, '(')
end := strings.IndexByte(line, ')')
if start >= 0 && end > start && strings.Contains(line[start:end], "-security") {
security++
}
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
packages = append(packages, pkg)
}
return total, security
return packages
}
// parseDnfCheckUpdate counts "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not counted twice.
func parseDnfCheckUpdate(out string) (count uint16) {
// parseDnfCheckUpdate parses "name.arch version repo" lines, stopping at the
// obsoletes section so obsoleted packages are not listed twice. Names keep the
// arch so they can be matched with rpm output. dnf4 wraps a long name.arch onto
// its own line, with the version and repo on the next line.
func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
var wrappedName string
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
@@ -260,11 +356,44 @@ func parseDnfCheckUpdate(out string) (count uint16) {
break
}
fields := strings.Fields(line)
if len(fields) == 3 && strings.Contains(fields[0], ".") {
count++
if wrappedName != "" && len(fields) == 2 {
fields = []string{wrappedName, fields[0], fields[1]}
}
wrappedName = ""
switch {
case len(fields) == 3 && strings.Contains(fields[0], "."):
packages = append(packages, system.PackageUpdate{Name: fields[0], Available: fields[1]})
case len(fields) == 1 && strings.Contains(fields[0], ".") && !strings.HasPrefix(line, " "):
wrappedName = fields[0]
}
}
return count
return packages
}
// rpmInstalledQueryFormat prints "name.arch [epoch:]version-release", matching
// the version format of dnf check-update.
const rpmInstalledQueryFormat = `%{NAME}.%{ARCH} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`
// parseRpmInstalled maps name.arch to its installed version. For packages with
// several installed versions, such as kernels, the last one listed wins.
func parseRpmInstalled(out string) map[string]string {
installed := make(map[string]string)
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
// "package foo.x86_64 is not installed" has more than two fields
if fields := strings.Fields(scanner.Text()); len(fields) == 2 {
installed[fields[0]] = fields[1]
}
}
return installed
}
// trimRpmArch removes the ".arch" suffix from a dnf package name.
func trimRpmArch(name string) string {
if i := strings.LastIndexByte(name, '.'); i > 0 {
return name[:i]
}
return name
}
// parseZypperTable counts the data rows of a zypper table (the lines after the
@@ -286,25 +415,94 @@ func parseZypperTable(out string) (count uint16) {
return count
}
// parsePacmanCheckUpdates counts "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (count uint16) {
// parseZypperListUpdates parses the `zypper list-updates` table, locating the
// columns by their header names.
func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
nameCol, currentCol, availableCol := -1, -1, -1
var header []string
inTable := false
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
if strings.Contains(scanner.Text(), " -> ") {
count++
line := scanner.Text()
switch {
case !inTable && strings.HasPrefix(line, "--") && strings.Contains(line, "-+-"):
for i, col := range header {
switch strings.TrimSpace(col) {
case "Name":
nameCol = i
case "Current Version":
currentCol = i
case "Available Version":
availableCol = i
}
}
if nameCol < 0 || availableCol < 0 {
return nil
}
inTable = true
case !inTable:
header = strings.Split(line, "|")
case strings.Contains(line, "|"):
cols := strings.Split(line, "|")
if len(cols) != len(header) {
continue
}
pkg := system.PackageUpdate{
Name: strings.TrimSpace(cols[nameCol]),
Available: strings.TrimSpace(cols[availableCol]),
}
if currentCol >= 0 {
pkg.Current = strings.TrimSpace(cols[currentCol])
}
packages = append(packages, pkg)
default:
return packages
}
}
return count
return packages
}
// parseApkUpgradable counts lines of `apk -u list`, which look like
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
func parseApkUpgradable(out string) (count uint16) {
// parsePacmanCheckUpdates parses "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
if strings.Contains(scanner.Text(), "[upgradable from:") {
count++
fields := strings.Fields(scanner.Text())
if len(fields) >= 4 && fields[2] == "->" {
packages = append(packages, system.PackageUpdate{Name: fields[0], Current: fields[1], Available: fields[3]})
}
}
return count
return packages
}
// parseApkUpgradable parses lines of `apk -u list`, which look like
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
func parseApkUpgradable(out string) (packages []system.PackageUpdate) {
const marker = "[upgradable from:"
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
i := strings.Index(line, marker)
fields := strings.Fields(line)
if i < 0 || len(fields) == 0 {
continue
}
name, available := splitApkNameVersion(fields[0])
_, current := splitApkNameVersion(strings.TrimSuffix(strings.TrimSpace(line[i+len(marker):]), "]"))
packages = append(packages, system.PackageUpdate{Name: name, Current: current, Available: available})
}
return packages
}
// splitApkNameVersion splits "name-version-rN" into name and "version-rN".
// Names may contain dashes, but versions do not.
func splitApkNameVersion(s string) (name, version string) {
rel := strings.LastIndexByte(s, '-')
if rel <= 0 || !strings.HasPrefix(s[rel+1:], "r") {
return s, ""
}
ver := strings.LastIndexByte(s[:rel], '-')
if ver <= 0 {
return s, ""
}
return s[:ver], s[ver+1:]
}
+273 -24
View File
@@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -25,44 +26,125 @@ func readPackageUpdatesTestData(t *testing.T, name string) string {
// Test data files are real command outputs captured in containers.
// findPackage returns the named package from a parsed list.
func findPackage(t *testing.T, packages []system.PackageUpdate, name string) system.PackageUpdate {
t.Helper()
for _, pkg := range packages {
if pkg.Name == name {
return pkg
}
}
t.Fatalf("package %q not found", name)
return system.PackageUpdate{}
}
// fakeCommands puts shell scripts named after package manager commands first on PATH.
func fakeCommands(t *testing.T, scripts map[string]string) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("requires shell scripts on PATH")
}
binDir := t.TempDir()
for name, script := range scripts {
require.NoError(t, os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\n"+script), 0o755))
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func testDataPath(t *testing.T, name string) string {
t.Helper()
path, err := filepath.Abs(filepath.Join("test-data", "package_updates", name))
require.NoError(t, err)
return path
}
func TestPackageUpdatesInterval(t *testing.T) {
tests := []struct {
name string
value *string
interval time.Duration
enabled bool
}{
{"unset", nil, time.Hour, true},
{"duration", new("30m"), 30 * time.Minute, true},
{"compound duration", new("1h30m"), 90 * time.Minute, true},
{"zero disables", new("0"), 0, false},
{"zero with unit disables", new("0s"), 0, false},
{"negative keeps default", new("-5m"), time.Hour, true},
{"no unit keeps default", new("60"), time.Hour, true},
{"invalid keeps default", new("hourly"), time.Hour, true},
{"empty keeps default", new(""), time.Hour, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL"))
t.Setenv("PACKAGE_UPDATES_INTERVAL", "")
require.NoError(t, os.Unsetenv("PACKAGE_UPDATES_INTERVAL"))
if tt.value != nil {
t.Setenv("PACKAGE_UPDATES_INTERVAL", *tt.value)
}
interval, enabled := packageUpdatesInterval()
assert.Equal(t, tt.interval, interval)
assert.Equal(t, tt.enabled, enabled)
})
}
t.Run("prefixed variable takes precedence", func(t *testing.T) {
t.Setenv("PACKAGE_UPDATES_INTERVAL", "0")
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "6h")
interval, enabled := packageUpdatesInterval()
assert.Equal(t, 6*time.Hour, interval)
assert.True(t, enabled)
})
}
func TestParseAptSimulate(t *testing.T) {
tests := []struct {
file string
total, security uint16
total, security int
}{
{"apt_debian12.txt", 44, 5},
{"apt_ubuntu2204.txt", 58, 45},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
total, security := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Equal(t, tt.total, total)
assert.Equal(t, tt.security, security)
packages := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
assert.Len(t, packages, tt.total)
assert.EqualValues(t, tt.security, countSecurity(packages))
})
}
t.Run("versions", func(t *testing.T) {
packages := parseAptSimulate(readPackageUpdatesTestData(t, "apt_ubuntu2204.txt"))
assert.Equal(t, system.PackageUpdate{Name: "libc6", Current: "2.35-0ubuntu3.4", Available: "2.35-0ubuntu3.15", Security: true}, findPackage(t, packages, "libc6"))
assert.Equal(t, system.PackageUpdate{Name: "base-files", Current: "12ubuntu4.4", Available: "12ubuntu4.7"}, findPackage(t, packages, "base-files"))
packages = parseAptSimulate(readPackageUpdatesTestData(t, "apt_debian12.txt"))
assert.Equal(t, system.PackageUpdate{Name: "tzdata", Current: "2023c-5+deb12u1", Available: "2026b-0+deb12u1"}, findPackage(t, packages, "tzdata"))
})
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
Remv oldpkg [1.0]`
total, security := parseAptSimulate(out)
assert.Equal(t, uint16(2), total)
assert.Equal(t, uint16(1), security)
assert.Equal(t, []system.PackageUpdate{
{Name: "linux-image-generic", Current: "6.8.0-49.49", Available: "6.8.0-50.50", Security: true},
{Name: "gcc-12-base", Current: "12.3.0-1ubuntu1~22.04", Available: "12.3.0-1ubuntu1~22.04.3"},
}, parseAptSimulate(out))
})
t.Run("no updates", func(t *testing.T) {
total, security := parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n")
assert.Zero(t, total)
assert.Zero(t, security)
assert.Empty(t, parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n"))
})
}
func TestParseDnfCheckUpdate(t *testing.T) {
tests := []struct {
file string
count uint16
count int
}{
{"dnf4_rocky9_check_update.txt", 110},
{"dnf4_rocky9_check_update_security.txt", 53},
@@ -71,19 +153,95 @@ func TestParseDnfCheckUpdate(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
assert.Equal(t, tt.count, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)))
assert.Len(t, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)), tt.count)
})
}
t.Run("obsoletes section and notices", func(t *testing.T) {
t.Run("versions keep epoch and arch", func(t *testing.T) {
packages := parseDnfCheckUpdate(readPackageUpdatesTestData(t, "dnf5_fedora42_check_update.txt"))
assert.Equal(t, system.PackageUpdate{Name: "openssl-libs.aarch64", Available: "1:3.2.6-4.fc42"}, findPackage(t, packages, "openssl-libs.aarch64"))
})
t.Run("obsoletes section, notices and wrapped names", func(t *testing.T) {
out := `
kernel.x86_64 5.14.0-503.el9 baseos
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
python3-some-very-long-package-name-that-wraps.noarch
1.2.3-4.el9 appstream
Obsoleting Packages
grub2-tools.x86_64 1:2.06-80.el9 baseos
grub2-tools.x86_64 1:2.06-77.el9 @baseos
`
assert.Equal(t, uint16(1), parseDnfCheckUpdate(out))
assert.Equal(t, []system.PackageUpdate{
{Name: "kernel.x86_64", Available: "5.14.0-503.el9"},
{Name: "python3-some-very-long-package-name-that-wraps.noarch", Available: "1.2.3-4.el9"},
}, parseDnfCheckUpdate(out))
})
}
func TestParseRpmInstalled(t *testing.T) {
installed := parseRpmInstalled(readPackageUpdatesTestData(t, "dnf4_rocky9_rpm_installed.txt"))
assert.Len(t, installed, 110)
assert.Equal(t, "2.34-83.el9.7", installed["glibc.aarch64"])
assert.Equal(t, "1:3.0.7-24.el9", installed["openssl-libs.aarch64"])
assert.NotContains(t, installed, "package")
// several installed kernels: the last one wins
installed = parseRpmInstalled("kernel.x86_64 5.14.0-427.el9\nkernel.x86_64 5.14.0-503.el9\n")
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
}
func TestCheckDnf(t *testing.T) {
tests := []struct {
name, updates, security, installed string
total, securityCount int
pkg system.PackageUpdate
}{
{
name: "dnf4",
updates: "dnf4_rocky9_check_update.txt", security: "dnf4_rocky9_check_update_security.txt", installed: "dnf4_rocky9_rpm_installed.txt",
total: 110, securityCount: 53,
pkg: system.PackageUpdate{Name: "vim-minimal", Current: "2:8.2.2637-20.el9_1", Available: "2:8.2.2637-26.el9_8.21", Security: true},
},
{
name: "dnf5",
updates: "dnf5_fedora42_check_update.txt", security: "dnf5_fedora42_check_update_security.txt", installed: "dnf5_fedora42_rpm_installed.txt",
total: 20, securityCount: 5,
pkg: system.PackageUpdate{Name: "openssl-libs", Current: "1:3.2.6-3.fc42", Available: "1:3.2.6-4.fc42", Security: true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) cat "` + testDataPath(t, tt.security) + `" ;; *) cat "` + testDataPath(t, tt.updates) + `" ;; esac
exit 100`,
"rpm": `cat "` + testDataPath(t, tt.installed) + `"
exit 1`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{uint16(tt.total), uint16(tt.securityCount)}, result.counts)
assert.True(t, result.securityKnown)
assert.Len(t, result.packages, tt.total)
assert.Equal(t, tt.pkg, findPackage(t, result.packages, tt.pkg.Name))
for _, pkg := range result.packages {
assert.NotEmpty(t, pkg.Current, pkg.Name)
}
})
}
t.Run("security query fails", func(t *testing.T) {
fakeCommands(t, map[string]string{
"dnf": `case "$*" in *--security*) exit 1 ;; esac
echo "bash.x86_64 5.1.8-9.el9 baseos"
exit 100`,
"rpm": `echo "bash.x86_64 5.1.8-6.el9_1"`,
})
result, err := checkDnf(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, result.counts)
assert.False(t, result.securityKnown)
assert.Equal(t, []system.PackageUpdate{{Name: "bash", Current: "5.1.8-6.el9_1", Available: "5.1.8-9.el9"}}, result.packages)
})
}
@@ -103,23 +261,76 @@ func TestParseZypperTable(t *testing.T) {
}
}
func TestParseZypperListUpdates(t *testing.T) {
packages := parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_updates.txt"))
assert.Len(t, packages, 22)
assert.Equal(t, system.PackageUpdate{Name: "zypper", Current: "1.14.76-150500.6.6.15", Available: "1.14.78-150500.6.14.1"}, findPackage(t, packages, "zypper"))
assert.Equal(t, system.PackageUpdate{Name: "aaa_base", Current: "84.87+git20180409.04c9dae-150300.10.20.1", Available: "84.87+git20180409.04c9dae-150300.10.23.1"}, findPackage(t, packages, "aaa_base"))
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap156_list_updates_none.txt")))
// patch tables have no version columns
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_patches_security.txt")))
}
func TestCheckZypper(t *testing.T) {
fakeCommands(t, map[string]string{
"zypper": `case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`,
})
result, err := checkZypper(context.Background())
require.NoError(t, err)
// security patches don't map to packages, so only the count is known
assert.Equal(t, []uint16{22, 4}, result.counts)
assert.False(t, result.securityKnown)
assert.Len(t, result.packages, 22)
}
func TestParsePacmanCheckUpdates(t *testing.T) {
assert.Equal(t, uint16(4), parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Zero(t, parsePacmanCheckUpdates(""))
assert.Equal(t, []system.PackageUpdate{
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
{Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"},
{Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"},
{Name: "tzdata", Current: "2026c-1", Available: "2026d-1"},
}, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
assert.Empty(t, parsePacmanCheckUpdates(""))
}
func TestParseApkUpgradable(t *testing.T) {
assert.Equal(t, uint16(10), parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt")))
assert.Zero(t, parseApkUpgradable(""))
packages := parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt"))
assert.Len(t, packages, 10)
assert.Equal(t, system.PackageUpdate{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}, packages[6])
// names with dashes and digits
assert.Equal(t, system.PackageUpdate{Name: "busybox-binsh", Current: "1.36.1-r28", Available: "1.36.1-r31"}, packages[2])
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates-bundle", Current: "20240226-r0", Available: "20260413-r0"}, packages[3])
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Current: "3.3.0-r2", Available: "3.3.7-r0"}, packages[4])
assert.Empty(t, parseApkUpgradable(""))
}
func TestSplitApkNameVersion(t *testing.T) {
tests := []struct{ in, name, version string }{
{"musl-1.2.5-r3", "musl", "1.2.5-r3"},
{"py3-foo-bar-2.0_rc1-r0", "py3-foo-bar", "2.0_rc1-r0"},
{"apk-tools-2.14.4-r1", "apk-tools", "2.14.4-r1"},
// unexpected formats keep the whole string as the name
{"noversion", "noversion", ""},
{"name-1.0", "name-1.0", ""},
{"-1.0-r0", "-1.0-r0", ""},
}
for _, tt := range tests {
name, version := splitApkNameVersion(tt.in)
assert.Equal(t, tt.name, name, tt.in)
assert.Equal(t, tt.version, version, tt.in)
}
}
func TestPackageUpdatesManagerCaching(t *testing.T) {
calls := make(chan struct{}, 10)
result := []uint16{3, 1}
packages := []system.PackageUpdate{{Name: "libc6", Current: "1", Available: "2", Security: true}}
result := packageUpdatesResult{counts: []uint16{3, 1}, packages: packages, securityKnown: true}
var resultErr error
pm := &packageUpdatesManager{
name: "apt",
interval: time.Hour,
check: func(context.Context) ([]uint16, error) {
check: func(context.Context) (packageUpdatesResult, error) {
calls <- struct{}{}
return result, resultErr
},
@@ -132,6 +343,9 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
}, time.Second, time.Millisecond)
}
// no check has finished yet
assert.Equal(t, system.PackageUpdates{Manager: "apt"}, pm.list())
now := time.Now()
// first call starts a background check and returns nothing yet
assert.Nil(t, pm.get(now))
@@ -141,15 +355,49 @@ func TestPackageUpdatesManagerCaching(t *testing.T) {
// cached result within interval, no new check
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
assert.Len(t, calls, 1)
list := pm.list()
assert.Equal(t, "apt", list.Manager)
assert.True(t, list.SecurityKnown)
assert.Equal(t, packages, list.Packages)
assert.NotZero(t, list.CheckedAt)
// list never starts a check
assert.Len(t, calls, 1)
// stale after interval: returns cached value and refreshes in background
result, resultErr = nil, errors.New("boom")
result, resultErr = packageUpdatesResult{}, errors.New("boom")
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
waitIdle()
assert.Len(t, calls, 2)
// failed check clears the counts
// failed check clears the counts and the list
assert.Nil(t, pm.get(time.Now()))
assert.Nil(t, pm.list().Packages)
assert.False(t, pm.list().SecurityKnown)
}
func TestGetPackageUpdatesHandler(t *testing.T) {
var sent any
hctx := &HandlerContext{
Agent: &Agent{},
SendResponse: func(data any, _ *uint32) error {
sent = data
return nil
},
}
handler := &GetPackageUpdatesHandler{}
// no supported package manager
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{}, sent)
packages := []system.PackageUpdate{{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}}
hctx.Agent.packageUpdates = &packageUpdatesManager{
name: "apk",
result: packageUpdatesResult{counts: []uint16{1}, packages: packages},
checkedAt: time.Unix(1700000000, 0),
}
require.NoError(t, handler.Handle(hctx))
assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent)
}
func TestPacmanCheckSync(t *testing.T) {
@@ -177,9 +425,10 @@ echo "linux 6.1-1 -> 6.2-1"
}
// first check syncs
counts, err := check(context.Background())
result, err := check(context.Background())
require.NoError(t, err)
assert.Equal(t, []uint16{1}, counts)
assert.Equal(t, []uint16{1}, result.counts)
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
// later checks reuse the synced copy
_, err = check(context.Background())
require.NoError(t, err)
@@ -0,0 +1,111 @@
alternatives.aarch64 1.24-1.el9
audit-libs.aarch64 3.0.7-104.el9
basesystem.noarch 11-13.el9
bash.aarch64 5.1.8-6.el9_1
binutils.aarch64 2.35.2-42.el9
binutils-gold.aarch64 2.35.2-42.el9
bzip2-libs.aarch64 1.0.8-8.el9
ca-certificates.noarch 2023.2.60_v7.0.306-90.1.el9_2
coreutils-single.aarch64 8.32-34.el9
cracklib.aarch64 2.9.6-27.el9
cracklib-dicts.aarch64 2.9.6-27.el9
crypto-policies.noarch 20230731-1.git94f0e2c.el9_3.1
crypto-policies-scripts.noarch 20230731-1.git94f0e2c.el9_3.1
curl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
cyrus-sasl-lib.aarch64 2.1.27-21.el9
dnf.noarch 4.14.0-8.el9
dnf-data.noarch 4.14.0-8.el9
elfutils-debuginfod-client.aarch64 0.189-3.el9
elfutils-default-yama-scope.noarch 0.189-3.el9
elfutils-libelf.aarch64 0.189-3.el9
elfutils-libs.aarch64 0.189-3.el9
expat.aarch64 2.5.0-1.el9
file-libs.aarch64 5.39-14.el9
filesystem.aarch64 3.16-2.el9
findutils.aarch64 1:4.8.0-6.el9
gdbm-libs.aarch64 1:1.19-4.el9
glib2.aarch64 2.68.4-11.el9
glibc.aarch64 2.34-83.el9.7
glibc-common.aarch64 2.34-83.el9.7
glibc-minimal-langpack.aarch64 2.34-83.el9.7
gnupg2.aarch64 2.3.3-4.el9
gnutls.aarch64 3.7.6-23.el9
gzip.aarch64 1.12-1.el9
ima-evm-utils.aarch64 1.4-4.el9
krb5-libs.aarch64 1.21.1-1.el9
less.aarch64 590-2.el9_2
libacl.aarch64 2.3.1-3.el9
libarchive.aarch64 3.5.3-4.el9
libatomic.aarch64 11.4.1-2.1.el9
libattr.aarch64 2.5.1-3.el9
libblkid.aarch64 2.37.4-15.el9
libcap.aarch64 2.48-9.el9_2
libcom_err.aarch64 1.46.5-3.el9
libcurl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
libdb.aarch64 5.3.28-53.el9
libdnf.aarch64 0.69.0-6.el9_3
libeconf.aarch64 0.4.1-3.el9_2
libevent.aarch64 2.1.12-6.el9
libfdisk.aarch64 2.37.4-15.el9
libgcc.aarch64 11.4.1-2.1.el9
libgcrypt.aarch64 1.10.0-10.el9_2
libgomp.aarch64 11.4.1-2.1.el9
libksba.aarch64 1.5.1-6.el9_1
libmount.aarch64 2.37.4-15.el9
libnghttp2.aarch64 1.43.0-5.el9_3.1
librepo.aarch64 1.14.5-1.el9
libselinux.aarch64 3.5-1.el9
libsemanage.aarch64 3.5-2.el9
libsepol.aarch64 3.5-1.el9
libsmartcols.aarch64 2.37.4-15.el9
libsolv.aarch64 0.7.24-2.el9
libstdc++.aarch64 11.4.1-2.1.el9
libtasn1.aarch64 4.16.0-8.el9_1
libusbx.aarch64 1.0.26-1.el9
libuser.aarch64 0.63-13.el9
libuuid.aarch64 2.37.4-15.el9
libxml2.aarch64 2.9.13-4.el9
libzstd.aarch64 1.5.1-2.el9
mpfr.aarch64 4.1.0-7.el9
ncurses-base.noarch 6.2-10.20210508.el9
ncurses-libs.aarch64 6.2-10.20210508.el9
nettle.aarch64 3.8-3.el9_0
openldap.aarch64 2.6.3-1.el9
openssl.aarch64 1:3.0.7-24.el9
openssl-libs.aarch64 1:3.0.7-24.el9
p11-kit.aarch64 0.24.1-2.el9
p11-kit-trust.aarch64 0.24.1-2.el9
pam.aarch64 1.5.1-15.el9
pcre.aarch64 8.44-3.el9.3
pcre2.aarch64 10.40-2.el9
pcre2-syntax.noarch 10.40-2.el9
python3.aarch64 3.9.18-1.el9_3
python3-dnf.noarch 4.14.0-8.el9
python3-hawkey.aarch64 0.69.0-6.el9_3
python3-libdnf.aarch64 0.69.0-6.el9_3
python3-libs.aarch64 3.9.18-1.el9_3
python3-pip-wheel.noarch 21.2.3-7.el9
python3-rpm.aarch64 4.16.1.3-25.el9
python3-setuptools-wheel.noarch 53.0.0-12.el9
rocky-gpg-keys.noarch 9.3-1.1.el9
rocky-release.noarch 9.3-1.1.el9
rocky-repos.noarch 9.3-1.1.el9
rootfiles.noarch 8.1-31.el9
rpm.aarch64 4.16.1.3-25.el9
rpm-build-libs.aarch64 4.16.1.3-25.el9
rpm-libs.aarch64 4.16.1.3-25.el9
rpm-sign-libs.aarch64 4.16.1.3-25.el9
sed.aarch64 4.8-9.el9
setup.noarch 2.13.7-9.el9
shadow-utils.aarch64 2:4.9-8.el9
sqlite-libs.aarch64 3.34.1-6.el9_1
systemd-libs.aarch64 252-18.el9
tar.aarch64 2:1.34-6.el9_1
tpm2-tss.aarch64 3.2.2-2.el9
tzdata.noarch 2023c-1.el9
usermode.aarch64 1.114-4.el9
util-linux.aarch64 2.37.4-15.el9
util-linux-core.aarch64 2.37.4-15.el9
vim-minimal.aarch64 2:8.2.2637-20.el9_1
yum.noarch 4.14.0-8.el9
package nonexistent-pkg.x86_64 is not installed
@@ -0,0 +1,21 @@
dnf5.aarch64 5.2.18.0-2.fc42
dnf5-plugins.aarch64 5.2.18.0-2.fc42
elfutils-default-yama-scope.noarch 0.194-1.fc42
elfutils-libelf.aarch64 0.194-1.fc42
elfutils-libs.aarch64 0.194-1.fc42
fedora-release-common.noarch 42-30
fedora-release-container.noarch 42-30
fedora-release-identity-container.noarch 42-30
glibc.aarch64 2.41-16.fc42
glibc-common.aarch64 2.41-16.fc42
glibc-minimal-langpack.aarch64 2.41-16.fc42
krb5-libs.aarch64 1.21.3-6.fc42
libdnf5.aarch64 5.2.18.0-2.fc42
libdnf5-cli.aarch64 5.2.18.0-2.fc42
libsolv.aarch64 0.7.36-2.fc42
openssl-libs.aarch64 1:3.2.6-3.fc42
rpm-sequoia.aarch64 1.10.1-1.fc42
tzdata.noarch 2025c-1.fc42
vim-data.noarch 2:9.2.280-1.fc42
vim-minimal.aarch64 2:9.2.280-1.fc42
package nonexistent-pkg.x86_64 is not installed
+2
View File
@@ -26,6 +26,8 @@ const (
GetZfsData
// Sync network monitor configuration to agent
SyncNetworkMonitors
// Request the list of pending package updates from agent
GetPackageUpdates
// Add new actions here...
)
@@ -0,0 +1,20 @@
package system
// PackageUpdate is one pending package update on the host.
type PackageUpdate struct {
Name string `json:"name" cbor:"0,keyasint"`
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
Available string `json:"available" cbor:"2,keyasint"`
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
}
// PackageUpdates is the detail payload returned by the agent for the
// GetPackageUpdates action. The counts in Info.PackageUpdates come from the same check.
type PackageUpdates struct {
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
// SecurityKnown is true if the package manager flags security updates per package.
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
}
+19
View File
@@ -202,6 +202,8 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// get pending package updates
apiAuth.GET("/package-updates", h.getPackageUpdates)
// /containers routes
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -445,6 +447,23 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// getPackageUpdates handles GET /api/beszel/package-updates requests
func (h *Hub) getPackageUpdates(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.BadRequestError("Invalid system parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
updates, err := system.FetchPackageUpdatesFromAgent()
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, updates)
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
+53
View File
@@ -548,6 +548,59 @@ func TestApiRoutesAuthentication(t *testing.T) {
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /package-updates route
{
Name: "GET /package-updates - no auth should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/package-updates",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/package-updates?system=invalid-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /package-updates - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /package-updates - good user should pass validation",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/package-updates?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /systemd routes
{
Name: "GET /systemd/info - no auth should fail",
+9
View File
@@ -791,6 +791,15 @@ func (sys *System) FetchSmartDataFromAgent() (smart.SmartDataResponse, error) {
return result, err
}
// FetchPackageUpdatesFromAgent fetches the list of pending package updates from the agent.
func (sys *System) FetchPackageUpdatesFromAgent() (system.PackageUpdates, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var result system.PackageUpdates
err := sys.request(ctx, common.GetPackageUpdates, nil, &result)
return result, err
}
// FetchZfsDataFromAgent fetches ZFS detail data from the agent.
func (sys *System) FetchZfsDataFromAgent(force bool) (*zfs.ZfsData, error) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -1,6 +1,7 @@
import { memo, useState } from "react"
import { Trans } from "@lingui/react/macro"
import { compareSemVer, parseSemVer, supportsNetworkMonitors } from "@/lib/utils"
import { SystemStatus } from "@/lib/enums"
import type { GPUData } from "@/types"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import InfoBar from "./system/info-bar"
@@ -16,6 +17,7 @@ import { GpuPowerChart, GpuCharts } from "./system/charts/gpu-charts"
import {
LazyContainersTable,
LazyNetworkMonitorsTable,
LazyPackageUpdatesTable,
LazySmartTable,
LazySystemdTable,
LazyZfsTable,
@@ -73,6 +75,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
const hasGpu = hasGpuData || hasGpuPowerData
const hasZfs = Object.keys(systemStats.at(-1)?.stats?.z ?? {}).length > 0
const hasNetworkMonitors = supportsNetworkMonitors(system)
// counts key the table so it refetches the list only after a new check
const packageUpdates = system.status === SystemStatus.Up && system.info.pu?.[0] ? system.info.pu.join(",") : ""
// keep tabsRef in sync for keyboard navigation
const tabs = ["core", "network", "disk"]
@@ -163,6 +167,8 @@ export default memo(function SystemDetail({ id }: { id: string }) {
{hasSystemd && <LazySystemdTable systemId={system.id} />}
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
{hasNetworkMonitors && <LazyNetworkMonitorsTable systemId={system.id} />}
</>
)
@@ -215,6 +221,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
<BatteryChart system={system} {...coreProps} />
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
</div>
{packageUpdates && <LazyPackageUpdatesTable systemId={system.id} counts={packageUpdates} />}
</TabsContent>
<TabsContent value="network" forceMount className={activeTab === "network" ? "contents" : "hidden"}>
@@ -47,6 +47,17 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
)
}
const PackageUpdatesTable = lazy(() => import("./package-updates-table"))
export function LazyPackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <PackageUpdatesTable systemId={systemId} counts={counts} />}
</div>
)
}
const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table"))
export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) {
@@ -0,0 +1,307 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import {
type Column,
type ColumnDef,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
} from "@tanstack/react-table"
import {
ArrowUpDownIcon,
GitCompareArrowsIcon,
PackageCheckIcon,
PackageIcon,
PackageOpenIcon,
ShieldAlertIcon,
XIcon,
} from "lucide-react"
import { useEffect, useMemo, useState } from "react"
import { Badge, type BadgeProps } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { pb } from "@/lib/api"
import { classifyVersionChange, type VersionChange } from "@/lib/package-updates"
import { cn, formatShortDate } from "@/lib/utils"
import type { PackageUpdate, PackageUpdates } from "@/types"
interface PackageUpdateRow extends PackageUpdate {
change: VersionChange
}
/** Sort order of version changes, largest first. */
const changeRank: Record<VersionChange, number> = { major: 4, minor: 3, patch: 2, revision: 1, other: 0 }
const changeVariant: Record<VersionChange, BadgeProps["variant"]> = {
major: "danger",
minor: "warning",
patch: "success",
revision: "secondary",
other: "outline",
}
function changeLabel(change: VersionChange) {
switch (change) {
case "major":
return t({ message: "Major", context: "Version change" })
case "minor":
return t({ message: "Minor", context: "Version change" })
case "patch":
return t({ message: "Patch", context: "Version change" })
case "revision":
return t({ message: "Revision", context: "Version change" })
default:
return t({ message: "Other", context: "Version change" })
}
}
function HeaderButton({
column,
name,
Icon,
}: {
column: Column<PackageUpdateRow>
name: string
Icon: React.ElementType
}) {
const isSorted = column.getIsSorted()
return (
<Button
className={cn(
"h-9 px-3 flex items-center gap-2 duration-50",
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
)}
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
<Icon className="size-4" />
{name}
<ArrowUpDownIcon className="size-4" />
</Button>
)
}
function getColumns(securityKnown: boolean): ColumnDef<PackageUpdateRow>[] {
const columns: ColumnDef<PackageUpdateRow>[] = [
{
id: "name",
accessorFn: (pkg) => pkg.name,
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
header: ({ column }) => <HeaderButton column={column} name={t`Package`} Icon={PackageIcon} />,
cell: ({ getValue }) => <span className="ms-1.5 block">{getValue() as string}</span>,
},
{
id: "current",
accessorFn: (pkg) => pkg.current ?? "",
enableSorting: false,
header: () => (
<span className="flex items-center gap-2 px-3">
<PackageCheckIcon className="size-4" />
<Trans context="Installed package version">Current</Trans>
</span>
),
cell: ({ getValue }) => (
<span className="ms-1.5 block font-mono text-xs text-muted-foreground">{(getValue() as string) || "-"}</span>
),
},
{
id: "available",
accessorFn: (pkg) => pkg.available,
enableSorting: false,
header: () => (
<span className="flex items-center gap-2 px-3">
<PackageOpenIcon className="size-4" />
<Trans context="Package version available to install">Available</Trans>
</span>
),
cell: ({ getValue }) => <span className="ms-1.5 block font-mono text-xs">{getValue() as string}</span>,
},
{
id: "change",
accessorFn: (pkg) => changeRank[pkg.change],
header: ({ column }) => <HeaderButton column={column} name={t`Change`} Icon={GitCompareArrowsIcon} />,
cell: ({ row }) => (
<Badge variant={changeVariant[row.original.change]} className="ms-1.5">
{changeLabel(row.original.change)}
</Badge>
),
},
]
if (securityKnown) {
columns.push({
id: "security",
accessorFn: (pkg) => (pkg.security ? 1 : 0),
header: ({ column }) => <HeaderButton column={column} name={t`Security`} Icon={ShieldAlertIcon} />,
cell: ({ row }) =>
row.original.security ? (
<span className="ms-1.5 flex items-center gap-1.5 text-red-600 dark:text-red-400">
<ShieldAlertIcon className="size-4" />
<Trans>Security</Trans>
</span>
) : null,
})
}
return columns
}
/**
* Lists pending package updates reported by the agent. The agent caches the result of
* its background check, so this refetches only when the update counts change.
*/
export default function PackageUpdatesTable({ systemId, counts }: { systemId: string; counts: string }) {
const [data, setData] = useState<PackageUpdates | null>(null)
const [error, setError] = useState<string | null>(null)
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
const [globalFilter, setGlobalFilter] = useState("")
useEffect(() => {
let cancelled = false
pb.send<PackageUpdates>("/api/beszel/package-updates", { query: { system: systemId } })
.then((result) => {
if (cancelled) return
setData(result)
setError(null)
})
.catch((err) => {
if (cancelled) return
setError(err?.message || t`Failed to load package updates`)
})
return () => {
cancelled = true
}
}, [systemId, counts])
const rows = useMemo<PackageUpdateRow[]>(
() => (data?.packages ?? []).map((pkg) => ({ ...pkg, change: classifyVersionChange(pkg.current, pkg.available) })),
[data]
)
const securityKnown = !!data?.securityKnown
const columns = useMemo(() => getColumns(securityKnown), [securityKnown])
const table = useReactTable({
data: rows,
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onSortingChange: setSorting,
onGlobalFilterChange: setGlobalFilter,
state: { sorting, globalFilter },
globalFilterFn: (row, _columnId, filterValue: string) => {
const pkg = row.original
const searchString = `${pkg.name} ${pkg.current ?? ""} ${pkg.available} ${changeLabel(pkg.change)}`.toLowerCase()
return filterValue
.toLowerCase()
.split(" ")
.every((term) => searchString.includes(term))
},
})
if (!data && !error) {
return null
}
const securityCount = rows.filter((pkg) => pkg.security).length
const tableRows = table.getRowModel().rows
return (
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Package Updates</Trans>
</CardTitle>
<CardDescription className="flex items-center flex-wrap">
{data?.manager && (
<>
<span className="font-mono">{data.manager}</span>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
</>
)}
<Trans>Total: {rows.length}</Trans>
{securityKnown && (
<>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Security: {securityCount}</Trans>
</>
)}
{!!data?.checkedAt && (
<>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Checked {formatShortDate(new Date(data.checkedAt * 1000).toISOString())}</Trans>
</>
)}
</CardDescription>
</div>
{rows.length > 0 && (
<div className="relative ms-auto w-full max-w-full md:w-64">
<Input
placeholder={t`Filter...`}
value={globalFilter}
onChange={(event) => setGlobalFilter(event.target.value)}
className="px-4 w-full max-w-full md:w-64"
/>
{globalFilter && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={t`Clear`}
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-muted-foreground"
onClick={() => setGlobalFilter("")}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</div>
)}
</div>
</CardHeader>
{error ? (
<p className="px-2 sm:px-1 text-sm text-muted-foreground">{error}</p>
) : (
<div className="h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md">
<table className="text-sm w-full text-nowrap">
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
</tr>
))}
</TableHeader>
<TableBody>
{tableRows.length ? (
tableRows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="py-2.5">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center pointer-events-none">
{rows.length ? <Trans>No results.</Trans> : <Trans>Up to date</Trans>}
</TableCell>
</TableRow>
)}
</TableBody>
</table>
</div>
)}
</Card>
)
}
@@ -0,0 +1,61 @@
import { expect, test } from "bun:test"
import { classifyVersionChange } from "./package-updates"
// version pairs are taken from real apt, dnf, zypper, pacman and apk output
test("major, minor and patch use the first differing upstream component", () => {
expect(classifyVersionChange("1.10.7-1", "2.0.0-1")).toBe("major")
expect(classifyVersionChange("1.10.7-1", "1.11.0-1")).toBe("minor")
expect(classifyVersionChange("3.0.7-104.el9", "3.1.5-8.el9")).toBe("minor")
expect(classifyVersionChange("1.3.7-1", "1.3.8-1")).toBe("patch")
expect(classifyVersionChange("0.21.7-1", "0.21.8.2-1")).toBe("patch")
expect(classifyVersionChange("3.3.0-r2", "3.3.7-r0")).toBe("patch")
expect(classifyVersionChange("0.7.36-2.fc42", "0.7.37-2.fc42")).toBe("patch")
// missing components count as zero
expect(classifyVersionChange("1.2", "1.2.1")).toBe("patch")
expect(classifyVersionChange("1.2", "1.3.0")).toBe("minor")
// components compare as numbers, not strings
expect(classifyVersionChange("1.9.0", "1.10.0")).toBe("minor")
// dotted versions without a revision, such as Ubuntu kernel metapackages
expect(classifyVersionChange("5.15.0.91.88", "5.15.0.92.89")).toBe("patch")
})
test("epochs are stripped when equal", () => {
expect(classifyVersionChange("2:9.2.280-1.fc42", "2:9.2.390-1.fc42")).toBe("patch")
expect(classifyVersionChange("1:2.3.4-1ubuntu1", "1:2.4.0-1ubuntu1")).toBe("minor")
expect(classifyVersionChange("1:3.2.6-3.fc42", "1:3.2.6-4.fc42")).toBe("revision")
})
test("revision-only changes", () => {
expect(classifyVersionChange("2.35-0ubuntu3.4", "2.35-0ubuntu3.15")).toBe("revision")
expect(classifyVersionChange("5.15.0-91.101", "5.15.0-92.102")).toBe("revision")
expect(classifyVersionChange("12.3.0-1ubuntu1~22.04", "12.3.0-1ubuntu1~22.04.3")).toBe("revision")
expect(classifyVersionChange("1.36.1-r28", "1.36.1-r31")).toBe("revision")
expect(classifyVersionChange("4.4-150400.25.22", "4.4-150400.27.3.2")).toBe("revision")
expect(classifyVersionChange("42-30", "42-31")).toBe("revision")
expect(
classifyVersionChange("84.87+git20180409.04c9dae-150300.10.20.1", "84.87+git20180409.04c9dae-150300.10.23.1")
).toBe("revision")
})
test("falls back to other when the change can't be classified safely", () => {
// unknown or identical versions
expect(classifyVersionChange(undefined, "1.0-1")).toBe("other")
expect(classifyVersionChange("", "1.0-1")).toBe("other")
expect(classifyVersionChange("1.0-1", "1.0-1")).toBe("other")
// epoch changes reset the version scheme
expect(classifyVersionChange("1.5-1", "1:1.0-1")).toBe("other")
expect(classifyVersionChange("1:2.0-1", "2:2.0-1")).toBe("other")
// calendar versions
expect(classifyVersionChange("2025c-1.fc42", "2026b-1.fc42")).toBe("other")
expect(classifyVersionChange("2026c-1", "2026d-1")).toBe("other")
expect(classifyVersionChange("20240226-r0", "20260413-r0")).toBe("other")
expect(classifyVersionChange("2023.2.60_v7.0.306-90.1.el9_2", "2025.2.80_v9.0.305-91.el9")).toBe("other")
expect(classifyVersionChange("20230731-1.git94f0e2c.el9_3.1", "20250905-1.git377cc42.el9_7")).toBe("other")
// pre-release and suffix-only changes
expect(classifyVersionChange("2.0~rc1-1", "2.0-1")).toBe("other")
expect(classifyVersionChange("1.2.3+dfsg-1", "1.2.3+dfsg2-1")).toBe("other")
// non-numeric versions
expect(classifyVersionChange("git20240101-1", "git20240301-1")).toBe("other")
// downgrades
expect(classifyVersionChange("1.3.0-1", "1.2.9-1")).toBe("other")
})
+81
View File
@@ -0,0 +1,81 @@
/**
* Kind of version change between an installed and an available package version.
* - major / minor / patch: first differing numeric component of the upstream version
* - revision: same upstream version, only the distro packaging revision changed
* - other: anything that can't be classified safely (unknown current version,
* epoch change, calendar versions, pre-release suffixes, downgrades)
*/
export type VersionChange = "major" | "minor" | "patch" | "revision" | "other"
interface SplitVersion {
epoch: number
upstream: string
revision: string
}
/**
* Splits a distro version string into epoch, upstream version and packaging revision.
* Works for the Debian ("1:2.3.4-1ubuntu1"), RPM ("2:9.2.390-1.fc42"), pacman ("1.3.7-1")
* and apk ("1.2.5-r3") formats. The revision follows the last "-".
*/
function splitVersion(version: string): SplitVersion {
let epoch = 0
const epochMatch = /^(\d+):/.exec(version)
if (epochMatch) {
epoch = Number(epochMatch[1])
version = version.slice(epochMatch[0].length)
}
const dash = version.lastIndexOf("-")
if (dash > 0) {
return { epoch, upstream: version.slice(0, dash), revision: version.slice(dash + 1) }
}
return { epoch, upstream: version, revision: "" }
}
/**
* Leading components at or above this look like dates or years (20240226, 2026b,
* 2025.2.80), where a change in the first component is not a major upgrade.
*/
const CALENDAR_VERSION_MIN = 1000
/** Classifies the change from `current` to `available` as major, minor, patch or revision. */
export function classifyVersionChange(current?: string, available?: string): VersionChange {
current = current?.trim()
available = available?.trim()
if (!current || !available || current === available) {
return "other"
}
const from = splitVersion(current)
const to = splitVersion(available)
// a new epoch means the version scheme was reset, so the numbers aren't comparable
if (from.epoch !== to.epoch) {
return "other"
}
if (from.upstream === to.upstream) {
return from.revision !== to.revision ? "revision" : "other"
}
const fromMatch = /^\d+(?:\.\d+)*/.exec(from.upstream)
const toMatch = /^\d+(?:\.\d+)*/.exec(to.upstream)
if (!fromMatch || !toMatch) {
return "other"
}
const fromParts = fromMatch[0].split(".").map(Number)
const toParts = toMatch[0].split(".").map(Number)
if (fromParts[0] >= CALENDAR_VERSION_MIN || toParts[0] >= CALENDAR_VERSION_MIN) {
return "other"
}
const length = Math.max(fromParts.length, toParts.length)
for (let i = 0; i < length; i++) {
const a = fromParts[i] ?? 0
const b = toParts[i] ?? 0
if (a === b) {
continue
}
if (b < a) {
return "other"
}
return i === 0 ? "major" : i === 1 ? "minor" : "patch"
}
// same numbers, so only a suffix such as "~rc1", "+dfsg" or a letter changed
return "other"
}
+19
View File
@@ -226,6 +226,25 @@ export interface ZfsVdev {
checksumErrs?: number
}
/** pending package update from GET /api/beszel/package-updates */
export interface PackageUpdate {
name: string
/** installed version, missing if unknown */
current?: string
available: string
security?: boolean
}
export interface PackageUpdates {
/** package manager name, e.g. "apt" */
manager?: string
/** unix time in seconds of the last check */
checkedAt?: number
/** true if the package manager flags security updates per package */
securityKnown?: boolean
packages: PackageUpdate[] | null
}
export interface ZfsDataset {
name: string
used?: number