mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 03:36:07 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a9ad283c |
+1
-16
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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+
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{}))
|
||||
}
|
||||
@@ -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
@@ -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 /
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
|
||||
@@ -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:]
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user