From 8613cfe548c882b57c5748efd7007ecbe2ba2553 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Sun, 27 Sep 2026 20:57:13 +0530 Subject: [PATCH] feat: list pending package updates on the system page (#2427) --- agent/handlers.go | 16 + agent/package_updates.go | 332 ++++++++++++++---- agent/package_updates_test.go | 297 ++++++++++++++-- .../dnf4_rocky9_rpm_installed.txt | 111 ++++++ .../dnf5_fedora42_rpm_installed.txt | 21 ++ internal/common/common-ws.go | 2 + internal/entities/system/package_updates.go | 20 ++ internal/hub/api.go | 19 + internal/hub/api_test.go | 53 +++ internal/hub/systems/system.go | 9 + .../site/src/components/routes/system.tsx | 7 + .../components/routes/system/lazy-tables.tsx | 11 + .../routes/system/package-updates-table.tsx | 307 ++++++++++++++++ internal/site/src/lib/package-updates.test.ts | 61 ++++ internal/site/src/lib/package-updates.ts | 81 +++++ internal/site/src/types.d.ts | 19 + 16 files changed, 1275 insertions(+), 91 deletions(-) create mode 100644 agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt create mode 100644 agent/test-data/package_updates/dnf5_fedora42_rpm_installed.txt create mode 100644 internal/entities/system/package_updates.go create mode 100644 internal/site/src/components/routes/system/package-updates-table.tsx create mode 100644 internal/site/src/lib/package-updates.test.ts create mode 100644 internal/site/src/lib/package-updates.ts diff --git a/agent/handlers.go b/agent/handlers.go index 67b69a73..482e2f71 100644 --- a/agent/handlers.go +++ b/agent/handlers.go @@ -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) +} + //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// //////////////////////////////////////////////////////////////////////////// diff --git a/agent/package_updates.go b/agent/package_updates.go index d61de6c9..da00419c 100644 --- a/agent/package_updates.go +++ b/agent/package_updates.go @@ -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:] } diff --git a/agent/package_updates_test.go b/agent/package_updates_test.go index 555e92d4..bb49f39d 100644 --- a/agent/package_updates_test.go +++ b/agent/package_updates_test.go @@ -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) diff --git a/agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt b/agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt new file mode 100644 index 00000000..2aa34ada --- /dev/null +++ b/agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt @@ -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 diff --git a/agent/test-data/package_updates/dnf5_fedora42_rpm_installed.txt b/agent/test-data/package_updates/dnf5_fedora42_rpm_installed.txt new file mode 100644 index 00000000..d58d7b98 --- /dev/null +++ b/agent/test-data/package_updates/dnf5_fedora42_rpm_installed.txt @@ -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 diff --git a/internal/common/common-ws.go b/internal/common/common-ws.go index b9e8aea0..23b2fd57 100644 --- a/internal/common/common-ws.go +++ b/internal/common/common-ws.go @@ -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... ) diff --git a/internal/entities/system/package_updates.go b/internal/entities/system/package_updates.go new file mode 100644 index 00000000..c07fe130 --- /dev/null +++ b/internal/entities/system/package_updates.go @@ -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"` +} diff --git a/internal/hub/api.go b/internal/hub/api.go index a97819cb..59da88f3 100644 --- a/internal/hub/api.go +++ b/internal/hub/api.go @@ -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 { diff --git a/internal/hub/api_test.go b/internal/hub/api_test.go index 6130d380..745d3abe 100644 --- a/internal/hub/api_test.go +++ b/internal/hub/api_test.go @@ -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", diff --git a/internal/hub/systems/system.go b/internal/hub/systems/system.go index 58206b93..91e4b945 100644 --- a/internal/hub/systems/system.go +++ b/internal/hub/systems/system.go @@ -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) diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 72051efe..adc6f7ad 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -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 && } + {packageUpdates && } + {hasNetworkMonitors && } ) @@ -215,6 +221,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { {pageBottomExtraMargin > 0 &&
} + {packageUpdates && } diff --git a/internal/site/src/components/routes/system/lazy-tables.tsx b/internal/site/src/components/routes/system/lazy-tables.tsx index 60b9eb5c..1dde0cee 100644 --- a/internal/site/src/components/routes/system/lazy-tables.tsx +++ b/internal/site/src/components/routes/system/lazy-tables.tsx @@ -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 ( +
+ {isIntersecting && } +
+ ) +} + const NetworkMonitorsTable = lazy(() => import("../../network-monitors-table/network-monitors-table")) export function LazyNetworkMonitorsTable({ systemId }: { systemId: string }) { diff --git a/internal/site/src/components/routes/system/package-updates-table.tsx b/internal/site/src/components/routes/system/package-updates-table.tsx new file mode 100644 index 00000000..8f99b8fe --- /dev/null +++ b/internal/site/src/components/routes/system/package-updates-table.tsx @@ -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 = { major: 4, minor: 3, patch: 2, revision: 1, other: 0 } + +const changeVariant: Record = { + 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 + name: string + Icon: React.ElementType +}) { + const isSorted = column.getIsSorted() + return ( + + ) +} + +function getColumns(securityKnown: boolean): ColumnDef[] { + const columns: ColumnDef[] = [ + { + id: "name", + accessorFn: (pkg) => pkg.name, + sortingFn: (a, b) => a.original.name.localeCompare(b.original.name), + header: ({ column }) => , + cell: ({ getValue }) => {getValue() as string}, + }, + { + id: "current", + accessorFn: (pkg) => pkg.current ?? "", + enableSorting: false, + header: () => ( + + + Current + + ), + cell: ({ getValue }) => ( + {(getValue() as string) || "-"} + ), + }, + { + id: "available", + accessorFn: (pkg) => pkg.available, + enableSorting: false, + header: () => ( + + + Available + + ), + cell: ({ getValue }) => {getValue() as string}, + }, + { + id: "change", + accessorFn: (pkg) => changeRank[pkg.change], + header: ({ column }) => , + cell: ({ row }) => ( + + {changeLabel(row.original.change)} + + ), + }, + ] + if (securityKnown) { + columns.push({ + id: "security", + accessorFn: (pkg) => (pkg.security ? 1 : 0), + header: ({ column }) => , + cell: ({ row }) => + row.original.security ? ( + + + Security + + ) : 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(null) + const [error, setError] = useState(null) + const [sorting, setSorting] = useState([{ id: "name", desc: false }]) + const [globalFilter, setGlobalFilter] = useState("") + + useEffect(() => { + let cancelled = false + pb.send("/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( + () => (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 ( + + +
+
+ + Package Updates + + + {data?.manager && ( + <> + {data.manager} + + + )} + Total: {rows.length} + {securityKnown && ( + <> + + Security: {securityCount} + + )} + {!!data?.checkedAt && ( + <> + + Checked {formatShortDate(new Date(data.checkedAt * 1000).toISOString())} + + )} + +
+ {rows.length > 0 && ( +
+ setGlobalFilter(event.target.value)} + className="px-4 w-full max-w-full md:w-64" + /> + {globalFilter && ( + + )} +
+ )} +
+
+ {error ? ( +

{error}

+ ) : ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {tableRows.length ? ( + tableRows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + {rows.length ? No results. : Up to date} + + + )} + +
+
+ )} +
+ ) +} diff --git a/internal/site/src/lib/package-updates.test.ts b/internal/site/src/lib/package-updates.test.ts new file mode 100644 index 00000000..ade1be04 --- /dev/null +++ b/internal/site/src/lib/package-updates.test.ts @@ -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") +}) diff --git a/internal/site/src/lib/package-updates.ts b/internal/site/src/lib/package-updates.ts new file mode 100644 index 00000000..775bdfe2 --- /dev/null +++ b/internal/site/src/lib/package-updates.ts @@ -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" +} diff --git a/internal/site/src/types.d.ts b/internal/site/src/types.d.ts index ceb9868a..e4cd9476 100644 --- a/internal/site/src/types.d.ts +++ b/internal/site/src/types.d.ts @@ -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