mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-28 04:17:46 +02:00
feat: list pending package updates on the system page (#2427)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
111
agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt
Normal file
111
agent/test-data/package_updates/dnf4_rocky9_rpm_installed.txt
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user