Compare commits

..
Author SHA1 Message Date
github-actions[bot] 88a9ad283c chore(helm): update app version to 0.20.0 2026-09-19 15:33:32 +00:00
232 changed files with 2365 additions and 13270 deletions
+1 -16
View File
@@ -29,7 +29,6 @@ type Agent struct {
fsNames []string // List of filesystem device names being monitored
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
diskPrev map[uint16]map[string]prevDisk // Previous disk I/O counters per cache interval
diskBaseline map[string]prevDisk // Latest disk I/O counters of any interval, seeds a new interval
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
lastDiskUsageUpdate time.Time // Last time disk usage was collected
netInterfaces map[string]struct{} // Stores all valid network interfaces
@@ -51,7 +50,6 @@ type Agent struct {
systemdManager *systemdManager // Manages systemd services
monitorManager *MonitorManager // Manages network monitors
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
packageUpdates *packageUpdatesManager // Checks for pending package updates
}
// NewAgent creates a new agent with the given data directory for persisting data.
@@ -151,17 +149,12 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
if err != nil {
slog.Debug("Systemd", "err", err)
}
if agent.systemdManager != nil {
agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled
}
agent.smartManager, err = NewSmartManager()
if err != nil {
slog.Debug("SMART", "err", err)
}
agent.packageUpdates = newPackageUpdatesManager(agent.dataDir)
// initialize GPU manager
agent.gpuManager, err = NewGPUManager()
if err != nil {
@@ -226,10 +219,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
}
}
if a.packageUpdates != nil {
data.Info.PackageUpdates = a.packageUpdates.get(time.Now())
}
data.Stats.ExtraFs = make(map[string]*system.FsStats)
data.Info.ExtraFsPct = make(map[string]float64)
for name, stats := range a.fsStats {
@@ -263,11 +252,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
// Start initializes and starts the agent with optional WebSocket connection
func (a *Agent) Start(serverOptions ServerOptions) error {
a.keys = serverOptions.Keys
err := a.connectionManager.Start(serverOptions)
if err != nil {
a.cleanupSensorShadow()
}
return err
return a.connectionManager.Start(serverOptions)
}
func (a *Agent) getFingerprint() string {
+4 -8
View File
@@ -17,7 +17,6 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
@@ -236,11 +235,8 @@ func TestWebSocketClient_TLSVerification(t *testing.T) {
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
systemRoots, err := x509.SystemCertPool()
require.NoError(t, err)
require.True(t, systemRoots.AppendCertsFromPEM(serverCertPEM))
client := newClient(t, caCertFile)
tlsConfig := client.getOptions().TlsConfig
require.NotNil(t, tlsConfig)
assert.True(t, tlsConfig.RootCAs.Equal(systemRoots))
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
require.NoError(t, err)
require.NoError(t, conn.NetConn().Close())
@@ -320,7 +316,7 @@ func TestGetTLSConfigErrors(t *testing.T) {
require.Error(t, err)
assert.Nil(t, tlsConfig)
assert.Contains(t, err.Error(), tc.errorMatch)
assert.Contains(t, err.Error(), strconv.Quote(tc.path))
assert.Contains(t, err.Error(), tc.path)
})
}
}
@@ -685,12 +681,12 @@ func TestGetToken(t *testing.T) {
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
// Set TOKEN_FILE to a non-existent file
t.Setenv("TOKEN_FILE", filepath.Join(t.TempDir(), "missing.txt"))
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
token, err := getToken()
assert.Error(t, err)
assert.Equal(t, "", token)
assert.ErrorIs(t, err, os.ErrNotExist)
assert.Contains(t, err.Error(), "no such file or directory")
})
t.Run("handles empty token file", func(t *testing.T) {
+16 -55
View File
@@ -8,7 +8,6 @@ import (
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -21,10 +20,7 @@ import (
// It handles both WebSocket and SSH connections, automatically switching between
// them based on availability and managing reconnection attempts.
type ConnectionManager struct {
agent *Agent // Reference to the parent agent
// mu guards State and isConnecting, which are read and written from both
// the main event loop and the goroutine spawned by connect().
mu sync.Mutex
agent *Agent // Reference to the parent agent
State ConnectionState // Current connection state
eventChan chan ConnectionEvent // Channel for connection events
wsClient *WebSocketClient // WebSocket client for hub communication
@@ -82,29 +78,6 @@ func (c *ConnectionManager) stopWsTicker() {
}
}
// getState returns the current connection state.
func (c *ConnectionManager) getState() ConnectionState {
c.mu.Lock()
defer c.mu.Unlock()
return c.State
}
// setConnecting sets the isConnecting flag and reports its previous value.
func (c *ConnectionManager) setConnecting(v bool) (previous bool) {
c.mu.Lock()
defer c.mu.Unlock()
previous = c.isConnecting
c.isConnecting = v
return previous
}
// isConnectingNow reports whether a reconnection attempt is currently in flight.
func (c *ConnectionManager) isConnectingNow() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.isConnecting
}
// Start begins connection attempts and enters the main event loop.
// It handles connection events, periodic health updates, and graceful shutdown.
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
@@ -149,10 +122,7 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
case connectionEvent := <-c.eventChan:
c.handleEvent(connectionEvent)
case <-c.wsTicker.C:
// skip if connect() is still running its own attempt
if !c.isConnectingNow() {
_ = c.startWebSocketConnection()
}
_ = c.startWebSocketConnection()
case <-healthTicker:
_ = health.Update()
case <-sigCtx.Done():
@@ -185,7 +155,6 @@ func (c *ConnectionManager) stop() error {
_ = c.agent.StopServer()
c.agent.monitorManager.Stop()
c.closeWebSocket()
c.agent.cleanupSensorShadow()
return health.CleanUp()
}
@@ -195,15 +164,15 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
case WebSocketConnect:
c.handleStateChange(WebSocketConnected)
case SSHConnect:
if c.getState() == Disconnected {
if c.State == Disconnected {
c.handleStateChange(SSHConnected)
}
case WebSocketDisconnect:
if c.getState() == WebSocketConnected {
if c.State == WebSocketConnected {
c.handleStateChange(Disconnected)
}
case SSHDisconnect:
if c.getState() == SSHConnected {
if c.State == SSHConnected {
c.handleStateChange(Disconnected)
}
}
@@ -212,40 +181,30 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
// handleStateChange updates the connection state and performs necessary actions
// based on the new state, including stopping services and initiating reconnections.
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
c.mu.Lock()
if c.State == newState {
c.mu.Unlock()
return
}
c.State = newState
c.mu.Unlock()
switch newState {
case WebSocketConnected:
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
c.ConnectionType = system.ConnectionTypeWebSocket
c.stopWsTicker()
_ = c.agent.StopServer()
c.setConnecting(false)
c.isConnecting = false
case SSHConnected:
// stop new ws connection attempts
slog.Info("SSH connection established")
c.ConnectionType = system.ConnectionTypeSSH
c.stopWsTicker()
c.setConnecting(false)
c.isConnecting = false
case Disconnected:
c.ConnectionType = system.ConnectionTypeNone
// Always keep the ticker running while disconnected. A pending WebSocket
// handshake started by connect() can fail asynchronously (e.g. the hub
// closes the socket, or the deadline set in OnOpen expires) after
// connect() has already returned with a nil error, in which case the
// ticker would otherwise never get re-armed and the agent would stop
// retrying entirely (#2326).
c.startWsTicker()
if c.setConnecting(true) {
if c.isConnecting {
// Already handling reconnection, avoid duplicate attempts
return
}
c.isConnecting = true
slog.Warn("Disconnected from hub")
// make sure old ws connection is closed
c.closeWebSocket()
@@ -257,8 +216,10 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
// connect handles the connection logic with proper delays and priority.
// It attempts WebSocket connection first, falling back to SSH server if needed.
func (c *ConnectionManager) connect() {
c.setConnecting(true)
defer c.setConnecting(false)
c.isConnecting = true
defer func() {
c.isConnecting = false
}()
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
time.Sleep(5 * time.Second)
@@ -272,7 +233,7 @@ func (c *ConnectionManager) connect() {
_ = c.stop()
os.Exit(1)
}
if c.getState() == Disconnected {
if c.State == Disconnected {
c.startSSHServer()
c.startWsTicker()
}
@@ -281,7 +242,7 @@ func (c *ConnectionManager) connect() {
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
func (c *ConnectionManager) startWebSocketConnection() error {
if c.getState() != Disconnected {
if c.State != Disconnected {
return errors.New("already connected")
}
if c.wsClient == nil {
@@ -301,7 +262,7 @@ func (c *ConnectionManager) startWebSocketConnection() error {
// startSSHServer starts the SSH server if the agent is currently disconnected.
func (c *ConnectionManager) startSSHServer() {
if c.getState() == Disconnected {
if c.State == Disconnected {
go c.agent.StartServer(c.serverOptions)
}
}
+3 -56
View File
@@ -9,7 +9,6 @@ import (
"net"
"net/url"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
@@ -78,10 +77,6 @@ func TestConnectionManager_StateTransitions(t *testing.T) {
cm.handleStateChange(SSHConnected)
assert.Equal(t, SSHConnected, cm.State, "State should change to SSHConnected")
// Prevent handleStateChange from spawning its async reconnect goroutine:
// this test only checks the synchronous state machine, and the goroutine
// would otherwise race with the direct field writes below.
cm.setConnecting(true)
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
@@ -100,6 +95,7 @@ func TestConnectionManager_EventHandling(t *testing.T) {
Host: "localhost:8080",
},
}
testCases := []struct {
name string
initialState ConnectionState
@@ -152,11 +148,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Prevent handleStateChange from spawning its async reconnect
// goroutine: this test only checks the synchronous state machine,
// and the goroutine would otherwise race with the direct field
// writes here and in later subtests.
cm.setConnecting(true)
cm.State = tc.initialState
cm.handleEvent(tc.event)
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
@@ -230,56 +221,12 @@ func TestConnectionManager_ReconnectionLogic(t *testing.T) {
// Test that isConnecting flag prevents duplicate reconnection attempts
// Start from connected state, then simulate disconnect
cm.State = WebSocketConnected
cm.setConnecting(false)
cm.isConnecting = false
// First disconnect should trigger reconnection logic
cm.handleStateChange(Disconnected)
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
assert.True(t, cm.isConnectingNow(), "Should set isConnecting flag")
}
// TestConnectionManager_TickerSurvivesStaleDisconnect reproduces the freeze from
// https://github.com/henrygd/beszel/issues/2326: a reconnect attempt's handshake
// can fail asynchronously (after connect() already returned with a nil error)
// while the manager is still in the Disconnected state. Previously the ticker
// was only re-armed from connect()'s synchronous error branch, so once that
// window was missed, the agent stopped retrying forever. The ticker must keep
// running any time the manager transitions into Disconnected, regardless of
// what happens to the in-flight handshake afterwards.
func TestConnectionManager_TickerSurvivesStaleDisconnect(t *testing.T) {
agent := createTestAgent(t)
cm := agent.connectionManager
cm.eventChan = make(chan ConnectionEvent, 1)
// Run on synctest's fake clock so the ticker fires without waiting a real
// wsTickerInterval. The ticker must be created inside the bubble.
synctest.Test(t, func(t *testing.T) {
// Simulate a healthy WebSocket connection, then a disconnect - mirroring
// handleStateChange's own Disconnected branch, but without launching the
// real async connect() goroutine so the ticker state can be asserted
// deterministically.
cm.State = WebSocketConnected
cm.stopWsTicker()
cm.setConnecting(true)
cm.handleStateChange(Disconnected)
require.NotNil(t, cm.wsTicker, "ticker must be armed as soon as the manager becomes Disconnected")
defer cm.stopWsTicker()
// Now simulate connect()'s in-flight handshake dying asynchronously with the
// manager still Disconnected (e.g. a late OnClose on an unauthenticated
// connection). This event is dropped by handleEvent since State is not
// WebSocketConnected, but the ticker armed above must still be running so
// the manager keeps retrying.
cm.setConnecting(false)
cm.handleEvent(WebSocketDisconnect)
assert.Equal(t, Disconnected, cm.State)
select {
case <-cm.wsTicker.C:
case <-time.After(wsTickerInterval + 2*time.Second):
t.Fatal("ticker did not fire after a stale disconnect event - agent would freeze forever")
}
})
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
}
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
+1 -8
View File
@@ -35,12 +35,6 @@ type CpuMetrics struct {
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
// It returns percentages for total, user, system, iowait, and steal time.
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
// Inside a container, /proc/stat reports the host cores' counters (via
// lxcfs on LXC, or the host's procfs elsewhere), not the container's own
// usage. Prefer the cgroup's own CPU accounting when available. (#2332)
if metrics, ok := containerCpuMetrics(cacheTimeMs); ok {
return metrics, nil
}
times, err := cpu.Times(false)
if err != nil || len(times) == 0 {
return CpuMetrics{}, err
@@ -125,8 +119,7 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
func getAllBusy(t cpu.TimesStat) (float64, float64) {
tot := t.User + t.System + t.Idle + t.Nice + t.Iowait + t.Irq +
t.Softirq + t.Steal + t.Guest + t.GuestNice
tot := t.Total()
if runtime.GOOS == "linux" {
tot -= t.Guest // Linux 2.6.24+
tot -= t.GuestNice // Linux 3.2.0+
-417
View File
@@ -1,417 +0,0 @@
//go:build linux
package agent
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
)
// Container-aware CPU accounting (issue #2332).
//
// Inside a container /proc/stat does not describe the container's own usage:
// lxcfs serves LXC guests the raw counters of the host cores in their cpuset,
// and plain runtimes (Docker, k8s) expose the host's /proc outright. An idle
// container sharing a host core with a busy neighbor then reports near-100%
// CPU while doing nothing. The cgroup's own accounting (cpu.stat /
// cpuacct.usage) reflects only the container's processes, so when the agent
// runs inside a container we derive CPU% from that instead.
// File paths and hooks are variables so tests can point them at fixtures.
var (
cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
cpuCgroupMountinfo = "/proc/self/mountinfo"
cpuProcSelfCgroup = "/proc/self/cgroup"
cpuProcOneEnviron = "/proc/1/environ"
cpuDockerenvPath = "/.dockerenv"
cpuContainerenv = "/run/.containerenv"
cpuSystemdContPath = "/run/systemd/container"
cpuNumCPU = runtime.NumCPU
cpuNow = time.Now
)
// cpuUserHZ is the USER_HZ jiffies-per-second rate cpuacct.stat reports in.
const cpuUserHZ = 100
var (
containerOnce sync.Once
containerDetected bool
)
// inContainer reports whether the agent itself runs inside a container.
// The result is cached because it cannot change during the process lifetime.
func inContainer() bool {
containerOnce.Do(func() { containerDetected = detectContainer() })
return containerDetected
}
// detectContainer looks for the usual container markers.
func detectContainer() bool {
// set by systemd-nspawn, LXC, Podman and others
if os.Getenv("container") != "" {
return true
}
for _, p := range []string{cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath} {
if utils.FileExists(p) {
return true
}
}
// liblxc always puts container=lxc in the container init's environment,
// which survives on non-systemd guests such as Alpine LXC.
if data, err := os.ReadFile(cpuProcOneEnviron); err == nil {
if bytes.HasPrefix(data, []byte("container=")) ||
bytes.Contains(data, []byte("\x00container=")) {
return true
}
}
// an lxcfs mount means /proc/stat is virtualized with host core counters
if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil &&
bytes.Contains(data, []byte(" - fuse.lxcfs ")) {
return true
}
return false
}
// cgroupCpuSample is one read of the container's cumulative CPU accounting.
type cgroupCpuSample struct {
usageUsec uint64
userUsec uint64
systemUsec uint64
cores float64 // usable CPU cores: affinity ∩ cpuset ∩ quota
at time.Time
}
var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
// init seeds the container CPU baseline so the first reported value is a real
// delta since startup rather than zero.
func init() {
if !inContainer() {
return
}
if s, ok := readContainerCpuSample(); ok {
s.at = cpuNow()
lastCgroupCpuSamples[60000] = s
}
}
// containerCpuMetrics derives CPU metrics from the agent's own cgroup
// accounting when running inside a container. It returns ok=false on plain
// hosts and whenever cgroup accounting is unreadable, so callers keep the
// /proc/stat fallback.
func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) {
if !inContainer() {
return CpuMetrics{}, false
}
cur, ok := readContainerCpuSample()
if !ok {
return CpuMetrics{}, false
}
cur.at = cpuNow()
prev, ok := lastCgroupCpuSamples[cacheTimeMs]
if !ok {
prev = lastCgroupCpuSamples[60000]
}
lastCgroupCpuSamples[cacheTimeMs] = cur
// No baseline yet, a backwards counter (cgroup recreated), or a
// non-positive clock delta: report zero this tick instead of guessing.
elapsedUsec := cur.at.Sub(prev.at).Microseconds()
if prev.at.IsZero() || elapsedUsec <= 0 || cur.usageUsec < prev.usageUsec {
return CpuMetrics{}, true
}
cores := cur.cores
if cores <= 0 {
cores = 1
}
window := float64(elapsedUsec) * cores
metrics := CpuMetrics{
Total: clampPercent(float64(cur.usageUsec-prev.usageUsec) / window * 100),
User: clampPercent(float64(cur.userUsec-prev.userUsec) / window * 100),
System: clampPercent(float64(cur.systemUsec-prev.systemUsec) / window * 100),
}
// cgroup accounting has no iowait/steal; everything not busy is idle.
metrics.Idle = clampPercent(100 - metrics.Total)
return metrics, true
}
// readContainerCpuSample reads the container's cumulative CPU usage, preferring
// the cgroup v2 unified hierarchy and falling back to the v1 cpuacct
// controller.
func readContainerCpuSample() (cgroupCpuSample, bool) {
if s, ok := readCgroupV2CpuSample(); ok {
return s, true
}
return readCgroupV1CpuSample()
}
// readCgroupV2CpuSample reads usage from the unified hierarchy's cpu.stat.
//
// The mount root is usually the right cgroup to read: inside a private cgroup
// namespace (LXC, default Docker) /sys/fs/cgroup already is the container's
// root cgroup, and its cpu.stat accounts for every process in the container,
// including siblings of the agent's own service cgroup. When the hierarchy is
// not namespaced (e.g. docker run --cgroupns=host) /proc/self/cgroup instead
// holds the container's real host-side path, which is joined onto the mount.
func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
rel := selfCgroupPath("0::")
if rel == "" {
return cgroupCpuSample{}, false // no v2 membership; try v1
}
dir := cpuCgroupRoot
if mount := cgroupMountPoint("cgroup2", ""); mount != "" {
dir = mount
}
if rel != "/" && hasContainerRuntimeMarker(rel) {
if cand := filepath.Join(dir, rel); directoryExistsOK(cand) {
dir = cand
}
}
stat := filepath.Join(dir, "cpu.stat")
usage, ok := cgroupStatValue(stat, "usage_usec")
if !ok {
return cgroupCpuSample{}, false
}
s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)}
s.userUsec, _ = cgroupStatValue(stat, "user_usec")
s.systemUsec, _ = cgroupStatValue(stat, "system_usec")
return s, true
}
// readCgroupV1CpuSample reads usage from the legacy cpuacct controller.
// Runtimes bind-mount the container's own cpuacct directory at the hierarchy
// mount, so the mount root is normally already the container's cgroup; if the
// process's cgroup path still resolves below the mount (shared host view),
// that subdirectory is used instead.
func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
mount := cgroupMountPoint("cgroup", "cpuacct")
if mount == "" {
return cgroupCpuSample{}, false
}
dir := mount
if rel := selfCgroupPath("cpuacct"); rel != "" && rel != "/" {
if cand := filepath.Join(mount, rel); utils.FileExists(filepath.Join(cand, "cpuacct.usage")) {
dir = cand
}
}
usageNs, ok := utils.ReadUintFile(filepath.Join(dir, "cpuacct.usage"))
if !ok {
return cgroupCpuSample{}, false
}
s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)}
// cpuacct.stat reports user/system in USER_HZ jiffies.
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok {
s.userUsec = v * 1e6 / cpuUserHZ
}
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok {
s.systemUsec = v * 1e6 / cpuUserHZ
}
return s, true
}
// selfCgroupPath returns the agent's cgroup path from /proc/self/cgroup: the
// path after "0::" for the v2 unified hierarchy, or the path of the entry
// whose controller list contains the given v1 controller (e.g. "cpuacct").
func selfCgroupPath(selector string) string {
data, err := os.ReadFile(cpuProcSelfCgroup)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
parts := strings.SplitN(line, ":", 3)
if len(parts) != 3 {
continue
}
if selector == "0::" {
if parts[0] == "0" && parts[1] == "" {
return parts[2]
}
continue
}
for _, ctrl := range strings.Split(parts[1], ",") {
if ctrl == selector {
return parts[2]
}
}
}
return ""
}
// cgroupMountPoint returns the mount point of a cgroup hierarchy from
// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose
// super options list the wanted v1 controller.
func cgroupMountPoint(fstype, v1ctrl string) string {
data, err := os.ReadFile(cpuCgroupMountinfo)
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
left, right, found := strings.Cut(line, " - ")
if !found {
continue
}
post := strings.Fields(right)
if len(post) == 0 || post[0] != fstype {
continue
}
if v1ctrl != "" && !mountOptHas(post, v1ctrl) {
continue
}
fields := strings.Fields(left)
if len(fields) >= 5 {
return unescapeMountPoint(fields[4])
}
}
return ""
}
// mountOptHas reports whether the comma-separated super options (field 3 after
// the " - " separator) contain opt.
func mountOptHas(post []string, opt string) bool {
if len(post) < 3 {
return false
}
for _, o := range strings.Split(post[2], ",") {
if o == opt {
return true
}
}
return false
}
// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in
// mountinfo paths.
func unescapeMountPoint(s string) string {
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
}
// hasContainerRuntimeMarker reports whether a cgroup path looks like a real
// host-side container cgroup path (docker/k8s/lxc/podman), meaning the visible
// hierarchy is not namespaced and the path can be resolved under the mount.
func hasContainerRuntimeMarker(path string) bool {
for _, m := range []string{"docker", "kubepods", "lxc", "crio", "libpod", "containerd", "podman"} {
if strings.Contains(path, m) {
return true
}
}
return false
}
// cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the
// smallest of the process affinity mask, the cgroup cpuset, and the CPU quota.
func cpuCgroupCores(dir string) float64 {
cores := float64(cpuNumCPU())
if n := cpusetCount(dir); n > 0 && n < cores {
cores = n
}
if q, ok := cpuQuotaCores(dir); ok && q < cores {
cores = q
}
if cores <= 0 {
cores = 1
}
return cores
}
// cpusetCount returns the number of CPUs in the cgroup's cpuset, e.g. "0-3" or
// "2,5-7". An empty or missing file means unconstrained.
func cpusetCount(dir string) float64 {
for _, name := range []string{"cpuset.cpus.effective", "cpuset.cpus"} {
raw, err := os.ReadFile(filepath.Join(dir, name))
if err != nil {
continue
}
if n := countCpuList(strings.TrimSpace(string(raw))); n > 0 {
return float64(n)
}
}
return 0
}
// countCpuList counts the CPUs in a Linux CPU list like "0-3,5,8-9".
func countCpuList(list string) int {
total := 0
for _, part := range strings.Split(list, ",") {
lo, hi, ranged := strings.Cut(part, "-")
a, err := strconv.Atoi(lo)
if err != nil {
continue
}
b := a
if ranged {
if v, err := strconv.Atoi(hi); err == nil {
b = v
}
}
if b >= a {
total += b - a + 1
}
}
return total
}
// cpuQuotaCores returns the cgroup's CPU quota in cores. v2 uses cpu.max
// ("<quota|max> <period>"), v1 uses cpu.cfs_quota_us / cpu.cfs_period_us.
func cpuQuotaCores(dir string) (float64, bool) {
if raw, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil {
fields := strings.Fields(string(raw))
if len(fields) == 2 && fields[0] != "max" {
if quota, err := strconv.ParseFloat(fields[0], 64); err == nil && quota > 0 {
if period, err := strconv.ParseFloat(fields[1], 64); err == nil && period > 0 {
return quota / period, true
}
}
}
}
if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
return float64(quota) / float64(period), true
}
}
return 0, false
}
// cgroupStatValue returns the value of key in a cgroup "key value" stat file.
func cgroupStatValue(path, key string) (uint64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
for _, line := range strings.Split(string(data), "\n") {
name, value, found := strings.Cut(line, " ")
if !found || name != key {
continue
}
v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
return v, err == nil
}
return 0, false
}
// readCgroupInt reads a file containing a single signed integer
// (cpu.cfs_quota_us is -1 when no quota is set).
func readCgroupInt(path string) (int64, bool) {
data, err := os.ReadFile(path)
if err != nil {
return 0, false
}
v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
return v, err == nil
}
// directoryExistsOK reports whether path is a directory.
func directoryExistsOK(path string) bool {
ok, _ := directoryExists(path)
return ok
}
-337
View File
@@ -1,337 +0,0 @@
//go:build testing && linux
package agent
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// swapCpuContainerSeams points every container-detection and cgroup path at
// empty fixtures under a temp dir, then restores them on cleanup.
func swapCpuContainerSeams(t *testing.T) {
t.Helper()
backup := struct {
root, mountinfo, selfCgroup, oneEnviron, dockerenv, containerenv, systemdCont string
numCPU func() int
now func() time.Time
}{
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron,
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath, cpuNumCPU, cpuNow,
}
detected := containerDetected
samples := lastCgroupCpuSamples
env, hadEnv := os.LookupEnv("container")
t.Cleanup(func() {
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron = backup.root, backup.mountinfo, backup.selfCgroup, backup.oneEnviron
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath = backup.dockerenv, backup.containerenv, backup.systemdCont
cpuNumCPU, cpuNow = backup.numCPU, backup.now
containerOnce = sync.Once{}
containerDetected = detected
lastCgroupCpuSamples = samples
if hadEnv {
os.Setenv("container", env)
}
})
containerOnce = sync.Once{}
containerDetected = false
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
os.Unsetenv("container")
tmp := t.TempDir()
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
cpuProcOneEnviron = filepath.Join(tmp, "one-environ")
cpuDockerenvPath = filepath.Join(tmp, "dockerenv")
cpuContainerenv = filepath.Join(tmp, "containerenv")
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
}
func writeCpuFixture(t *testing.T, path, contents string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
}
// fakeNow installs a controllable clock and returns a function to advance it.
func fakeNow(t *testing.T) func(time.Duration) {
t.Helper()
cur := time.Unix(1_700_000_000, 0)
cpuNow = func() time.Time { return cur }
return func(d time.Duration) { cur = cur.Add(d) }
}
func TestDetectContainer(t *testing.T) {
tests := []struct {
name string
setup func(t *testing.T)
want bool
}{
{"plain host", func(t *testing.T) {}, false},
{"container env", func(t *testing.T) { t.Setenv("container", "lxc") }, true},
{".dockerenv", func(t *testing.T) { writeCpuFixture(t, cpuDockerenvPath, "") }, true},
{".containerenv", func(t *testing.T) { writeCpuFixture(t, cpuContainerenv, "") }, true},
{"systemd container", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
{"init environ container=lxc", func(t *testing.T) {
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00container=lxc\x00HOME=/root\x00")
}, true},
{"init environ without marker", func(t *testing.T) {
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00HOME=/root\x00")
}, false},
{"lxcfs serving /proc", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
}, true},
{"cgroup-only mountinfo", func(t *testing.T) {
writeCpuFixture(t, cpuCgroupMountinfo,
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
swapCpuContainerSeams(t)
tt.setup(t)
assert.Equal(t, tt.want, detectContainer())
})
}
}
func TestReadCgroupV2CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
cpuNumCPU = func() int { return 8 }
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 3000000, s.usageUsec)
assert.EqualValues(t, 2000000, s.userUsec)
assert.EqualValues(t, 1000000, s.systemUsec)
assert.InDelta(t, 4, s.cores, 0.001) // cpuset 2,5-7 = 4 cores
}
// In a namespaced container the agent may sit in a sub-cgroup (e.g. a systemd
// service); the mount root still accounts for the whole container and must win.
func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/beszel-agent.service\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "system.slice/beszel-agent.service/cpu.stat"), "usage_usec 5\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 9000, s.usageUsec)
}
// Without a cgroup namespace the mount shows the real host hierarchy and the
// container's own path (docker/kubepods/lxc markers) resolves under it.
func TestReadCgroupV2ResolvesRuntimePath(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/docker-deadbeef.scope\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
sub := filepath.Join(cpuCgroupRoot, "system.slice/docker-deadbeef.scope")
writeCpuFixture(t, filepath.Join(sub, "cpu.stat"), "usage_usec 5\n")
writeCpuFixture(t, filepath.Join(sub, "cpu.max"), "100000 100000\n")
s, ok := readCgroupV2CpuSample()
require.True(t, ok)
assert.EqualValues(t, 5, s.usageUsec)
assert.InDelta(t, 1, s.cores, 0.001) // cpu.max quota of 1 core
}
func TestReadCgroupV1CpuSample(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
v1 := filepath.Join(t.TempDir(), "cpuacct")
writeCpuFixture(t, cpuCgroupMountinfo,
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
cpuNumCPU = func() int { return 4 }
s, ok := readContainerCpuSample() // no 0:: line -> falls through to v1
require.True(t, ok)
assert.EqualValues(t, 2000000, s.usageUsec) // ns -> usec
assert.EqualValues(t, 1000000, s.userUsec) // 100 jiffies * 1e6/100
assert.EqualValues(t, 500000, s.systemUsec) // 50 jiffies
assert.InDelta(t, 4, s.cores, 0.001)
}
func TestContainerCpuMetricsMath(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total) // first call only seeds the baseline
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01)
assert.InDelta(t, 25, m.User, 0.01)
assert.InDelta(t, 12.5, m.System, 0.01)
assert.Zero(t, m.Iowait)
assert.Zero(t, m.Steal)
assert.InDelta(t, 50, m.Idle, 0.01)
}
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
cpuNumCPU = func() int { return 8 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
containerCpuMetrics(60000)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
}
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
// seed the baseline, then do not advance the clock: elapsed <= 0
containerCpuMetrics(60000)
m, ok := containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// counter goes backwards (cgroup recreated): report zero and re-baseline
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.Zero(t, m.Total)
// next tick measures from the new baseline, not the stale one
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
m, ok = containerCpuMetrics(60000)
require.True(t, ok)
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
}
func TestContainerCpuMetricsFallbacks(t *testing.T) {
t.Run("not in container", func(t *testing.T) {
swapCpuContainerSeams(t)
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
t.Run("in container without cgroup accounting", func(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
// cpuCgroupRoot has no cpu.stat
_, ok := containerCpuMetrics(60000)
assert.False(t, ok)
})
}
// The host path must keep reporting through gopsutil untouched.
func TestGetCpuMetricsHostFallback(t *testing.T) {
swapCpuContainerSeams(t)
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.GreaterOrEqual(t, m.Total, 0.0)
assert.LessOrEqual(t, m.Total, 100.0)
}
// Inside a container getCpuMetrics must report the cgroup-derived value, not
// the host core counters from /proc/stat.
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
swapCpuContainerSeams(t)
writeCpuFixture(t, cpuDockerenvPath, "")
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
writeCpuFixture(t, cpuCgroupMountinfo, "")
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
cpuNumCPU = func() int { return 4 }
advance := fakeNow(t)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
m, err := getCpuMetrics(60000)
require.NoError(t, err)
assert.Zero(t, m.Total)
advance(time.Second)
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
m, err = getCpuMetrics(60000)
require.NoError(t, err)
assert.InDelta(t, 50, m.Total, 0.01)
}
func TestCountCpuList(t *testing.T) {
assert.Equal(t, 4, countCpuList("0-3"))
assert.Equal(t, 4, countCpuList("2,5-7"))
assert.Equal(t, 1, countCpuList("2"))
assert.Equal(t, 0, countCpuList(""))
assert.Equal(t, 0, countCpuList("max"))
assert.Equal(t, 6, countCpuList("0-3,8-9"))
}
func TestCpuQuotaCores(t *testing.T) {
dir := t.TempDir()
_, ok := cpuQuotaCores(dir)
assert.False(t, ok) // no quota files
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "max 100000\n")
_, ok = cpuQuotaCores(dir)
assert.False(t, ok) // unlimited
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "150000 100000\n")
q, ok := cpuQuotaCores(dir)
require.True(t, ok)
assert.InDelta(t, 1.5, q, 0.001)
// v1 files
v1 := t.TempDir()
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "-1\n")
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_period_us"), "100000\n")
_, ok = cpuQuotaCores(v1)
assert.False(t, ok)
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "50000\n")
q, ok = cpuQuotaCores(v1)
require.True(t, ok)
assert.InDelta(t, 0.5, q, 0.001)
}
-28
View File
@@ -1,28 +0,0 @@
//go:build testing
package agent
import (
"runtime"
"testing"
"github.com/shirou/gopsutil/v4/cpu"
"github.com/stretchr/testify/assert"
)
func TestGetAllBusy(t *testing.T) {
times := cpu.TimesStat{
User: 1, System: 2, Idle: 3, Nice: 4, Iowait: 5,
Irq: 6, Softirq: 7, Steal: 8, Guest: 9, GuestNice: 10,
}
wantTotal, wantBusy := 55.0, 47.0
if runtime.GOOS == "linux" {
wantTotal, wantBusy = 36, 28
}
total, busy := getAllBusy(times)
assert.Equal(t, wantTotal, total)
assert.Equal(t, wantBusy, busy)
assert.InDelta(t, wantBusy/wantTotal*100, calculateBusy(cpu.TimesStat{}, times), 1e-10)
assert.Zero(t, calculateBusy(times, times))
assert.Zero(t, calculateBusy(times, cpu.TimesStat{}))
}
-9
View File
@@ -1,9 +0,0 @@
//go:build !linux
package agent
// containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep
// the gopsutil /proc path.
func containerCpuMetrics(uint16) (CpuMetrics, bool) {
return CpuMetrics{}, false
}
+32 -90
View File
@@ -3,7 +3,6 @@ package agent
import (
"context"
"log/slog"
"math"
"os"
"path/filepath"
"runtime"
@@ -154,12 +153,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
}
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
// key and reports whether it was added. The key selection itself lives in
// registerFilesystemStats so that logic can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
// key. The key selection itself lives in buildFsStatRegistration so that logic
// can stay directly unit-tested.
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
if !ok {
return false
return
}
d.agent.fsStats[key] = fsStats
name := key
@@ -167,7 +166,6 @@ func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customNa
name = customName
}
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
return true
}
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
@@ -205,24 +203,14 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
// partition looks like the active root mount but still needs translating to an
// I/O device key.
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
// device is passed through as-is: findIoDevice normalizes it, and
// filepath.Base would turn a Windows volume name such as "C:" into "\"
// on the way in (#2417).
fs, match := findIoDevice(device, d.ctx.diskIoCounters)
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
if !match {
return false
}
// The root device is already resolved, so if it was registered earlier as an
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
// entry rather than letting addLastResortRootFs guess a different device.
if stats, exists := d.agent.fsStats[fs]; exists {
stats.Root = true
stats.Mountpoint = mountpoint
return true
}
// Use the resolved I/O device directly to avoid a second fallback search
// inside registerFilesystemStats.
return d.addFsStat(fs, mountpoint, true, "")
// The resolved I/O device is already known here, so use it directly to avoid
// a second fallback search inside buildFsStatRegistration.
d.addFsStat(fs, mountpoint, true, "")
return true
}
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
@@ -538,43 +526,13 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
// normalizeDeviceName canonicalizes device strings for comparisons.
func normalizeDeviceName(value string) string {
name := strings.TrimSpace(value)
if volume, ok := windowsVolumeName(name); ok {
return volume
}
name = filepath.Base(name)
name := filepath.Base(strings.TrimSpace(value))
if name == "." {
return ""
}
return name
}
// windowsVolumeName returns the canonical form of a bare Windows volume
// specifier, so that "C:", "c:", `C:\` and "C:/" all name the same drive.
// Drive letters are case-insensitive on Windows, so the letter is uppercased.
//
// filepath.Base cannot do this. On Windows it treats "C:" as a volume name
// with no path element to take the base of and returns "\", so every drive
// letter normalizes to the same key. findIoDevice then returns whichever
// counter the map happened to yield first, which registers the root
// filesystem under a random drive (#2417).
func windowsVolumeName(value string) (string, bool) {
if len(value) < 2 || value[1] != ':' {
return "", false
}
if c := value[0]; !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') {
return "", false
}
// Only separators may follow the specifier. "C:data" is a drive-relative
// path, not a volume.
for i := 2; i < len(value); i++ {
if value[i] != '\\' && value[i] != '/' {
return "", false
}
}
return strings.ToUpper(value[:2]), true
}
// Sets start values for disk I/O stats.
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
a.fsNames = a.fsNames[:0]
@@ -596,9 +554,9 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
continue
}
// populate initial values
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
a.setDiskBaseline(device, prevDiskFromCounter(d, now))
// add to list of valid io device names
a.fsNames = append(a.fsNames, device)
}
@@ -681,9 +639,19 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// Previous snapshot for this interval and device
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
if !hasPrev {
// Seed from the latest counters of any interval, else seed from current
prev, hasPrev = a.diskBaseline[name]
if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{
readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() {
prev = prevDiskFromCounter(d, now)
}
}
@@ -718,31 +686,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
diskReadTime := utils.TwoDecimals(float64(deltaReadTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(deltaWriteTime) / float64(msElapsed) * 100)
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
// r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
}
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
}
// Update the baseline that seeds new intervals
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
// Update global fsStats baseline for cross-interval correctness
stats.Time = now
stats.TotalRead = d.ReadBytes
stats.TotalWrite = d.WriteBytes
stats.DiskReadPs = readMbPerSecond
@@ -774,30 +740,6 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
}
}
// setDiskBaseline stores the latest counters of a device. A cache interval
// without its own snapshot measures its first sample from them.
func (a *Agent) setDiskBaseline(name string, d prevDisk) {
if a.diskBaseline == nil {
a.diskBaseline = make(map[string]prevDisk)
}
a.diskBaseline[name] = d
}
// ioTimeDelta returns the increase of a cumulative millisecond counter from
// the disk I/O stats. Linux prints these fields of /proc/diskstats as 32-bit
// unsigned ints, so they wrap to zero at 2^32. A busy disk reaches that in
// days for the weighted I/O time. Other platforms report 64-bit counters,
// so a lower value there is a reset.
func ioTimeDelta(current, previous uint64) uint64 {
if current >= previous {
return current - previous
}
if runtime.GOOS == "linux" && previous <= math.MaxUint32 {
return current + (math.MaxUint32 + 1 - previous)
}
return 0
}
// getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
-125
View File
@@ -1,125 +0,0 @@
//go:build linux
package agent
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Linux prints four millisecond fields of /proc/diskstats as 32-bit unsigned ints:
// read time, write time, io time and weighted io time. They wrap to zero at 2^32.
func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
const wrap = uint64(1) << 32
tests := []struct {
name string
base uint64 // added to every previous time counter
}{
{"no wrap", 0},
{"32-bit wrap", wrap - 1000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Deltas over 60s: read 300ms / 10 ops, write 400ms / 20 ops,
// io time 1200ms, weighted io 3000ms.
prev := prevDisk{
readBytes: 20000 * 512,
writeBytes: 10000 * 512,
readTime: tt.base + 900,
writeTime: tt.base + 700,
ioTime: tt.base + 400,
weightedIO: tt.base,
readCount: 1000,
writeCount: 500,
at: time.Now().Add(-60 * time.Second),
}
cur := func(v uint64) uint64 { return v % wrap }
line := fmt.Sprintf(" 8 0 sda %d 0 %d %d %d 0 %d %d 0 %d %d\n",
1010, 21200, cur(prev.readTime+300),
520, 10400, cur(prev.writeTime+400),
cur(prev.ioTime+1200), cur(prev.weightedIO+3000))
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
fs := &system.FsStats{Root: true}
a := &Agent{
fsNames: []string{"sda"},
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{60000: {"sda": prev}},
}
var stats system.Stats
a.updateDiskIo(60000, &stats)
// Same order as DiskIoStats in system.FsStats.
want := [6]float64{0.5, 0.67, 2, 30, 20, 5}
for i := range want {
assert.InDelta(t, want[i], fs.DiskIoStats[i], 0.01, "DiskIoStats[%d]", i)
assert.InDelta(t, want[i], stats.DiskIoStats[i], 0.01, "system DiskIoStats[%d]", i)
}
})
}
}
// The first sample of a cache interval has no snapshot of its own. It must
// measure the time counters from the same baseline as the byte counters.
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
dir := t.TempDir()
t.Setenv("HOST_PROC", dir)
t.Setenv("HOST_SYS", dir)
t.Setenv("HOST_DEV", dir)
t.Setenv("HOST_RUN", dir)
writeDiskstats := func(line string) {
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
}
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
counters, err := disk.IOCounters("sda")
require.NoError(t, err)
fs := &system.FsStats{Root: true}
a := &Agent{
fsStats: map[string]*system.FsStats{"sda": fs},
diskPrev: map[uint16]map[string]prevDisk{},
}
a.initializeDiskIoStats(counters)
// updateDiskIo skips samples less than 100ms apart.
time.Sleep(150 * time.Millisecond)
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
var stats system.Stats
a.updateDiskIo(60000, &stats)
require.NotZero(t, fs.DiskReadBytes, "bytes are measured from the baseline")
for i := range 3 {
assert.NotZero(t, fs.DiskIoStats[i], "DiskIoStats[%d]", i)
}
assert.InDelta(t, 30, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 20, fs.DiskIoStats[4], 0.01, "w_await")
assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
// A second interval starts from the latest counters, not from the ones at start.
time.Sleep(150 * time.Millisecond)
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
a.updateDiskIo(1000, &stats)
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
}
+98 -140
View File
@@ -3,17 +3,14 @@
package agent
import (
"math"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/shirou/gopsutil/v4/disk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseFilesystemEntry(t *testing.T) {
@@ -507,13 +504,13 @@ func TestAddConfiguredExtraFilesystems(t *testing.T) {
func TestAddExtraFilesystemFolders(t *testing.T) {
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
agent := &Agent{fsStats: map[string]*system.FsStats{
"existing": {Mountpoint: filepath.FromSlash("/extra-filesystems/existing")},
"existing": {Mountpoint: "/extra-filesystems/existing"},
}}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: filepath.FromSlash("/extra-filesystems"),
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"newdisk": {Name: "newdisk"},
},
@@ -522,10 +519,10 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
require.Len(t, agent.fsStats, 2)
assert.Len(t, agent.fsStats, 2)
stats, exists := agent.fsStats["newdisk"]
require.True(t, exists)
assert.Equal(t, filepath.FromSlash("/extra-filesystems/newdisk__Archive"), stats.Mountpoint)
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
assert.Equal(t, "Archive", stats.Name)
})
}
@@ -536,7 +533,7 @@ func TestAddPartitionExtraFs(t *testing.T) {
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: filepath.FromSlash("/extra-filesystems"),
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1p1": {Name: "nvme0n1p1"},
"nvme1n1": {Name: "nvme1n1"},
@@ -551,12 +548,12 @@ func TestAddPartitionExtraFs(t *testing.T) {
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"),
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
})
stats, exists := agent.fsStats["nvme0n1p1"]
require.True(t, exists)
assert.Equal(t, filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"), stats.Mountpoint)
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
assert.Equal(t, "caddy1-root", stats.Name)
})
@@ -567,10 +564,10 @@ func TestAddPartitionExtraFs(t *testing.T) {
// These simulate the virtual mounts that appear when host / is bind-mounted
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
for _, nested := range []string{
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc"),
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys"),
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/dev"),
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/run"),
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
} {
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
}
@@ -583,20 +580,18 @@ func TestAddPartitionExtraFs(t *testing.T) {
d := makeDiscovery(agent)
partitions := []disk.PartitionStat{
{Device: "/dev/nvme0n1p1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root")},
{Device: "/dev/nvme1n1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme1n1__caddy1-docker")},
{Device: "proc", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc")},
{Device: "sysfs", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys")},
{Device: "overlay", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker")},
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
}
for _, p := range partitions {
d.addPartitionExtraFs(p)
}
require.Len(t, agent.fsStats, 2)
require.Contains(t, agent.fsStats, "nvme0n1p1")
assert.Len(t, agent.fsStats, 2)
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
require.Contains(t, agent.fsStats, "nvme1n1")
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
})
@@ -769,6 +764,82 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
}
}
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
// Test with custom names
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
// Mock disk partitions (we'll just test the parsing logic)
// Since the actual disk operations are system-dependent, we'll focus on the parsing
testCases := []struct {
envValue string
expectedFs []string
expectedNames map[string]string
}{
{
envValue: "sda1__my-storage,sdb1__backup-drive",
expectedFs: []string{"sda1", "sdb1"},
expectedNames: map[string]string{
"sda1": "my-storage",
"sdb1": "backup-drive",
},
},
{
envValue: "sda1,nvme0n1p2__fast-ssd",
expectedFs: []string{"sda1", "nvme0n1p2"},
expectedNames: map[string]string{
"nvme0n1p2": "fast-ssd",
},
},
}
for _, tc := range testCases {
t.Run("env_"+tc.envValue, func(t *testing.T) {
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
// Create mock partitions that would match our test cases
partitions := []disk.PartitionStat{}
for _, fs := range tc.expectedFs {
if strings.HasPrefix(fs, "/dev/") {
partitions = append(partitions, disk.PartitionStat{
Device: fs,
Mountpoint: fs,
})
} else {
partitions = append(partitions, disk.PartitionStat{
Device: "/dev/" + fs,
Mountpoint: "/" + fs,
})
}
}
// Test the parsing logic by calling the relevant part
// We'll create a simplified version to test just the parsing
extraFilesystems := tc.envValue
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
// Parse the entry
fsEntry = strings.TrimSpace(fsEntry)
var fs, customName string
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
fs = strings.TrimSpace(parts[0])
customName = strings.TrimSpace(parts[1])
} else {
fs = fsEntry
}
// Verify the device is in our expected list
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
// Check if custom name should exist
if expectedName, exists := tc.expectedNames[fs]; exists {
assert.Equal(t, expectedName, customName, "custom name should match expected")
} else {
assert.Empty(t, customName, "custom name should be empty when not expected")
}
}
})
}
}
func TestFsStatsWithCustomNames(t *testing.T) {
// Test that FsStats properly stores custom names
fsStats := &system.FsStats{
@@ -959,10 +1030,8 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Len(t, agent.fsNames, 2)
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes)
assert.False(t, agent.diskBaseline["sda"].at.IsZero())
assert.False(t, agent.diskBaseline["sdb"].at.IsZero())
assert.False(t, agent.fsStats["sda"].Time.IsZero())
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
@@ -972,114 +1041,3 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
}
func TestIoTimeDelta(t *testing.T) {
assert.Equal(t, uint64(300), ioTimeDelta(1200, 900))
// A lower value is a 32-bit wrap only on Linux. Other platforms
// report 64-bit counters, so there it is a reset.
var want uint64
if runtime.GOOS == "linux" {
want = 1200
}
assert.Equal(t, want, ioTimeDelta(200, math.MaxUint32+1-1000))
assert.Equal(t, uint64(0), ioTimeDelta(200, math.MaxUint32+1000))
}
func TestNormalizeDeviceName(t *testing.T) {
// A Windows volume name is not a path element, so every spelling of the
// same drive has to normalize to the same key. filepath.Base cannot do
// this: on Windows it strips the "C:" specifier and returns "\", which
// collapses every drive letter onto one key (#2417).
for _, spelling := range []string{"C:", `C:\`, "C:/", `C:\\`} {
assert.Equal(t, "C:", normalizeDeviceName(spelling), "spelling %q", spelling)
}
// Drive letters are case-insensitive, so the letter is uppercased.
assert.Equal(t, "D:", normalizeDeviceName("d:"))
assert.Equal(t, "C:", normalizeDeviceName(" c: "))
assert.Equal(t, "C:", normalizeDeviceName(`c:\`))
// Non-volume inputs keep using filepath.Base.
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1"))
assert.Equal(t, "sda1", normalizeDeviceName("/dev/sda1/"))
assert.Equal(t, "nvme0n1p2", normalizeDeviceName(" /dev/nvme0n1p2 "))
assert.Equal(t, "", normalizeDeviceName("."))
assert.Equal(t, "", normalizeDeviceName(" "))
// A drive-relative path is a path, not a volume.
assert.Equal(t, filepath.Base(`C:data`), normalizeDeviceName(`C:data`))
}
func TestFindIoDeviceWindowsVolumeNames(t *testing.T) {
// Every drive normalizes to a distinct key, so the root drive resolves
// exactly instead of to whichever counter the map yielded first (#2417).
ioCounters := map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10, WriteBytes: 10},
"D:": {Name: "D:", ReadBytes: 20, WriteBytes: 20},
"P:": {Name: "P:", ReadBytes: 30, WriteBytes: 30},
}
for i := 0; i < 32; i++ {
device, ok := findIoDevice("C:", ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
// The drive may arrive with a trailing separator, as a mount point does.
device, ok := findIoDevice(`C:\`, ioCounters)
assert.True(t, ok)
assert.Equal(t, "C:", device)
}
func TestAddPartitionRootFsWindowsDrive(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
discovery := diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:"},
"D:": {Name: "D:"},
"P:": {Name: "P:"},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 1)
stats, exists := agent.fsStats["C:"]
assert.True(t, exists)
assert.True(t, stats.Root)
}
func TestAddPartitionRootFsKeyAlreadyRegistered(t *testing.T) {
// The root drive is also listed in EXTRA_FILESYSTEMS, so its key is taken
// before the root fallback runs. The existing entry must be promoted to root
// rather than falling back to the most active device, which here is D:.
agent := &Agent{fsStats: map[string]*system.FsStats{
"C:": {Mountpoint: `C:\`, Name: "System"},
"D:": {Mountpoint: `D:\`},
}}
discovery := diskDiscovery{
agent: agent,
rootMountPoint: `C:\`,
ctx: fsRegistrationContext{
isWindows: true,
diskIoCounters: map[string]disk.IOCountersStat{
"C:": {Name: "C:", ReadBytes: 10},
"D:": {Name: "D:", ReadBytes: 100},
},
},
}
ok := discovery.addPartitionRootFs("C:", `C:\`)
assert.True(t, ok)
assert.Len(t, agent.fsStats, 2)
assert.True(t, agent.fsStats["C:"].Root)
assert.Equal(t, `C:\`, agent.fsStats["C:"].Mountpoint)
assert.Equal(t, "System", agent.fsStats["C:"].Name)
assert.False(t, agent.fsStats["D:"].Root)
}
+8 -12
View File
@@ -68,11 +68,10 @@ type dockerManager struct {
excludeContainers []string // Patterns to exclude containers by name
usingPodman bool // Whether the Docker Engine API is running on Podman
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress
registryClient *http.Client // Client for registry requests; nil uses a client with a 10-second timeout
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
imageUpdatesRunning bool // Whether a background image-update batch is in progress
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -689,8 +688,6 @@ func newDockerManager(agent *Agent) *dockerManager {
userAgent: "Docker-Client/",
}
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
// Read container exclusion patterns from environment variable
var excludeContainers []string
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
@@ -710,11 +707,10 @@ func newDockerManager(agent *Agent) *dockerManager {
Timeout: timeout,
Transport: userAgentTransport,
},
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers,
imageUpdatesDisabled: dockerImageCheck == "false",
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
excludeContainers: excludeContainers,
// Initialize cache-time-aware tracking structures
lastCpuContainer: make(map[uint16]map[string]uint64),
-3
View File
@@ -31,9 +31,6 @@ func normalizedImageReference(image string) string {
// refreshImageUpdates starts at most one background batch. Neither its network
// work nor its completion is part of the container metrics wait group.
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
if dm.imageUpdatesDisabled {
return
}
dm.imageUpdatesMutex.Lock()
defer dm.imageUpdatesMutex.Unlock()
if dm.imageUpdatesRunning {
-23
View File
@@ -27,29 +27,6 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
}, time.Second*3, time.Millisecond)
}
func TestDisableDockerImageUpdateCheck(t *testing.T) {
t.Setenv("BESZEL_AGENT_DOCKER_IMAGE_CHECK", "false")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/version" {
fmt.Fprint(w, `{"Version":"25.0.0"}`)
return
}
http.NotFound(w, r)
}))
defer server.Close()
t.Setenv("BESZEL_AGENT_DOCKER_HOST", server.URL)
dm := newDockerManager(nil)
require.True(t, dm.imageUpdatesDisabled)
dm.registryClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Fatal("disabled image update check made a registry request")
return nil, nil
})}
dm.refreshImageUpdates([]*container.ApiInfo{{Image: "nginx", Names: []string{"/nginx"}}}, time.Now())
require.False(t, dm.imageUpdatesRunning)
require.Nil(t, dm.imageUpdates)
}
func TestImageUpdateCacheAndStats(t *testing.T) {
local := "sha256:" + strings.Repeat("a", 64)
remote := "sha256:" + strings.Repeat("b", 64)
+19 -21
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"time"
@@ -38,7 +37,7 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
repository := reference.Path(named)
tag := named.(reference.Tagged).Tag()
localDigests, err := dm.inspectImageDigests(image, registry, repository)
localDigest, err := dm.inspectImageDigest(image, registry, repository)
if err != nil {
return false, err
}
@@ -48,49 +47,48 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
return false, err
}
return !slices.Contains(localDigests, remoteDigest), nil
return remoteDigest != localDigest, nil
}
// inspectImageDigests reads Docker's image metadata without using dm.decode.
// inspectImageDigest reads Docker's image metadata without using dm.decode.
// The checker runs in the image-discovery goroutine, so it must not hold any
// of the container statistics locks while waiting on the Docker API.
func (dm *dockerManager) inspectImageDigests(image, registry, repository string) ([]string, error) {
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
if dm.client == nil {
return nil, fmt.Errorf("inspect image %q: Docker client is unavailable", image)
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
}
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
resp, err := dm.client.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("inspect image %q: %w", image, err)
return "", fmt.Errorf("inspect image %q: %w", image, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
}
var inspect struct {
RepoDigests []string `json:"RepoDigests"`
}
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
return nil, fmt.Errorf("decode image inspect %q: %w", image, err)
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
}
if len(inspect.RepoDigests) == 0 {
return nil, fmt.Errorf("inspect image %q returned no repository digests", image)
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
}
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
if len(localDigests) == 0 {
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
if !ok {
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
}
return localDigests, nil
return localDigest, nil
}
// matchingRepositoryDigests returns all valid digests belonging to the requested
// repository. Container engines can return both index and platform manifest digests for one
// local image, in either order.
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
var digests []string
// matchingRepositoryDigest returns a valid digest belonging to the requested
// repository. Docker can return multiple RepoDigests for one local image; an
// unrelated first entry must never be used for the comparison.
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
for _, repoDigest := range repoDigests {
repoDigest = strings.TrimSpace(repoDigest)
at := strings.LastIndexByte(repoDigest, '@')
@@ -110,9 +108,9 @@ func matchingRepositoryDigests(repoDigests []string, registry, repository string
if err != nil {
continue
}
digests = append(digests, d.String())
return d.String(), true
}
return digests
return "", false
}
func sameRegistry(left, right string) bool {
-39
View File
@@ -80,45 +80,6 @@ func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
require.EqualValues(t, 1, manifestCalls.Load())
}
func TestCheckImageUpdateMatchesAnyRepositoryDigest(t *testing.T) {
platform := registryDigest('a')
index := registryDigest('b')
other := registryDigest('c')
for _, test := range []struct {
name string
digests []string
remote string
available bool
}{
{name: "platform then index, remote index", digests: []string{platform, index}, remote: index},
{name: "index then platform, remote index", digests: []string{index, platform}, remote: index},
{name: "platform then index, remote platform", digests: []string{platform, index}, remote: platform},
{name: "index then platform, remote platform", digests: []string{index, platform}, remote: platform},
{name: "neither matches", digests: []string{platform, index}, remote: other, available: true},
} {
t.Run(test.name, func(t *testing.T) {
inspect := fmt.Sprintf(`{"RepoDigests":["docker.io/library/busybox@%s","docker.io/library/alpine@%s","docker.io/library/alpine@sha256:invalid","docker.io/library/alpine@%s"]}`, test.remote, test.digests[0], test.digests[1])
var manifestCalls atomic.Int32
dm := newRegistryChecker(t, inspect, registryTransportFunc(func(req *http.Request) (*http.Response, error) {
if req.Method == http.MethodGet {
return registryResponse(http.StatusOK, `{"token":"test"}`), nil
}
manifestCalls.Add(1)
require.Equal(t, http.MethodHead, req.Method)
resp := registryResponse(http.StatusOK, "")
resp.Header.Set("Docker-Content-Digest", test.remote)
return resp, nil
}))
available, err := dm.checkImageUpdate("alpine")
require.NoError(t, err)
require.Equal(t, test.available, available)
require.EqualValues(t, 1, manifestCalls.Load())
})
}
}
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
for _, test := range []struct {
name string
+2 -18
View File
@@ -12,7 +12,7 @@ import (
)
type fanSensor struct {
key, path, chip string
key, path string
}
var getFanSensors = newFanSensorCache(hwmonRoot)
@@ -34,10 +34,6 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
slog.Debug("Error reading fans", "err", err)
return
}
// Filter before reading fan*_input: each read can wake an idle GPU.
if a.sensorConfig != nil && a.sensorConfig.skipGPU {
sensors = filterGpuFans(sensors)
}
fans := readFanSensors(sensors)
if len(fans) == 0 {
return
@@ -104,7 +100,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
if label != "" {
key = chipName + "_" + label
}
sensors = append(sensors, fanSensor{key, inputPath, chipName})
sensors = append(sensors, fanSensor{key, inputPath})
}
}
return sensors, nil
@@ -119,15 +115,3 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
}
return fans
}
// filterGpuFans drops GPU chips without touching the shared cache backing array.
func filterGpuFans(sensors []fanSensor) []fanSensor {
kept := make([]fanSensor, 0, len(sensors))
for _, sensor := range sensors {
if isGpuChipName(sensor.chip) {
continue
}
kept = append(kept, sensor)
}
return kept
}
-17
View File
@@ -103,20 +103,3 @@ func TestFanDiscoveryCache(t *testing.T) {
fans = readFanSensors(sensors)
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
}
func TestFilterGpuFans(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(root, "hwmon0", "fan1_input"), "1200\n")
writeFile(t, filepath.Join(root, "hwmon1", "name"), "nct6798\n")
writeFile(t, filepath.Join(root, "hwmon1", "fan1_input"), "800\n")
discovered, err := discoverHwmonFans(root)
require.NoError(t, err)
require.Len(t, discovered, 2)
filtered := filterGpuFans(discovered)
require.Len(t, filtered, 1)
assert.Equal(t, "nct6798_fan1", filtered[0].key)
assert.Len(t, discovered, 2)
}
+2 -29
View File
@@ -454,8 +454,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
// It only reports capability presence and does not apply policy decisions.
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps := gpuCapabilities{
hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(),
hasAmdSysfs: gm.hasAmdSysfs(),
hasXe: gm.hasXe(),
hasIntelSysfs: gm.hasIntelSysfs(),
}
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
@@ -750,36 +750,9 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
return priorities
}
// gpuHwmonChips are hwmon chip names belonging to GPUs. Sensor reads on some
// of these drivers (notably Intel Xe, where each read is a runtime PM resume)
// wake the card, so SKIP_GPU must avoid touching them, not just hide them.
var gpuHwmonChips = []string{"xe", "i915", "amdgpu", "radeon", "nvidia", "nouveau"}
func isGpuChipName(name string) bool {
name = strings.ToLower(strings.TrimSpace(name))
for _, chip := range gpuHwmonChips {
if name == chip {
return true
}
}
return false
}
// SensorKeys are "<chip>" or "<chip>_<label>".
func isGpuSensorKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
for _, chip := range gpuHwmonChips {
if key == chip || strings.HasPrefix(key, chip+"_") {
return true
}
}
return false
}
// NewGPUManager creates and initializes a new GPUManager
func NewGPUManager() (*GPUManager, error) {
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
return nil, nil
}
var gm GPUManager
-85
View File
@@ -1,85 +0,0 @@
//go:build testing
package agent
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Run a copy of the test binary as a GPU command so fixtures do not need a shell.
func TestMain(m *testing.M) {
executable, err := os.Executable()
if err != nil {
panic(err)
}
switch strings.TrimSuffix(filepath.Base(executable), ".exe") {
case nvidiaSmiCmd, rocmSmiCmd, tegraStatsCmd, nvtopCmd, intelGpuStatsCmd:
output, err := os.ReadFile(executable + ".stdout")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Only the parent creates files; a late collector must not undo cleanup.
args, err := os.OpenFile(executable+".args", os.O_WRONLY|os.O_TRUNC, 0)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
_, err = io.WriteString(args, strings.Join(os.Args[1:], " "))
closeErr := args.Close()
if err == nil {
err = closeErr
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Print(string(output))
os.Exit(0)
}
os.Exit(m.Run())
}
func gpuCommandFixture(t *testing.T, dir, name, output string) string {
t.Helper()
executable, err := os.Executable()
require.NoError(t, err)
if runtime.GOOS == "windows" {
name += ".exe"
}
path := filepath.Join(dir, name)
if err := os.Link(executable, path); err != nil {
src, err := os.Open(executable)
require.NoError(t, err)
defer src.Close()
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0755)
require.NoError(t, err)
_, err = io.Copy(dst, src)
closeErr := dst.Close()
require.NoError(t, err)
require.NoError(t, closeErr)
}
require.NoError(t, os.WriteFile(path+".stdout", []byte(output), 0600))
require.NoError(t, os.WriteFile(path+".args", nil, 0600))
return path + ".args"
}
func TestGPUFixtureDoesNotRecreateRemovedArgs(t *testing.T) {
argsFile := gpuCommandFixture(t, t.TempDir(), nvidiaSmiCmd, "fixture output\n")
require.NoError(t, os.WriteFile(argsFile, nil, 0600))
require.NoError(t, os.Remove(argsFile))
cmd := exec.Command(strings.TrimSuffix(argsFile, ".args"))
err := cmd.Run()
require.NoFileExists(t, argsFile, "a late fixture process must not recreate files removed by cleanup")
require.Error(t, err, "the fixture must report a missing argument-capture file")
}
+100 -120
View File
@@ -2,9 +2,7 @@ package agent
import (
"bufio"
"encoding/json"
"io"
"log/slog"
"os/exec"
"strconv"
"strings"
@@ -51,10 +49,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
return true
}
// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output.
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
func (gm *GPUManager) collectIntelStats() (err error) {
// Build command arguments, optionally selecting a device via -d
args := []string{"-s", intelGpuStatsInterval, "-J"}
args := []string{"-s", intelGpuStatsInterval, "-l"}
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
args = append(args, "-d", dev)
}
@@ -82,64 +80,48 @@ func (gm *GPUManager) collectIntelStats() (err error) {
}
}()
if err := gm.parseIntelJSONStream(stdout); err != nil {
return err
}
// The closing "]" is printed as the process exits, so read to EOF to let
// it finish instead of killing it.
_, _ = io.Copy(io.Discard, stdout)
return nil
}
// parseIntelJSONStream decodes samples from intel_gpu_top -J output and
// aggregates them. Since v1.28 the samples are wrapped in an array ("[", then
// comma separated objects, and "]" only when the process exits). Older
// versions print the same comma separated objects without the opening "[", so
// it is added here to let both formats decode as an array.
func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
er := &eofReader{r: r}
br := bufio.NewReader(er)
first, err := peekNonSpace(br)
if err != nil {
if err == io.EOF {
return errNoValidData
}
return err
}
var src io.Reader = br
if first != '[' {
src = io.MultiReader(strings.NewReader("["), br)
}
dec := json.NewDecoder(src)
if _, err := dec.Token(); err != nil { // opening "["
return err
}
scanner := bufio.NewScanner(stdout)
var header1 string
var engineNames []string
var friendlyNames []string
var preEngineCols int
var powerIndex int
var hadDataRow bool
// skip first data row because it sometimes has erroneous data
var skippedFirstDataRow bool
// Decode reads one object and skips the commas between them. The array is
// usually never closed, so output ending mid-array or mid-sample (the
// process was killed) is the normal end of the stream rather than an error.
for dec.More() {
var sample intelGpuJSONSample
if err := dec.Decode(&sample); err != nil {
if er.eof {
break
}
return err
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// first header line
if strings.HasPrefix(line, "Freq") {
header1 = line
continue
}
// second header line
if strings.HasPrefix(line, "req") {
engineNames, friendlyNames, powerIndex, preEngineCols = gm.parseIntelHeaders(header1, line)
continue
}
// Data row
if !skippedFirstDataRow {
skippedFirstDataRow = true
continue
}
stats := parseIntelJSONSample(sample)
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) {
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg)
continue
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
if err != nil {
return err
}
hadDataRow = true
gm.updateIntelFromStats(&stats)
gm.updateIntelFromStats(&sample)
}
if scanErr := scanner.Err(); scanErr != nil {
return scanErr
}
if !hadDataRow {
return errNoValidData
@@ -147,82 +129,80 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
return nil
}
// eofReader records whether the underlying reader has returned io.EOF. The
// json decoder reports a stream ending mid-value as a syntax error, so this
// is how a truncated final sample is told apart from invalid output.
type eofReader struct {
r io.Reader
eof bool
}
func (e *eofReader) Read(p []byte) (int, error) {
n, err := e.r.Read(p)
if err == io.EOF {
e.eof = true
}
return n, err
}
// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it.
func peekNonSpace(br *bufio.Reader) (byte, error) {
for {
b, err := br.Peek(1)
if err != nil {
return 0, err
}
switch b[0] {
case ' ', '\t', '\n', '\r':
_, _ = br.ReadByte()
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
// Build indexes
h1 := strings.Fields(header1)
h2 := strings.Fields(header2)
powerIndex = -1 // Initialize to -1, will be set to actual index if found
// Collect engine names from header1
for _, col := range h1 {
key := strings.TrimRightFunc(col, func(r rune) bool {
return (r >= '0' && r <= '9') || r == '/'
})
var friendly string
switch key {
case "RCS":
friendly = "Render/3D"
case "BCS":
friendly = "Blitter"
case "VCS":
friendly = "Video"
case "VECS":
friendly = "VideoEnhance"
case "CCS":
friendly = "Compute"
default:
return b[0], nil
continue
}
engineNames = append(engineNames, key)
friendlyNames = append(friendlyNames, friendly)
}
// find power gpu index among pre-engine columns
if n := len(engineNames); n > 0 {
preEngineCols = max(len(h2)-3*n, 0)
limit := min(len(h2), preEngineCols)
for i := range limit {
if strings.EqualFold(h2[i], "gpu") {
powerIndex = i
break
}
}
}
return engineNames, friendlyNames, powerIndex, preEngineCols
}
// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the
// needed fields are mapped.
type intelGpuJSONSample struct {
Power *struct {
GPU float64 `json:"GPU"`
Package float64 `json:"Package"`
} `json:"power"`
Engines map[string]struct {
Busy float64 `json:"busy"`
} `json:"engines"`
}
// validIntelPower reports whether a power reading from intel_gpu_top is plausible.
func validIntelPower(watts float64) bool {
// 5000 is well above any real GPU or package draw. intel_gpu_top
// computes power from unsigned energy counter deltas, so a counter that reads
// lower than the previous sample produces an enormous value for that period.
return watts >= 0 && watts <= 5000
}
// parseIntelJSONSample converts one intel_gpu_top JSON sample into intelGpuStats.
func parseIntelJSONSample(sample intelGpuJSONSample) (stats intelGpuStats) {
if sample.Power != nil {
stats.PowerGPU = sample.Power.GPU
stats.PowerPkg = sample.Power.Package
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
fields := strings.Fields(line)
if len(fields) == 0 {
return sample, errNoValidData
}
if len(sample.Engines) > 0 {
stats.Engines = make(map[string]float64, len(sample.Engines))
for key, engine := range sample.Engines {
stats.Engines[intelEngineClass(key)] += engine.Busy
// Make sure row has enough columns for engines
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
return sample, errNoValidData
}
if powerIndex >= 0 && powerIndex < len(fields) {
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
sample.PowerGPU = v
}
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
sample.PowerPkg = v
}
}
return stats
}
// intelEngineClass returns the engine class name for an engine key. Keys are
// class names ("Render/3D", "Video") in class view, which JSON output uses by
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in
// physical view, which older versions use.
func intelEngineClass(key string) string {
if i := strings.LastIndexByte(key, '/'); i >= 0 {
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil {
return key[:i]
if len(engineNames) > 0 {
sample.Engines = make(map[string]float64, len(engineNames))
for k := range engineNames {
base := preEngineCols + 3*k
if base < len(fields) {
busy := 0.0
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
busy = v
}
cur := sample.Engines[friendlyNames[k]]
sample.Engines[friendlyNames[k]] = cur + busy
} else {
sample.Engines[friendlyNames[k]] = 0
}
}
}
return key
return sample, nil
}
-30
View File
@@ -1,30 +0,0 @@
//go:build testing && !(amd64 && (windows || (linux && glibc)))
package agent
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This fallback requires NVML initialisation to fail, as guaranteed by the unsupported implementation.
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14`+"\n")
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
waitGPUs(t, gm, "0")
gm.Lock()
defer gm.Unlock()
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Fallback GPU", gpu.Name)
}
+350 -258
View File
@@ -3,10 +3,9 @@
package agent
import (
"encoding/json"
"fmt"
"os"
"slices"
"path/filepath"
"strings"
"testing"
"time"
@@ -1124,7 +1123,7 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
tests := []struct {
name string
setupCommands func(*testing.T, string)
setupCommands func(string) error
wantNvidiaSmi bool
wantRocmSmi bool
wantTegrastats bool
@@ -1132,8 +1131,10 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
wantErr bool
}{
{
name: "nvidia-smi not available",
setupCommands: func(*testing.T, string) {},
name: "nvidia-smi not available",
setupCommands: func(_ string) error {
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: false,
wantTegrastats: false,
@@ -1142,8 +1143,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
},
{
name: "nvidia-smi available",
setupCommands: func(t *testing.T, tempDir string) {
gpuCommandFixture(t, tempDir, "nvidia-smi", "test\n")
setupCommands: func(tempDir string) error {
path := filepath.Join(tempDir, "nvidia-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: true,
wantTegrastats: false,
@@ -1153,8 +1160,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
},
{
name: "rocm-smi available",
setupCommands: func(t *testing.T, tempDir string) {
gpuCommandFixture(t, tempDir, "rocm-smi", "test\n")
setupCommands: func(tempDir string) error {
path := filepath.Join(tempDir, "rocm-smi")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: true,
@@ -1164,8 +1177,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
},
{
name: "tegrastats available",
setupCommands: func(t *testing.T, tempDir string) {
gpuCommandFixture(t, tempDir, "tegrastats", "test\n")
setupCommands: func(tempDir string) error {
path := filepath.Join(tempDir, "tegrastats")
script := `#!/bin/sh
echo "test"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: false,
@@ -1175,8 +1194,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
},
{
name: "nvtop available",
setupCommands: func(t *testing.T, tempDir string) {
gpuCommandFixture(t, tempDir, "nvtop", "test\n")
setupCommands: func(tempDir string) error {
path := filepath.Join(tempDir, "nvtop")
script := `#!/bin/sh
echo "[]"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
wantNvidiaSmi: false,
wantRocmSmi: false,
@@ -1185,9 +1210,12 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
wantErr: false,
},
{
name: "no gpu tools available",
setupCommands: func(*testing.T, string) {},
wantErr: true,
name: "no gpu tools available",
setupCommands: func(_ string) error {
// The subtest already restricts PATH to its empty temporary directory.
return nil
},
wantErr: true,
},
}
@@ -1195,7 +1223,9 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
t.Setenv("PATH", tempDir)
tt.setupCommands(t, tempDir)
if err := tt.setupCommands(tempDir); err != nil {
t.Fatal(err)
}
gm := &GPUManager{}
caps := gm.discoverGpuCapabilities()
@@ -1237,21 +1267,6 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
}
}
func waitGPUs(t *testing.T, gm *GPUManager, ids ...string) {
t.Helper()
require.Eventually(t, func() bool {
gm.Lock()
defer gm.Unlock()
for _, id := range ids {
gpu := gm.GpuDataMap[id]
if gpu == nil || gpu.Count == 0 {
return false
}
}
return true
}, 5*time.Second, 10*time.Millisecond, "GPU collectors did not produce data for %v", ids)
}
func TestCollectorStartHelpers(t *testing.T) {
// Set up temp dir with the commands
dir := t.TempDir()
@@ -1260,17 +1275,21 @@ func TestCollectorStartHelpers(t *testing.T) {
tests := []struct {
name string
command string
gpuID string
setup func(t *testing.T)
setup func(t *testing.T) error
validate func(t *testing.T, gm *GPUManager)
gm *GPUManager
}{
{
name: "nvidia-smi collector",
command: "nvidia-smi",
gpuID: "0",
setup: func(t *testing.T) {
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100`+"\n")
setup: func(t *testing.T) error {
path := filepath.Join(dir, "nvidia-smi")
script := `#!/bin/sh
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"]
@@ -1285,9 +1304,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{
name: "rocm-smi collector",
command: "rocm-smi",
gpuID: "34756",
setup: func(t *testing.T) {
gpuCommandFixture(t, dir, "rocm-smi", `{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}`+"\n")
setup: func(t *testing.T) error {
path := filepath.Join(dir, "rocm-smi")
script := `#!/bin/sh
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["34756"]
@@ -1302,9 +1326,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{
name: "tegrastats collector",
command: "tegrastats",
gpuID: "0",
setup: func(t *testing.T) {
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n")
setup: func(t *testing.T) error {
path := filepath.Join(dir, "tegrastats")
script := `#!/bin/sh
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["0"]
@@ -1322,9 +1351,14 @@ func TestCollectorStartHelpers(t *testing.T) {
{
name: "nvtop collector",
command: "nvtop",
gpuID: "n0",
setup: func(t *testing.T) {
gpuCommandFixture(t, dir, "nvtop", `[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]`+"\n")
setup: func(t *testing.T) error {
path := filepath.Join(dir, "nvtop")
script := `#!/bin/sh
echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'`
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err
}
return nil
},
validate: func(t *testing.T, gm *GPUManager) {
gpu, exists := gm.GpuDataMap["n0"]
@@ -1339,7 +1373,9 @@ func TestCollectorStartHelpers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.setup(t)
if err := tt.setup(t); err != nil {
t.Fatal(err)
}
if tt.gm == nil {
tt.gm = &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
@@ -1357,9 +1393,7 @@ func TestCollectorStartHelpers(t *testing.T) {
default:
t.Fatalf("unknown test command %q", tt.command)
}
waitGPUs(t, tt.gm, tt.gpuID)
tt.gm.Lock()
defer tt.gm.Unlock()
time.Sleep(50 * time.Millisecond) // Give collector time to run
tt.validate(t, tt.gm)
})
}
@@ -1370,17 +1404,21 @@ func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
gpuCommandFixture(t, dir, "nvtop", `not-json`+"\n")
nvtopPath := filepath.Join(dir, "nvtop")
nvtopScript := `#!/bin/sh
echo 'not-json'`
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25`+"\n")
nvidiaPath := filepath.Join(dir, "nvidia-smi")
nvidiaScript := `#!/bin/sh
echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
waitGPUs(t, gm, "0")
gm.Lock()
defer gm.Unlock()
time.Sleep(150 * time.Millisecond)
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Priority GPU", gpu.Name)
@@ -1392,27 +1430,52 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
intelOutput := intelJSONStream(true,
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
)
gpuCommandFixture(t, dir, intelGpuStatsCmd, intelOutput+"\n")
intelPath := filepath.Join(dir, "intel_gpu_top")
intelScript := `#!/bin/sh
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
echo " req act /s % gpu pkg rd wr % se wa % se wa"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
`
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
gpuCommandFixture(t, dir, "rocm-smi", `{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}`+"\n")
rocmPath := filepath.Join(dir, "rocm-smi")
rocmScript := `#!/bin/sh
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}'
`
require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755))
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
waitGPUs(t, gm, "i0", "34756")
gm.Lock()
defer gm.Unlock()
time.Sleep(150 * time.Millisecond)
_, intelOk := gm.GpuDataMap["i0"]
_, amdOk := gm.GpuDataMap["34756"]
assert.True(t, intelOk)
assert.True(t, amdOk)
}
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
nvidiaPath := filepath.Join(dir, "nvidia-smi")
nvidiaScript := `#!/bin/sh
echo "0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14"`
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
time.Sleep(150 * time.Millisecond)
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "Fallback GPU", gpu.Name)
}
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
@@ -1447,12 +1510,8 @@ func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
gm, err := NewGPUManager()
if err == nil {
// Native NVML can be available even with no tools on PATH.
require.NotNil(t, gm)
return
}
require.Nil(t, gm)
require.Error(t, err)
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
assert.NotContains(t, err.Error(), noGPUFoundMsg)
}
@@ -1462,15 +1521,16 @@ func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
t.Setenv("PATH", dir)
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n")
tegraPath := filepath.Join(dir, "tegrastats")
tegraScript := `#!/bin/sh
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755))
gm, err := NewGPUManager()
require.NoError(t, err)
require.NotNil(t, gm)
waitGPUs(t, gm, "0")
gm.Lock()
defer gm.Unlock()
time.Sleep(100 * time.Millisecond)
gpu, ok := gm.GpuDataMap["0"]
require.True(t, ok)
assert.Equal(t, "GPU", gpu.Name)
@@ -1692,60 +1752,22 @@ func TestIntelUpdateFromStats(t *testing.T) {
assert.Equal(t, float64(2), gpu.Count)
}
// intelJSONSample returns one sample object formatted like intel_gpu_top -J output
func intelJSONSample(powerGPU, powerPkg float64, engines map[string]float64) string {
var sb strings.Builder
sb.WriteString("{\n\t\"period\": {\n\t\t\"duration\": 3300.123456,\n\t\t\"unit\": \"ms\"\n\t},\n")
sb.WriteString("\t\"frequency\": {\n\t\t\"requested\": 373.000000,\n\t\t\"actual\": 373.000000,\n\t\t\"unit\": \"MHz\"\n\t},\n")
fmt.Fprintf(&sb, "\t\"power\": {\n\t\t\"GPU\": %f,\n\t\t\"Package\": %f,\n\t\t\"unit\": \"W\"\n\t},\n", powerGPU, powerPkg)
sb.WriteString("\t\"engines\": {")
names := make([]string, 0, len(engines))
for name := range engines {
names = append(names, name)
}
slices.Sort(names)
for i, name := range names {
if i > 0 {
sb.WriteString(",")
}
fmt.Fprintf(&sb, "\n\t\t%q: {\n\t\t\t\"busy\": %f,\n\t\t\t\"sema\": 0.000000,\n\t\t\t\"wait\": 0.000000,\n\t\t\t\"unit\": \"%%\"\n\t\t}", name, engines[name])
}
sb.WriteString("\n\t}\n}")
return sb.String()
}
// intelJSONStream joins samples as intel_gpu_top -J prints them. Since v1.28
// the output starts with "[" (withArray); older versions omit it.
func intelJSONStream(withArray bool, samples ...string) string {
var sb strings.Builder
if withArray {
sb.WriteString("[\n")
}
for i, s := range samples {
if i > 0 {
sb.WriteString(",\n")
}
sb.WriteString(s)
}
return sb.String()
}
func TestIntelCollectorStreaming(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
engines := func(render, blitter, video float64) map[string]float64 {
return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video}
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
scriptPath := filepath.Join(dir, "intel_gpu_top")
script := `#!/bin/sh
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1"
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
output := intelJSONStream(true,
intelJSONSample(1.5, 4.13, engines(12.34, 0, 5)),
intelJSONSample(2.0, 2.69, engines(0, 0, 0)),
intelJSONSample(1.8, 2.45, engines(8.5, 15, 22)),
intelJSONSample(2.2, 3.12, engines(5.75, 9.5, 12)),
) + "\n]"
// Create a fake intel_gpu_top that prints -J output with four samples (first will be skipped) and exits
gpuCommandFixture(t, dir, intelGpuStatsCmd, output+"\n")
gm := &GPUManager{
GpuDataMap: make(map[string]*system.GPUData),
@@ -1759,168 +1781,229 @@ func TestIntelCollectorStreaming(t *testing.T) {
gpu := gm.GpuDataMap["i0"]
require.NotNil(t, gpu)
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
assert.InDelta(t, 6.0, gpu.Power, 0.001)
assert.EqualValues(t, 6.0, gpu.Power)
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
// Engines aggregated from samples 2-4
assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00
assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
// Count should be 3 samples (first is skipped)
assert.Equal(t, float64(3), gpu.Count)
}
func TestParseIntelJSONStream(t *testing.T) {
first := intelJSONSample(9, 9, map[string]float64{"Render/3D": 99, "Compute": 99})
classView := []string{
intelJSONSample(2, 3, map[string]float64{"Render/3D": 10, "Blitter": 1, "Video": 5, "VideoEnhance": 0, "Compute": 40}),
intelJSONSample(1, 2, map[string]float64{"Render/3D": 20, "Blitter": 0, "Video": 5, "VideoEnhance": 3, "Compute": 60}),
}
classViewWant := map[string]float64{"Render/3D": 30, "Blitter": 1, "Video": 10, "VideoEnhance": 3, "Compute": 100}
func TestParseIntelHeaders(t *testing.T) {
tests := []struct {
name string
input string
wantErr error
wantAnyErr bool
wantCount float64
wantPower float64
wantPkg float64
wantEngines map[string]float64
name string
header1 string
header2 string
wantEngineNames []string
wantFriendlyNames []string
wantPowerIndex int
wantPreEngineCols int
}{
{
name: "array still open while process runs",
input: intelJSONStream(true, first, classView[0], classView[1]),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "basic headers with RCS BCS VCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
wantEngineNames: []string{"RCS", "BCS", "VCS"},
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
wantPowerIndex: 4, // "gpu" is at index 4
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
},
{
name: "closed array",
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n",
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "basic headers with RCS BCS VCS using index in name",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
wantEngineNames: []string{"RCS", "BCS", "VCS"},
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
wantPowerIndex: 4, // "gpu" is at index 4
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
},
{
name: "truncated final sample",
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "headers with only RCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
header2: " req act /s % gpu pkg rd wr % se wa",
wantEngineNames: []string{"RCS"},
wantFriendlyNames: []string{"Render/3D"},
wantPowerIndex: 4,
wantPreEngineCols: 8, // 11 total - 3*1 = 8
},
{
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names
name: "legacy output without array and with engine instances",
input: intelJSONStream(false,
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}),
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}),
),
wantCount: 1,
wantPower: 1.5,
wantPkg: 2.5,
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
name: "headers with VECS and CCS",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
wantEngineNames: []string{"VECS", "CCS"},
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
wantPowerIndex: 4,
wantPreEngineCols: 8, // 14 total - 3*2 = 8
},
{
// energy counter read lower than the previous sample in intel_gpu_top
name: "sample with invalid power is skipped",
input: intelJSONStream(true, first, classView[0],
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}),
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}),
classView[1],
),
wantCount: 2,
wantPower: 3,
wantPkg: 5,
wantEngines: classViewWant,
name: "no engines",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
header2: " req act /s % gpu pkg rd wr",
wantEngineNames: nil, // no engines found, slices remain nil
wantFriendlyNames: nil,
wantPowerIndex: -1, // no engines, so no search
wantPreEngineCols: 0,
},
{
name: "only samples with invalid power",
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})),
wantErr: errNoValidData,
name: "power index not found",
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
wantEngineNames: []string{"RCS"},
wantFriendlyNames: []string{"Render/3D"},
wantPowerIndex: -1, // "gpu" not found
wantPreEngineCols: 8, // 11 total - 3*1 = 8
},
{
name: "empty output",
input: "",
wantErr: errNoValidData,
},
{
name: "only first sample, which is skipped",
input: intelJSONStream(true, first),
wantErr: errNoValidData,
},
{
name: "invalid output",
input: "intel_gpu_top: command failed",
wantAnyErr: true,
name: "empty headers",
header1: "",
header2: "",
wantEngineNames: nil, // empty input, slices remain nil
wantFriendlyNames: nil,
wantPowerIndex: -1,
wantPreEngineCols: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
err := gm.parseIntelJSONStream(strings.NewReader(tt.input))
if tt.wantAnyErr {
assert.Error(t, err)
return
}
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Empty(t, gm.GpuDataMap)
return
}
require.NoError(t, err)
gm := &GPUManager{}
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
gpu := gm.GpuDataMap["i0"]
require.NotNil(t, gpu)
assert.Equal(t, tt.wantCount, gpu.Count)
assert.InDelta(t, tt.wantPower, gpu.Power, 0.001)
assert.InDelta(t, tt.wantPkg, gpu.PowerPkg, 0.001)
assert.Len(t, gpu.Engines, len(tt.wantEngines))
for name, want := range tt.wantEngines {
assert.InDelta(t, want, gpu.Engines[name], 0.001, name)
}
assert.Equal(t, tt.wantEngineNames, engineNames)
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
assert.Equal(t, tt.wantPowerIndex, powerIndex)
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
})
}
}
func TestParseIntelJSONSample(t *testing.T) {
t.Run("without power", func(t *testing.T) {
var sample intelGpuJSONSample
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample))
stats := parseIntelJSONSample(sample)
assert.Zero(t, stats.PowerGPU)
assert.Zero(t, stats.PowerPkg)
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines)
})
t.Run("without engines", func(t *testing.T) {
var sample intelGpuJSONSample
require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample))
stats := parseIntelJSONSample(sample)
assert.Equal(t, 1.25, stats.PowerGPU)
assert.Equal(t, 4.5, stats.PowerPkg)
assert.Nil(t, stats.Engines)
})
}
func TestIntelEngineClass(t *testing.T) {
tests := map[string]string{
"Render/3D": "Render/3D",
"Render/3D/0": "Render/3D",
"Blitter": "Blitter",
"Blitter/0": "Blitter",
"Video/1": "Video",
"VideoEnhance/0": "VideoEnhance",
"Compute/3": "Compute",
"[unknown]": "[unknown]",
"[unknown]/0": "[unknown]",
"Video/": "Video/",
func TestParseIntelData(t *testing.T) {
tests := []struct {
name string
line string
engineNames []string
friendlyNames []string
powerIndex int
preEngineCols int
wantPowerGPU float64
wantEngines map[string]float64
wantErr error
}{
{
name: "basic data with power and engines",
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with zero power",
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.00,
wantEngines: map[string]float64{
"Render/3D": 0.00,
"Blitter": 0.00,
"Video": 0.00,
},
},
{
name: "data with no power index",
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: -1,
preEngineCols: 8,
wantPowerGPU: 0.0, // no power parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with insufficient columns",
line: "373 373 224 45 1.50", // too few columns
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil, // empty sample returned
wantErr: errNoValidData,
},
{
name: "empty line",
line: "",
engineNames: []string{"RCS"},
friendlyNames: []string{"Render/3D"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0,
wantEngines: nil,
wantErr: errNoValidData,
},
{
name: "data with invalid power value",
line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 0.0, // N/A can't be parsed
wantEngines: map[string]float64{
"Render/3D": 12.34,
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with invalid engine value",
line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0",
engineNames: []string{"RCS", "BCS", "VCS"},
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: map[string]float64{
"Render/3D": 0.0, // N/A becomes 0
"Blitter": 0.00,
"Video": 5.00,
},
},
{
name: "data with no engines",
line: "373 373 224 45 1.50 4.13 2554 714",
engineNames: []string{},
friendlyNames: []string{},
powerIndex: 4,
preEngineCols: 8,
wantPowerGPU: 1.50,
wantEngines: nil,
},
}
for key, want := range tests {
assert.Equal(t, want, intelEngineClass(key), key)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gm := &GPUManager{}
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
assert.Equal(t, tt.wantErr, err)
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
assert.Equal(t, tt.wantEngines, sample.Engines)
})
}
}
@@ -1928,12 +2011,21 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
dir := t.TempDir()
t.Setenv("PATH", dir)
// Prepare a file to capture args
argsFile := filepath.Join(dir, "args.txt")
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
output := intelJSONStream(true,
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
)
argsFile := gpuCommandFixture(t, dir, intelGpuStatsCmd, output)
scriptPath := filepath.Join(dir, "intel_gpu_top")
script := fmt.Sprintf(`#!/bin/sh
echo "$@" > %s
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
`, argsFile)
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatal(err)
}
// Set device selector via prefixed env var
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
@@ -1951,5 +2043,5 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
argsStr := strings.TrimSpace(string(data))
require.Contains(t, argsStr, "-d sriov")
require.Contains(t, argsStr, "-s ")
require.Contains(t, argsStr, "-J")
require.Contains(t, argsStr, "-l")
}
-44
View File
@@ -9,7 +9,6 @@ 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"
)
@@ -53,10 +52,8 @@ func NewHandlerRegistry() *HandlerRegistry {
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{})
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
return registry
}
@@ -201,20 +198,6 @@ 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)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
@@ -246,33 +229,6 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// GetSystemdLogsHandler handles recent systemd service log requests.
type GetSystemdLogsHandler struct{}
func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error {
if hctx.Agent.systemdManager == nil {
return errors.ErrUnsupported
}
var req common.SystemdLogsRequest
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
return err
}
if req.ServiceName == "" {
return errors.New("service name is required")
}
logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName)
if err != nil {
return err
}
return hctx.SendResponse(logs, hctx.RequestID)
}
////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
type SyncNetworkMonitorsHandler struct{}
+3 -22
View File
@@ -15,7 +15,6 @@ type MonitorManager struct {
mu sync.RWMutex
monitors map[string]*monitorTask // keyed by monitor ID
probe monitorProbe
certCheck certChecker
resumeGuard monitorResumeGuard
}
@@ -24,7 +23,7 @@ func newMonitorManager() *MonitorManager {
}
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
}
// SyncMonitors replaces all monitor tasks with the given configs.
@@ -108,7 +107,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
if !runNow {
return nil, nil
}
return pm.runNow(task), nil
return task.runProbe(pm.probe), nil
}
if exists {
task.cancel()
@@ -120,7 +119,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
pm.mu.Unlock()
if runNow {
result := pm.runNow(task)
result := task.runProbe(pm.probe)
pm.startMonitor(task)
return result, nil
}
@@ -128,19 +127,6 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
return nil, nil
}
// runNow runs a probe and any due certificate check concurrently, so the
// response fits within the hub's single probe timeout budget.
func (pm *MonitorManager) runNow(task *monitorTask) *monitor.Result {
var wg sync.WaitGroup
wg.Go(func() { task.refreshCert(pm.certCheck) })
result := task.runProbe(pm.probe)
wg.Wait()
if result != nil {
result.Cert = task.certInfo()
}
return result
}
// DeleteMonitor stops and removes a single monitor task.
func (pm *MonitorManager) DeleteMonitor(id string) {
if id == "" {
@@ -172,11 +158,6 @@ func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Resul
if !ok {
continue
}
// Only the default interval updates monitor records on the hub, so
// realtime requests must not consume the unsent certificate.
if durationMs == defaultDataCacheTimeMs {
result.Cert = task.takeUnsentCert()
}
results[task.config.ID] = result
}
-74
View File
@@ -1,74 +0,0 @@
package agent
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const (
certCheckInterval = 24 * time.Hour
certCheckRetryInterval = time.Hour
)
// certChecker fetches the leaf certificate for an HTTPS target.
type certChecker func(context.Context, string) (monitor.CertInfo, error)
// certCheckEnabled reports whether a monitor's certificate is checked, which is
// the case for every HTTP monitor with an https target.
func certCheckEnabled(config monitor.Config) bool {
return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://")
}
// checkCert reads the leaf certificate presented by an HTTPS target. The chain is
// not verified, so expired or self-signed certificates are still reported.
func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) {
address, host, err := certAddress(target)
if err != nil {
return monitor.CertInfo{}, err
}
ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout)
defer cancel()
dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
conn, err := dialer.DialContext(ctx, "tcp", address)
if err != nil {
return monitor.CertInfo{}, err
}
defer conn.Close()
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
if len(certs) == 0 {
return monitor.CertInfo{}, errors.New("no peer certificates")
}
leaf := certs[0]
return monitor.CertInfo{
Expires: leaf.NotAfter.UnixMilli(),
Issuer: leaf.Issuer.CommonName,
}, nil
}
// certAddress returns the dial address and server name for an HTTPS URL.
func certAddress(target string) (address, host string, err error) {
u, err := url.Parse(target)
if err != nil {
return "", "", err
}
if !strings.EqualFold(u.Scheme, "https") {
return "", "", fmt.Errorf("certificate check requires an https target: %s", target)
}
host = u.Hostname()
if host == "" {
return "", "", fmt.Errorf("missing host in target: %s", target)
}
port := u.Port()
if port == "" {
port = "443"
}
return net.JoinHostPort(host, port), host, nil
}
-184
View File
@@ -1,184 +0,0 @@
//go:build testing
package agent
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCheckCertReadsUnverifiedLeaf(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
// httptest uses a self-signed certificate, which must still be reported.
info, err := checkCert(context.Background(), server.URL)
require.NoError(t, err)
leaf := server.Certificate()
assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires)
assert.Equal(t, leaf.Issuer.CommonName, info.Issuer)
}
func TestCertAddress(t *testing.T) {
tests := []struct {
target, address, host string
wantErr bool
}{
{target: "https://example.com", address: "example.com:443", host: "example.com"},
{target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"},
{target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"},
{target: "http://example.com", wantErr: true},
{target: "https://", wantErr: true},
}
for _, tt := range tests {
address, host, err := certAddress(tt.target)
if tt.wantErr {
assert.Error(t, err, tt.target)
continue
}
require.NoError(t, err, tt.target)
assert.Equal(t, tt.address, address)
assert.Equal(t, tt.host, host)
}
}
func TestRefreshCertCadence(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
var fail error
// Far enough out that the regular interval applies for the whole test.
expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
if fail != nil {
return monitor.CertInfo{}, fail
}
return monitor.CertInfo{Expires: expires + int64(calls)}, nil
}
task.refreshCert(check)
require.NotNil(t, task.certInfo())
assert.Equal(t, expires+1, task.certInfo().Expires)
// Not due again until the check interval passes.
time.Sleep(certCheckInterval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
// Failures keep the last known certificate and retry sooner.
fail = errors.New("connection refused")
time.Sleep(certCheckInterval)
task.refreshCert(check)
assert.Equal(t, 3, calls)
assert.Equal(t, expires+2, task.certInfo().Expires)
time.Sleep(certCheckRetryInterval)
fail = nil
task.refreshCert(check)
assert.Equal(t, 4, calls)
assert.Equal(t, expires+4, task.certInfo().Expires)
})
}
func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) {
for _, tc := range []struct {
name string
expires time.Duration // relative to the check
interval time.Duration
}{
{"expired", -time.Hour, certCheckRetryInterval},
{"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval},
{"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval},
} {
t.Run(tc.name, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
defer task.cancel()
var calls int
check := func(context.Context, string) (monitor.CertInfo, error) {
calls++
return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil
}
task.refreshCert(check)
time.Sleep(tc.interval - time.Second)
task.refreshCert(check)
assert.Equal(t, 1, calls)
time.Sleep(time.Second)
task.refreshCert(check)
assert.Equal(t, 2, calls)
})
})
}
}
func TestCertCheckEnabled(t *testing.T) {
tests := []struct {
protocol, target string
want bool
}{
{"http", "https://example.com", true},
{"http", "HTTPS://example.com:8443/path", true},
{"http", "http://example.com", false},
{"http", "https://", false},
{"tcp", "https://example.com", false},
{"icmp", "example.com", false},
}
for _, tt := range tests {
assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target)
}
}
func TestRefreshCertSkipsNonHTTPS(t *testing.T) {
task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"})
defer task.cancel()
task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) {
t.Fatal("certificate check must not run for non-https targets")
return monitor.CertInfo{}, nil
})
assert.Nil(t, task.certInfo())
}
func TestUpsertMonitorRunNowIncludesCert(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
defer server.Close()
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil })
defer pm.Stop()
config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60}
result, err := pm.UpsertMonitor(config, true)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, result.Cert)
assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires)
// Realtime results never carry the certificate, and the default interval
// sends it only once per check.
assert.Nil(t, pm.GetResults(1000)["cert"].Cert)
results := pm.GetResults(defaultDataCacheTimeMs)
require.NotNil(t, results["cert"].Cert)
assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires)
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
// Changing the interval keeps the known certificate without resending it.
config.Interval = 30
_, err = pm.UpsertMonitor(config, false)
require.NoError(t, err)
pm.mu.RLock()
task := pm.monitors["cert"]
pm.mu.RUnlock()
assert.NotNil(t, task.certInfo())
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
}
+4 -32
View File
@@ -8,12 +8,9 @@ import (
"net/http"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
)
const networkMonitorUserAgent = "Beszel-Agent/" + beszel.Version + " (+https://beszel.dev)"
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
// Implementations must honor cancellation and bound their execution time.
type monitorProbe func(context.Context, monitor.Config) (int64, error)
@@ -28,7 +25,7 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
case "http":
return monitorHTTP(ctx, client, config.Target)
case "dns":
return monitorDNS(ctx, config.Target, config.Server)
return monitorDNS(ctx, config.Target)
default:
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
}
@@ -73,43 +70,19 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
return -1, err
}
// monitorDNS measures DNS resolution response time in microseconds. If server is
// non-empty, the lookup is sent to that DNS server (host or host:port, default
// port 53) instead of the system resolver. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target, server string) (int64, error) {
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
func monitorDNS(ctx context.Context, target string) (int64, error) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
resolver := net.DefaultResolver
if server != "" {
resolver = dnsResolverForServer(server)
}
start := time.Now()
ips, err := resolver.LookupHost(ctx, target)
ips, err := net.DefaultResolver.LookupHost(ctx, target)
if err != nil || len(ips) == 0 {
return -1, err
}
return time.Since(start).Microseconds(), nil
}
// dnsResolverForServer builds a resolver that sends lookups to the given DNS
// server address instead of the system resolver. server may be a bare host or
// host:port; when no port is given, the standard DNS port 53 is used.
func dnsResolverForServer(server string) *net.Resolver {
address := server
if _, _, err := net.SplitHostPort(server); err != nil {
address = net.JoinHostPort(server, "53")
}
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, network, address)
},
}
}
// monitorHTTP measures HTTP GET request response in microseconds. Returns -1 and an error on failure.
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
if client == nil {
@@ -120,7 +93,6 @@ func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, e
if err != nil {
return -1, err
}
req.Header.Set("User-Agent", networkMonitorUserAgent)
resp, err := client.Do(req)
if err != nil {
return -1, err
-3
View File
@@ -14,12 +14,9 @@ func (pm *MonitorManager) startMonitor(task *monitorTask) {
}
delay := getStagger(interval.Milliseconds())
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
// Certificate checks piggyback on probe ticks, so they run at most once per
// probe interval after they become due.
go runMonitorSchedule(task.ctx, interval, delay, func() {
if _, allowed := task.resumeGuard.snapshot(); allowed {
task.runProbe(pm.probe)
task.refreshCert(pm.certCheck)
}
})
}
-75
View File
@@ -21,12 +21,6 @@ type monitorTask struct {
runMu sync.Mutex
inflight *monitorRun
lastFailureLog int64 // Unix nanoseconds
certMu sync.Mutex
cert *monitor.CertInfo
certUnsent bool // cert has not been included in a stats result yet
certChecking bool
nextCertCheck time.Time
}
type monitorRun struct {
@@ -51,11 +45,6 @@ func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *m
task := newMonitorTask(config)
if existing != nil {
task.history = existing.history.clone()
// Keep the last known certificate, but check again soon for the new config.
// The hub already stores it, so it is not marked unsent.
if config.Target == existing.config.Target {
task.cert = existing.certInfo()
}
}
return task
}
@@ -118,70 +107,6 @@ func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
return copyMonitorResult(run.result)
}
// refreshCert checks the certificate of an HTTPS target when due. A failed
// check keeps the last known certificate and retries sooner, as does a
// certificate that expires before the next regular check, so renewals show up
// quickly. Concurrent callers skip rather than wait, and no lock is held during
// network I/O.
func (task *monitorTask) refreshCert(check certChecker) {
if check == nil || !certCheckEnabled(task.config) {
return
}
task.certMu.Lock()
if task.certChecking || time.Now().Before(task.nextCertCheck) {
task.certMu.Unlock()
return
}
task.certChecking = true
task.certMu.Unlock()
info, err := check(task.ctx, task.config.Target)
task.certMu.Lock()
defer task.certMu.Unlock()
task.certChecking = false
if task.ctx.Err() != nil {
return
}
if err != nil {
task.nextCertCheck = time.Now().Add(certCheckRetryInterval)
slog.Warn("certificate check failed", "err", err, "target", task.config.Target)
return
}
task.cert = &info
task.certUnsent = true
now := time.Now()
interval := certCheckInterval
if time.UnixMilli(info.Expires).Before(now.Add(certCheckInterval)) {
interval = certCheckRetryInterval
}
task.nextCertCheck = now.Add(interval)
}
// certInfo returns a copy of the latest certificate info, or nil if unknown.
func (task *monitorTask) certInfo() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if task.cert == nil {
return nil
}
cert := *task.cert
return &cert
}
// takeUnsentCert returns the latest certificate info once after each successful
// check, so unchanged info is not resent with every stats result.
func (task *monitorTask) takeUnsentCert() *monitor.CertInfo {
task.certMu.Lock()
defer task.certMu.Unlock()
if !task.certUnsent {
return nil
}
task.certUnsent = false
cert := *task.cert
return &cert
}
func copyMonitorResult(result *monitor.Result) *monitor.Result {
if result == nil {
return nil
+3 -69
View File
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -241,7 +240,6 @@ func TestMonitorManagerGetRandomDelay(t *testing.T) {
func TestMonitorHTTP(t *testing.T) {
t.Run("success", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "Beszel-Agent/"+beszel.Version+" (+https://beszel.dev)", r.Header.Get("User-Agent"))
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
@@ -376,79 +374,15 @@ func tcpMonitorTestResolver(ips []string) *net.Resolver {
}}
}
// udpDNSTestServer starts a UDP server on loopback that answers A queries with the
// given IPs, and returns its listen address (host:port).
func udpDNSTestServer(t *testing.T, ips []string) string {
t.Helper()
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
require.NoError(t, err)
t.Cleanup(func() { conn.Close() })
go func() {
buf := make([]byte, 512)
for {
n, addr, err := conn.ReadFromUDP(buf)
if err != nil {
return
}
var msg dnsmessage.Message
if err := msg.Unpack(buf[:n]); err != nil {
continue
}
msg.Header.Response = true
msg.Header.RecursionAvailable = true
for _, question := range msg.Questions {
if question.Type != dnsmessage.TypeA {
continue
}
for _, ip := range ips {
msg.Answers = append(msg.Answers, dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
})
}
}
packet, err := msg.Pack()
if err != nil {
continue
}
_, _ = conn.WriteToUDP(packet, addr)
}
}()
return conn.LocalAddr().String()
}
func TestMonitorDNS(t *testing.T) {
t.Run("success", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "localhost", "")
responseUs, err := monitorDNS(context.Background(), "localhost")
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("lookup failure", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "", "")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
t.Run("custom server", func(t *testing.T) {
serverAddr := udpDNSTestServer(t, []string{"192.0.2.10"})
responseUs, err := monitorDNS(context.Background(), "example.test.", serverAddr)
require.NoError(t, err)
assert.GreaterOrEqual(t, responseUs, int64(0))
})
t.Run("custom server without port defaults to 53", func(t *testing.T) {
resolver := dnsResolverForServer("127.0.0.1")
conn, err := resolver.Dial(context.Background(), "udp", "")
require.NoError(t, err)
defer conn.Close()
assert.Equal(t, "127.0.0.1:53", conn.RemoteAddr().String())
})
t.Run("custom server unreachable", func(t *testing.T) {
responseUs, err := monitorDNS(context.Background(), "example.test.", "127.0.0.1:1")
responseUs, err := monitorDNS(context.Background(), "")
assert.Equal(t, int64(-1), responseUs)
require.Error(t, err)
})
@@ -543,7 +477,7 @@ func TestMonitorResolutionCancellation(t *testing.T) {
case "tcp":
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
case "dns":
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.", "")
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
case "icmp":
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
}
-508
View File
@@ -1,508 +0,0 @@
package agent
import (
"bufio"
"context"
"errors"
"log/slog"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
const (
defaultPackageUpdatesInterval = time.Hour
packageUpdatesTimeout = 5 * time.Minute
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
// databases. Checks in between reuse the last synced copy.
pacmanSyncInterval = 12 * time.Hour
)
// 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
result packageUpdatesResult
checkedAt time.Time
running bool
}
// newPackageUpdatesManager returns nil if disabled or no supported package manager
// is found. Agents running in a container are skipped because the container's
// package database is not the host's. dataDir holds pacman's private sync databases.
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
if runtime.GOOS != "linux" || runningInContainer() {
return nil
}
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{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.
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
pm.Lock()
defer pm.Unlock()
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
pm.running = true
go pm.refresh()
}
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()
result, err := pm.check(ctx)
if err != nil {
slog.Debug("Package updates check failed", "err", err)
result = packageUpdatesResult{}
}
pm.Lock()
pm.result = result
pm.checkedAt = time.Now()
pm.running = false
pm.Unlock()
}
func runningInContainer() bool {
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
if _, err := os.Stat(path); err == nil {
return true
}
}
return false
}
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
switch {
case commandExists("apt-get"):
return "apt", checkApt
case commandExists("dnf"):
return "dnf", checkDnf
case commandExists("zypper"):
return "zypper", checkZypper
case commandExists("checkupdates"):
return "pacman", newPacmanCheck(dataDir)
case commandExists("apk"):
return "apk", checkApk
}
return "", nil
}
func commandExists(name string) bool {
_, err := exec.LookPath(name)
return err == nil
}
// runPackageCommand runs a read-only package manager command and returns stdout.
// okCodes lists non-zero exit codes that still mean success.
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
}
// runPackageCommandEnv is runPackageCommand with extra environment variables.
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LC_ALL=C")
cmd.Env = append(cmd.Env, env...)
// checkupdates is a shell script, so a timeout kills only the script and its
// children can keep stdout open. WaitDelay stops Output from waiting on them.
cmd.WaitDelay = 10 * time.Second
out, err := cmd.Output()
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) {
return string(out), nil
}
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) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
if err != nil {
return packageUpdatesResult{}, err
}
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.
// 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 packageUpdatesResult{}, err
}
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 {
// --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
}
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
}
// 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 packageUpdatesResult{}, err
}
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 {
result.counts = append(result.counts, parseZypperTable(out))
}
return result, nil
}
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
// the databases and never touches pacman's own. The copy lives in dataDir because
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
// Local upgrades show up right away since checkupdates links the live local DB.
// Exit code 2 means no updates.
func newPacmanCheck(dataDir string) packageUpdatesCheck {
var env []string
var syncDir string
if dataDir != "" {
dbPath := filepath.Join(dataDir, "checkup-db")
env = []string{"CHECKUPDATES_DB=" + dbPath}
syncDir = filepath.Join(dbPath, "sync")
}
// checks never overlap (packageUpdatesManager.running), so no lock is needed
var lastSync time.Time
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
if !sync && syncDir != "" {
if _, err := os.Stat(syncDir); err != nil {
sync = true
}
}
var args []string
if !sync {
args = append(args, "-n")
}
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
if err != nil {
return packageUpdatesResult{}, err
}
if sync {
lastSync = time.Now()
}
packages := parsePacmanCheckUpdates(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
}
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
if err != nil {
return packageUpdatesResult{}, err
}
packages := parseApkUpgradable(out)
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
}
// 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 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], "[") || !strings.HasPrefix(fields[3], "(") {
continue
}
pkg := system.PackageUpdate{
Name: fields[1],
Current: strings.Trim(fields[2], "[]"),
Available: strings.TrimPrefix(fields[3], "("),
}
start := strings.IndexByte(line, '(')
end := strings.IndexByte(line, ')')
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
packages = append(packages, pkg)
}
return packages
}
// 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()
if strings.HasPrefix(line, "Obsoleting") {
break
}
fields := strings.Fields(line)
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 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
// "---+---" separator).
func parseZypperTable(out string) (count uint16) {
inTable := false
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
switch {
case !inTable:
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
case strings.Contains(line, "|"):
count++
default:
return count
}
}
return count
}
// 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() {
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 packages
}
// parsePacmanCheckUpdates parses "name old -> new" lines.
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
scanner := bufio.NewScanner(strings.NewReader(out))
for scanner.Scan() {
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 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:]
}
-445
View File
@@ -1,445 +0,0 @@
//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())
}
+2 -123
View File
@@ -5,9 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
@@ -34,8 +32,6 @@ type SensorConfig struct {
isBlacklist bool
hasWildcards bool
skipCollection bool
skipGPU bool
sensorShadow string
firstRun bool
}
@@ -45,14 +41,13 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
skipGPU, _ := utils.GetEnv("SKIP_GPU")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
}
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig {
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
timeout := 2 * time.Second
if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
@@ -67,7 +62,6 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
primarySensor: primarySensor,
timeout: timeout,
skipCollection: skipCollection,
skipGPU: skipGPU,
firstRun: true,
sensors: make(map[string]struct{}),
}
@@ -79,19 +73,6 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
}
if skipGPU && runtime.GOOS == "linux" {
// gopsutil reads every temp*_input before results can be filtered, so
// point it at a shadow tree built from the effective sysfs root instead.
if shadow, err := buildNonGpuSysShadow(effectiveSysRoot(config.context)); err == nil {
slog.Info("SKIP_GPU enabled, using non-GPU sensor sysfs shadow", "path", shadow)
config.sensorShadow = shadow
config.context = context.WithValue(config.context,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: shadow},
)
} else {
slog.Warn("SKIP_GPU sensor shadow unavailable, falling back to post-read filtering", "err", err)
}
}
// handle blacklist
if strings.HasPrefix(sensorsEnvVal, "-") {
@@ -168,9 +149,6 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
if !isValidSensor(sensorName, a.sensorConfig) {
continue
}
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
continue
}
// set dashboard temperature
switch a.sensorConfig.primarySensor {
case "":
@@ -267,102 +245,3 @@ func scaleTemperature(temp float64) float64 {
}
return scaled100
}
// effectiveSysRoot mirrors gopsutil's HostSys lookup, which lives in its
// internal package: context override, then HOST_SYS env, then /sys.
func effectiveSysRoot(ctx context.Context) string {
if envMap, ok := ctx.Value(common.EnvKey).(common.EnvMap); ok {
if v := envMap[common.HostSysEnvKey]; v != "" {
return v
}
}
if v := os.Getenv("HOST_SYS"); v != "" {
return v
}
return "/sys"
}
func (config *SensorConfig) cleanupSensorShadow() {
if config.sensorShadow == "" {
return
}
if err := os.RemoveAll(config.sensorShadow); err != nil {
slog.Warn("Error removing sensor sysfs shadow", "path", config.sensorShadow, "err", err)
return
}
config.sensorShadow = ""
}
func (a *Agent) cleanupSensorShadow() {
if a.sensorConfig != nil {
a.sensorConfig.cleanupSensorShadow()
}
}
func isGpuThermalZone(zoneType string) bool {
zoneType = strings.ToLower(strings.TrimSpace(zoneType))
return isGpuChipName(zoneType) || strings.Contains(zoneType, "gpu")
}
// buildNonGpuSysShadow links non-GPU sensor directories into a temp dir. Only
// static chip names and thermal-zone types are read; no sensor values are touched.
func buildNonGpuSysShadow(sysRoot string) (string, error) {
shadow, err := os.MkdirTemp("", "beszel-sensors-*")
if err != nil {
return "", err
}
shadowHwmon := filepath.Join(shadow, "class", "hwmon")
if err := os.MkdirAll(shadowHwmon, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
entries, err := os.ReadDir(filepath.Join(sysRoot, "class", "hwmon"))
if err != nil && !os.IsNotExist(err) {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range entries {
chipDir := filepath.Join(sysRoot, "class", "hwmon", entry.Name())
// Some hwmon devices expose name under device/ (gopsutil's CentOS fallback).
name, ok := utils.ReadStringFileOK(filepath.Join(chipDir, "name"))
if !ok {
name, ok = utils.ReadStringFileOK(filepath.Join(chipDir, "device", "name"))
}
if !ok || isGpuChipName(name) {
continue
}
if err := os.Symlink(chipDir, filepath.Join(shadowHwmon, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
thermalEntries, err := os.ReadDir(filepath.Join(sysRoot, "class", "thermal"))
if err != nil {
if os.IsNotExist(err) {
return shadow, nil
}
os.RemoveAll(shadow)
return "", err
}
shadowThermal := filepath.Join(shadow, "class", "thermal")
if err := os.MkdirAll(shadowThermal, 0o755); err != nil {
os.RemoveAll(shadow)
return "", err
}
for _, entry := range thermalEntries {
if !strings.HasPrefix(entry.Name(), "thermal_zone") {
continue
}
zoneDir := filepath.Join(sysRoot, "class", "thermal", entry.Name())
zoneType, ok := utils.ReadStringFileOK(filepath.Join(zoneDir, "type"))
if !ok || isGpuThermalZone(zoneType) {
continue
}
if err := os.Symlink(zoneDir, filepath.Join(shadowThermal, entry.Name())); err != nil {
os.RemoveAll(shadow)
return "", err
}
}
return shadow, nil
}
+1 -154
View File
@@ -5,9 +5,6 @@ package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"time"
@@ -331,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
// Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -623,153 +620,3 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures)
}
func TestIsGpuSensorKey(t *testing.T) {
for _, key := range []string{"xe", "XE_temp1", "amdgpu_edge", "NVIDIA"} {
assert.True(t, isGpuSensorKey(key), key)
}
for _, key := range []string{"coretemp_core_0", "acpitz", "xen_temp", "myxe", ""} {
assert.False(t, isGpuSensorKey(key), key)
}
}
func TestSkipGpuSensorShadow(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestSkipGpuSensorShadowDeviceName(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "device", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "device", "temp1_input"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
assert.FileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon0", "device", "temp1_input"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestSkipGpuSensorShadowKeepsThermalZonesWithoutNonGpuHwmon(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "48000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "type"), "cpu-thermal\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone0", "temp"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "type"), "gpu\n")
writeFile(t, filepath.Join(sysRoot, "class", "thermal", "thermal_zone1", "temp"), "48000\n")
shadow, err := buildNonGpuSysShadow(sysRoot)
require.NoError(t, err)
t.Cleanup(func() { os.RemoveAll(shadow) })
hwmonTemps, err := filepath.Glob(filepath.Join(shadow, "class", "hwmon", "hwmon*", "temp*_input"))
require.NoError(t, err)
assert.Empty(t, hwmonTemps)
assert.FileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone0", "temp"))
assert.NoFileExists(t, filepath.Join(shadow, "class", "thermal", "thermal_zone1"))
}
func TestNewSensorConfigSkipGpuWiresShadow(t *testing.T) {
t.Setenv("SKIP_GPU", "true")
agent := &Agent{}
config := agent.newSensorConfig()
assert.True(t, config.skipGPU)
if runtime.GOOS != "linux" {
assert.Empty(t, config.sensorShadow)
assert.Nil(t, config.context.Value(common.EnvKey))
return
}
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
assert.DirExists(t, filepath.Join(shadow, "class", "hwmon"))
assert.Equal(t, shadow, config.sensorShadow)
config.cleanupSensorShadow()
assert.NoDirExists(t, shadow)
assert.Empty(t, config.sensorShadow)
}
func TestSkipGpuShadowUsesSysSensorsRoot(t *testing.T) {
sysRoot := t.TempDir()
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "name"), "coretemp\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0", "temp1_input"), "55000\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "name"), "xe\n")
writeFile(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon1", "temp1_input"), "48000\n")
agent := &Agent{}
config := agent.newSensorConfigWithEnv("", sysRoot, "", "", false, true)
t.Cleanup(config.cleanupSensorShadow)
envMap, ok := config.context.Value(common.EnvKey).(common.EnvMap)
require.True(t, ok, "SKIP_GPU should point the sensor context at a sysfs shadow")
shadow, ok := envMap[common.HostSysEnvKey]
require.True(t, ok)
if runtime.GOOS != "linux" {
assert.Equal(t, sysRoot, shadow)
assert.Empty(t, config.sensorShadow)
return
}
require.NotEqual(t, sysRoot, shadow, "shadow must not be the SYS_SENSORS tree itself")
target, err := os.Readlink(filepath.Join(shadow, "class", "hwmon", "hwmon0"))
require.NoError(t, err)
assert.Equal(t, filepath.Join(sysRoot, "class", "hwmon", "hwmon0"), target)
assert.NoFileExists(t, filepath.Join(shadow, "class", "hwmon", "hwmon1"))
}
func TestUpdateTemperaturesSkipGpu(t *testing.T) {
originalGetSensorTemps := getSensorTemps
t.Cleanup(func() {
getSensorTemps = originalGetSensorTemps
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{
{SensorKey: "coretemp_core_0", Temperature: 55},
{SensorKey: "XE", Temperature: 48},
}, nil
}
newAgent := func(skipGPU bool) *Agent {
agent := &Agent{
systemInfo: system.Info{},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
skipGPU: skipGPU,
},
}
return agent
}
stats := &system.Stats{}
newAgent(true).updateTemperatures(stats)
assert.Equal(t, map[string]float64{"coretemp_core_0": 55}, stats.Temperatures)
stats = &system.Stats{}
newAgent(false).updateTemperatures(stats)
assert.Len(t, stats.Temperatures, 2)
}
+11 -14
View File
@@ -57,11 +57,21 @@ func (a *Agent) StartServer(opts ServerOptions) error {
}
defer ln.Close()
// base config (limit to allowed algorithms)
config := &gossh.ServerConfig{
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
config.KeyExchanges = common.DefaultKeyExchanges
config.MACs = common.DefaultMACs
config.Ciphers = common.DefaultCiphers
// set default handler
ssh.Handle(a.handleSession)
a.server = &ssh.Server{
ServerConfigCallback: newSSHServerConfig,
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
return config
},
// check public key(s)
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
remoteAddr := ctx.RemoteAddr()
@@ -86,19 +96,6 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return a.server.Serve(ln)
}
// newSSHServerConfig returns a separate config for each connection because
// gliderlabs adds host keys and connection-specific callbacks to it.
func newSSHServerConfig(ssh.Context) *gossh.ServerConfig {
return &gossh.ServerConfig{
Config: gossh.Config{
KeyExchanges: common.DefaultKeyExchanges,
MACs: common.DefaultMACs,
Ciphers: common.DefaultCiphers,
},
ServerVersion: fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version),
}
}
// getHubVersion extracts the hub version from the SSH client version string
// for a given session. Returns a zero version if parsing fails.
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
-111
View File
@@ -1,111 +0,0 @@
//go:build testing
package agent
import (
"crypto/ed25519"
"fmt"
"net"
"sync"
"testing"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/common"
"github.com/gliderlabs/ssh"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
)
func TestSSHServerConfigConcurrentConnections(t *testing.T) {
_, key, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
signer, err := gossh.NewSignerFromKey(key)
require.NoError(t, err)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
const connections = 2
configs := make(chan *gossh.ServerConfig, connections)
release := make(chan struct{})
var releaseOnce sync.Once
unblock := func() { releaseOnce.Do(func() { close(release) }) }
server := &ssh.Server{
HostSigners: []ssh.Signer{signer},
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
config := newSSHServerConfig(ctx)
configs <- config
// Both connections must obtain their configuration before either
// lets gliderlabs add host keys and connection-specific callbacks.
<-release
return config
},
PublicKeyHandler: func(_ ssh.Context, key ssh.PublicKey) bool {
return ssh.KeysEqual(key, signer.PublicKey())
},
Handler: func(session ssh.Session) { _ = session.Exit(0) },
}
served := make(chan error, 1)
go func() { served <- server.Serve(listener) }()
t.Cleanup(func() {
unblock()
_ = listener.Close()
_ = server.Close()
select {
case <-served:
case <-time.After(5 * time.Second):
t.Error("SSH test server did not stop")
}
})
results := make(chan error, connections)
for range connections {
go func() {
conn, err := net.DialTimeout("tcp", listener.Addr().String(), 5*time.Second)
if err != nil {
results <- err
return
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
client, _, _, err := gossh.NewClientConn(conn, listener.Addr().String(), &gossh.ClientConfig{
User: "test",
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
HostKeyCallback: gossh.FixedHostKey(signer.PublicKey()),
})
if err == nil {
err = client.Close()
}
results <- err
}()
}
var first *gossh.ServerConfig
for range connections {
select {
case config := <-configs:
assert.Equal(t, fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), config.ServerVersion)
assert.Equal(t, common.DefaultKeyExchanges, config.KeyExchanges)
assert.Equal(t, common.DefaultMACs, config.MACs)
assert.Equal(t, common.DefaultCiphers, config.Ciphers)
if first == nil {
first = config
} else {
assert.NotSame(t, first, config, "SSH connections must not share mutable configuration")
}
case <-time.After(5 * time.Second):
t.Fatal("SSH connections did not reach their config callbacks")
}
}
unblock()
for range connections {
select {
case err := <-results:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("SSH handshake did not finish")
}
}
}
+9 -25
View File
@@ -311,13 +311,11 @@ func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceIn
return filtered
}
// detectSmartOutputType inspects protocol-specific sections and the reported
// device type to choose a parser, including when the NVMe health log is missing.
// detectSmartOutputType inspects sections that are unique to each smartctl
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
// when the reported device type is ambiguous or missing.
func detectSmartOutputType(output []byte) string {
var hints struct {
Device struct {
Type string `json:"type"`
} `json:"device"`
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
@@ -328,7 +326,7 @@ func detectSmartOutputType(output []byte) string {
}
switch {
case hasJSONValue(hints.NVMeSmartHealthInformationLog), normalizeParserType(hints.Device.Type) == "nvme":
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
return "nvme"
case hasJSONValue(hints.AtaSmartAttributes):
return "sat"
@@ -399,11 +397,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
}
}
// Inspect every response so a failed NVMe query cannot reach other parsers.
structureType := detectSmartOutputType(output)
// Update the stored parser only when it is not yet verified.
// Only run the type detection when we do not yet know which parser works
// or the previous attempt failed.
needsDetection := deviceType == "" || !deviceInfo.typeVerified
if needsDetection {
structureType := detectSmartOutputType(output)
if deviceType != structureType {
deviceType = structureType
deviceInfo.parserType = structureType
@@ -444,11 +442,6 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
// Try the selected parsers in order until we find one that succeeds.
for _, parser := range selectedParsers {
// A failed NVMe response may still contain a serial number, which is
// enough for the SATA and SCSI parsers to accept incorrect zero values.
if structureType == "nvme" && parser.Type != "nvme" {
continue
}
hasData, _ := parser.Parse(output)
if hasData {
deviceInfo.parserType = parser.Type
@@ -1152,16 +1145,6 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
return false, data.Smartctl.ExitStatus
}
// smartctl may return device identity fields before failing to read the NVMe
// health log (for example, on an unsupported controller path or with insufficient
// permissions). Do not accept that partial response as valid SMART data: doing
// so stores incorrect zero values and prevents the namespace-path fallback.
log := data.NVMeSmartHealthInformationLog
if log == nil {
slog.Debug("no NVMe SMART health information", "device", data.Device.Name)
return false, data.Smartctl.ExitStatus
}
sm.Lock()
defer sm.Unlock()
@@ -1184,7 +1167,7 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Temperature = log.Temperature
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
smartData.DiskType = data.Device.Type
@@ -1194,6 +1177,7 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
// nvme attributes does not follow the same format as ata attributes,
// so we manually map each field to SmartAttributes
log := data.NVMeSmartHealthInformationLog
smartData.Attributes = []*smart.SmartAttribute{
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
{Name: "Temperature", RawValue: uint64(log.Temperature)},
-21
View File
@@ -837,27 +837,6 @@ func TestParseSmartOutputMarksVerified(t *testing.T) {
assert.True(t, device.typeVerified)
}
func TestParseSmartForNvmeRejectsIdentityOnlyResponse(t *testing.T) {
jsonPayload := []byte(`{
"smartctl": {"exit_status": 2},
"device": {"name": "/dev/nvme0", "type": "nvme"},
"model_name": "Netac NVMe SSD 500GB",
"serial_number": "IDENTITY-ONLY",
"user_capacity": {"bytes": 500107862016}
}`)
sm := &SmartManager{SmartDataMap: make(map[string]*smart.SmartData)}
hasData, exitStatus := sm.parseSmartForNvme(jsonPayload, "")
assert.False(t, hasData)
assert.Equal(t, 2, exitStatus)
assert.NotContains(t, sm.SmartDataMap, "IDENTITY-ONLY")
device := &DeviceInfo{Name: "/dev/nvme0", Type: "nvme"}
assert.False(t, sm.parseSmartOutput(device, jsonPayload))
assert.NotContains(t, sm.SmartDataMap, "IDENTITY-ONLY")
}
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
fixturePath := filepath.Join("test-data", "smart", "sda.json")
data, err := os.ReadFile(fixturePath)
+21 -71
View File
@@ -54,18 +54,12 @@ type poolBackend struct {
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
// Utility-backed caches below are refreshed in the background after the
// first collection, so cacheMu guards them against those goroutines.
cacheMu sync.Mutex
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
poolRefreshing bool
poolData []zfs.PoolStat // cached pool inventory (TTL below)
lastPoolStats time.Time
kernelSamples map[string]poolKernelSample
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
lastUsageRefresh time.Time
usageRefreshing bool
kernelSamples map[string]poolKernelSample
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
// an interval. Accessed from handler goroutines, so it is mutex-protected.
@@ -183,40 +177,19 @@ func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
}
// poolStats returns the cached pool inventory, calling its collector at most
// every poolStatsRefreshInterval. Only the first collection blocks; later
// refreshes run in the background because utilities like `zpool list` can hang
// for seconds on busy hosts, which would otherwise delay the hub's stats
// response. On failure the previous inventory is retained and the refresh is
// retried on the next cadence.
// every poolStatsRefreshInterval. On failure the previous inventory is
// retained and the refresh is retried on the next cadence.
func (b *poolBackend) poolStats() []zfs.PoolStat {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
return b.poolData
}
if b.lastPoolStats.IsZero() {
b.storePoolStats(b.poolStatsFn())
return b.poolData
}
b.poolRefreshing = true
go func() {
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
pools, err := b.poolStatsFn()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.poolRefreshing = false
b.storePoolStats(pools, err)
}()
return b.poolData
}
// storePoolStats records a pool inventory result. Callers must hold cacheMu.
func (b *poolBackend) storePoolStats(pools []zfs.PoolStat, err error) {
if err != nil {
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
} else {
b.poolData = pools
if err != nil {
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
} else {
b.poolData = pools
}
b.lastPoolStats = time.Now()
}
b.lastPoolStats = time.Now()
return b.poolData
}
// kernelStats reads cumulative pool counters and converts them to per-second
@@ -252,33 +225,12 @@ func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]z
}
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
// and returns the mountpoint-keyed usage map. Like poolStats, only the first
// collection blocks and later refreshes run in the background.
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
return b.datasetUsage
// and rebuilds the mountpoint-keyed usage map.
func (b *poolBackend) refreshDatasetUsage() {
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
return
}
if b.lastUsageRefresh.IsZero() {
b.storeDatasetUsage(b.datasets())
return b.datasetUsage
}
b.usageRefreshing = true
go func() {
datasets, err := b.datasets()
b.cacheMu.Lock()
defer b.cacheMu.Unlock()
b.usageRefreshing = false
b.storeDatasetUsage(datasets, err)
}()
return b.datasetUsage
}
// storeDatasetUsage rebuilds the usage map from a dataset listing. The map is
// replaced rather than mutated so returned references stay safe to read.
// Callers must hold cacheMu.
func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
datasets, err := b.datasets()
if err != nil {
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
} else {
@@ -299,7 +251,8 @@ func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
for _, backend := range m.backends {
if backend.name == "zfs" {
return backend.refreshDatasetUsage()
backend.refreshDatasetUsage()
return backend.datasetUsage
}
}
return nil
@@ -489,10 +442,7 @@ func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystem
}
}
for _, backend := range m.backends {
backend.cacheMu.Lock()
pools := backend.poolData
backend.cacheMu.Unlock()
for _, pool := range pools {
for _, pool := range backend.poolData {
sample := stats.ZfsPools[pool.Name]
if sample == nil || pool.MountID == "" {
continue
-28
View File
@@ -518,31 +518,3 @@ func TestBtrfsPoolIdentities(t *testing.T) {
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
}
func TestStaleUtilityCachesRefreshInBackground(t *testing.T) {
release := make(chan struct{})
b := &poolBackend{name: "zfs"}
b.poolStatsFn = func() ([]zfs.PoolStat, error) {
<-release
return []zfs.PoolStat{{Name: "new"}}, nil
}
b.datasetsFn = func() ([]zfs.Dataset, error) {
<-release
return []zfs.Dataset{{Name: "new", Mountpoint: "/new"}}, nil
}
b.poolData = []zfs.PoolStat{{Name: "old"}}
b.lastPoolStats = time.Now().Add(-2 * poolStatsRefreshInterval)
b.datasetUsage = map[string]zfsDatasetUsage{"/old": {}}
b.lastUsageRefresh = time.Now().Add(-2 * datasetUsageRefreshInterval)
// A hung utility must not block collection; cached data is served meanwhile.
for range 2 {
assert.Equal(t, "old", b.poolStats()[0].Name)
assert.Contains(t, b.refreshDatasetUsage(), "/old")
}
close(release)
require.Eventually(t, func() bool {
return b.poolStats()[0].Name == "new" && b.refreshDatasetUsage()["/new"] == zfsDatasetUsage{}
}, time.Second, time.Millisecond)
}
-9
View File
@@ -14,7 +14,6 @@ import (
"github.com/henrygd/beszel/agent/battery"
"github.com/henrygd/beszel/agent/btrfs"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/agent/wifi"
"github.com/henrygd/beszel/agent/zfs"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
@@ -268,14 +267,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
}
}
// Wi-Fi collection spawns a process on macOS and dumps the BSS cache on
// Linux, so only refresh on the default interval. Real-time requests reuse
// the last snapshot.
if cacheTimeMs == defaultDataCacheTimeMs {
a.systemInfo.WiFi = wifi.Collect()
}
systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi)
// update system info
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
a.systemInfo.Cpu = systemStats.Cpu
+4 -115
View File
@@ -3,15 +3,12 @@
package agent
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"maps"
"math"
"os"
"os/exec"
"strconv"
"strings"
"sync"
@@ -23,50 +20,6 @@ import (
)
var errNoActiveTime = errors.New("no active time")
var errSystemdLogLimitReached = errors.New("systemd log size limit reached")
const systemdLogsTail = 200
// canReadSystemJournal probes whether the agent's current credentials can read
// the system journal. A successful empty result is still readable: entries may
// be written after the agent starts.
func canReadSystemJournal() bool {
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
_, err := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--lines", "1").Output()
return err == nil
}
// systemdLogsEnabled reports whether service logs can be served to the hub.
func systemdLogsEnabled() bool {
if skip, _ := utils.GetEnv("SKIP_SYSTEMD_LOGS"); skip == "true" {
return false
}
return canReadSystemJournal()
}
// limitedBuffer bounds command output before it is sent over the agent connection.
type limitedBuffer struct {
buffer bytes.Buffer
limit int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remaining := b.limit - b.buffer.Len()
if remaining <= 0 {
return 0, errSystemdLogLimitReached
}
if len(p) > remaining {
_, _ = b.buffer.Write(p[:remaining])
return remaining, errSystemdLogLimitReached
}
return b.buffer.Write(p)
}
func (b *limitedBuffer) String() string {
return b.buffer.String()
}
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
@@ -74,7 +27,6 @@ type systemdManager struct {
serviceStatsMap map[string]*systemd.Service
isRunning bool
hasFreshStats bool
logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS
patterns []string
}
@@ -116,7 +68,6 @@ func newSystemdManager() (*systemdManager, error) {
manager := &systemdManager{
serviceStatsMap: make(map[string]*systemd.Service),
logsEnabled: systemdLogsEnabled(),
patterns: getServicePatterns(),
}
@@ -281,14 +232,6 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat
return service, nil
}
// serviceUnitName preserves monitored timer units and defaults bare names to services.
func serviceUnitName(name string) string {
if strings.HasSuffix(name, ".service") || strings.HasSuffix(name, ".timer") {
return name
}
return name + ".service"
}
// getServiceDetails collects extended information for a specific systemd service.
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
conn, err := dbus.NewSystemConnectionContext(context.Background())
@@ -297,7 +240,10 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
}
defer conn.Close()
unitName := serviceUnitName(serviceName)
unitName := serviceName
if !strings.HasSuffix(unitName, ".service") {
unitName += ".service"
}
ctx := context.Background()
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
@@ -332,63 +278,6 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
return details, nil
}
// monitoredUnitName resolves a service name to the unit name of a monitored
// service. Only monitored units are accepted so a request can't read other
// journal entries (journalctl --unit also accepts glob patterns).
func (sm *systemdManager) monitoredUnitName(serviceName string) (string, bool) {
sm.Lock()
defer sm.Unlock()
unitName := serviceUnitName(serviceName)
if _, ok := sm.serviceStatsMap[unitName]; ok {
return unitName, true
}
// Service names are unescaped, so match against the stored name as well.
for unitName, service := range sm.serviceStatsMap {
if service.Name == serviceName {
return unitName, true
}
}
return "", false
}
// getServiceLogs returns the newest journal entries for a monitored service.
// journalctl receives the unit name as an argument (rather than through a
// shell), so a service name can never alter the command being run.
func (sm *systemdManager) getServiceLogs(serviceName string) (string, error) {
if !sm.logsEnabled {
return "", errors.New("systemd logs disabled")
}
unitName, ok := sm.monitoredUnitName(serviceName)
if !ok {
return "", fmt.Errorf("service %q is not monitored", serviceName)
}
ctx, cancel := context.WithTimeout(context.Background(), 2100*time.Millisecond)
defer cancel()
cmd := exec.CommandContext(ctx, "journalctl", "--system", "--quiet", "--no-pager", "--output=short-iso", "--unit", unitName, "--lines", strconv.Itoa(systemdLogsTail))
output := limitedBuffer{limit: maxTotalLogSize}
cmd.Stdout = &output
stderr := limitedBuffer{limit: 1024}
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, errSystemdLogLimitReached) {
return output.String(), nil
}
if ctx.Err() != nil {
return "", ctx.Err()
}
message := strings.TrimSpace(stderr.String())
if message != "" {
return "", fmt.Errorf("journalctl failed: %s", message)
}
return "", fmt.Errorf("journalctl failed: %w", err)
}
return output.String(), nil
}
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
func unescapeServiceName(name string) string {
if !strings.Contains(name, "\\x") {
-5
View File
@@ -11,7 +11,6 @@ import (
// systemdManager manages the collection of systemd service statistics.
type systemdManager struct {
hasFreshStats bool
logsEnabled bool
}
// newSystemdManager creates a new systemdManager.
@@ -37,7 +36,3 @@ func (sm *systemdManager) getFailedServiceCount() uint16 {
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
return nil, errors.New("systemd manager unavailable")
}
func (sm *systemdManager) getServiceLogs(string) (string, error) {
return "", errors.New("systemd manager unavailable")
}
-125
View File
@@ -3,14 +3,10 @@
package agent
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/henrygd/beszel/internal/entities/systemd"
"github.com/stretchr/testify/assert"
)
@@ -36,127 +32,6 @@ func TestUnescapeServiceName(t *testing.T) {
}
}
func TestLimitedBuffer(t *testing.T) {
buffer := limitedBuffer{limit: 5}
n, err := buffer.Write([]byte("abcdef"))
assert.Equal(t, 5, n)
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
n, err = buffer.Write([]byte("g"))
assert.Zero(t, n)
assert.True(t, errors.Is(err, errSystemdLogLimitReached))
}
func TestLimitedBufferCapsExecOutput(t *testing.T) {
buffer := limitedBuffer{limit: 5}
cmd := exec.Command("sh", "-c", "printf 'abcdef'")
cmd.Stdout = &buffer
err := cmd.Run()
assert.ErrorIs(t, err, errSystemdLogLimitReached)
assert.Equal(t, "abcde", buffer.String())
}
func TestServiceUnitName(t *testing.T) {
tests := map[string]string{
"nginx": "nginx.service",
"nginx.service": "nginx.service",
"backup.timer": "backup.timer",
}
for input, want := range tests {
t.Run(input, func(t *testing.T) {
assert.Equal(t, want, serviceUnitName(input))
})
}
}
func TestCanReadSystemJournal(t *testing.T) {
tests := []struct {
name string
script string
want bool
}{
{"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true},
{"empty", "#!/bin/sh\nexit 0\n", true},
{"denied", "#!/bin/sh\nexit 1\n", false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.Equal(t, test.want, canReadSystemJournal())
})
}
}
func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) {
// Fake journalctl prints the unit it was asked for
dir := t.TempDir()
script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n"
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{
"nginx.service": {Name: "nginx"},
"backup.timer": {Name: "backup.timer"},
"foo\\x2dbar.service": {Name: "foo-bar"},
"getty@tty1.service": {Name: "getty@tty1"},
}}
tests := []struct {
name string
want string
}{
{"nginx", "nginx.service"},
{"nginx.service", "nginx.service"},
{"backup.timer", "backup.timer"},
{"foo-bar", "foo\\x2dbar.service"},
{"getty@tty1", "getty@tty1.service"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
logs, err := sm.getServiceLogs(test.name)
assert.NoError(t, err)
assert.Equal(t, test.want, logs)
})
}
for _, name := range []string{"sshd", "*", "*.service", "nginx*"} {
t.Run("rejects "+name, func(t *testing.T) {
logs, err := sm.getServiceLogs(name)
assert.Error(t, err)
assert.Empty(t, logs)
})
}
t.Run("disabled", func(t *testing.T) {
sm.logsEnabled = false
logs, err := sm.getServiceLogs("nginx")
assert.Error(t, err)
assert.Empty(t, logs)
})
}
func TestSystemdLogsEnabled(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
assert.True(t, systemdLogsEnabled())
t.Setenv("SKIP_SYSTEMD_LOGS", "true")
assert.False(t, systemdLogsEnabled())
}
func TestUnescapeServiceNameInvalid(t *testing.T) {
// Test invalid escape sequences - should return original string
invalidInputs := []string{
@@ -1,10 +0,0 @@
apk-tools-2.14.4-r1 aarch64 {apk-tools} (GPL-2.0-only) [upgradable from: apk-tools-2.14.4-r0]
busybox-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r28]
busybox-binsh-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-binsh-1.36.1-r28]
ca-certificates-bundle-20260413-r0 aarch64 {ca-certificates} (MPL-2.0 AND MIT) [upgradable from: ca-certificates-bundle-20240226-r0]
libcrypto3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.0-r2]
libssl3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.3.0-r2]
musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]
musl-utils-1.2.5-r3 aarch64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [upgradable from: musl-utils-1.2.5-r0]
ssl_client-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: ssl_client-1.36.1-r28]
zlib-1.3.2-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.1-r1]
@@ -1,101 +0,0 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
base-files bash bsdutils debian-archive-keyring dpkg e2fsprogs gcc-12-base
gpgv init-system-helpers libblkid1 libc-bin libc6 libcap2 libcom-err2
libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 liblzma5 libmount1
libpam-modules libpam-modules-bin libpam-runtime libpam0g libpcre2-8-0
libseccomp2 libsmartcols1 libss2 libstdc++6 libsystemd0 libtasn1-6 libudev1
libuuid1 login logsave mount passwd perl-base sed tar tzdata usr-is-merged
util-linux util-linux-extra
44 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst base-files [12.4+deb12u4] (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Conf base-files (12.4+deb12u15 Debian:12.15/oldstable [arm64])
Inst bash [5.2.15-2+b2] (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Conf bash (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
Inst bsdutils [1:2.38.1-5+b1] (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf bsdutils (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tar [1.34+dfsg-1.2] (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Conf tar (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
Inst dpkg [1.21.22] (1.21.23 Debian:12.15/oldstable [arm64])
Conf dpkg (1.21.23 Debian:12.15/oldstable [arm64])
Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf login (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst perl-base [5.36.0-7+deb12u1] (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Conf perl-base (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
Inst sed [4.9-1] (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf sed (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst gcc-12-base [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Conf libstdc++6 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
Inst libc6 [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc6 (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libsmartcols1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libsmartcols1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux-extra [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux-extra (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst util-linux [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf util-linux (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst usr-is-merged [35] (37~deb12u1 Debian:12.15/oldstable [all])
Conf usr-is-merged (37~deb12u1 Debian:12.15/oldstable [all])
Inst init-system-helpers [1.65.2] (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Conf init-system-helpers (1.65.2+deb12u1 Debian:12.15/oldstable [all])
Inst libc-bin [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Conf libc-bin (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
Inst libpam0g [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam0g (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst libpam-modules-bin [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Conf libpam-modules (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
Inst logsave [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libext2fs2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst mount [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpam-runtime [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Conf libpam-runtime (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Conf passwd (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
Inst debian-archive-keyring [2023.3+deb12u1] (2023.3+deb12u2 Debian:12.15/oldstable [all])
Conf debian-archive-keyring (2023.3+deb12u2 Debian:12.15/oldstable [all])
Inst libgcrypt20 [1.10.1-3] (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgcrypt20 (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst gpgv [2.2.40-1.1] (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Conf gpgv (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
Inst libblkid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libblkid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libcap2 [1:2.66-4] (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Conf libcap2 (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
Inst libtasn1-6 [4.19.0-2] (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libtasn1-6 (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst libgnutls30 [3.7.9-2+deb12u1] (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Conf libgnutls30 (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
Inst liblzma5 [5.4.1-0.2] (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Conf liblzma5 (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
Inst libmount1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libmount1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst libpcre2-8-0 [10.42-1] (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Conf libpcre2-8-0 (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
Inst libseccomp2 [2.5.4-1+b3] (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Conf libseccomp2 (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
Inst libsystemd0 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libsystemd0 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libudev1 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Conf libudev1 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
Inst libuuid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf libuuid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Inst tzdata [2023c-5+deb12u1] (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Inst libcom-err2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Inst libss2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf logsave (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf e2fsprogs (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf mount (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
Conf tzdata (2026b-0+deb12u1 Debian:12.15/oldstable [all])
Conf libcom-err2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
Conf libss2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
@@ -1,130 +0,0 @@
Reading package lists...
Building dependency tree...
Reading state information...
Calculating upgrade...
The following packages will be upgraded:
apt base-files bash bsdutils coreutils diffutils dpkg e2fsprogs gcc-12-base
gpgv gzip libapt-pkg6.0 libattr1 libblkid1 libbz2-1.0 libc-bin libc6 libcap2
libcom-err2 libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 libgssapi-krb5-2
libk5crypto3 libkrb5-3 libkrb5support0 liblzma5 libmount1 libncurses6
libncursesw6 libp11-kit0 libpam-modules libpam-modules-bin libpam-runtime
libpam0g libprocps8 libseccomp2 libsmartcols1 libss2 libssl3 libstdc++6
libsystemd0 libtasn1-6 libtinfo6 libudev1 libuuid1 login logsave mount
ncurses-base ncurses-bin passwd perl-base procps sed tar util-linux
58 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Conf gcc-12-base (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
Inst libgcc-s1 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Conf libgcc-s1 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
Inst libstdc++6 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libstdc++6 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc6 (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst base-files [12ubuntu4.4] (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Conf base-files (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
Inst bash [5.1-6ubuntu1] (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bash (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst bsdutils [1:2.37.2-4ubuntu3] (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf bsdutils (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst coreutils [8.32-4.1ubuntu1] (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf coreutils (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst diffutils [1:3.8-0ubuntu2] (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf diffutils (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libbz2-1.0 [1.0.8-5build1] (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libbz2-1.0 (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgcrypt20 [1.9.4-3ubuntu3] (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgcrypt20 (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst liblzma5 [5.2.5-2ubuntu1] (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf liblzma5 (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libsystemd0 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsystemd0 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libudev1 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libudev1 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libapt-pkg6.0 [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf libapt-pkg6.0 (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst tar [1.34+dfsg-1ubuntu0.1.22.04.1] (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf tar (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst dpkg [1.21.1ubuntu2.2] (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf dpkg (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst gzip [1.10-4ubuntu4.1] (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gzip (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst login [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf login (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-bin [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf ncurses-bin (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst perl-base [5.34.0-3ubuntu1.2] (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf perl-base (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst sed [4.8-1ubuntu2] (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf sed (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst util-linux [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf util-linux (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libc-bin [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libc-bin (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst ncurses-base [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf ncurses-base (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst gpgv [2.2.27-3ubuntu2.1] (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf gpgv (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libp11-kit0 [0.24.0-6build1] (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libp11-kit0 (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libtasn1-6 [4.18.0-4build1] (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtasn1-6 (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libgnutls30 [3.7.3-4ubuntu1.2] (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libgnutls30 (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libseccomp2 [2.5.3-2ubuntu2] (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Conf libseccomp2 (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
Inst apt [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Conf apt (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
Inst libpam0g [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam0g (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-modules-bin [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
Conf libpam-modules-bin (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 ]
Inst libpam-modules [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libpam-modules (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst logsave [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libext2fs2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
Conf libext2fs2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 ]
Inst e2fsprogs [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst mount [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libattr1 [1:2.5.1-1build1] (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libattr1 (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libblkid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libblkid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcap2 [1:2.44-1ubuntu0.22.04.1] (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libcap2 (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libcom-err2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf libcom-err2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst libk5crypto3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libk5crypto3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libkrb5support0 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Conf libkrb5support0 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
Inst libkrb5-3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Conf libkrb5-3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
Inst libgssapi-krb5-2 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Conf libgssapi-krb5-2 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libssl3 (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libmount1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libmount1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libpam-runtime [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Conf libpam-runtime (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
Inst libsmartcols1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libsmartcols1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libncurses6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libncursesw6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
Inst libtinfo6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libtinfo6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libuuid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libuuid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst passwd [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf passwd (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libprocps8 [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Inst libss2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Inst procps [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf logsave (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf e2fsprogs (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf mount (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncurses6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libncursesw6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libprocps8 (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
Conf libss2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
Conf procps (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
@@ -1,111 +0,0 @@
alternatives.aarch64 1.24-2.el9 baseos
audit-libs.aarch64 3.1.5-8.el9 baseos
basesystem.noarch 11-13.el9.0.1 baseos
bash.aarch64 5.1.8-9.el9 baseos
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
ca-certificates.noarch 2025.2.80_v9.0.305-91.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
cracklib.aarch64 2.9.6-28.el9 baseos
cracklib-dicts.aarch64 2.9.6-28.el9 baseos
crypto-policies.noarch 20260224-1.gitea0f072.el9 baseos
crypto-policies-scripts.noarch 20260224-1.gitea0f072.el9 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
cyrus-sasl-lib.aarch64 2.1.27-22.el9_7 baseos
dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
dnf-data.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
elfutils-debuginfod-client.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-default-yama-scope.noarch 0.194-1.el9.rocky.0.1 baseos
elfutils-libelf.aarch64 0.194-1.el9.rocky.0.1 baseos
elfutils-libs.aarch64 0.194-1.el9.rocky.0.1 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
filesystem.aarch64 3.16-5.el9 baseos
findutils.aarch64 1:4.8.0-7.el9 baseos
gdbm-libs.aarch64 1:1.23-1.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
ima-evm-utils.aarch64 1.6.2-2.el9.rocky.0.2 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcom_err.aarch64 1.46.5-8.el9 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libdb.aarch64 5.3.28-57.el9_6 baseos
libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
libeconf.aarch64 0.4.1-7.el9_8 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libksba.aarch64 1.5.1-7.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
librepo.aarch64 1.19.0-1.el9 baseos
libselinux.aarch64 3.6-3.el9 baseos
libsemanage.aarch64 3.6-5.el9_6 baseos
libsepol.aarch64 3.6-3.el9 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libusbx.aarch64 1.0.30-1.el9_8 baseos
libuser.aarch64 0.63-17.el9 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
libzstd.aarch64 1.5.5-1.el9 baseos
mpfr.aarch64 4.1.0-10.el9 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
nettle.aarch64 3.10.1-1.el9 baseos
openldap.aarch64 2.6.8-4.el9.0.1 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
pcre.aarch64 8.44-4.el9 baseos
pcre2.aarch64 10.40-6.el9 baseos
pcre2-syntax.noarch 10.40-6.el9 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
python3-hawkey.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-pip-wheel.noarch 21.3.1-2.el9_8.rocky.0.1 baseos
python3-rpm.aarch64 4.16.1.3-40.el9 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
rocky-gpg-keys.noarch 9.8-1.2.el9 baseos
rocky-release.noarch 9.8-1.2.el9 baseos
rocky-repos.noarch 9.8-1.2.el9 baseos
rootfiles.noarch 8.1-35.el9 baseos
rpm.aarch64 4.16.1.3-40.el9 baseos
rpm-build-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-libs.aarch64 4.16.1.3-40.el9 baseos
rpm-sign-libs.aarch64 4.16.1.3-40.el9 baseos
sed.aarch64 4.8-10.el9_8 baseos
setup.noarch 2.13.7-10.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
tpm2-tss.aarch64 3.2.3-1.el9 baseos
tzdata.noarch 2026c-1.el9_8 baseos
usermode.aarch64 1.114-7.el9 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
yum.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
@@ -1,54 +0,0 @@
binutils.aarch64 2.35.2-72.el9 baseos
binutils-gold.aarch64 2.35.2-72.el9 baseos
bzip2-libs.aarch64 1.0.8-11.el9 baseos
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
expat.aarch64 2.5.0-6.el9_8.3 baseos
file-libs.aarch64 5.39-17.el9 baseos
glib2.aarch64 2.68.4-19.el9_8.10 baseos
glibc.aarch64 2.34-275.el9_8 baseos
glibc-common.aarch64 2.34-275.el9_8 baseos
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
gnupg2.aarch64 2.3.3-5.el9_7 baseos
gnutls.aarch64 3.8.10-8.el9_8 baseos
gzip.aarch64 1.12-2.el9_8 baseos
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
less.aarch64 590-6.el9 baseos
libacl.aarch64 2.4.0-1.el9_8 baseos
libarchive.aarch64 3.5.3-11.el9_8 baseos
libatomic.aarch64 11.5.0-14.el9 baseos
libattr.aarch64 2.6.0-1.el9_8 baseos
libblkid.aarch64 2.37.4-25.el9 baseos
libcap.aarch64 2.48-10.el9_7.1 baseos
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
libevent.aarch64 2.1.13-1.el9_8 baseos
libfdisk.aarch64 2.37.4-25.el9 baseos
libgcc.aarch64 11.5.0-14.el9 baseos
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
libgomp.aarch64 11.5.0-14.el9 baseos
libmount.aarch64 2.37.4-25.el9 baseos
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
libsmartcols.aarch64 2.37.4-25.el9 baseos
libsolv.aarch64 0.7.24-6.el9_8 baseos
libstdc++.aarch64 11.5.0-14.el9 baseos
libtasn1.aarch64 4.16.0-10.el9_8 baseos
libuuid.aarch64 2.37.4-25.el9 baseos
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
ncurses-base.noarch 6.2-12.20210508.el9 baseos
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
openssl.aarch64 1:3.5.8-1.el9_8 baseos
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
p11-kit.aarch64 0.26.4-1.el9_8 baseos
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
pam.aarch64 1.5.1-28.el9_8.1 baseos
python3.aarch64 3.9.25-7.el9_8.3 baseos
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
shadow-utils.aarch64 2:4.9-16.el9 baseos
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
tar.aarch64 2:1.34-13.el9_8 baseos
util-linux.aarch64 2.37.4-25.el9 baseos
util-linux-core.aarch64 2.37.4-25.el9 baseos
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
@@ -1,111 +0,0 @@
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
@@ -1,20 +0,0 @@
dnf5.aarch64 5.2.18.0-3.fc42 updates
dnf5-plugins.aarch64 5.2.18.0-3.fc42 updates
elfutils-default-yama-scope.noarch 0.195-1.fc42 updates
elfutils-libelf.aarch64 0.195-1.fc42 updates
elfutils-libs.aarch64 0.195-1.fc42 updates
fedora-release-common.noarch 42-31 updates
fedora-release-container.noarch 42-31 updates
fedora-release-identity-container.noarch 42-31 updates
glibc.aarch64 2.41-18.fc42 updates
glibc-common.aarch64 2.41-18.fc42 updates
glibc-minimal-langpack.aarch64 2.41-18.fc42 updates
krb5-libs.aarch64 1.21.3-7.fc42 updates
libdnf5.aarch64 5.2.18.0-3.fc42 updates
libdnf5-cli.aarch64 5.2.18.0-3.fc42 updates
libsolv.aarch64 0.7.37-2.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
tzdata.noarch 2026b-1.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
@@ -1,5 +0,0 @@
krb5-libs.aarch64 1.21.3-7.fc42 updates
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
vim-data.noarch 2:9.2.390-1.fc42 updates
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
@@ -1,21 +0,0 @@
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
@@ -1,4 +0,0 @@
libpcap 1.10.7-1 -> 1.11.0-1
libsecret 0.21.7-1 -> 0.21.8.2-1
libtirpc 1.3.7-1 -> 1.3.8-1
tzdata 2026c-1 -> 2026d-1
@@ -1,15 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
Repository | Name | Category | Severity | Interactive | Status | Summary
-------------------------------------------------------------+-----------------------------+----------+-----------+-------------+--------+--------------------------------
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3765 | security | moderate | --- | needed | Security update for openssl-1_1
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3926 | security | moderate | --- | needed | Security update for curl
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4078 | security | important | --- | needed | Security update for glib2
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4359 | security | moderate | --- | needed | Security update for curl
4 patches needed (4 security patches)
@@ -1,29 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
S | Repository | Name | Current Version | Available Version | Arch
---+--------------------------------------------------------------+--------------------+------------------------------------------+------------------------------------------+--------
v | Update repository with updates from SUSE Linux Enterprise 15 | aaa_base | 84.87+git20180409.04c9dae-150300.10.20.1 | 84.87+git20180409.04c9dae-150300.10.23.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | bash-sh | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | crypto-policies | 20210917.c9d86d1-150400.3.6.1 | 20210917.c9d86d1-150400.3.8.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | curl | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | glibc | 2.31-150300.86.3 | 2.31-150300.89.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcom_err2 | 1.46.4-150400.3.6.2 | 1.46.4-150400.3.9.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libcurl4 | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libgcc_s1 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libglib-2_0-0 | 2.70.5-150400.3.14.1 | 2.70.5-150400.3.17.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1-hmac | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libreadline7 | 7.0-150400.25.22 | 7.0-150400.27.3.2 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools-base | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libstdc++6 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libudev1 | 249.17-150400.8.43.1 | 249.17-150400.8.46.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | libzypp | 17.35.8-150500.6.13.1 | 17.35.16-150500.6.31.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | login_defs | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | noarch
v | Update repository with updates from SUSE Linux Enterprise 15 | openssl-1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | shadow | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | aarch64
v | Update repository with updates from SUSE Linux Enterprise 15 | zypper | 1.14.76-150500.6.6.15 | 1.14.78-150500.6.14.1 | aarch64
@@ -1,3 +0,0 @@
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
-11
View File
@@ -1,11 +0,0 @@
# Connected Wi-Fi signal
Reports connected station interfaces only (no scans). `info.wf` holds the current
snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
`stats.wf` stores available RSSI as integer dBm. Collected on the default
interval only; real-time requests reuse the last snapshot.
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
- Windows: native WLAN API, keyed by interface GUID.
- Other platforms: unsupported.
-46
View File
@@ -1,46 +0,0 @@
// Package wifi collects only currently associated station interfaces. Collection
// failures are empty snapshots, never cached connected state.
package wifi
import (
"context"
"math"
"time"
"unicode/utf8"
"github.com/henrygd/beszel/internal/entities/system"
)
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
// text strings require UTF-8. Metadata must never invalidate the whole response.
func validSSID(ssid string) string {
if !utf8.ValidString(ssid) {
return ""
}
return ssid
}
// Collect uses a single deadline across interface queries where supported.
// Unsupported platforms and denied association access produce no readings;
// later polls retry.
func Collect() map[string]system.WiFi {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return collect(ctx)
}
// Signals reduces a snapshot to the RSSI values stored in stats history.
// Interfaces without an available reading are omitted.
func Signals(snapshot map[string]system.WiFi) map[string]int8 {
var signals map[string]int8
for id, reading := range snapshot {
if reading.Signal == nil {
continue
}
if signals == nil {
signals = make(map[string]int8, len(snapshot))
}
signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8))
}
return signals
}
-49
View File
@@ -1,49 +0,0 @@
//go:build darwin
package wifi
import (
"context"
"encoding/json"
"os"
"os/exec"
"time"
"github.com/henrygd/beszel/internal/entities/system"
)
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
const coreWLANScript = `ObjC.import('CoreWLAN');
var result = {};
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
if (interfaces) {
for (var i = 0; i < interfaces.count; i++) {
var iface = interfaces.objectAtIndex(i);
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
var name = ObjC.unwrap(iface.interfaceName);
if (!name) continue;
var reading = {};
var ssid = ObjC.unwrap(iface.ssid);
if (ssid) reading.s = ssid;
var signal = Number(iface.rssiValue);
if (signal >= -150 && signal < 0) reading.r = signal;
result[name] = reading;
}
}
JSON.stringify(result);`
func collect(ctx context.Context) map[string]system.WiFi {
cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
cmd.WaitDelay = 100 * time.Millisecond
output, err := cmd.Output()
if err != nil {
return nil
}
var result map[string]system.WiFi
if json.Unmarshal(output, &result) != nil {
return nil
}
return result
}
-134
View File
@@ -1,134 +0,0 @@
//go:build linux
package wifi
import (
"context"
"errors"
"net"
"time"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/mdlayher/genetlink"
"github.com/mdlayher/netlink"
native "github.com/mdlayher/wifi"
"golang.org/x/sys/unix"
)
type linuxClient interface {
Interfaces() ([]*native.Interface, error)
BSS(*native.Interface) (*native.BSS, error)
Station(*native.Interface, net.HardwareAddr) (*native.StationInfo, error)
SetDeadline(time.Time) error
Close() error
}
// nl80211Client adds a targeted GET_STATION request, as used by `iw link`.
// mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
// out-of-tree Realtek USB) answer with an empty list.
type nl80211Client struct {
*native.Client
conn *genetlink.Conn
family genetlink.Family
}
func newNL80211Client() (*nl80211Client, error) {
client, err := native.New()
if err != nil {
return nil, err
}
conn, err := genetlink.Dial(nil)
if err != nil {
client.Close()
return nil, err
}
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
if err != nil {
conn.Close()
client.Close()
return nil, err
}
return &nl80211Client{Client: client, conn: conn, family: family}, nil
}
func (c *nl80211Client) Station(ifi *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
ae := netlink.NewAttributeEncoder()
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifi.Index))
ae.Bytes(unix.NL80211_ATTR_MAC, mac)
data, err := ae.Encode()
if err != nil {
return nil, err
}
msgs, err := c.conn.Execute(genetlink.Message{
Header: genetlink.Header{Command: unix.NL80211_CMD_GET_STATION, Version: c.family.Version},
Data: data,
}, c.family.ID, netlink.Request)
if err != nil {
return nil, err
}
if len(msgs) == 0 {
return nil, errors.New("no station info")
}
return native.ParseStationInfo(msgs[0].Data)
}
func (c *nl80211Client) SetDeadline(t time.Time) error {
return errors.Join(c.Client.SetDeadline(t), c.conn.SetDeadline(t))
}
func (c *nl80211Client) Close() error {
return errors.Join(c.conn.Close(), c.Client.Close())
}
func collect(ctx context.Context) map[string]system.WiFi {
client, err := newNL80211Client()
if err != nil {
return nil
}
defer client.Close()
return collectLinux(ctx, client)
}
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
result := make(map[string]system.WiFi)
if ctx.Err() != nil {
return result
}
if deadline, ok := ctx.Deadline(); ok {
if client.SetDeadline(deadline) != nil {
return result
}
}
interfaces, err := client.Interfaces()
if err != nil {
return result
}
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
continue
}
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
// Only the explicit associated status proves a current connection.
bss, err := client.BSS(iface)
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
continue
}
reading := system.WiFi{SSID: validSSID(bss.SSID)}
// Station statistics may require permissions unavailable in default
// containers. Keep association even when RSSI cannot be read. Do not
// substitute cached scan signal, which may be arbitrarily old.
if len(bss.BSSID) > 0 {
if station, err := client.Station(iface, bss.BSSID); err == nil && station != nil {
signal := float64(station.Signal)
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
}
result[iface.Name] = reading
}
return result
}
-142
View File
@@ -1,142 +0,0 @@
//go:build linux
package wifi
import (
"bytes"
"context"
"errors"
"net"
"testing"
"time"
native "github.com/mdlayher/wifi"
)
type fakeLinuxClient struct {
interfaces []*native.Interface
bss map[string]*native.BSS
stations map[string][]*native.StationInfo
interfacesErr, bssErr, stationErr, deadlineErr error
deadline time.Time
stationCalls int
}
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
return f.interfaces, f.interfacesErr
}
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
return f.bss[i.Name], f.bssErr
}
func (f *fakeLinuxClient) Station(i *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
f.stationCalls++
if f.stationErr != nil {
return nil, f.stationErr
}
for _, station := range f.stations[i.Name] {
if bytes.Equal(station.HardwareAddr, mac) {
return station, nil
}
}
return nil, errors.New("no such station")
}
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
func (f *fakeLinuxClient) Close() error { return nil }
func connectedClient() *fakeLinuxClient {
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
return &fakeLinuxClient{
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
}
}
func TestLinuxSnapshots(t *testing.T) {
for _, tc := range []struct {
name string
modify func(*fakeLinuxClient)
want int
wantSignal bool
}{
{"connected", func(f *fakeLinuxClient) {}, 1, true},
{"multiple", func(f *fakeLinuxClient) {
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
f.bss["wlan1"] = f.bss["wlan0"]
}, 2, true},
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
{"station permission denied", func(f *fakeLinuxClient) {
f.stationErr = errors.New("permission denied")
f.bss["wlan0"].Signal = -4200
}, 1, false},
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
} {
t.Run(tc.name, func(t *testing.T) {
f := connectedClient()
tc.modify(f)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
got := collectLinux(ctx, f)
if len(got) != tc.want {
t.Fatalf("got %#v", got)
}
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
t.Fatalf("signal: %#v", got["wlan0"])
}
if tc.wantSignal && *got["wlan0"].Signal != -52 {
t.Fatal(got)
}
if tc.want == 0 && f.stationCalls != 0 {
t.Fatal("queried station without association")
}
deadline, _ := ctx.Deadline()
if f.deadline != deadline {
t.Fatal("deadline not shared")
}
})
}
}
func TestReconnect(t *testing.T) {
f := connectedClient()
if len(collectLinux(context.Background(), f)) != 1 {
t.Fatal("initial")
}
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
if len(collectLinux(context.Background(), f)) != 0 {
t.Fatal("stale association")
}
f.bss["wlan0"].Status = native.BSSStatusAssociated
f.bss["wlan0"].SSID = "new"
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
t.Fatal("stale SSID")
}
}
func TestLinuxInvalidSSID(t *testing.T) {
f := connectedClient()
f.bss["wlan0"].SSID = "raw\xff"
got := collectLinux(context.Background(), f)
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
t.Fatal(got)
}
}
func TestLinuxCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
f := connectedClient()
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
t.Fatal("ignored cancellation")
}
}
-57
View File
@@ -1,57 +0,0 @@
package wifi
import (
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/henrygd/beszel/internal/entities/system"
)
func TestSSIDWireSafety(t *testing.T) {
for _, tc := range []struct{ input, want string }{
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
} {
t.Run(tc.input, func(t *testing.T) {
ssid := validSSID(tc.input)
if ssid != tc.want {
t.Fatalf("got %q, want %q", ssid, tc.want)
}
signal := -50.0
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
wire, err := cbor.Marshal(payload)
if err != nil {
t.Fatal(err)
}
var decoded map[string]system.WiFi
if err := cbor.Unmarshal(wire, &decoded); err != nil {
t.Fatal(err)
}
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
t.Fatal(decoded)
}
})
}
}
func TestSignals(t *testing.T) {
strong, weak, rounded := -40.0, -200.0, -52.6
got := Signals(map[string]system.WiFi{
"wlan0": {SSID: "home", Signal: &strong},
"wlan1": {Signal: &weak},
"wlan2": {Signal: &rounded},
"wlan3": {SSID: "no rssi"},
})
want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for id, signal := range want {
if got[id] != signal {
t.Fatalf("got %v, want %v", got, want)
}
}
if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil {
t.Fatal("expected nil without available readings")
}
}
-11
View File
@@ -1,11 +0,0 @@
//go:build !linux && !windows && !darwin
package wifi
import (
"context"
"github.com/henrygd/beszel/internal/entities/system"
)
func collect(context.Context) map[string]system.WiFi { return nil }
-99
View File
@@ -1,99 +0,0 @@
//go:build windows
package wifi
import (
"context"
"unsafe"
"github.com/henrygd/beszel/internal/entities/system"
"golang.org/x/sys/windows"
)
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
var wlanOpen = wlan.NewProc("WlanOpenHandle")
var wlanClose = wlan.NewProc("WlanCloseHandle")
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
var wlanQuery = wlan.NewProc("WlanQueryInterface")
var wlanFree = wlan.NewProc("WlanFreeMemory")
type wlanInterface struct {
GUID windows.GUID
Description [256]uint16
State uint32
}
type wlanConnection struct {
State uint32
Mode uint32
Profile [256]uint16
SSIDLength uint32
SSID [32]byte
// Only the prefix through DOT11_SSID is read.
}
func collect(ctx context.Context) map[string]system.WiFi {
result := make(map[string]system.WiFi)
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
if proc.Find() != nil {
return result
}
}
var handle windows.Handle
var version uint32
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
return result
}
defer wlanClose.Call(uintptr(handle), 0)
var list unsafe.Pointer
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
return result
}
defer wlanFree.Call(uintptr(list))
count := *(*uint32)(list)
if count > 1024 {
return result
}
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
for _, iface := range interfaces {
if ctx.Err() != nil {
break
}
if iface.State != 1 {
continue
} // wlan_interface_state_connected
reading := system.WiFi{}
// SSID access may be denied by location privacy policy. Association comes
// from the interface state, so missing SSID does not suppress valid RSSI.
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
connection := (*wlanConnection)(data)
if connection.State == 1 && connection.SSIDLength <= 32 {
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
}
}
wlanFree.Call(uintptr(data))
}
// Native RSSI LONG, not the quality percentage in association attributes.
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
if size >= 4 {
signal := float64(*(*int32)(data))
if signal >= -150 && signal < 0 {
reading.Signal = &signal
}
}
wlanFree.Call(uintptr(data))
}
result[iface.GUID.String()] = reading
}
return result
}
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
var data unsafe.Pointer
var size uint32
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
return nil, 0
}
return data, size
}
+4 -9
View File
@@ -6,14 +6,11 @@ require (
github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0
github.com/distribution/reference v0.6.0
github.com/ebitengine/purego v0.11.1
github.com/ebitengine/purego v0.11.0
github.com/fxamacker/cbor/v2 v2.9.4
github.com/gliderlabs/ssh v0.3.8
github.com/lxzan/gws v1.10.2
github.com/mdlayher/genetlink v1.4.0
github.com/mdlayher/netlink v1.11.2
github.com/mdlayher/wifi v0.9.0
github.com/nicholas-fedor/shoutrrr v0.21.1
github.com/nicholas-fedor/shoutrrr v0.21.0
github.com/opencontainers/go-digest v1.0.0
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.40.4
@@ -46,16 +43,14 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.20.1 // indirect
github.com/klauspost/compress v1.20.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mdlayher/socket v0.7.0 // indirect
github.com/ncruces/go-strftime v1.1.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+12 -20
View File
@@ -21,8 +21,8 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
@@ -69,8 +69,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jarcoal/httpmock v1.4.2 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -83,22 +83,14 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy
github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ=
github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4=
github.com/mdlayher/wifi v0.9.0 h1:d5mmqw9S2U4f95dcW5wnnUagpI6GJh328/AchVSP4ko=
github.com/mdlayher/wifi v0.9.0/go.mod h1:Bfkrz+VncrVPaOLcFG/bR9tSY2PbmYNQicWvAZlUZlI=
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
github.com/nicholas-fedor/shoutrrr v0.21.1 h1:6xp6FCVatK6EJKC9OcQPkFF3wmOpzsXXdn24lSEnn6M=
github.com/nicholas-fedor/shoutrrr v0.21.1/go.mod h1:q97ykXzJCVEre7+AVW50onUVi+NmoqSw86V6NJ+Ep3c=
github.com/onsi/ginkgo/v2 v2.33.0 h1:C8gBA6Uc2ZEubiV+SXiu5tZnMTwEmXHgkJwGozKtZf8=
github.com/onsi/ginkgo/v2 v2.33.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.1 h1:vGIPFuYrIO6/0Z09s0I0QQQgFchiX4+tb1re3MScJYo=
github.com/onsi/gomega v1.43.1/go.mod h1:e/C2HwaZ1DhvjzXXuFhcR7hY7Sh9pl7MmoWKEjzwcdA=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
github.com/nicholas-fedor/shoutrrr v0.21.0/go.mod h1:dgg4kJv9K0tLXBH/1TXiSibNbM2hcd4SK6xb0sglyU4=
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-4
View File
@@ -212,10 +212,6 @@ func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
// SendAlert sends an alert to the user
func (am *AlertManager) SendAlert(data AlertMessageData) error {
// Stored subscriptions and queued notifications may outlive system access.
if data.SystemID != "" && !userHasSystem(am.hub, data.UserID, data.SystemID) {
return nil
}
// Check if alert is silenced
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
-361
View File
@@ -1,361 +0,0 @@
//go:build testing
package alerts_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/entities/system"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func alertAPIRouter(t *testing.T, hub *beszelTests.TestHub) http.Handler {
t.Helper()
router, err := apis.NewRouter(hub)
require.NoError(t, err)
require.NoError(t, hub.OnServe().Trigger(&core.ServeEvent{App: hub, Router: router}))
mux, err := router.BuildMux()
require.NoError(t, err)
return mux
}
func alertAPIRequest(t *testing.T, handler http.Handler, token, method, path string, body any, status int) map[string]any {
t.Helper()
req := httptest.NewRequest(method, path, jsonReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", token)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
assert.Equal(t, status, res.Code, "%s %s: %s", method, path, res.Body.String())
var result map[string]any
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &result))
return result
}
func TestAlertRecordSystemAccess(t *testing.T) {
for _, shareAll := range []string{"false", "true"} {
t.Run("share_all="+shareAll, func(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", shareAll)
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
hub.StartHub()
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
require.NoError(t, err)
other, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
require.NoError(t, err)
token, err := user.NewAuthToken()
require.NoError(t, err)
own, err := beszelTests.CreateSystems(hub, 2, user.Id, "paused")
require.NoError(t, err)
foreign, err := beszelTests.CreateSystems(hub, 1, other.Id, "paused")
require.NoError(t, err)
handler := alertAPIRouter(t, hub)
const records = "/api/collections/alerts/records"
// Collection rules reject inaccessible creates as 400 and hide inaccessible updates as 404.
readStatus, createStatus, updateStatus := 404, 400, 404
if shareAll == "true" {
readStatus, createStatus, updateStatus = 200, 200, 200
}
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+own[0].Id, nil, 200)
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+foreign[0].Id, nil, readStatus)
collection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
for _, name := range collection.Fields.GetByName("name").(*core.SelectField).Values {
t.Run(name, func(t *testing.T) {
// Scalar and array representations both pass through PocketBase's relation binding.
for _, relation := range []any{foreign[0].Id, []string{foreign[0].Id}} {
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
"name": name, "user": user.Id, "system": relation, "value": 50,
}, createStatus)
if id, ok := result["id"].(string); ok {
record, err := hub.FindRecordById("alerts", id)
require.NoError(t, err)
require.NoError(t, hub.Delete(record))
}
}
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
"name": name, "user": user.Id, "system": own[0].Id, "value": 50,
}, 200)
id := result["id"].(string)
defer func() {
record, err := hub.FindRecordById("alerts", id)
require.NoError(t, err)
require.NoError(t, hub.Delete(record))
}()
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, map[string]any{"value": 51}, 200)
// The system of an existing alert is immutable through the API, even between accessible systems.
for _, body := range []map[string]any{
{"system": foreign[0].Id},
{"system+": foreign[0].Id},
{"system": []string{own[1].Id}},
} {
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, body, 404)
}
saved, err := hub.FindRecordById("alerts", id)
require.NoError(t, err)
assert.Equal(t, own[0].Id, saved.GetString("system"))
// Internal saves can still move an alert, which must remove its previous cache binding.
saved.Set("system", own[1].Id)
require.NoError(t, hub.Save(saved))
cache := hub.GetAlertManager().GetSystemAlertsCache()
assert.Empty(t, cache.GetSystemAlerts(own[0].Id), "moving an alert must remove its previous cache binding")
assert.Len(t, cache.GetSystemAlerts(own[1].Id), 1)
})
}
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": own[0].Id}, 400)
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": "missing00000000"}, 400)
alertAPIRequest(t, handler, "", "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 400)
// An old subscription must still be checked when PATCH omits the relation.
oldAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU", "user": user.Id, "system": foreign[0].Id, "value": 50,
})
require.NoError(t, err)
alertAPIRequest(t, handler, token, "PATCH", records+"/"+oldAlert.Id, map[string]any{"value": 51}, updateStatus)
require.NoError(t, hub.Delete(oldAlert))
otherAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU", "user": other.Id, "system": foreign[0].Id,
})
require.NoError(t, err)
alertAPIRequest(t, handler, token, "PATCH", records+"/"+otherAlert.Id, map[string]any{"system": own[0].Id}, 404)
require.NoError(t, hub.Delete(otherAlert))
// Alerts cannot be handed to another user.
ownAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU", "user": user.Id, "system": own[0].Id,
})
require.NoError(t, err)
alertAPIRequest(t, handler, token, "PATCH", records+"/"+ownAlert.Id, map[string]any{"user": other.Id}, 404)
ownAlert, err = hub.FindRecordById("alerts", ownAlert.Id)
require.NoError(t, err)
assert.Equal(t, user.Id, ownAlert.GetString("user"))
require.NoError(t, hub.Delete(ownAlert))
// Readonly users retain their own alert preferences; superusers retain their bypass.
user.Set("role", "readonly")
require.NoError(t, hub.Save(user))
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 200)
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "Memory"}, 200)
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "NetworkMonitorLoss"}, 400)
record, err := hub.FindRecordById("alerts", result["id"].(string))
require.NoError(t, err)
require.NoError(t, hub.Delete(record))
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
require.NoError(t, err)
superToken, err := superuser.NewAuthToken()
require.NoError(t, err)
result = alertAPIRequest(t, handler, superToken, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": foreign[0].Id}, 200)
record, err = hub.FindRecordById("alerts", result["id"].(string))
require.NoError(t, err)
require.NoError(t, hub.Delete(record))
// Bulk requests continue to skip inaccessible systems and accept accessible ones.
alertAPIRequest(t, handler, token, "POST", "/api/beszel/user-alerts", map[string]any{
"name": "CPU", "systems": []string{own[0].Id, foreign[0].Id}, "value": 50,
}, 200)
count, err := hub.CountRecords("alerts")
require.NoError(t, err)
expectedCount := 1
if shareAll == "true" {
expectedCount = 2
}
assert.EqualValues(t, expectedCount, count)
})
}
}
func TestAlertRecordForeignSystemDelivery(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
hub.StartHub()
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
require.NoError(t, err)
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
require.NoError(t, err)
for _, recipient := range []*core.Record{user, owner} {
_, err := beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": recipient.Id, "settings": map[string]any{"emails": []string{recipient.GetString("email")}, "webhooks": []string{}},
})
require.NoError(t, err)
}
foreign, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
require.NoError(t, err)
foreign[0].Set("name", "private-system")
require.NoError(t, hub.Save(foreign[0]))
handler := alertAPIRouter(t, hub)
for _, recipient := range []*core.Record{user, owner} {
token, err := recipient.NewAuthToken()
require.NoError(t, err)
status := 200
if recipient.Id == user.Id {
status = 400
}
alertAPIRequest(t, handler, token, "POST", "/api/collections/alerts/records", map[string]any{
"name": "CPU", "user": recipient.Id, "system": foreign[0].Id, "min": 1, "value": 50,
}, status)
}
synctest.Test(t, func(t *testing.T) {
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(foreign[0], &system.CombinedData{Info: system.Info{Cpu: 91}}))
synctest.Wait()
messages := hub.TestMailer.Messages()
assert.Len(t, messages, 1, "only the authorised subscriber should receive telemetry")
for _, message := range messages {
assert.Equal(t, "owner@example.com", message.To[0].Address)
assert.Contains(t, message.Subject, "private-system CPU above threshold")
assert.Contains(t, message.Text, "91.00%")
t.Logf("captured synthetic email: recipient=%s subject=%q body=%q", message.To[0].Address, message.Subject, message.Text)
}
history, err := hub.FindAllRecords("alerts_history", dbx.HashExp{"system": foreign[0].Id})
require.NoError(t, err)
assert.Len(t, history, 1)
for _, record := range history {
assert.Equal(t, owner.Id, record.GetString("user"))
}
t.Logf("captured synthetic history entries=%d", len(history))
})
}
func TestAlertStoredSubscriptionAccess(t *testing.T) {
for _, mode := range []string{"foreign", "revoked", "shared", "share_all", "revoked_active"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
if mode == "share_all" {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
}
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
hub.StartHub()
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
require.NoError(t, err)
subscriber, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
require.NoError(t, err)
systems, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
require.NoError(t, err)
systemRecord := systems[0]
if mode == "revoked" || mode == "shared" || mode == "revoked_active" {
systemRecord.Set("users", []string{owner.Id, subscriber.Id})
require.NoError(t, hub.Save(systemRecord))
}
for _, user := range []*core.Record{owner, subscriber} {
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user.Id, "settings": map[string]any{"emails": []string{user.GetString("email")}, "webhooks": []string{}},
})
require.NoError(t, err)
// Direct saves model records already stored before the request-hook fix.
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "CPU", "user": user.Id, "system": systemRecord.Id, "value": 50, "min": 1,
})
require.NoError(t, err)
}
require.NoError(t, hub.GetAlertManager().GetSystemAlertsCache().PopulateFromDB(true))
if mode == "revoked" {
systemRecord.Set("users", []string{owner.Id})
require.NoError(t, hub.Save(systemRecord))
}
synctest.Test(t, func(t *testing.T) {
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 91}}))
synctest.Wait()
})
allowed := mode == "shared" || mode == "share_all" || mode == "revoked_active"
want := 1
if allowed {
want = 2
}
assert.Equal(t, want, hub.TestMailer.TotalSend())
for _, message := range hub.TestMailer.Messages() {
if !allowed {
assert.Equal(t, "owner@example.com", message.To[0].Address)
}
t.Logf("%s captured recipient=%s body=%q", mode, message.To[0].Address, message.Text)
}
history, err := hub.FindAllRecords("alerts_history")
require.NoError(t, err)
assert.Len(t, history, want)
for _, record := range history {
if !allowed {
assert.Equal(t, owner.Id, record.GetString("user"))
}
}
count, err := hub.CountRecords("alerts")
require.NoError(t, err)
assert.EqualValues(t, 2, count, "stored subscriptions are preserved")
if mode == "revoked_active" {
systemRecord.Set("users", []string{owner.Id})
require.NoError(t, hub.Save(systemRecord))
synctest.Test(t, func(t *testing.T) {
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 40}}))
synctest.Wait()
})
assert.Equal(t, 3, hub.TestMailer.TotalSend(), "only the owner should receive recovery after revocation")
previous, err := hub.FindFirstRecordByFilter("alerts_history", "user={:user}", dbx.Params{"user": subscriber.Id})
require.NoError(t, err)
assert.True(t, previous.GetDateTime("resolved").IsZero(), "revoked subscribers must not learn recovery timing through history")
}
})
}
}
func TestAlertPendingStatusAccessRevoked(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
hub, err := beszelTests.NewTestHub(t.TempDir())
require.NoError(t, err)
defer hub.Cleanup()
hub.StartHub()
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
require.NoError(t, err)
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
"user": user.Id, "settings": map[string]any{"emails": []string{"subscriber@example.com"}, "webhooks": []string{}},
})
require.NoError(t, err)
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status", "user": user.Id, "system": systems[0].Id, "min": 1,
})
require.NoError(t, err)
synctest.Test(t, func(t *testing.T) {
defer hub.GetAlertManager().Stop()
require.NoError(t, hub.GetAlertManager().HandleStatusAlerts("down", systems[0]))
require.Equal(t, 1, hub.GetAlertManager().GetPendingAlertsCount())
systems[0].Set("users", []string{owner.Id})
require.NoError(t, hub.Save(systems[0]))
time.Sleep(61 * time.Second)
synctest.Wait()
assert.Zero(t, hub.TestMailer.TotalSend())
count, err := hub.CountRecords("alerts_history")
require.NoError(t, err)
assert.Zero(t, count)
})
}
func TestAlertStoredNetworkMonitorAccess(t *testing.T) {
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
hub, systemRecord, _, monitors := networkAlertSetup(t)
other, err := beszelTests.CreateUser(hub, "foreign@example.com", "password")
require.NoError(t, err)
foreign, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "NetworkMonitorLoss", "user": other.Id, "system": systemRecord.Id, "value": 5,
})
require.NoError(t, err)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
require.NoError(t, am.HandleNetworkMonitorAlerts(systemRecord, map[string]monitor.Result{monitors[0].Id: monitorResult(10)}))
history, err := hub.FindAllRecords("alerts_history")
require.NoError(t, err)
require.Len(t, history, 1, "foreign subscriptions must not block the authorised incident transaction")
assert.NotEqual(t, foreign.Id, history[0].GetString("alert_id"))
assert.Equal(t, 1, hub.TestMailer.TotalSend())
}
+13 -16
View File
@@ -20,7 +20,6 @@ import (
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
@@ -185,7 +184,7 @@ func TestUserAlertsApi(t *testing.T) {
"min": 10,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
alerts, _ := app.CountRecords("alerts")
@@ -210,7 +209,7 @@ func TestUserAlertsApi(t *testing.T) {
"overwrite": false,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
@@ -244,7 +243,7 @@ func TestUserAlertsApi(t *testing.T) {
"overwrite": true,
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system2.Id,
@@ -272,7 +271,7 @@ func TestUserAlertsApi(t *testing.T) {
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
@@ -301,7 +300,7 @@ func TestUserAlertsApi(t *testing.T) {
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system1.Id,
@@ -330,7 +329,7 @@ func TestUserAlertsApi(t *testing.T) {
"systems": []string{system1.Id, system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
for _, systemId := range []string{system1.Id, system2.Id} {
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "Memory",
@@ -364,7 +363,7 @@ func TestUserAlertsApi(t *testing.T) {
"systems": []string{system1.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU", "system": system1.Id, "user": user2.Id, "value": 80,
})
@@ -389,7 +388,7 @@ func TestUserAlertsApi(t *testing.T) {
"systems": []string{system2.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
beszelTests.ClearCollection(t, app, "alerts")
for _, user := range []string{user1.Id, user2.Id} {
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
@@ -431,21 +430,19 @@ func TestSendTestNotification(t *testing.T) {
localURL := "generic+" + server.URL
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
require.NoError(t, err)
assert.NoError(t, err)
readonlyToken, err := readonlyUser.NewAuthToken()
require.NoError(t, err)
assert.NoError(t, err)
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create user auth token")
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
require.NoError(t, err, "Failed to create admin user")
assert.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
require.NoError(t, err, "Failed to create admin auth token")
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
require.NoError(t, err, "Failed to create superuser")
assert.NoError(t, err, "Failed to create superuser")
superuserToken, err := superuser.NewAuthToken()
require.NoError(t, err, "Failed to create superuser auth token")
assert.NoError(t, err, "Failed to create superuser auth token")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
+42 -22
View File
@@ -5,7 +5,6 @@ package alerts_test
import (
"encoding/json"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/system"
@@ -24,7 +23,7 @@ func TestBatteryAlertLogic(t *testing.T) {
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
@@ -69,12 +68,13 @@ func TestBatteryAlertLogic(t *testing.T) {
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with high battery
am := hub.GetAlertManager()
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
@@ -108,11 +108,15 @@ func TestBatteryAlertLogic(t *testing.T) {
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with low battery
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (battery 15% is below threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
@@ -146,11 +150,15 @@ func TestBatteryAlertLogic(t *testing.T) {
// Update system timestamp
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts with recovered battery
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataRecovered))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
require.NoError(t, err)
// Wait for the alert to be processed
time.Sleep(20 * time.Millisecond)
// Verify alert is now resolved (battery 25% is above threshold 20%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
@@ -164,7 +172,7 @@ func TestBatteryAlertNoBattery(t *testing.T) {
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
@@ -198,12 +206,16 @@ func TestBatteryAlertNoBattery(t *testing.T) {
// Simulate system update time
systemRecord.Set("updated", time.Now().UTC())
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts
am := hub.GetAlertManager()
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedData))
})
err = am.HandleSystemAlerts(systemRecord, combinedData)
require.NoError(t, err)
// Wait a moment for processing
time.Sleep(20 * time.Millisecond)
// Verify alert is NOT triggered (no battery data should skip the alert)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
@@ -218,7 +230,7 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
@@ -290,11 +302,15 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
// Update system timestamp
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should trigger because average battery is below threshold
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert IS triggered (average battery 15% is below threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
@@ -352,11 +368,15 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
// Update system timestamp to the new time window
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
// Handle system alerts - should resolve because average battery is now above threshold
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
// Wait for alert processing
time.Sleep(20 * time.Millisecond)
// Verify alert is resolved (average battery 50% is above threshold 25%)
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
+1 -4
View File
@@ -63,10 +63,7 @@ func NewAlertsCache(app core.App) *AlertsCache {
// bindEvents sets up event listeners to keep the cache in sync with database changes.
func (c *AlertsCache) bindEvents() *AlertsCache {
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
// Remove the previous binding if the system changed (only possible through superuser or internal saves).
if original := e.Record.Original(); original.GetString("system") != e.Record.GetString("system") {
c.Delete(original)
}
// c.Delete(e.Record.Original()) // this would be needed if the system field on an existing alert was changed, however we don't currently allow that in the UI so we'll leave it commented out
c.Update(e.Record)
return e.Next()
})
+13 -8
View File
@@ -5,7 +5,6 @@ package alerts_test
import (
"encoding/json"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/system"
@@ -23,7 +22,7 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
@@ -84,10 +83,13 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
}
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
@@ -138,10 +140,13 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
}
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
-5
View File
@@ -39,11 +39,6 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
return e.Next()
}
// History is visible to the subscriber, including after system access is removed.
if !userHasSystem(e.App, new.GetString("user"), new.GetString("system")) {
return e.Next()
}
// if new state is triggered, create new alert history record
if newTriggered {
_, _ = createAlertHistoryRecord(e.App, new)
+3 -3
View File
@@ -32,6 +32,9 @@ func (am *AlertManager) bindNetworkMonitorAlertEvents() {
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
}
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
if !e.HasSuperuserAuth() && (e.Auth == nil || !userHasSystem(e.App, e.Auth.Id, e.Record.GetString("system"))) {
return e.ForbiddenError("You do not have access to this system", nil)
}
e.Record.Set("triggered", e.Record.Original().GetBool("triggered"))
value := e.Record.GetFloat("value")
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
@@ -133,9 +136,6 @@ func (am *AlertManager) evaluateNetworkMonitorAlerts(app core.App, systemID stri
}
now := time.Now()
for _, alert := range alerts {
if !userHasSystem(tx, alert.GetString("user"), systemID) {
continue
}
var state networkMonitorAlertState
if err := alert.UnmarshalJSONField("state", &state); err != nil {
return err
@@ -248,7 +248,7 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
{name: "negative threshold", value: -1, status: 400},
{name: "unreachable threshold", value: 100, status: 400},
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 400},
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 403},
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
{name: "direct private state", value: 5, direct: true, status: 200},
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
@@ -287,6 +287,9 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
if tc.status == 400 {
content = `"status":400`
}
if tc.status == 403 {
content = `"status":403`
}
scenario := beszelTests.ApiScenario{
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
+1 -6
View File
@@ -323,14 +323,9 @@ func TestAlertSilencedMultiUser(t *testing.T) {
func TestAlertSilencedWithActualAlert(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
-48
View File
@@ -1,48 +0,0 @@
//go:build testing
package alerts_test
import (
"sync"
"testing"
"github.com/henrygd/beszel/internal/alerts"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/require"
)
func TestStandaloneAlertManagerStopsBeforeDatabaseCleanup(t *testing.T) {
for _, state := range []string{"pending", "already stopped", "delivered"} {
t.Run(state, func(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
cleanup := sync.OnceFunc(hub.Cleanup)
defer cleanup()
setStatusAlertEmail(t, hub, user.Id, "shutdown@example.com")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
require.NoError(t, err)
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
"name": "Status", "system": systems[0].Id, "user": user.Id, "min": 1,
})
require.NoError(t, err)
am := alerts.NewTestAlertManagerWithoutWorker(hub)
defer am.Stop()
require.NoError(t, am.HandleStatusAlerts("down", systems[0]))
require.Equal(t, 1, am.GetPendingAlertsCount())
switch state {
case "already stopped":
am.Stop()
case "delivered":
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
require.Len(t, processed, 1)
require.Equal(t, 1, hub.TestMailer.TotalSend())
}
cleanup()
// No timer may retain the disposed app until its eventual deadline.
require.Zero(t, am.GetPendingAlertsCount())
})
}
}
+6 -36
View File
@@ -30,14 +30,9 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
func TestStatusAlerts(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
assert.NoError(t, err)
@@ -241,14 +236,9 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
@@ -349,14 +339,9 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -442,14 +427,9 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
hub, user1 := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
@@ -841,14 +821,9 @@ func TestResolveStatusAlerts(t *testing.T) {
func TestAlertsHistoryStatus(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
@@ -913,14 +888,9 @@ func TestAlertsHistoryStatus(t *testing.T) {
func TestStatusAlertClearedBeforeSend(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
// Create a system
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
+1 -6
View File
@@ -16,14 +16,9 @@ import (
func TestAlertsHistory(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
synctest.Test(t, func(t *testing.T) {
t.Cleanup(func() {
synctest.Wait()
hub.GetAlertManager().Stop()
})
hub.GetSystemManager().ResetContextForTesting(t)
defer hub.Cleanup()
// Create systems and alerts
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
+1 -7
View File
@@ -10,17 +10,11 @@ import (
)
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
am := &AlertManager{
return &AlertManager{
hub: app,
alertsCache: NewAlertsCache(app),
networkMonitors: newNetworkMonitorCache(app),
}
// Standalone managers can own status timers even without the serve hooks.
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error {
am.Stop()
return e.Next()
})
return am
}
// GetSystemAlertsCache returns the internal system alerts cache.
+18 -15
View File
@@ -5,7 +5,6 @@ package alerts_test
import (
"encoding/json"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/system"
@@ -24,7 +23,7 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
systemRecord := systems[0]
@@ -81,10 +80,13 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
}
systemRecord.Set("updated", now)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
@@ -128,10 +130,13 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
}
systemRecord.Set("updated", newNow)
err = hub.SaveNoValidate(systemRecord)
require.NoError(t, err)
synctest.Test(t, func(t *testing.T) {
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
})
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
require.NoError(t, err)
time.Sleep(20 * time.Millisecond)
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
require.NoError(t, err)
@@ -142,7 +147,7 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
func TestDiskAlertIgnoresRawPool(t *testing.T) {
for _, minutes := range []int{0, 2} {
hub, user := beszelTests.GetHubWithUser(t)
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
require.NoError(t, err)
alert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{"name": "Disk", "system": systems[0].Id, "user": user.Id, "value": 80, "min": minutes})
require.NoError(t, err)
@@ -155,18 +160,16 @@ func TestDiskAlertIgnoresRawPool(t *testing.T) {
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
require.NoError(t, hub.SaveNoValidate(record))
}
synctest.Test(t, func(t *testing.T) {
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
})
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
time.Sleep(20 * time.Millisecond)
record, err := hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("triggered"))
if minutes > 0 {
// A current usable sample must not make raw historical values eligible.
pools["btrfs"].Raw = false
synctest.Test(t, func(t *testing.T) {
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
})
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
time.Sleep(20 * time.Millisecond)
record, err = hub.FindRecordById("alerts", alert.Id)
require.NoError(t, err)
assert.False(t, record.GetBool("triggered"))
+1 -23
View File
@@ -1,11 +1,9 @@
package main
import (
"errors"
"fmt"
"log"
"os"
"runtime"
"strings"
"github.com/henrygd/beszel"
@@ -16,12 +14,6 @@ import (
"golang.org/x/crypto/ssh"
)
type noKeyProvidedError struct{}
func (noKeyProvidedError) Error() string {
return "no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage"
}
// cli options
type cmdOptions struct {
key string // key is the public key(s) for SSH authentication.
@@ -132,7 +124,7 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
// Try key file
keyFile, ok := utils.GetEnv("KEY_FILE")
if !ok {
return nil, noKeyProvidedError{}
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
}
pubKey, err := os.ReadFile(keyFile)
@@ -146,14 +138,6 @@ func (opts *cmdOptions) getAddress() string {
return agent.GetAddress(opts.listen)
}
func isBenignStartupError(err error, goos string) bool {
if goos != "windows" {
return false
}
var noKeyErr noKeyProvidedError
return errors.As(err, &noKeyErr)
}
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
func handleFingerprint() {
subCmd := ""
@@ -198,12 +182,6 @@ func main() {
var err error
serverConfig.Keys, err = opts.loadPublicKeys()
if err != nil {
if isBenignStartupError(err, runtime.GOOS) {
// WinGet launches the executable without configuration during validation.
// Exit successfully in that case while retaining the error on other platforms.
log.Print("Failed to load public keys:", err)
return
}
log.Fatal("Failed to load public keys:", err)
}
-21
View File
@@ -2,7 +2,6 @@ package main
import (
"crypto/ed25519"
"errors"
"os"
"path/filepath"
"testing"
@@ -188,26 +187,6 @@ func TestLoadPublicKeys(t *testing.T) {
}
}
func TestIsBenignStartupError(t *testing.T) {
tests := []struct {
name string
err error
goos string
want bool
}{
{name: "missing key on windows", err: noKeyProvidedError{}, goos: "windows", want: true},
{name: "wrapped missing key on windows", err: errors.Join(errors.New("startup failed"), noKeyProvidedError{}), goos: "windows", want: true},
{name: "missing key on linux", err: noKeyProvidedError{}, goos: "linux", want: false},
{name: "different error on windows", err: errors.New("invalid key"), goos: "windows", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, isBenignStartupError(tt.err, tt.goos))
})
}
}
func TestGetNetwork(t *testing.T) {
tests := []struct {
name string
-8
View File
@@ -26,10 +26,6 @@ const (
GetZfsData
// Sync network monitor configuration to agent
SyncNetworkMonitors
// Request the list of pending package updates from agent
GetPackageUpdates
// Request recent logs for a systemd service from the agent.
GetSystemdLogs
// Add new actions here...
)
@@ -87,7 +83,3 @@ type ContainerInfoRequest struct {
type SystemdInfoRequest struct {
ServiceName string `cbor:"0,keyasint"`
}
type SystemdLogsRequest struct {
ServiceName string `cbor:"0,keyasint"`
}
+1 -3
View File
@@ -70,9 +70,7 @@ RUN set -eux; \
# --------------------------
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
# zfsutils-linux is distributed in Debian's contrib component.
RUN sed -i 's/Components: main/Components: main contrib/' /etc/apt/sources.list.d/debian.sources \
&& apt-get update && apt-get install -y --no-install-recommends \
RUN apt-get update && apt-get install -y --no-install-recommends \
zfsutils-linux \
&& rm -rf /var/lib/apt/lists/*
-12
View File
@@ -25,16 +25,6 @@ type Config struct {
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
Port uint16 `cbor:"3,keyasint,omitempty"`
Interval uint16 `cbor:"4,keyasint"` // seconds
// Server is the DNS server to query (host or host:port, default port 53).
// Only used when Protocol is "dns"; empty means use the system resolver.
Server string `cbor:"5,keyasint,omitempty"`
}
// CertInfo holds details of the leaf TLS certificate presented by a target.
type CertInfo struct {
// Expires is the certificate's NotAfter Unix timestamp in milliseconds.
Expires int64 `cbor:"0,keyasint" json:"expires"`
Issuer string `cbor:"1,keyasint,omitempty" json:"issuer,omitempty"`
}
// SyncRequest defines an incremental or full monitor sync request sent to the agent.
@@ -86,8 +76,6 @@ type Result struct {
TotalCount int64 `cbor:"10,keyasint"`
SuccessCount int64 `cbor:"11,keyasint"`
ResponseSum int64 `cbor:"12,keyasint"`
// Cert is set for HTTPS targets when a certificate check has new info the hub has not stored yet.
Cert *CertInfo `cbor:"13,keyasint,omitempty"`
}
// Stats holds response times in microseconds and packet loss percentage (0-100).
+5 -5
View File
@@ -503,11 +503,11 @@ type SmartInfoForNvme struct {
UserCapacity UserCapacity `json:"user_capacity"`
// LogicalBlockSize int `json:"logical_block_size"`
// LocalTime LocalTime `json:"local_time"`
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint16 `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
Temperature TemperatureInfoNvme `json:"temperature"`
PowerCycleCount uint16 `json:"power_cycle_count"`
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
}
type TemperatureInfoNvme struct {
@@ -1,20 +0,0 @@
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"`
}
+12 -23
View File
@@ -11,14 +11,6 @@ import (
"github.com/henrygd/beszel/internal/entities/systemd"
)
// WiFi describes a currently connected station interface. Keys in WiFi maps are
// OS interface identities, not SSIDs. Signal is native dBm only; nil means the
// OS confirmed association but could not supply RSSI (never convert quality %).
type WiFi struct {
SSID string `json:"s,omitempty" cbor:"0,keyasint,omitempty"`
Signal *float64 `json:"r,omitempty" cbor:"1,keyasint,omitempty"`
}
type Stats struct {
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
@@ -63,7 +55,6 @@ type Stats struct {
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
ZfsPools map[string]*ZfsPool `json:"z,omitempty" cbor:"39,keyasint,omitempty"` // ZFS pool metrics, keyed by pool name
DiskIOTotal [2]uint64 `json:"diot,omitzero" cbor:"38,keyasint,omitzero"` // [total read bytes, total write bytes] cumulative device counters
WiFi map[string]int8 `json:"wf,omitempty" cbor:"40,keyasint,omitempty"` // RSSI dBm keyed by interface; unavailable readings omitted
}
@@ -119,17 +110,18 @@ type GPUData struct {
}
type FsStats struct {
Root bool `json:"-"`
Mountpoint string `json:"-"`
Name string `json:"-"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
Time time.Time `json:"-"`
Root bool `json:"-"`
Mountpoint string `json:"-"`
Name string `json:"-"`
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
@@ -192,9 +184,6 @@ type Info struct {
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
RootDiskName string `json:"rdn,omitempty" cbor:"24,keyasint,omitempty"` // custom name for root disk (set via FILESYSTEM=device__name)
PackageUpdates []uint16 `json:"pu,omitempty" cbor:"25,keyasint,omitempty"` // [totalUpdates, securityUpdates] (security omitted if unknown)
WiFi map[string]WiFi `json:"wf,omitempty" cbor:"26,keyasint,omitempty"` // connected Wi-Fi interfaces
SystemdLogs bool `json:"jl,omitempty" cbor:"27,keyasint,omitempty"` // agent can read the system journal
}
// Data that does not change during process lifetime and is not needed in All Systems table
-40
View File
@@ -1,40 +0,0 @@
package system
import (
"encoding/json"
"testing"
"github.com/fxamacker/cbor/v2"
)
func TestWiFiWireSnapshot(t *testing.T) {
signal := -55.0
for _, wifi := range []map[string]WiFi{nil, {}, {"wlan0": {SSID: "home", Signal: &signal}, "wlan1": {}}} {
original := CombinedData{Info: Info{WiFi: wifi}, Stats: Stats{WiFi: make(map[string]int8, len(wifi))}}
for id := range wifi {
original.Stats.WiFi[id] = -55
}
encoded, err := cbor.Marshal(original)
if err != nil {
t.Fatal(err)
}
var decoded CombinedData
if err = cbor.Unmarshal(encoded, &decoded); err != nil {
t.Fatal(err)
}
if len(decoded.Info.WiFi) != len(wifi) || len(decoded.Stats.WiFi) != len(wifi) {
t.Fatal(decoded)
}
encoded, err = json.Marshal(decoded.Info)
if err != nil {
t.Fatal(err)
}
var info map[string]any
if err = json.Unmarshal(encoded, &info); err != nil {
t.Fatal(err)
}
if _, ok := info["wf"]; ok != (len(wifi) > 0) {
t.Fatalf("wf present = %v for snapshot %v", ok, wifi)
}
}
}
+18 -26
View File
@@ -3,9 +3,7 @@
package systemd_test
import (
"runtime"
"testing"
"testing/synctest"
"time"
"github.com/henrygd/beszel/internal/entities/systemd"
@@ -67,36 +65,30 @@ func TestServiceUpdateCPUPercent(t *testing.T) {
})
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
// Half of one second's CPU capacity across all cores.
cpuUsage := service.PrevCpuUsage + uint64(time.Second/2)*uint64(runtime.NumCPU())
service.UpdateCPUPercent(cpuUsage)
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
assert.Equal(t, 50.0, service.Cpu)
assert.Equal(t, cpuUsage, service.PrevCpuUsage)
assert.Equal(t, time.Now(), service.PrevReadTime)
assert.Equal(t, 50.0, service.CpuPeak)
})
// CPU usage should be positive and reasonable
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
})
t.Run("CPU peak updates only when higher", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(service.PrevCpuUsage + uint64(time.Second/2)*uint64(runtime.NumCPU()))
assert.Equal(t, 50.0, service.CpuPeak)
service := &systemd.Service{}
service.PrevCpuUsage = 1000
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
initialPeak := service.CpuPeak
// A smaller increase in the cumulative counter gives 25% usage.
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(service.PrevCpuUsage + uint64(time.Second/4)*uint64(runtime.NumCPU()))
assert.Equal(t, 25.0, service.Cpu)
assert.Equal(t, 50.0, service.CpuPeak, "Peak should not update for lower CPU usage")
})
// Now try with much lower CPU usage - should not update peak
service.PrevReadTime = time.Now().Add(-time.Second)
service.UpdateCPUPercent(1000000) // Much lower usage
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
})
t.Run("handles zero duration", func(t *testing.T) {
+13 -7
View File
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
@@ -31,18 +32,23 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) {
if err != nil {
return nil, nil, err
}
hub := NewHub(testApp)
if err := hub.sm.InitializeSSHConfigForTesting(); err != nil {
cleanupTestHub(hub, testApp)
return nil, nil, err
}
return hub, testApp, nil
return NewHub(testApp), testApp, err
}
// cleanupTestHub stops background system goroutines before tearing down the app.
func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) {
if hub != nil {
hub.GetSystemManager().RemoveAllSystems()
sm := hub.GetSystemManager()
sm.RemoveAllSystems()
// Give updater goroutines a brief window to observe cancellation before DB teardown.
for range 20 {
if sm.GetSystemCount() == 0 {
break
}
runtime.Gosched()
time.Sleep(5 * time.Millisecond)
}
time.Sleep(20 * time.Millisecond)
}
if testApp != nil {
testApp.Cleanup()
+1 -62
View File
@@ -15,7 +15,6 @@ import (
"github.com/blang/semver"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
systementity "github.com/henrygd/beszel/internal/entities/system"
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems"
@@ -203,10 +202,6 @@ 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 recent logs for a systemd service
apiAuth.GET("/systemd/logs", h.getSystemdLogs)
// get pending package updates
apiAuth.GET("/package-updates", h.getPackageUpdates)
// /containers routes
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
@@ -446,66 +441,10 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
if err != nil {
return e.InternalServerError("", err)
}
e.Response.Header().Set("Cache-Control", "private, max-age=60")
e.Response.Header().Add("Vary", "Authorization")
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// getSystemdLogs handles GET /api/beszel/systemd/logs requests.
func (h *Hub) getSystemdLogs(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.BadRequestError("Invalid system or service parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth) {
return e.NotFoundError("", nil)
}
// Only fetch logs for services that are currently monitored on this system.
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
"system": systemID,
"name": serviceName,
})
if err != nil {
return e.NotFoundError("", err)
}
// Old agents and agents without journal access do not advertise this capability.
systemRecord, err := e.App.FindRecordById("systems", systemID)
if err != nil {
return e.NotFoundError("", err)
}
var info systementity.Info
if err := systemRecord.UnmarshalJSONField("info", &info); err != nil || !info.SystemdLogs {
return e.JSON(http.StatusOK, map[string]string{"logs": ""})
}
logs, err := system.FetchSystemdLogsFromAgent(serviceName)
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, map[string]string{"logs": logs})
}
// 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 {
-107
View File
@@ -1,107 +0,0 @@
package hub_test
import (
"io"
"net"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/fxamacker/cbor/v2"
"github.com/gliderlabs/ssh"
"github.com/henrygd/beszel/internal/common"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/stretchr/testify/require"
)
func systemdInfoTestHandler(t *testing.T) (http.Handler, string, string, string, *atomic.Int32) {
t.Helper()
hub, handler := firstUserTestMux(t)
t.Cleanup(hub.Cleanup)
owner, err := beszelTests.CreateUserWithRole(hub, "owner@example.com", "password123", "user")
require.NoError(t, err)
other, err := beszelTests.CreateUserWithRole(hub, "other@example.com", "password123", "user")
require.NoError(t, err)
ownerToken, err := owner.NewAuthToken()
require.NoError(t, err)
otherToken, err := other.NewAuthToken()
require.NoError(t, err)
var requests atomic.Int32
agent := &ssh.Server{Version: "beszel_0.20.0", Handler: func(s ssh.Session) {
var request common.HubRequest[common.SystemdInfoRequest]
if err := cbor.NewDecoder(s).Decode(&request); err != nil {
t.Error(err)
return
}
if request.Action != common.GetSystemdInfo || request.Data.ServiceName != "private.service" {
t.Errorf("unexpected agent request: %+v", request)
return
}
requests.Add(1)
data, err := cbor.Marshal(map[string]any{"Description": "private service details"})
if err == nil {
err = cbor.NewEncoder(s).Encode(common.AgentResponse{Data: data})
}
if err != nil {
t.Error(err)
}
}}
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = agent.Close() })
go func() { _ = agent.Serve(listener) }()
host, port, err := net.SplitHostPort(listener.Addr().String())
require.NoError(t, err)
record, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system", "host": host, "port": port, "users": []string{owner.Id},
})
require.NoError(t, err)
system, err := hub.GetSystemManager().GetSystem(record.Id)
require.NoError(t, err)
system.StopUpdater()
_, err = beszelTests.CreateRecord(hub, "systemd_services", map[string]any{
"system": record.Id, "name": "private.service", "state": 0, "sub": 1,
})
require.NoError(t, err)
return handler, "/api/beszel/systemd/info?system=" + record.Id + "&service=private.service", ownerToken, otherToken, &requests
}
func TestSystemdInfoCachePolicy(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", "false")
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
handler, path, owner, other, requests := systemdInfoTestHandler(t)
for _, tc := range []struct {
name, token, shareAll string
status int
}{
{"owner", owner, "false", http.StatusOK},
{"other user", other, "false", http.StatusNotFound},
{"unauthenticated", "", "false", http.StatusUnauthorized},
{"shared system", other, "true", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("SHARE_ALL_SYSTEMS", tc.shareAll)
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", tc.shareAll)
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Authorization", tc.token)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
response := recorder.Result()
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
require.NoError(t, err)
require.Equal(t, tc.status, response.StatusCode)
if tc.status == http.StatusOK {
require.JSONEq(t, `{"details":{"Description":"private service details"}}`, string(body))
require.Equal(t, "private, max-age=60", response.Header.Get("Cache-Control"))
require.Contains(t, response.Header.Values("Vary"), "Authorization")
} else {
require.NotContains(t, string(body), "private service details")
require.NotContains(t, response.Header.Get("Cache-Control"), "public")
}
})
}
require.EqualValues(t, 2, requests.Load(), "only authorised requests should reach the agent")
}
-73
View File
@@ -99,7 +99,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
require.NoError(t, err, "Failed to create admin auth token")
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
require.NoError(t, err, "Failed to create readonly user")
@@ -549,59 +548,6 @@ 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",
@@ -685,25 +631,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
})
},
},
{
Name: "GET /systemd/logs - old agent without capability returns empty logs",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/logs?system=%s&service=nginx.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"logs":""`},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0,
"sub": 1,
})
},
},
// Auth Optional Routes - Should work without authentication
{
-19
View File
@@ -118,25 +118,6 @@ func setCollectionAuthSettings(app core.App) error {
return err
}
// Alerts belong to their user and may only reference systems the user can access.
// The user and system of an existing alert cannot be changed through the API.
// Readonly users can still manage their own alerts, so these build on the read rule.
alertsOwnerRule := authenticatedRule + " && user = @request.auth.id"
alertsCreateRule := alertsOwnerRule
alertsUpdateRule := alertsOwnerRule + " && @request.body.user:changed = false && @request.body.system:changed = false"
if shareAllSystems != "true" {
alertsCreateRule += " && system.users.id ?= @request.auth.id"
alertsUpdateRule += " && system.users.id ?= @request.auth.id"
}
if err := applyCollectionRules(app, []string{"alerts"}, collectionRules{
list: &alertsOwnerRule,
create: &alertsCreateRule,
update: &alertsUpdateRule,
delete: &alertsOwnerRule,
}); err != nil {
return err
}
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
list: &systemScopedReadRule,
view: &systemScopedReadRule,
+3 -3
View File
@@ -47,8 +47,8 @@ func TestCollectionRulesDefault(t *testing.T) {
require.NoError(t, err, "Failed to find alerts collection")
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser+` && system.users.id ?= @request.auth.id`, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser+` && @request.body.user:changed = false && @request.body.system:changed = false && system.users.id ?= @request.auth.id`, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
@@ -183,7 +183,7 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
assert.Nil(t, alertsCollection.ViewRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
assert.Equal(t, isUserMatchesUser+` && @request.body.user:changed = false && @request.body.system:changed = false`, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
// alerts_history collection

Some files were not shown because too many files have changed in this diff Show More