mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-27 18:34:29 +00:00
446 lines
16 KiB
Go
446 lines
16 KiB
Go
//go:build testing
|
|
|
|
package agent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/henrygd/beszel/internal/entities/system"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func readPackageUpdatesTestData(t *testing.T, name string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(filepath.Join("test-data", "package_updates", name))
|
|
require.NoError(t, err)
|
|
return string(data)
|
|
}
|
|
|
|
// 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 int
|
|
}{
|
|
{"apt_debian12.txt", 44, 5},
|
|
{"apt_ubuntu2204.txt", 58, 45},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.file, func(t *testing.T) {
|
|
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]`
|
|
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) {
|
|
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 int
|
|
}{
|
|
{"dnf4_rocky9_check_update.txt", 110},
|
|
{"dnf4_rocky9_check_update_security.txt", 53},
|
|
{"dnf5_fedora42_check_update.txt", 20},
|
|
{"dnf5_fedora42_check_update_security.txt", 5},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.file, func(t *testing.T) {
|
|
assert.Len(t, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)), tt.count)
|
|
})
|
|
}
|
|
|
|
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, []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)
|
|
})
|
|
}
|
|
|
|
func TestParseZypperTable(t *testing.T) {
|
|
tests := []struct {
|
|
file string
|
|
count uint16
|
|
}{
|
|
{"zypper_leap155_list_updates.txt", 22},
|
|
{"zypper_leap155_list_patches_security.txt", 4},
|
|
{"zypper_leap156_list_updates_none.txt", 0},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.file, func(t *testing.T) {
|
|
assert.Equal(t, tt.count, parseZypperTable(readPackageUpdatesTestData(t, tt.file)))
|
|
})
|
|
}
|
|
}
|
|
|
|
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, []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) {
|
|
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)
|
|
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) (packageUpdatesResult, error) {
|
|
calls <- struct{}{}
|
|
return result, resultErr
|
|
},
|
|
}
|
|
waitIdle := func() {
|
|
require.Eventually(t, func() bool {
|
|
pm.Lock()
|
|
defer pm.Unlock()
|
|
return !pm.running
|
|
}, 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))
|
|
waitIdle()
|
|
assert.Len(t, calls, 1)
|
|
|
|
// 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 = 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 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) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("requires a shell script on PATH")
|
|
}
|
|
binDir := t.TempDir()
|
|
dataDir := t.TempDir()
|
|
logFile := filepath.Join(binDir, "calls.log")
|
|
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
|
|
script := `#!/bin/sh
|
|
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
|
|
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
|
|
echo "linux 6.1-1 -> 6.2-1"
|
|
`
|
|
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
|
|
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
|
|
check := newPacmanCheck(dataDir)
|
|
dbPath := filepath.Join(dataDir, "checkup-db")
|
|
readCalls := func() []string {
|
|
data, err := os.ReadFile(logFile)
|
|
require.NoError(t, err)
|
|
return strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
}
|
|
|
|
// first check syncs
|
|
result, err := check(context.Background())
|
|
require.NoError(t, err)
|
|
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)
|
|
// a missing private copy forces a sync
|
|
require.NoError(t, os.RemoveAll(dbPath))
|
|
_, err = check(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, []string{
|
|
"args=[] db=" + dbPath,
|
|
"args=[-n] db=" + dbPath,
|
|
"args=[] db=" + dbPath,
|
|
}, readCalls())
|
|
}
|