mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-30 19:56:21 +00:00
Compare commits
92
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a07933a91b | ||
|
|
3cc19602c1 | ||
|
|
6c47ca8ecc | ||
|
|
3281055d95 | ||
|
|
02653f8ed9 | ||
|
|
67c5905b36 | ||
|
|
1997984325 | ||
|
|
97db8bd199 | ||
|
|
24881fbfdf | ||
|
|
4422be717c | ||
|
|
0484c54919 | ||
|
|
a77ed345d7 | ||
|
|
1ab38e57c5 | ||
|
|
68ca6936a7 | ||
|
|
5df0a256c6 | ||
|
|
8b38db1ba0 | ||
|
|
130365f5d3 | ||
|
|
921ded1af0 | ||
|
|
09a277f074 | ||
|
|
083b28a1fc | ||
|
|
c556dc478d | ||
|
|
62f8a39c8a | ||
|
|
be3dad7ed4 | ||
|
|
666529e073 | ||
|
|
7626e5e3c8 | ||
|
|
ad215788ed | ||
|
|
b684ac9910 | ||
|
|
65f00ae119 | ||
|
|
4b4ef0a2fe | ||
|
|
81fd571169 | ||
|
|
8613cfe548 | ||
|
|
c1505804bd | ||
|
|
739649a6db | ||
|
|
3f20ecae50 | ||
|
|
b3feff9a28 | ||
|
|
67c3c1cb43 | ||
|
|
fb4e93f098 | ||
|
|
1ed02bb517 | ||
|
|
15994474f6 | ||
|
|
24ec18a937 | ||
|
|
c7f2177b08 | ||
|
|
a042e19549 | ||
|
|
fe83f5b831 | ||
|
|
46fa7c581e | ||
|
|
24792aa24f | ||
|
|
16e3fbadce | ||
|
|
86ab0fae8b | ||
|
|
badd4c8245 | ||
|
|
7bea20e3b6 | ||
|
|
433b83800f | ||
|
|
3dfe062ee4 | ||
|
|
3fb97b800c | ||
|
|
d708def38f | ||
|
|
f50fb4f8e5 | ||
|
|
c25408651f | ||
|
|
d591da46f3 | ||
|
|
d80a2f49f9 | ||
|
|
21b648a005 | ||
|
|
151423ac63 | ||
|
|
f7528a0208 | ||
|
|
a20a7d2edc | ||
|
|
f2adb9cf94 | ||
|
|
4d10ea2e03 | ||
|
|
b5ef015451 | ||
|
|
6141b15f03 | ||
|
|
c21412f45d | ||
|
|
367d2f39da | ||
|
|
eabd9a950a | ||
|
|
0be9882b34 | ||
|
|
e4b84b72ab | ||
|
|
fc33e62736 | ||
|
|
a99fe5e997 | ||
|
|
0870716052 | ||
|
|
b1270e341c | ||
|
|
9042a8c5c8 | ||
|
|
8bf6917fe0 | ||
|
|
2d5ea3fa08 | ||
|
|
627d364071 | ||
|
|
8047f005d4 | ||
|
|
4a4610bbc3 | ||
|
|
1aaabfc255 | ||
|
|
97ea3c16cb | ||
|
|
c9de35fad2 | ||
|
|
a5f216f425 | ||
|
|
cbe4824ac3 | ||
|
|
2c69197d2d | ||
|
|
97e6f64bdc | ||
|
|
4a5915b141 | ||
|
|
e68372dce4 | ||
|
|
c52f3acb94 | ||
|
|
c09eb8c6df | ||
|
|
ada8c69817 |
+16
-1
@@ -29,6 +29,7 @@ type Agent struct {
|
|||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
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
|
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)
|
diskUsageCacheDuration time.Duration // How long to cache disk usage (to avoid waking sleeping disks)
|
||||||
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
lastDiskUsageUpdate time.Time // Last time disk usage was collected
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
@@ -50,6 +51,7 @@ type Agent struct {
|
|||||||
systemdManager *systemdManager // Manages systemd services
|
systemdManager *systemdManager // Manages systemd services
|
||||||
monitorManager *MonitorManager // Manages network monitors
|
monitorManager *MonitorManager // Manages network monitors
|
||||||
storagePoolManager *StoragePoolManager // Manages storage pool and dataset data
|
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.
|
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||||
@@ -149,12 +151,17 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Systemd", "err", err)
|
slog.Debug("Systemd", "err", err)
|
||||||
}
|
}
|
||||||
|
if agent.systemdManager != nil {
|
||||||
|
agent.systemInfo.SystemdLogs = agent.systemdManager.logsEnabled
|
||||||
|
}
|
||||||
|
|
||||||
agent.smartManager, err = NewSmartManager()
|
agent.smartManager, err = NewSmartManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("SMART", "err", err)
|
slog.Debug("SMART", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agent.packageUpdates = newPackageUpdatesManager(agent.dataDir)
|
||||||
|
|
||||||
// initialize GPU manager
|
// initialize GPU manager
|
||||||
agent.gpuManager, err = NewGPUManager()
|
agent.gpuManager, err = NewGPUManager()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -219,6 +226,10 @@ 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.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
data.Info.ExtraFsPct = make(map[string]float64)
|
data.Info.ExtraFsPct = make(map[string]float64)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
@@ -252,7 +263,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
func (a *Agent) Start(serverOptions ServerOptions) error {
|
func (a *Agent) Start(serverOptions ServerOptions) error {
|
||||||
a.keys = serverOptions.Keys
|
a.keys = serverOptions.Keys
|
||||||
return a.connectionManager.Start(serverOptions)
|
err := a.connectionManager.Start(serverOptions)
|
||||||
|
if err != nil {
|
||||||
|
a.cleanupSensorShadow()
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -235,8 +236,11 @@ func TestWebSocketClient_TLSVerification(t *testing.T) {
|
|||||||
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
|
t.Run("custom CA trusts self-signed certificate", func(t *testing.T) {
|
||||||
systemRoots, err := x509.SystemCertPool()
|
systemRoots, err := x509.SystemCertPool()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.True(t, systemRoots.AppendCertsFromPEM(serverCertPEM))
|
||||||
client := newClient(t, caCertFile)
|
client := newClient(t, caCertFile)
|
||||||
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
|
tlsConfig := client.getOptions().TlsConfig
|
||||||
|
require.NotNil(t, tlsConfig)
|
||||||
|
assert.True(t, tlsConfig.RootCAs.Equal(systemRoots))
|
||||||
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
conn, _, err := gws.NewClient(&gws.BuiltinEventHandler{}, client.getOptions())
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, conn.NetConn().Close())
|
require.NoError(t, conn.NetConn().Close())
|
||||||
@@ -316,7 +320,7 @@ func TestGetTLSConfigErrors(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Nil(t, tlsConfig)
|
assert.Nil(t, tlsConfig)
|
||||||
assert.Contains(t, err.Error(), tc.errorMatch)
|
assert.Contains(t, err.Error(), tc.errorMatch)
|
||||||
assert.Contains(t, err.Error(), tc.path)
|
assert.Contains(t, err.Error(), strconv.Quote(tc.path))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -681,12 +685,12 @@ func TestGetToken(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("error when TOKEN_FILE points to non-existent file", func(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
|
// Set TOKEN_FILE to a non-existent file
|
||||||
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
t.Setenv("TOKEN_FILE", filepath.Join(t.TempDir(), "missing.txt"))
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "", token)
|
assert.Equal(t, "", token)
|
||||||
assert.Contains(t, err.Error(), "no such file or directory")
|
assert.ErrorIs(t, err, os.ErrNotExist)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles empty token file", func(t *testing.T) {
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
|||||||
+55
-16
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -20,7 +21,10 @@ import (
|
|||||||
// It handles both WebSocket and SSH connections, automatically switching between
|
// It handles both WebSocket and SSH connections, automatically switching between
|
||||||
// them based on availability and managing reconnection attempts.
|
// them based on availability and managing reconnection attempts.
|
||||||
type ConnectionManager struct {
|
type ConnectionManager struct {
|
||||||
agent *Agent // Reference to the parent agent
|
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
|
||||||
State ConnectionState // Current connection state
|
State ConnectionState // Current connection state
|
||||||
eventChan chan ConnectionEvent // Channel for connection events
|
eventChan chan ConnectionEvent // Channel for connection events
|
||||||
wsClient *WebSocketClient // WebSocket client for hub communication
|
wsClient *WebSocketClient // WebSocket client for hub communication
|
||||||
@@ -78,6 +82,29 @@ 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.
|
// Start begins connection attempts and enters the main event loop.
|
||||||
// It handles connection events, periodic health updates, and graceful shutdown.
|
// It handles connection events, periodic health updates, and graceful shutdown.
|
||||||
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||||
@@ -122,7 +149,10 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
case connectionEvent := <-c.eventChan:
|
case connectionEvent := <-c.eventChan:
|
||||||
c.handleEvent(connectionEvent)
|
c.handleEvent(connectionEvent)
|
||||||
case <-c.wsTicker.C:
|
case <-c.wsTicker.C:
|
||||||
_ = c.startWebSocketConnection()
|
// skip if connect() is still running its own attempt
|
||||||
|
if !c.isConnectingNow() {
|
||||||
|
_ = c.startWebSocketConnection()
|
||||||
|
}
|
||||||
case <-healthTicker:
|
case <-healthTicker:
|
||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigCtx.Done():
|
case <-sigCtx.Done():
|
||||||
@@ -155,6 +185,7 @@ func (c *ConnectionManager) stop() error {
|
|||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.agent.monitorManager.Stop()
|
c.agent.monitorManager.Stop()
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
|
c.agent.cleanupSensorShadow()
|
||||||
return health.CleanUp()
|
return health.CleanUp()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,15 +195,15 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
case WebSocketConnect:
|
case WebSocketConnect:
|
||||||
c.handleStateChange(WebSocketConnected)
|
c.handleStateChange(WebSocketConnected)
|
||||||
case SSHConnect:
|
case SSHConnect:
|
||||||
if c.State == Disconnected {
|
if c.getState() == Disconnected {
|
||||||
c.handleStateChange(SSHConnected)
|
c.handleStateChange(SSHConnected)
|
||||||
}
|
}
|
||||||
case WebSocketDisconnect:
|
case WebSocketDisconnect:
|
||||||
if c.State == WebSocketConnected {
|
if c.getState() == WebSocketConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
}
|
}
|
||||||
case SSHDisconnect:
|
case SSHDisconnect:
|
||||||
if c.State == SSHConnected {
|
if c.getState() == SSHConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,30 +212,40 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
// handleStateChange updates the connection state and performs necessary actions
|
// handleStateChange updates the connection state and performs necessary actions
|
||||||
// based on the new state, including stopping services and initiating reconnections.
|
// based on the new state, including stopping services and initiating reconnections.
|
||||||
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
||||||
|
c.mu.Lock()
|
||||||
if c.State == newState {
|
if c.State == newState {
|
||||||
|
c.mu.Unlock()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.State = newState
|
c.State = newState
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
switch newState {
|
switch newState {
|
||||||
case WebSocketConnected:
|
case WebSocketConnected:
|
||||||
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
slog.Info("WebSocket connected", "host", c.wsClient.hubURL.Host)
|
||||||
c.ConnectionType = system.ConnectionTypeWebSocket
|
c.ConnectionType = system.ConnectionTypeWebSocket
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
_ = c.agent.StopServer()
|
_ = c.agent.StopServer()
|
||||||
c.isConnecting = false
|
c.setConnecting(false)
|
||||||
case SSHConnected:
|
case SSHConnected:
|
||||||
// stop new ws connection attempts
|
// stop new ws connection attempts
|
||||||
slog.Info("SSH connection established")
|
slog.Info("SSH connection established")
|
||||||
c.ConnectionType = system.ConnectionTypeSSH
|
c.ConnectionType = system.ConnectionTypeSSH
|
||||||
c.stopWsTicker()
|
c.stopWsTicker()
|
||||||
c.isConnecting = false
|
c.setConnecting(false)
|
||||||
case Disconnected:
|
case Disconnected:
|
||||||
c.ConnectionType = system.ConnectionTypeNone
|
c.ConnectionType = system.ConnectionTypeNone
|
||||||
if c.isConnecting {
|
// 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) {
|
||||||
// Already handling reconnection, avoid duplicate attempts
|
// Already handling reconnection, avoid duplicate attempts
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.isConnecting = true
|
|
||||||
slog.Warn("Disconnected from hub")
|
slog.Warn("Disconnected from hub")
|
||||||
// make sure old ws connection is closed
|
// make sure old ws connection is closed
|
||||||
c.closeWebSocket()
|
c.closeWebSocket()
|
||||||
@@ -216,10 +257,8 @@ func (c *ConnectionManager) handleStateChange(newState ConnectionState) {
|
|||||||
// connect handles the connection logic with proper delays and priority.
|
// connect handles the connection logic with proper delays and priority.
|
||||||
// It attempts WebSocket connection first, falling back to SSH server if needed.
|
// It attempts WebSocket connection first, falling back to SSH server if needed.
|
||||||
func (c *ConnectionManager) connect() {
|
func (c *ConnectionManager) connect() {
|
||||||
c.isConnecting = true
|
c.setConnecting(true)
|
||||||
defer func() {
|
defer c.setConnecting(false)
|
||||||
c.isConnecting = false
|
|
||||||
}()
|
|
||||||
|
|
||||||
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
|
if c.wsClient != nil && time.Since(c.wsClient.lastConnectAttempt) < 5*time.Second {
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
@@ -233,7 +272,7 @@ func (c *ConnectionManager) connect() {
|
|||||||
_ = c.stop()
|
_ = c.stop()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if c.State == Disconnected {
|
if c.getState() == Disconnected {
|
||||||
c.startSSHServer()
|
c.startSSHServer()
|
||||||
c.startWsTicker()
|
c.startWsTicker()
|
||||||
}
|
}
|
||||||
@@ -242,7 +281,7 @@ func (c *ConnectionManager) connect() {
|
|||||||
|
|
||||||
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
// startWebSocketConnection attempts to establish a WebSocket connection to the hub.
|
||||||
func (c *ConnectionManager) startWebSocketConnection() error {
|
func (c *ConnectionManager) startWebSocketConnection() error {
|
||||||
if c.State != Disconnected {
|
if c.getState() != Disconnected {
|
||||||
return errors.New("already connected")
|
return errors.New("already connected")
|
||||||
}
|
}
|
||||||
if c.wsClient == nil {
|
if c.wsClient == nil {
|
||||||
@@ -262,7 +301,7 @@ func (c *ConnectionManager) startWebSocketConnection() error {
|
|||||||
|
|
||||||
// startSSHServer starts the SSH server if the agent is currently disconnected.
|
// startSSHServer starts the SSH server if the agent is currently disconnected.
|
||||||
func (c *ConnectionManager) startSSHServer() {
|
func (c *ConnectionManager) startSSHServer() {
|
||||||
if c.State == Disconnected {
|
if c.getState() == Disconnected {
|
||||||
go c.agent.StartServer(c.serverOptions)
|
go c.agent.StartServer(c.serverOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -77,6 +78,10 @@ func TestConnectionManager_StateTransitions(t *testing.T) {
|
|||||||
cm.handleStateChange(SSHConnected)
|
cm.handleStateChange(SSHConnected)
|
||||||
assert.Equal(t, SSHConnected, cm.State, "State should change to 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)
|
cm.handleStateChange(Disconnected)
|
||||||
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
|
assert.Equal(t, Disconnected, cm.State, "State should change to Disconnected")
|
||||||
|
|
||||||
@@ -95,7 +100,6 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
Host: "localhost:8080",
|
Host: "localhost:8080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
initialState ConnectionState
|
initialState ConnectionState
|
||||||
@@ -148,6 +152,11 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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.State = tc.initialState
|
||||||
cm.handleEvent(tc.event)
|
cm.handleEvent(tc.event)
|
||||||
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
|
assert.Equal(t, tc.expectedState, cm.State, "State should match expected after event")
|
||||||
@@ -221,12 +230,56 @@ func TestConnectionManager_ReconnectionLogic(t *testing.T) {
|
|||||||
// Test that isConnecting flag prevents duplicate reconnection attempts
|
// Test that isConnecting flag prevents duplicate reconnection attempts
|
||||||
// Start from connected state, then simulate disconnect
|
// Start from connected state, then simulate disconnect
|
||||||
cm.State = WebSocketConnected
|
cm.State = WebSocketConnected
|
||||||
cm.isConnecting = false
|
cm.setConnecting(false)
|
||||||
|
|
||||||
// First disconnect should trigger reconnection logic
|
// First disconnect should trigger reconnection logic
|
||||||
cm.handleStateChange(Disconnected)
|
cm.handleStateChange(Disconnected)
|
||||||
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
|
assert.Equal(t, Disconnected, cm.State, "Should change to disconnected")
|
||||||
assert.True(t, cm.isConnecting, "Should set isConnecting flag")
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
|
// TestConnectionManager_ConnectWithRateLimit tests connection rate limiting
|
||||||
|
|||||||
+8
-1
@@ -35,6 +35,12 @@ type CpuMetrics struct {
|
|||||||
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
// getCpuMetrics calculates detailed CPU usage metrics using cached previous measurements.
|
||||||
// It returns percentages for total, user, system, iowait, and steal time.
|
// It returns percentages for total, user, system, iowait, and steal time.
|
||||||
func getCpuMetrics(cacheTimeMs uint16) (CpuMetrics, error) {
|
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)
|
times, err := cpu.Times(false)
|
||||||
if err != nil || len(times) == 0 {
|
if err != nil || len(times) == 0 {
|
||||||
return CpuMetrics{}, err
|
return CpuMetrics{}, err
|
||||||
@@ -119,7 +125,8 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|||||||
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
|
// 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).
|
// Returns total CPU time and busy CPU time (total minus idle and I/O wait time).
|
||||||
func getAllBusy(t cpu.TimesStat) (float64, float64) {
|
func getAllBusy(t cpu.TimesStat) (float64, float64) {
|
||||||
tot := t.Total()
|
tot := t.User + t.System + t.Idle + t.Nice + t.Iowait + t.Irq +
|
||||||
|
t.Softirq + t.Steal + t.Guest + t.GuestNice
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
tot -= t.Guest // Linux 2.6.24+
|
tot -= t.Guest // Linux 2.6.24+
|
||||||
tot -= t.GuestNice // Linux 3.2.0+
|
tot -= t.GuestNice // Linux 3.2.0+
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
//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{}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
+90
-32
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -153,12 +154,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||||
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
// key and reports whether it was added. The key selection itself lives in
|
||||||
// can stay directly unit-tested.
|
// registerFilesystemStats so that logic can stay directly unit-tested.
|
||||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
|
||||||
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
key, fsStats, ok := registerFilesystemStats(d.agent.fsStats, device, mountpoint, root, customName, d.ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
d.agent.fsStats[key] = fsStats
|
d.agent.fsStats[key] = fsStats
|
||||||
name := key
|
name := key
|
||||||
@@ -166,6 +167,7 @@ func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customNa
|
|||||||
name = customName
|
name = customName
|
||||||
}
|
}
|
||||||
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
slog.Info("Detected disk", "name", name, "device", device, "mount", mountpoint, "io", key, "root", root)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
// addConfiguredRootFs resolves FILESYSTEM against partitions first, then falls
|
||||||
@@ -203,14 +205,24 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
|||||||
// partition looks like the active root mount but still needs translating to an
|
// partition looks like the active root mount but still needs translating to an
|
||||||
// I/O device key.
|
// I/O device key.
|
||||||
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
func (d *diskDiscovery) addPartitionRootFs(device, mountpoint string) bool {
|
||||||
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
// 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)
|
||||||
if !match {
|
if !match {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// The resolved I/O device is already known here, so use it directly to avoid
|
// The root device is already resolved, so if it was registered earlier as an
|
||||||
// a second fallback search inside buildFsStatRegistration.
|
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
|
||||||
d.addFsStat(fs, mountpoint, true, "")
|
// entry rather than letting addLastResortRootFs guess a different device.
|
||||||
return true
|
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, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
// addLastResortRootFs is only used when neither FILESYSTEM nor partition-based
|
||||||
@@ -526,13 +538,43 @@ func filesystemMatchesPartitionSetting(filesystem string, p disk.PartitionStat)
|
|||||||
|
|
||||||
// normalizeDeviceName canonicalizes device strings for comparisons.
|
// normalizeDeviceName canonicalizes device strings for comparisons.
|
||||||
func normalizeDeviceName(value string) string {
|
func normalizeDeviceName(value string) string {
|
||||||
name := filepath.Base(strings.TrimSpace(value))
|
name := strings.TrimSpace(value)
|
||||||
|
if volume, ok := windowsVolumeName(name); ok {
|
||||||
|
return volume
|
||||||
|
}
|
||||||
|
name = filepath.Base(name)
|
||||||
if name == "." {
|
if name == "." {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return name
|
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.
|
// Sets start values for disk I/O stats.
|
||||||
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersStat) {
|
||||||
a.fsNames = a.fsNames[:0]
|
a.fsNames = a.fsNames[:0]
|
||||||
@@ -554,9 +596,9 @@ func (a *Agent) initializeDiskIoStats(diskIoCounters map[string]disk.IOCountersS
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// populate initial values
|
// populate initial values
|
||||||
stats.Time = now
|
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
|
a.setDiskBaseline(device, prevDiskFromCounter(d, now))
|
||||||
// add to list of valid io device names
|
// add to list of valid io device names
|
||||||
a.fsNames = append(a.fsNames, device)
|
a.fsNames = append(a.fsNames, device)
|
||||||
}
|
}
|
||||||
@@ -639,19 +681,9 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// Previous snapshot for this interval and device
|
// Previous snapshot for this interval and device
|
||||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||||
if !hasPrev {
|
if !hasPrev {
|
||||||
// Seed from agent-level fsStats if present, else seed from current
|
// Seed from the latest counters of any interval, else seed from current
|
||||||
prev = prevDisk{
|
prev, hasPrev = a.diskBaseline[name]
|
||||||
readBytes: stats.TotalRead,
|
if !hasPrev {
|
||||||
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)
|
prev = prevDiskFromCounter(d, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -686,29 +718,31 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// This is the total number of milliseconds spent by all reads (as
|
// This is the total number of milliseconds spent by all reads (as
|
||||||
// measured from __make_request() to end_that_request_last()).
|
// measured from __make_request() to end_that_request_last()).
|
||||||
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
||||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
|
||||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
|
||||||
|
diskReadTime := utils.TwoDecimals(float64(deltaReadTime) / float64(msElapsed) * 100)
|
||||||
|
diskWriteTime := utils.TwoDecimals(float64(deltaWriteTime) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
diskIoUtilPct := utils.TwoDecimals(float64(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 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.
|
// 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).
|
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
diskWeightedIO := utils.TwoDecimals(float64(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||||
// Equivalent to r_await and w_await in iostat.
|
// Equivalent to r_await and w_await in iostat.
|
||||||
var rAwait, wAwait float64
|
var rAwait, wAwait float64
|
||||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
rAwait = utils.TwoDecimals(float64(deltaReadTime) / float64(deltaReadCount))
|
||||||
}
|
}
|
||||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update global fsStats baseline for cross-interval correctness
|
// Update the baseline that seeds new intervals
|
||||||
stats.Time = now
|
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
stats.DiskReadPs = readMbPerSecond
|
stats.DiskReadPs = readMbPerSecond
|
||||||
@@ -740,6 +774,30 @@ 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.
|
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||||
// On Windows it returns the system drive (e.g. "C:").
|
// On Windows it returns the system drive (e.g. "C:").
|
||||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
+140
-98
@@ -3,14 +3,17 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
"github.com/shirou/gopsutil/v4/disk"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseFilesystemEntry(t *testing.T) {
|
func TestParseFilesystemEntry(t *testing.T) {
|
||||||
@@ -504,13 +507,13 @@ func TestAddConfiguredExtraFilesystems(t *testing.T) {
|
|||||||
func TestAddExtraFilesystemFolders(t *testing.T) {
|
func TestAddExtraFilesystemFolders(t *testing.T) {
|
||||||
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
|
t.Run("adds missing folders and skips existing mountpoints", func(t *testing.T) {
|
||||||
agent := &Agent{fsStats: map[string]*system.FsStats{
|
agent := &Agent{fsStats: map[string]*system.FsStats{
|
||||||
"existing": {Mountpoint: "/extra-filesystems/existing"},
|
"existing": {Mountpoint: filepath.FromSlash("/extra-filesystems/existing")},
|
||||||
}}
|
}}
|
||||||
discovery := diskDiscovery{
|
discovery := diskDiscovery{
|
||||||
agent: agent,
|
agent: agent,
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
isWindows: false,
|
isWindows: false,
|
||||||
efPath: "/extra-filesystems",
|
efPath: filepath.FromSlash("/extra-filesystems"),
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"newdisk": {Name: "newdisk"},
|
"newdisk": {Name: "newdisk"},
|
||||||
},
|
},
|
||||||
@@ -519,10 +522,10 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
|
|||||||
|
|
||||||
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
||||||
|
|
||||||
assert.Len(t, agent.fsStats, 2)
|
require.Len(t, agent.fsStats, 2)
|
||||||
stats, exists := agent.fsStats["newdisk"]
|
stats, exists := agent.fsStats["newdisk"]
|
||||||
assert.True(t, exists)
|
require.True(t, exists)
|
||||||
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
|
assert.Equal(t, filepath.FromSlash("/extra-filesystems/newdisk__Archive"), stats.Mountpoint)
|
||||||
assert.Equal(t, "Archive", stats.Name)
|
assert.Equal(t, "Archive", stats.Name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -533,7 +536,7 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
agent: agent,
|
agent: agent,
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
isWindows: false,
|
isWindows: false,
|
||||||
efPath: "/extra-filesystems",
|
efPath: filepath.FromSlash("/extra-filesystems"),
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"nvme0n1p1": {Name: "nvme0n1p1"},
|
"nvme0n1p1": {Name: "nvme0n1p1"},
|
||||||
"nvme1n1": {Name: "nvme1n1"},
|
"nvme1n1": {Name: "nvme1n1"},
|
||||||
@@ -548,12 +551,12 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
|
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{
|
d.addPartitionExtraFs(disk.PartitionStat{
|
||||||
Device: "/dev/nvme0n1p1",
|
Device: "/dev/nvme0n1p1",
|
||||||
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"),
|
||||||
})
|
})
|
||||||
|
|
||||||
stats, exists := agent.fsStats["nvme0n1p1"]
|
stats, exists := agent.fsStats["nvme0n1p1"]
|
||||||
assert.True(t, exists)
|
require.True(t, exists)
|
||||||
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
assert.Equal(t, filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"), stats.Mountpoint)
|
||||||
assert.Equal(t, "caddy1-root", stats.Name)
|
assert.Equal(t, "caddy1-root", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -564,10 +567,10 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
// These simulate the virtual mounts that appear when host / is bind-mounted
|
// 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.
|
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
|
||||||
for _, nested := range []string{
|
for _, nested := range []string{
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc"),
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys"),
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/dev"),
|
||||||
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/run"),
|
||||||
} {
|
} {
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
||||||
}
|
}
|
||||||
@@ -580,18 +583,20 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
d := makeDiscovery(agent)
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
partitions := []disk.PartitionStat{
|
partitions := []disk.PartitionStat{
|
||||||
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
{Device: "/dev/nvme0n1p1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root")},
|
||||||
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
{Device: "/dev/nvme1n1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme1n1__caddy1-docker")},
|
||||||
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
{Device: "proc", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc")},
|
||||||
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
{Device: "sysfs", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys")},
|
||||||
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
{Device: "overlay", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker")},
|
||||||
}
|
}
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
d.addPartitionExtraFs(p)
|
d.addPartitionExtraFs(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, agent.fsStats, 2)
|
require.Len(t, agent.fsStats, 2)
|
||||||
|
require.Contains(t, agent.fsStats, "nvme0n1p1")
|
||||||
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
||||||
|
require.Contains(t, agent.fsStats, "nvme1n1")
|
||||||
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -764,82 +769,6 @@ 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) {
|
func TestFsStatsWithCustomNames(t *testing.T) {
|
||||||
// Test that FsStats properly stores custom names
|
// Test that FsStats properly stores custom names
|
||||||
fsStats := &system.FsStats{
|
fsStats := &system.FsStats{
|
||||||
@@ -1030,8 +959,10 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
|||||||
assert.Len(t, agent.fsNames, 2)
|
assert.Len(t, agent.fsNames, 2)
|
||||||
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
assert.Equal(t, uint64(10), agent.fsStats["sda"].TotalRead)
|
||||||
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
assert.Equal(t, uint64(20), agent.fsStats["sda"].TotalWrite)
|
||||||
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
assert.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
|
||||||
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
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())
|
||||||
|
|
||||||
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
agent.initializeDiskIoStats(map[string]disk.IOCountersStat{
|
||||||
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
"sdb": {Name: "sdb", ReadBytes: 50, WriteBytes: 60},
|
||||||
@@ -1041,3 +972,114 @@ func TestInitializeDiskIoStatsResetsTrackedDevices(t *testing.T) {
|
|||||||
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
assert.Equal(t, uint64(50), agent.fsStats["sdb"].TotalRead)
|
||||||
assert.Equal(t, uint64(60), agent.fsStats["sdb"].TotalWrite)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+12
-8
@@ -68,10 +68,11 @@ type dockerManager struct {
|
|||||||
excludeContainers []string // Patterns to exclude containers by name
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
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
|
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
|
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
|
||||||
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
||||||
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
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)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -688,6 +689,8 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
userAgent: "Docker-Client/",
|
userAgent: "Docker-Client/",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dockerImageCheck, _ := utils.GetEnv("DOCKER_IMAGE_CHECK")
|
||||||
|
|
||||||
// Read container exclusion patterns from environment variable
|
// Read container exclusion patterns from environment variable
|
||||||
var excludeContainers []string
|
var excludeContainers []string
|
||||||
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
if excludeStr, set := utils.GetEnv("EXCLUDE_CONTAINERS"); set && excludeStr != "" {
|
||||||
@@ -707,10 +710,11 @@ func newDockerManager(agent *Agent) *dockerManager {
|
|||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
},
|
},
|
||||||
containerStatsMap: make(map[string]*container.Stats),
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
sem: make(chan struct{}, 5),
|
sem: make(chan struct{}, 5),
|
||||||
apiContainerList: []*container.ApiInfo{},
|
apiContainerList: []*container.ApiInfo{},
|
||||||
excludeContainers: excludeContainers,
|
excludeContainers: excludeContainers,
|
||||||
|
imageUpdatesDisabled: dockerImageCheck == "false",
|
||||||
|
|
||||||
// Initialize cache-time-aware tracking structures
|
// Initialize cache-time-aware tracking structures
|
||||||
lastCpuContainer: make(map[uint16]map[string]uint64),
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ func normalizedImageReference(image string) string {
|
|||||||
// refreshImageUpdates starts at most one background batch. Neither its network
|
// refreshImageUpdates starts at most one background batch. Neither its network
|
||||||
// work nor its completion is part of the container metrics wait group.
|
// work nor its completion is part of the container metrics wait group.
|
||||||
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
func (dm *dockerManager) refreshImageUpdates(containers []*container.ApiInfo, now time.Time) {
|
||||||
|
if dm.imageUpdatesDisabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
dm.imageUpdatesMutex.Lock()
|
dm.imageUpdatesMutex.Lock()
|
||||||
defer dm.imageUpdatesMutex.Unlock()
|
defer dm.imageUpdatesMutex.Unlock()
|
||||||
if dm.imageUpdatesRunning {
|
if dm.imageUpdatesRunning {
|
||||||
|
|||||||
@@ -27,6 +27,29 @@ func waitForImageUpdates(t *testing.T, dm *dockerManager) {
|
|||||||
}, time.Second*3, time.Millisecond)
|
}, 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) {
|
func TestImageUpdateCacheAndStats(t *testing.T) {
|
||||||
local := "sha256:" + strings.Repeat("a", 64)
|
local := "sha256:" + strings.Repeat("a", 64)
|
||||||
remote := "sha256:" + strings.Repeat("b", 64)
|
remote := "sha256:" + strings.Repeat("b", 64)
|
||||||
|
|||||||
+21
-19
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|||||||
repository := reference.Path(named)
|
repository := reference.Path(named)
|
||||||
tag := named.(reference.Tagged).Tag()
|
tag := named.(reference.Tagged).Tag()
|
||||||
|
|
||||||
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
localDigests, err := dm.inspectImageDigests(image, registry, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -47,48 +48,49 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return remoteDigest != localDigest, nil
|
return !slices.Contains(localDigests, remoteDigest), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
// inspectImageDigests reads Docker's image metadata without using dm.decode.
|
||||||
// The checker runs in the image-discovery goroutine, so it must not hold any
|
// 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.
|
// of the container statistics locks while waiting on the Docker API.
|
||||||
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
func (dm *dockerManager) inspectImageDigests(image, registry, repository string) ([]string, error) {
|
||||||
if dm.client == nil {
|
if dm.client == nil {
|
||||||
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
return nil, fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
||||||
resp, err := dm.client.Get(endpoint)
|
resp, err := dm.client.Get(endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("inspect image %q: %w", image, err)
|
return nil, fmt.Errorf("inspect image %q: %w", image, err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
return nil, fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
var inspect struct {
|
var inspect struct {
|
||||||
RepoDigests []string `json:"RepoDigests"`
|
RepoDigests []string `json:"RepoDigests"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&inspect); err != nil {
|
||||||
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
return nil, fmt.Errorf("decode image inspect %q: %w", image, err)
|
||||||
}
|
}
|
||||||
if len(inspect.RepoDigests) == 0 {
|
if len(inspect.RepoDigests) == 0 {
|
||||||
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
return nil, fmt.Errorf("inspect image %q returned no repository digests", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
|
||||||
if !ok {
|
if len(localDigests) == 0 {
|
||||||
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
||||||
}
|
}
|
||||||
return localDigest, nil
|
return localDigests, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
// matchingRepositoryDigests returns all valid digests belonging to the requested
|
||||||
// repository. Docker can return multiple RepoDigests for one local image; an
|
// repository. Container engines can return both index and platform manifest digests for one
|
||||||
// unrelated first entry must never be used for the comparison.
|
// local image, in either order.
|
||||||
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
|
||||||
|
var digests []string
|
||||||
for _, repoDigest := range repoDigests {
|
for _, repoDigest := range repoDigests {
|
||||||
repoDigest = strings.TrimSpace(repoDigest)
|
repoDigest = strings.TrimSpace(repoDigest)
|
||||||
at := strings.LastIndexByte(repoDigest, '@')
|
at := strings.LastIndexByte(repoDigest, '@')
|
||||||
@@ -108,9 +110,9 @@ func matchingRepositoryDigest(repoDigests []string, registry, repository string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return d.String(), true
|
digests = append(digests, d.String())
|
||||||
}
|
}
|
||||||
return "", false
|
return digests
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameRegistry(left, right string) bool {
|
func sameRegistry(left, right string) bool {
|
||||||
|
|||||||
@@ -80,6 +80,45 @@ func TestCheckImageUpdateUsesInspectAndManifestDigests(t *testing.T) {
|
|||||||
require.EqualValues(t, 1, manifestCalls.Load())
|
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) {
|
func TestCheckImageUpdateReportsUnknownInspectState(t *testing.T) {
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+18
-2
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fanSensor struct {
|
type fanSensor struct {
|
||||||
key, path string
|
key, path, chip string
|
||||||
}
|
}
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
var getFanSensors = newFanSensorCache(hwmonRoot)
|
||||||
@@ -34,6 +34,10 @@ func (a *Agent) updateFans(systemStats *system.Stats) {
|
|||||||
slog.Debug("Error reading fans", "err", err)
|
slog.Debug("Error reading fans", "err", err)
|
||||||
return
|
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)
|
fans := readFanSensors(sensors)
|
||||||
if len(fans) == 0 {
|
if len(fans) == 0 {
|
||||||
return
|
return
|
||||||
@@ -100,7 +104,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|||||||
if label != "" {
|
if label != "" {
|
||||||
key = chipName + "_" + label
|
key = chipName + "_" + label
|
||||||
}
|
}
|
||||||
sensors = append(sensors, fanSensor{key, inputPath})
|
sensors = append(sensors, fanSensor{key, inputPath, chipName})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sensors, nil
|
return sensors, nil
|
||||||
@@ -115,3 +119,15 @@ func readFanSensors(sensors []fanSensor) map[string]uint16 {
|
|||||||
}
|
}
|
||||||
return fans
|
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,3 +103,20 @@ func TestFanDiscoveryCache(t *testing.T) {
|
|||||||
fans = readFanSensors(sensors)
|
fans = readFanSensors(sensors)
|
||||||
assert.Equal(t, map[string]uint16{"chip_fan1": 1200}, fans)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+29
-2
@@ -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.
|
// It only reports capability presence and does not apply policy decisions.
|
||||||
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
|
||||||
caps := gpuCapabilities{
|
caps := gpuCapabilities{
|
||||||
hasAmdSysfs: gm.hasAmdSysfs(),
|
hasAmdSysfs: gm.hasAmdSysfs(),
|
||||||
hasXe: gm.hasXe(),
|
hasXe: gm.hasXe(),
|
||||||
hasIntelSysfs: gm.hasIntelSysfs(),
|
hasIntelSysfs: gm.hasIntelSysfs(),
|
||||||
}
|
}
|
||||||
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
|
||||||
@@ -750,9 +750,36 @@ func (gm *GPUManager) resolveLegacyCollectorPriority(caps gpuCapabilities) []col
|
|||||||
return priorities
|
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
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
func NewGPUManager() (*GPUManager, error) {
|
func NewGPUManager() (*GPUManager, error) {
|
||||||
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
if skipGPU, _ := utils.GetEnv("SKIP_GPU"); skipGPU == "true" {
|
||||||
|
slog.Info("SKIP_GPU enabled, skipping GPU monitoring (collectors, temperatures, and fans)")
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
+122
-102
@@ -2,7 +2,9 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -49,10 +51,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output.
|
||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
func (gm *GPUManager) collectIntelStats() (err error) {
|
||||||
// Build command arguments, optionally selecting a device via -d
|
// Build command arguments, optionally selecting a device via -d
|
||||||
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
args := []string{"-s", intelGpuStatsInterval, "-J"}
|
||||||
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
if dev, ok := utils.GetEnv("INTEL_GPU_DEVICE"); ok && dev != "" {
|
||||||
args = append(args, "-d", dev)
|
args = append(args, "-d", dev)
|
||||||
}
|
}
|
||||||
@@ -80,48 +82,64 @@ func (gm *GPUManager) collectIntelStats() (err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
scanner := bufio.NewScanner(stdout)
|
if err := gm.parseIntelJSONStream(stdout); err != nil {
|
||||||
var header1 string
|
return err
|
||||||
var engineNames []string
|
}
|
||||||
var friendlyNames []string
|
// The closing "]" is printed as the process exits, so read to EOF to let
|
||||||
var preEngineCols int
|
// it finish instead of killing it.
|
||||||
var powerIndex int
|
_, _ = 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
|
||||||
|
}
|
||||||
var hadDataRow bool
|
var hadDataRow bool
|
||||||
// skip first data row because it sometimes has erroneous data
|
// skip first data row because it sometimes has erroneous data
|
||||||
var skippedFirstDataRow bool
|
var skippedFirstDataRow bool
|
||||||
|
// Decode reads one object and skips the commas between them. The array is
|
||||||
for scanner.Scan() {
|
// usually never closed, so output ending mid-array or mid-sample (the
|
||||||
line := strings.TrimSpace(scanner.Text())
|
// process was killed) is the normal end of the stream rather than an error.
|
||||||
if line == "" {
|
for dec.More() {
|
||||||
continue
|
var sample intelGpuJSONSample
|
||||||
|
if err := dec.Decode(&sample); err != nil {
|
||||||
|
if er.eof {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
if !skippedFirstDataRow {
|
||||||
skippedFirstDataRow = true
|
skippedFirstDataRow = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
stats := parseIntelJSONSample(sample)
|
||||||
if err != nil {
|
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) {
|
||||||
return err
|
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
hadDataRow = true
|
hadDataRow = true
|
||||||
gm.updateIntelFromStats(&sample)
|
gm.updateIntelFromStats(&stats)
|
||||||
}
|
|
||||||
if scanErr := scanner.Err(); scanErr != nil {
|
|
||||||
return scanErr
|
|
||||||
}
|
}
|
||||||
if !hadDataRow {
|
if !hadDataRow {
|
||||||
return errNoValidData
|
return errNoValidData
|
||||||
@@ -129,80 +147,82 @@ func (gm *GPUManager) collectIntelStats() (err error) {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
|
// eofReader records whether the underlying reader has returned io.EOF. The
|
||||||
// Build indexes
|
// json decoder reports a stream ending mid-value as a syntax error, so this
|
||||||
h1 := strings.Fields(header1)
|
// is how a truncated final sample is told apart from invalid output.
|
||||||
h2 := strings.Fields(header2)
|
type eofReader struct {
|
||||||
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
r io.Reader
|
||||||
// Collect engine names from header1
|
eof bool
|
||||||
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:
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
func (e *eofReader) Read(p []byte) (int, error) {
|
||||||
fields := strings.Fields(line)
|
n, err := e.r.Read(p)
|
||||||
if len(fields) == 0 {
|
if err == io.EOF {
|
||||||
return sample, errNoValidData
|
e.eof = true
|
||||||
}
|
}
|
||||||
// Make sure row has enough columns for engines
|
return n, err
|
||||||
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
}
|
||||||
return sample, errNoValidData
|
|
||||||
|
// 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()
|
||||||
|
default:
|
||||||
|
return b[0], nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if powerIndex >= 0 && powerIndex < len(fields) {
|
}
|
||||||
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
|
||||||
sample.PowerGPU = v
|
// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the
|
||||||
}
|
// needed fields are mapped.
|
||||||
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
|
type intelGpuJSONSample struct {
|
||||||
sample.PowerPkg = v
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(engineNames) > 0 {
|
return stats
|
||||||
sample.Engines = make(map[string]float64, len(engineNames))
|
}
|
||||||
for k := range engineNames {
|
|
||||||
base := preEngineCols + 3*k
|
// intelEngineClass returns the engine class name for an engine key. Keys are
|
||||||
if base < len(fields) {
|
// class names ("Render/3D", "Video") in class view, which JSON output uses by
|
||||||
busy := 0.0
|
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in
|
||||||
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
|
// physical view, which older versions use.
|
||||||
busy = v
|
func intelEngineClass(key string) string {
|
||||||
}
|
if i := strings.LastIndexByte(key, '/'); i >= 0 {
|
||||||
cur := sample.Engines[friendlyNames[k]]
|
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil {
|
||||||
sample.Engines[friendlyNames[k]] = cur + busy
|
return key[:i]
|
||||||
} else {
|
|
||||||
sample.Engines[friendlyNames[k]] = 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sample, nil
|
return key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//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)
|
||||||
|
}
|
||||||
+258
-350
@@ -3,9 +3,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1123,7 +1124,7 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
setupCommands func(string) error
|
setupCommands func(*testing.T, string)
|
||||||
wantNvidiaSmi bool
|
wantNvidiaSmi bool
|
||||||
wantRocmSmi bool
|
wantRocmSmi bool
|
||||||
wantTegrastats bool
|
wantTegrastats bool
|
||||||
@@ -1131,10 +1132,8 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nvidia-smi not available",
|
name: "nvidia-smi not available",
|
||||||
setupCommands: func(_ string) error {
|
setupCommands: func(*testing.T, string) {},
|
||||||
return nil
|
|
||||||
},
|
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
@@ -1143,14 +1142,8 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nvidia-smi available",
|
name: "nvidia-smi available",
|
||||||
setupCommands: func(tempDir string) error {
|
setupCommands: func(t *testing.T, tempDir string) {
|
||||||
path := filepath.Join(tempDir, "nvidia-smi")
|
gpuCommandFixture(t, tempDir, "nvidia-smi", "test\n")
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "test"`
|
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: true,
|
wantNvidiaSmi: true,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
@@ -1160,14 +1153,8 @@ echo "test"`
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rocm-smi available",
|
name: "rocm-smi available",
|
||||||
setupCommands: func(tempDir string) error {
|
setupCommands: func(t *testing.T, tempDir string) {
|
||||||
path := filepath.Join(tempDir, "rocm-smi")
|
gpuCommandFixture(t, tempDir, "rocm-smi", "test\n")
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "test"`
|
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: true,
|
wantRocmSmi: true,
|
||||||
@@ -1177,14 +1164,8 @@ echo "test"`
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tegrastats available",
|
name: "tegrastats available",
|
||||||
setupCommands: func(tempDir string) error {
|
setupCommands: func(t *testing.T, tempDir string) {
|
||||||
path := filepath.Join(tempDir, "tegrastats")
|
gpuCommandFixture(t, tempDir, "tegrastats", "test\n")
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "test"`
|
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
@@ -1194,14 +1175,8 @@ echo "test"`
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nvtop available",
|
name: "nvtop available",
|
||||||
setupCommands: func(tempDir string) error {
|
setupCommands: func(t *testing.T, tempDir string) {
|
||||||
path := filepath.Join(tempDir, "nvtop")
|
gpuCommandFixture(t, tempDir, "nvtop", "test\n")
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "[]"`
|
|
||||||
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
@@ -1210,12 +1185,9 @@ echo "[]"`
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func(_ string) error {
|
setupCommands: func(*testing.T, string) {},
|
||||||
// The subtest already restricts PATH to its empty temporary directory.
|
wantErr: true,
|
||||||
return nil
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1223,9 +1195,7 @@ echo "[]"`
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("PATH", tempDir)
|
t.Setenv("PATH", tempDir)
|
||||||
if err := tt.setupCommands(tempDir); err != nil {
|
tt.setupCommands(t, tempDir)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
gm := &GPUManager{}
|
gm := &GPUManager{}
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
@@ -1267,6 +1237,21 @@ echo "[]"`
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestCollectorStartHelpers(t *testing.T) {
|
||||||
// Set up temp dir with the commands
|
// Set up temp dir with the commands
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -1275,21 +1260,17 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
command string
|
command string
|
||||||
setup func(t *testing.T) error
|
gpuID string
|
||||||
|
setup func(t *testing.T)
|
||||||
validate func(t *testing.T, gm *GPUManager)
|
validate func(t *testing.T, gm *GPUManager)
|
||||||
gm *GPUManager
|
gm *GPUManager
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nvidia-smi collector",
|
name: "nvidia-smi collector",
|
||||||
command: "nvidia-smi",
|
command: "nvidia-smi",
|
||||||
setup: func(t *testing.T) error {
|
gpuID: "0",
|
||||||
path := filepath.Join(dir, "nvidia-smi")
|
setup: func(t *testing.T) {
|
||||||
script := `#!/bin/sh
|
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100`+"\n")
|
||||||
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) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["0"]
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
@@ -1304,14 +1285,9 @@ echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
|
|||||||
{
|
{
|
||||||
name: "rocm-smi collector",
|
name: "rocm-smi collector",
|
||||||
command: "rocm-smi",
|
command: "rocm-smi",
|
||||||
setup: func(t *testing.T) error {
|
gpuID: "34756",
|
||||||
path := filepath.Join(dir, "rocm-smi")
|
setup: func(t *testing.T) {
|
||||||
script := `#!/bin/sh
|
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")
|
||||||
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) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["34756"]
|
gpu, exists := gm.GpuDataMap["34756"]
|
||||||
@@ -1326,14 +1302,9 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
|
|||||||
{
|
{
|
||||||
name: "tegrastats collector",
|
name: "tegrastats collector",
|
||||||
command: "tegrastats",
|
command: "tegrastats",
|
||||||
setup: func(t *testing.T) error {
|
gpuID: "0",
|
||||||
path := filepath.Join(dir, "tegrastats")
|
setup: func(t *testing.T) {
|
||||||
script := `#!/bin/sh
|
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n")
|
||||||
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) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["0"]
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
@@ -1351,14 +1322,9 @@ echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000m
|
|||||||
{
|
{
|
||||||
name: "nvtop collector",
|
name: "nvtop collector",
|
||||||
command: "nvtop",
|
command: "nvtop",
|
||||||
setup: func(t *testing.T) error {
|
gpuID: "n0",
|
||||||
path := filepath.Join(dir, "nvtop")
|
setup: func(t *testing.T) {
|
||||||
script := `#!/bin/sh
|
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")
|
||||||
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) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["n0"]
|
gpu, exists := gm.GpuDataMap["n0"]
|
||||||
@@ -1373,9 +1339,7 @@ echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_uti
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if err := tt.setup(t); err != nil {
|
tt.setup(t)
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if tt.gm == nil {
|
if tt.gm == nil {
|
||||||
tt.gm = &GPUManager{
|
tt.gm = &GPUManager{
|
||||||
GpuDataMap: make(map[string]*system.GPUData),
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
@@ -1393,7 +1357,9 @@ echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_uti
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unknown test command %q", tt.command)
|
t.Fatalf("unknown test command %q", tt.command)
|
||||||
}
|
}
|
||||||
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
waitGPUs(t, tt.gm, tt.gpuID)
|
||||||
|
tt.gm.Lock()
|
||||||
|
defer tt.gm.Unlock()
|
||||||
tt.validate(t, tt.gm)
|
tt.validate(t, tt.gm)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1404,21 +1370,17 @@ func TestNewGPUManagerPriorityNvtopFallback(t *testing.T) {
|
|||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvtop,nvidia-smi")
|
||||||
|
|
||||||
nvtopPath := filepath.Join(dir, "nvtop")
|
gpuCommandFixture(t, dir, "nvtop", `not-json`+"\n")
|
||||||
nvtopScript := `#!/bin/sh
|
|
||||||
echo 'not-json'`
|
|
||||||
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
|
|
||||||
|
|
||||||
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25`+"\n")
|
||||||
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()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
waitGPUs(t, gm, "0")
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
gpu, ok := gm.GpuDataMap["0"]
|
gpu, ok := gm.GpuDataMap["0"]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, "Priority GPU", gpu.Name)
|
assert.Equal(t, "Priority GPU", gpu.Name)
|
||||||
@@ -1430,52 +1392,27 @@ func TestNewGPUManagerPriorityMixedCollectors(t *testing.T) {
|
|||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "intel_gpu_top,rocm-smi")
|
||||||
|
|
||||||
intelPath := filepath.Join(dir, "intel_gpu_top")
|
intelOutput := intelJSONStream(true,
|
||||||
intelScript := `#!/bin/sh
|
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
||||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
|
||||||
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"
|
gpuCommandFixture(t, dir, intelGpuStatsCmd, intelOutput+"\n")
|
||||||
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))
|
|
||||||
|
|
||||||
rocmPath := filepath.Join(dir, "rocm-smi")
|
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")
|
||||||
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()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
waitGPUs(t, gm, "i0", "34756")
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
_, intelOk := gm.GpuDataMap["i0"]
|
_, intelOk := gm.GpuDataMap["i0"]
|
||||||
_, amdOk := gm.GpuDataMap["34756"]
|
_, amdOk := gm.GpuDataMap["34756"]
|
||||||
assert.True(t, intelOk)
|
assert.True(t, intelOk)
|
||||||
assert.True(t, amdOk)
|
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) {
|
func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
@@ -1510,8 +1447,12 @@ func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
|||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
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.Nil(t, gm)
|
||||||
require.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
||||||
}
|
}
|
||||||
@@ -1521,16 +1462,15 @@ func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
|||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvidia-smi")
|
||||||
|
|
||||||
tegraPath := filepath.Join(dir, "tegrastats")
|
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n")
|
||||||
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()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
waitGPUs(t, gm, "0")
|
||||||
|
gm.Lock()
|
||||||
|
defer gm.Unlock()
|
||||||
gpu, ok := gm.GpuDataMap["0"]
|
gpu, ok := gm.GpuDataMap["0"]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Equal(t, "GPU", gpu.Name)
|
assert.Equal(t, "GPU", gpu.Name)
|
||||||
@@ -1752,22 +1692,60 @@ func TestIntelUpdateFromStats(t *testing.T) {
|
|||||||
assert.Equal(t, float64(2), gpu.Count)
|
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) {
|
func TestIntelCollectorStreaming(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
engines := func(render, blitter, video float64) map[string]float64 {
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video}
|
||||||
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{
|
gm := &GPUManager{
|
||||||
GpuDataMap: make(map[string]*system.GPUData),
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
@@ -1781,229 +1759,168 @@ echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50
|
|||||||
gpu := gm.GpuDataMap["i0"]
|
gpu := gm.GpuDataMap["i0"]
|
||||||
require.NotNil(t, gpu)
|
require.NotNil(t, gpu)
|
||||||
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
// Power should be sum of samples 2-4 (first is skipped): 2.0 + 1.8 + 2.2 = 6.0
|
||||||
assert.EqualValues(t, 6.0, gpu.Power)
|
assert.InDelta(t, 6.0, gpu.Power, 0.001)
|
||||||
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
||||||
// Engines aggregated from samples 2-4
|
// Engines aggregated from samples 2-4
|
||||||
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
|
assert.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75
|
||||||
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
|
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00
|
||||||
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 0.00 + 15.00 + 9.50
|
assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50
|
||||||
// Count should be 3 samples (first is skipped)
|
// Count should be 3 samples (first is skipped)
|
||||||
assert.Equal(t, float64(3), gpu.Count)
|
assert.Equal(t, float64(3), gpu.Count)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntelHeaders(t *testing.T) {
|
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}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header1 string
|
input string
|
||||||
header2 string
|
wantErr error
|
||||||
wantEngineNames []string
|
wantAnyErr bool
|
||||||
wantFriendlyNames []string
|
wantCount float64
|
||||||
wantPowerIndex int
|
wantPower float64
|
||||||
wantPreEngineCols int
|
wantPkg float64
|
||||||
|
wantEngines map[string]float64
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic headers with RCS BCS VCS",
|
name: "array still open while process runs",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
|
input: intelJSONStream(true, first, classView[0], classView[1]),
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
wantCount: 2,
|
||||||
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
wantPower: 3,
|
||||||
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
wantPkg: 5,
|
||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantEngines: classViewWant,
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "basic headers with RCS BCS VCS using index in name",
|
name: "closed array",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n",
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
wantCount: 2,
|
||||||
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
wantPower: 3,
|
||||||
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
wantPkg: 5,
|
||||||
wantPowerIndex: 4, // "gpu" is at index 4
|
wantEngines: classViewWant,
|
||||||
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "headers with only RCS",
|
name: "truncated final sample",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa",
|
wantCount: 2,
|
||||||
wantEngineNames: []string{"RCS"},
|
wantPower: 3,
|
||||||
wantFriendlyNames: []string{"Render/3D"},
|
wantPkg: 5,
|
||||||
wantPowerIndex: 4,
|
wantEngines: classViewWant,
|
||||||
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "headers with VECS and CCS",
|
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
|
name: "legacy output without array and with engine instances",
|
||||||
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
|
input: intelJSONStream(false,
|
||||||
wantEngineNames: []string{"VECS", "CCS"},
|
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}),
|
||||||
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
|
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}),
|
||||||
wantPowerIndex: 4,
|
),
|
||||||
wantPreEngineCols: 8, // 14 total - 3*2 = 8
|
wantCount: 1,
|
||||||
|
wantPower: 1.5,
|
||||||
|
wantPkg: 2.5,
|
||||||
|
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no engines",
|
// energy counter read lower than the previous sample in intel_gpu_top
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
|
name: "sample with invalid power is skipped",
|
||||||
header2: " req act /s % gpu pkg rd wr",
|
input: intelJSONStream(true, first, classView[0],
|
||||||
wantEngineNames: nil, // no engines found, slices remain nil
|
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}),
|
||||||
wantFriendlyNames: nil,
|
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}),
|
||||||
wantPowerIndex: -1, // no engines, so no search
|
classView[1],
|
||||||
wantPreEngineCols: 0,
|
),
|
||||||
|
wantCount: 2,
|
||||||
|
wantPower: 3,
|
||||||
|
wantPkg: 5,
|
||||||
|
wantEngines: classViewWant,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "power index not found",
|
name: "only samples with invalid power",
|
||||||
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})),
|
||||||
header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
|
wantErr: errNoValidData,
|
||||||
wantEngineNames: []string{"RCS"},
|
|
||||||
wantFriendlyNames: []string{"Render/3D"},
|
|
||||||
wantPowerIndex: -1, // "gpu" not found
|
|
||||||
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty headers",
|
name: "empty output",
|
||||||
header1: "",
|
input: "",
|
||||||
header2: "",
|
wantErr: errNoValidData,
|
||||||
wantEngineNames: nil, // empty input, slices remain nil
|
},
|
||||||
wantFriendlyNames: nil,
|
{
|
||||||
wantPowerIndex: -1,
|
name: "only first sample, which is skipped",
|
||||||
wantPreEngineCols: 0,
|
input: intelJSONStream(true, first),
|
||||||
|
wantErr: errNoValidData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid output",
|
||||||
|
input: "intel_gpu_top: command failed",
|
||||||
|
wantAnyErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
gm := &GPUManager{}
|
gm := &GPUManager{GpuDataMap: make(map[string]*system.GPUData)}
|
||||||
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
|
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)
|
||||||
|
|
||||||
assert.Equal(t, tt.wantEngineNames, engineNames)
|
gpu := gm.GpuDataMap["i0"]
|
||||||
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
|
require.NotNil(t, gpu)
|
||||||
assert.Equal(t, tt.wantPowerIndex, powerIndex)
|
assert.Equal(t, tt.wantCount, gpu.Count)
|
||||||
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
|
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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestParseIntelData(t *testing.T) {
|
func TestParseIntelJSONSample(t *testing.T) {
|
||||||
tests := []struct {
|
t.Run("without power", func(t *testing.T) {
|
||||||
name string
|
var sample intelGpuJSONSample
|
||||||
line string
|
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample))
|
||||||
engineNames []string
|
stats := parseIntelJSONSample(sample)
|
||||||
friendlyNames []string
|
assert.Zero(t, stats.PowerGPU)
|
||||||
powerIndex int
|
assert.Zero(t, stats.PowerPkg)
|
||||||
preEngineCols int
|
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines)
|
||||||
wantPowerGPU float64
|
})
|
||||||
wantEngines map[string]float64
|
|
||||||
wantErr error
|
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))
|
||||||
name: "basic data with power and engines",
|
stats := parseIntelJSONSample(sample)
|
||||||
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
assert.Equal(t, 1.25, stats.PowerGPU)
|
||||||
engineNames: []string{"RCS", "BCS", "VCS"},
|
assert.Equal(t, 4.5, stats.PowerPkg)
|
||||||
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
assert.Nil(t, stats.Engines)
|
||||||
powerIndex: 4,
|
})
|
||||||
preEngineCols: 8,
|
}
|
||||||
wantPowerGPU: 1.50,
|
|
||||||
wantEngines: map[string]float64{
|
func TestIntelEngineClass(t *testing.T) {
|
||||||
"Render/3D": 12.34,
|
tests := map[string]string{
|
||||||
"Blitter": 0.00,
|
"Render/3D": "Render/3D",
|
||||||
"Video": 5.00,
|
"Render/3D/0": "Render/3D",
|
||||||
},
|
"Blitter": "Blitter",
|
||||||
},
|
"Blitter/0": "Blitter",
|
||||||
{
|
"Video/1": "Video",
|
||||||
name: "data with zero power",
|
"VideoEnhance/0": "VideoEnhance",
|
||||||
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
|
"Compute/3": "Compute",
|
||||||
engineNames: []string{"RCS", "BCS", "VCS"},
|
"[unknown]": "[unknown]",
|
||||||
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
"[unknown]/0": "[unknown]",
|
||||||
powerIndex: 4,
|
"Video/": "Video/",
|
||||||
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 {
|
||||||
for _, tt := range tests {
|
assert.Equal(t, want, intelEngineClass(key), key)
|
||||||
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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2011,21 +1928,12 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
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
|
// Create a fake intel_gpu_top that records its arguments and prints minimal valid output
|
||||||
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
output := intelJSONStream(true,
|
||||||
script := fmt.Sprintf(`#!/bin/sh
|
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
||||||
echo "$@" > %s
|
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
|
||||||
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
)
|
||||||
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
argsFile := gpuCommandFixture(t, dir, intelGpuStatsCmd, output)
|
||||||
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
|
// Set device selector via prefixed env var
|
||||||
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
||||||
@@ -2043,5 +1951,5 @@ echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00
|
|||||||
argsStr := strings.TrimSpace(string(data))
|
argsStr := strings.TrimSpace(string(data))
|
||||||
require.Contains(t, argsStr, "-d sriov")
|
require.Contains(t, argsStr, "-d sriov")
|
||||||
require.Contains(t, argsStr, "-s ")
|
require.Contains(t, argsStr, "-s ")
|
||||||
require.Contains(t, argsStr, "-l")
|
require.Contains(t, argsStr, "-J")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
)
|
)
|
||||||
@@ -52,8 +53,10 @@ func NewHandlerRegistry() *HandlerRegistry {
|
|||||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||||
|
registry.Register(common.GetSystemdLogs, &GetSystemdLogsHandler{})
|
||||||
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
registry.Register(common.SyncNetworkMonitors, &SyncNetworkMonitorsHandler{})
|
||||||
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
registry.Register(common.GetZfsData, &GetZfsDataHandler{})
|
||||||
|
registry.Register(common.GetPackageUpdates, &GetPackageUpdatesHandler{})
|
||||||
|
|
||||||
return registry
|
return registry
|
||||||
}
|
}
|
||||||
@@ -198,6 +201,20 @@ func (h *GetZfsDataHandler) Handle(hctx *HandlerContext) error {
|
|||||||
return hctx.SendResponse(hctx.Agent.storagePoolManager.GetDetail(req.Force), hctx.RequestID)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -229,6 +246,33 @@ 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
|
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
|
||||||
type SyncNetworkMonitorsHandler struct{}
|
type SyncNetworkMonitorsHandler struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MonitorManager struct {
|
|||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
monitors map[string]*monitorTask // keyed by monitor ID
|
monitors map[string]*monitorTask // keyed by monitor ID
|
||||||
probe monitorProbe
|
probe monitorProbe
|
||||||
|
certCheck certChecker
|
||||||
resumeGuard monitorResumeGuard
|
resumeGuard monitorResumeGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ func newMonitorManager() *MonitorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
||||||
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
|
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncMonitors replaces all monitor tasks with the given configs.
|
// SyncMonitors replaces all monitor tasks with the given configs.
|
||||||
@@ -107,7 +108,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
if !runNow {
|
if !runNow {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return task.runProbe(pm.probe), nil
|
return pm.runNow(task), nil
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -119,7 +120,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
|
|
||||||
if runNow {
|
if runNow {
|
||||||
result := task.runProbe(pm.probe)
|
result := pm.runNow(task)
|
||||||
pm.startMonitor(task)
|
pm.startMonitor(task)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -127,6 +128,19 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
return nil, nil
|
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.
|
// DeleteMonitor stops and removes a single monitor task.
|
||||||
func (pm *MonitorManager) DeleteMonitor(id string) {
|
func (pm *MonitorManager) DeleteMonitor(id string) {
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -158,6 +172,11 @@ func (pm *MonitorManager) GetResults(durationMs uint16) map[string]monitor.Resul
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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
|
results[task.config.ID] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//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,9 +8,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"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.
|
// monitorProbe performs one check. Errors are recorded as loss by the task runner.
|
||||||
// Implementations must honor cancellation and bound their execution time.
|
// Implementations must honor cancellation and bound their execution time.
|
||||||
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
type monitorProbe func(context.Context, monitor.Config) (int64, error)
|
||||||
@@ -25,7 +28,7 @@ func networkMonitorProbe(client *http.Client) monitorProbe {
|
|||||||
case "http":
|
case "http":
|
||||||
return monitorHTTP(ctx, client, config.Target)
|
return monitorHTTP(ctx, client, config.Target)
|
||||||
case "dns":
|
case "dns":
|
||||||
return monitorDNS(ctx, config.Target)
|
return monitorDNS(ctx, config.Target, config.Server)
|
||||||
default:
|
default:
|
||||||
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
||||||
}
|
}
|
||||||
@@ -70,19 +73,43 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
|
|||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
|
// monitorDNS measures DNS resolution response time in microseconds. If server is
|
||||||
func monitorDNS(ctx context.Context, target string) (int64, error) {
|
// 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) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := net.DefaultResolver
|
||||||
|
if server != "" {
|
||||||
|
resolver = dnsResolverForServer(server)
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
ips, err := net.DefaultResolver.LookupHost(ctx, target)
|
ips, err := resolver.LookupHost(ctx, target)
|
||||||
if err != nil || len(ips) == 0 {
|
if err != nil || len(ips) == 0 {
|
||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
return time.Since(start).Microseconds(), nil
|
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.
|
// 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) {
|
func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, error) {
|
||||||
if client == nil {
|
if client == nil {
|
||||||
@@ -93,6 +120,7 @@ func monitorHTTP(ctx context.Context, client *http.Client, url string) (int64, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
|
req.Header.Set("User-Agent", networkMonitorUserAgent)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return -1, err
|
return -1, err
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ func (pm *MonitorManager) startMonitor(task *monitorTask) {
|
|||||||
}
|
}
|
||||||
delay := getStagger(interval.Milliseconds())
|
delay := getStagger(interval.Milliseconds())
|
||||||
slog.Debug("starting monitor task", "target", task.config.Target, "delay", delay, "interval", interval)
|
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() {
|
go runMonitorSchedule(task.ctx, interval, delay, func() {
|
||||||
if _, allowed := task.resumeGuard.snapshot(); allowed {
|
if _, allowed := task.resumeGuard.snapshot(); allowed {
|
||||||
task.runProbe(pm.probe)
|
task.runProbe(pm.probe)
|
||||||
|
task.refreshCert(pm.certCheck)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ type monitorTask struct {
|
|||||||
runMu sync.Mutex
|
runMu sync.Mutex
|
||||||
inflight *monitorRun
|
inflight *monitorRun
|
||||||
lastFailureLog int64 // Unix nanoseconds
|
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 {
|
type monitorRun struct {
|
||||||
@@ -45,6 +51,11 @@ func newMonitorTaskFromExisting(config monitor.Config, existing *monitorTask) *m
|
|||||||
task := newMonitorTask(config)
|
task := newMonitorTask(config)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
task.history = existing.history.clone()
|
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
|
return task
|
||||||
}
|
}
|
||||||
@@ -107,6 +118,70 @@ func (task *monitorTask) runProbe(probe monitorProbe) *monitor.Result {
|
|||||||
return copyMonitorResult(run.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 {
|
func copyMonitorResult(result *monitor.Result) *monitor.Result {
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
"github.com/henrygd/beszel/internal/entities/monitor"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -240,6 +241,7 @@ func TestMonitorManagerGetRandomDelay(t *testing.T) {
|
|||||||
func TestMonitorHTTP(t *testing.T) {
|
func TestMonitorHTTP(t *testing.T) {
|
||||||
t.Run("success", func(t *testing.T) {
|
t.Run("success", func(t *testing.T) {
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
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)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
@@ -374,15 +376,79 @@ 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) {
|
func TestMonitorDNS(t *testing.T) {
|
||||||
t.Run("success", func(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)
|
require.NoError(t, err)
|
||||||
assert.GreaterOrEqual(t, responseUs, int64(0))
|
assert.GreaterOrEqual(t, responseUs, int64(0))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("lookup failure", func(t *testing.T) {
|
t.Run("lookup failure", func(t *testing.T) {
|
||||||
responseUs, err := monitorDNS(context.Background(), "")
|
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")
|
||||||
assert.Equal(t, int64(-1), responseUs)
|
assert.Equal(t, int64(-1), responseUs)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
})
|
})
|
||||||
@@ -477,7 +543,7 @@ func TestMonitorResolutionCancellation(t *testing.T) {
|
|||||||
case "tcp":
|
case "tcp":
|
||||||
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
|
_, err = monitorTCP(ctx, "monitor-cancellation.invalid.", 80)
|
||||||
case "dns":
|
case "dns":
|
||||||
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.")
|
_, err = monitorDNS(ctx, "monitor-cancellation.invalid.", "")
|
||||||
case "icmp":
|
case "icmp":
|
||||||
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
|
_, err = monitorICMP(ctx, "monitor-cancellation.invalid.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
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:]
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
//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())
|
||||||
|
}
|
||||||
+123
-2
@@ -5,7 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -32,6 +34,8 @@ type SensorConfig struct {
|
|||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
|
skipGPU bool
|
||||||
|
sensorShadow string
|
||||||
firstRun bool
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,13 +45,14 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
|||||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
||||||
|
skipGPU, _ := utils.GetEnv("SKIP_GPU")
|
||||||
|
|
||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection, skipGPU == "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection, skipGPU bool) *SensorConfig {
|
||||||
timeout := 2 * time.Second
|
timeout := 2 * time.Second
|
||||||
if sensorsTimeout != "" {
|
if sensorsTimeout != "" {
|
||||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||||
@@ -62,6 +67,7 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
|
|||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
|
skipGPU: skipGPU,
|
||||||
firstRun: true,
|
firstRun: true,
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
@@ -73,6 +79,19 @@ func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal,
|
|||||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
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
|
// handle blacklist
|
||||||
if strings.HasPrefix(sensorsEnvVal, "-") {
|
if strings.HasPrefix(sensorsEnvVal, "-") {
|
||||||
@@ -149,6 +168,9 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
if !isValidSensor(sensorName, a.sensorConfig) {
|
if !isValidSensor(sensorName, a.sensorConfig) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if a.sensorConfig.skipGPU && isGpuSensorKey(sensorName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// set dashboard temperature
|
// set dashboard temperature
|
||||||
switch a.sensorConfig.primarySensor {
|
switch a.sensorConfig.primarySensor {
|
||||||
case "":
|
case "":
|
||||||
@@ -245,3 +267,102 @@ func scaleTemperature(temp float64) float64 {
|
|||||||
}
|
}
|
||||||
return scaled100
|
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
|
||||||
|
}
|
||||||
|
|||||||
+154
-1
@@ -5,6 +5,9 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -328,7 +331,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection, false)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -620,3 +623,153 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
||||||
assert.Equal(t, map[string]float64{}, stats.Temperatures)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
+14
-11
@@ -57,21 +57,11 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
}
|
}
|
||||||
defer ln.Close()
|
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
|
// set default handler
|
||||||
ssh.Handle(a.handleSession)
|
ssh.Handle(a.handleSession)
|
||||||
|
|
||||||
a.server = &ssh.Server{
|
a.server = &ssh.Server{
|
||||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
ServerConfigCallback: newSSHServerConfig,
|
||||||
return config
|
|
||||||
},
|
|
||||||
// check public key(s)
|
// check public key(s)
|
||||||
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
|
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
remoteAddr := ctx.RemoteAddr()
|
remoteAddr := ctx.RemoteAddr()
|
||||||
@@ -96,6 +86,19 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
return a.server.Serve(ln)
|
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
|
// getHubVersion extracts the hub version from the SSH client version string
|
||||||
// for a given session. Returns a zero version if parsing fails.
|
// for a given session. Returns a zero version if parsing fails.
|
||||||
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
func (a *Agent) getHubVersion(sessionCtx ssh.Context) semver.Version {
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-9
@@ -311,11 +311,13 @@ func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceIn
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSmartOutputType inspects sections that are unique to each smartctl
|
// detectSmartOutputType inspects protocol-specific sections and the reported
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
// device type to choose a parser, including when the NVMe health log is missing.
|
||||||
// when the reported device type is ambiguous or missing.
|
|
||||||
func detectSmartOutputType(output []byte) string {
|
func detectSmartOutputType(output []byte) string {
|
||||||
var hints struct {
|
var hints struct {
|
||||||
|
Device struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
} `json:"device"`
|
||||||
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
|
AtaSmartAttributes json.RawMessage `json:"ata_smart_attributes"`
|
||||||
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
|
NVMeSmartHealthInformationLog json.RawMessage `json:"nvme_smart_health_information_log"`
|
||||||
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
|
ScsiErrorCounterLog json.RawMessage `json:"scsi_error_counter_log"`
|
||||||
@@ -326,7 +328,7 @@ func detectSmartOutputType(output []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
|
case hasJSONValue(hints.NVMeSmartHealthInformationLog), normalizeParserType(hints.Device.Type) == "nvme":
|
||||||
return "nvme"
|
return "nvme"
|
||||||
case hasJSONValue(hints.AtaSmartAttributes):
|
case hasJSONValue(hints.AtaSmartAttributes):
|
||||||
return "sat"
|
return "sat"
|
||||||
@@ -397,11 +399,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only run the type detection when we do not yet know which parser works
|
// Inspect every response so a failed NVMe query cannot reach other parsers.
|
||||||
// or the previous attempt failed.
|
structureType := detectSmartOutputType(output)
|
||||||
|
// Update the stored parser only when it is not yet verified.
|
||||||
needsDetection := deviceType == "" || !deviceInfo.typeVerified
|
needsDetection := deviceType == "" || !deviceInfo.typeVerified
|
||||||
if needsDetection {
|
if needsDetection {
|
||||||
structureType := detectSmartOutputType(output)
|
|
||||||
if deviceType != structureType {
|
if deviceType != structureType {
|
||||||
deviceType = structureType
|
deviceType = structureType
|
||||||
deviceInfo.parserType = structureType
|
deviceInfo.parserType = structureType
|
||||||
@@ -442,6 +444,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
|
|
||||||
// Try the selected parsers in order until we find one that succeeds.
|
// Try the selected parsers in order until we find one that succeeds.
|
||||||
for _, parser := range selectedParsers {
|
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)
|
hasData, _ := parser.Parse(output)
|
||||||
if hasData {
|
if hasData {
|
||||||
deviceInfo.parserType = parser.Type
|
deviceInfo.parserType = parser.Type
|
||||||
@@ -1145,6 +1152,16 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
|
|||||||
return false, data.Smartctl.ExitStatus
|
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()
|
sm.Lock()
|
||||||
defer sm.Unlock()
|
defer sm.Unlock()
|
||||||
|
|
||||||
@@ -1167,7 +1184,7 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
|
|||||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||||
}
|
}
|
||||||
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
smartData.Temperature = log.Temperature
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
smartData.DiskName = data.Device.Name
|
||||||
smartData.DiskType = data.Device.Type
|
smartData.DiskType = data.Device.Type
|
||||||
@@ -1177,7 +1194,6 @@ func (sm *SmartManager) parseSmartForNvme(output []byte, deviceType string) (boo
|
|||||||
|
|
||||||
// nvme attributes does not follow the same format as ata attributes,
|
// nvme attributes does not follow the same format as ata attributes,
|
||||||
// so we manually map each field to SmartAttributes
|
// so we manually map each field to SmartAttributes
|
||||||
log := data.NVMeSmartHealthInformationLog
|
|
||||||
smartData.Attributes = []*smart.SmartAttribute{
|
smartData.Attributes = []*smart.SmartAttribute{
|
||||||
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
|
{Name: "CriticalWarning", RawValue: uint64(log.CriticalWarning)},
|
||||||
{Name: "Temperature", RawValue: uint64(log.Temperature)},
|
{Name: "Temperature", RawValue: uint64(log.Temperature)},
|
||||||
|
|||||||
@@ -837,6 +837,27 @@ func TestParseSmartOutputMarksVerified(t *testing.T) {
|
|||||||
assert.True(t, device.typeVerified)
|
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) {
|
func TestParseSmartOutputKeepsCustomType(t *testing.T) {
|
||||||
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
fixturePath := filepath.Join("test-data", "smart", "sda.json")
|
||||||
data, err := os.ReadFile(fixturePath)
|
data, err := os.ReadFile(fixturePath)
|
||||||
|
|||||||
+71
-21
@@ -54,12 +54,18 @@ type poolBackend struct {
|
|||||||
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
kernelStatsFn func() ([]zfs.PoolKernelStat, error) // procfs pool state/I/O source
|
||||||
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
poolStatusesFn func() ([]zfs.PoolStatus, error) // scrub/vdev detail source
|
||||||
|
|
||||||
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
// Utility-backed caches below are refreshed in the background after the
|
||||||
lastPoolStats time.Time
|
// first collection, so cacheMu guards them against those goroutines.
|
||||||
kernelSamples map[string]poolKernelSample
|
cacheMu sync.Mutex
|
||||||
|
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
||||||
|
lastPoolStats time.Time
|
||||||
|
poolRefreshing bool
|
||||||
|
|
||||||
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
datasetUsage map[string]zfsDatasetUsage // mountpoint -> usage
|
||||||
lastUsageRefresh time.Time
|
lastUsageRefresh time.Time
|
||||||
|
usageRefreshing bool
|
||||||
|
|
||||||
|
kernelSamples map[string]poolKernelSample
|
||||||
|
|
||||||
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
// Detail data (pools, vdevs, scrub, datasets) is cached and refreshed on
|
||||||
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
// an interval. Accessed from handler goroutines, so it is mutex-protected.
|
||||||
@@ -177,21 +183,42 @@ func (b *poolBackend) updateBackendStats(systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// poolStats returns the cached pool inventory, calling its collector at most
|
// poolStats returns the cached pool inventory, calling its collector at most
|
||||||
// every poolStatsRefreshInterval. On failure the previous inventory is
|
// every poolStatsRefreshInterval. Only the first collection blocks; later
|
||||||
// retained and the refresh is retried on the next cadence.
|
// 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.
|
||||||
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
func (b *poolBackend) poolStats() []zfs.PoolStat {
|
||||||
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
b.cacheMu.Lock()
|
||||||
pools, err := b.poolStatsFn()
|
defer b.cacheMu.Unlock()
|
||||||
if err != nil {
|
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
|
||||||
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
return b.poolData
|
||||||
} else {
|
|
||||||
b.poolData = pools
|
|
||||||
}
|
|
||||||
b.lastPoolStats = time.Now()
|
|
||||||
}
|
}
|
||||||
|
if b.lastPoolStats.IsZero() {
|
||||||
|
b.storePoolStats(b.poolStatsFn())
|
||||||
|
return b.poolData
|
||||||
|
}
|
||||||
|
b.poolRefreshing = true
|
||||||
|
go func() {
|
||||||
|
pools, err := b.poolStatsFn()
|
||||||
|
b.cacheMu.Lock()
|
||||||
|
defer b.cacheMu.Unlock()
|
||||||
|
b.poolRefreshing = false
|
||||||
|
b.storePoolStats(pools, err)
|
||||||
|
}()
|
||||||
return b.poolData
|
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
|
||||||
|
}
|
||||||
|
b.lastPoolStats = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
// kernelStats reads cumulative pool counters and converts them to per-second
|
// kernelStats reads cumulative pool counters and converts them to per-second
|
||||||
// rates. Counter decreases indicate a pool export/import and reset the
|
// rates. Counter decreases indicate a pool export/import and reset the
|
||||||
// baseline instead of producing an underflow spike.
|
// baseline instead of producing an underflow spike.
|
||||||
@@ -225,12 +252,33 @@ func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]z
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
||||||
// and rebuilds the mountpoint-keyed usage map.
|
// and returns the mountpoint-keyed usage map. Like poolStats, only the first
|
||||||
func (b *poolBackend) refreshDatasetUsage() {
|
// collection blocks and later refreshes run in the background.
|
||||||
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
|
||||||
return
|
b.cacheMu.Lock()
|
||||||
|
defer b.cacheMu.Unlock()
|
||||||
|
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
|
||||||
|
return b.datasetUsage
|
||||||
}
|
}
|
||||||
datasets, err := b.datasets()
|
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) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
slog.Debug("Storage pool dataset usage unavailable", "backend", b.name, "err", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -251,8 +299,7 @@ func (b *poolBackend) refreshDatasetUsage() {
|
|||||||
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
func (m *StoragePoolManager) DatasetUsage() map[string]zfsDatasetUsage {
|
||||||
for _, backend := range m.backends {
|
for _, backend := range m.backends {
|
||||||
if backend.name == "zfs" {
|
if backend.name == "zfs" {
|
||||||
backend.refreshDatasetUsage()
|
return backend.refreshDatasetUsage()
|
||||||
return backend.datasetUsage
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -442,7 +489,10 @@ func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, backend := range m.backends {
|
for _, backend := range m.backends {
|
||||||
for _, pool := range backend.poolData {
|
backend.cacheMu.Lock()
|
||||||
|
pools := backend.poolData
|
||||||
|
backend.cacheMu.Unlock()
|
||||||
|
for _, pool := range pools {
|
||||||
sample := stats.ZfsPools[pool.Name]
|
sample := stats.ZfsPools[pool.Name]
|
||||||
if sample == nil || pool.MountID == "" {
|
if sample == nil || pool.MountID == "" {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -518,3 +518,31 @@ func TestBtrfsPoolIdentities(t *testing.T) {
|
|||||||
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
assert.Equal(t, first, zm.GetDetail(true).Pools[1].Name)
|
||||||
assert.Equal(t, "renamed", zm.GetDetail(true).Pools[1].DisplayName)
|
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,6 +14,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
"github.com/henrygd/beszel/agent/btrfs"
|
"github.com/henrygd/beszel/agent/btrfs"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
|
"github.com/henrygd/beszel/agent/wifi"
|
||||||
"github.com/henrygd/beszel/agent/zfs"
|
"github.com/henrygd/beszel/agent/zfs"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -267,6 +268,14 @@ 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
|
// update system info
|
||||||
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
a.systemInfo.ConnectionType = a.connectionManager.ConnectionType
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
|||||||
+115
-4
@@ -3,12 +3,15 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -20,6 +23,50 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var errNoActiveTime = errors.New("no active time")
|
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.
|
// systemdManager manages the collection of systemd service statistics.
|
||||||
type systemdManager struct {
|
type systemdManager struct {
|
||||||
@@ -27,6 +74,7 @@ type systemdManager struct {
|
|||||||
serviceStatsMap map[string]*systemd.Service
|
serviceStatsMap map[string]*systemd.Service
|
||||||
isRunning bool
|
isRunning bool
|
||||||
hasFreshStats bool
|
hasFreshStats bool
|
||||||
|
logsEnabled bool // journal logs can be read and are not disabled via SKIP_SYSTEMD_LOGS
|
||||||
patterns []string
|
patterns []string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +116,7 @@ func newSystemdManager() (*systemdManager, error) {
|
|||||||
|
|
||||||
manager := &systemdManager{
|
manager := &systemdManager{
|
||||||
serviceStatsMap: make(map[string]*systemd.Service),
|
serviceStatsMap: make(map[string]*systemd.Service),
|
||||||
|
logsEnabled: systemdLogsEnabled(),
|
||||||
patterns: getServicePatterns(),
|
patterns: getServicePatterns(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +281,14 @@ func (sm *systemdManager) updateServiceStats(conn *dbus.Conn, unit dbus.UnitStat
|
|||||||
return service, nil
|
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.
|
// getServiceDetails collects extended information for a specific systemd service.
|
||||||
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.ServiceDetails, error) {
|
||||||
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
conn, err := dbus.NewSystemConnectionContext(context.Background())
|
||||||
@@ -240,10 +297,7 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
unitName := serviceName
|
unitName := serviceUnitName(serviceName)
|
||||||
if !strings.HasSuffix(unitName, ".service") {
|
|
||||||
unitName += ".service"
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
props, err := conn.GetUnitPropertiesContext(ctx, unitName)
|
||||||
@@ -278,6 +332,63 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
|
|||||||
return details, nil
|
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
|
// unescapeServiceName unescapes systemd service names that contain C-style escape sequences like \x2d
|
||||||
func unescapeServiceName(name string) string {
|
func unescapeServiceName(name string) string {
|
||||||
if !strings.Contains(name, "\\x") {
|
if !strings.Contains(name, "\\x") {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
// systemdManager manages the collection of systemd service statistics.
|
// systemdManager manages the collection of systemd service statistics.
|
||||||
type systemdManager struct {
|
type systemdManager struct {
|
||||||
hasFreshStats bool
|
hasFreshStats bool
|
||||||
|
logsEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// newSystemdManager creates a new systemdManager.
|
// newSystemdManager creates a new systemdManager.
|
||||||
@@ -36,3 +37,7 @@ func (sm *systemdManager) getFailedServiceCount() uint16 {
|
|||||||
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
func (sm *systemdManager) getServiceDetails(string) (systemd.ServiceDetails, error) {
|
||||||
return nil, errors.New("systemd manager unavailable")
|
return nil, errors.New("systemd manager unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *systemdManager) getServiceLogs(string) (string, error) {
|
||||||
|
return "", errors.New("systemd manager unavailable")
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +36,127 @@ 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) {
|
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
||||||
// Test invalid escape sequences - should return original string
|
// Test invalid escape sequences - should return original string
|
||||||
invalidInputs := []string{
|
invalidInputs := []string{
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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]
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
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])
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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])
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
alternatives.aarch64 1.24-1.el9
|
||||||
|
audit-libs.aarch64 3.0.7-104.el9
|
||||||
|
basesystem.noarch 11-13.el9
|
||||||
|
bash.aarch64 5.1.8-6.el9_1
|
||||||
|
binutils.aarch64 2.35.2-42.el9
|
||||||
|
binutils-gold.aarch64 2.35.2-42.el9
|
||||||
|
bzip2-libs.aarch64 1.0.8-8.el9
|
||||||
|
ca-certificates.noarch 2023.2.60_v7.0.306-90.1.el9_2
|
||||||
|
coreutils-single.aarch64 8.32-34.el9
|
||||||
|
cracklib.aarch64 2.9.6-27.el9
|
||||||
|
cracklib-dicts.aarch64 2.9.6-27.el9
|
||||||
|
crypto-policies.noarch 20230731-1.git94f0e2c.el9_3.1
|
||||||
|
crypto-policies-scripts.noarch 20230731-1.git94f0e2c.el9_3.1
|
||||||
|
curl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
|
||||||
|
cyrus-sasl-lib.aarch64 2.1.27-21.el9
|
||||||
|
dnf.noarch 4.14.0-8.el9
|
||||||
|
dnf-data.noarch 4.14.0-8.el9
|
||||||
|
elfutils-debuginfod-client.aarch64 0.189-3.el9
|
||||||
|
elfutils-default-yama-scope.noarch 0.189-3.el9
|
||||||
|
elfutils-libelf.aarch64 0.189-3.el9
|
||||||
|
elfutils-libs.aarch64 0.189-3.el9
|
||||||
|
expat.aarch64 2.5.0-1.el9
|
||||||
|
file-libs.aarch64 5.39-14.el9
|
||||||
|
filesystem.aarch64 3.16-2.el9
|
||||||
|
findutils.aarch64 1:4.8.0-6.el9
|
||||||
|
gdbm-libs.aarch64 1:1.19-4.el9
|
||||||
|
glib2.aarch64 2.68.4-11.el9
|
||||||
|
glibc.aarch64 2.34-83.el9.7
|
||||||
|
glibc-common.aarch64 2.34-83.el9.7
|
||||||
|
glibc-minimal-langpack.aarch64 2.34-83.el9.7
|
||||||
|
gnupg2.aarch64 2.3.3-4.el9
|
||||||
|
gnutls.aarch64 3.7.6-23.el9
|
||||||
|
gzip.aarch64 1.12-1.el9
|
||||||
|
ima-evm-utils.aarch64 1.4-4.el9
|
||||||
|
krb5-libs.aarch64 1.21.1-1.el9
|
||||||
|
less.aarch64 590-2.el9_2
|
||||||
|
libacl.aarch64 2.3.1-3.el9
|
||||||
|
libarchive.aarch64 3.5.3-4.el9
|
||||||
|
libatomic.aarch64 11.4.1-2.1.el9
|
||||||
|
libattr.aarch64 2.5.1-3.el9
|
||||||
|
libblkid.aarch64 2.37.4-15.el9
|
||||||
|
libcap.aarch64 2.48-9.el9_2
|
||||||
|
libcom_err.aarch64 1.46.5-3.el9
|
||||||
|
libcurl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
|
||||||
|
libdb.aarch64 5.3.28-53.el9
|
||||||
|
libdnf.aarch64 0.69.0-6.el9_3
|
||||||
|
libeconf.aarch64 0.4.1-3.el9_2
|
||||||
|
libevent.aarch64 2.1.12-6.el9
|
||||||
|
libfdisk.aarch64 2.37.4-15.el9
|
||||||
|
libgcc.aarch64 11.4.1-2.1.el9
|
||||||
|
libgcrypt.aarch64 1.10.0-10.el9_2
|
||||||
|
libgomp.aarch64 11.4.1-2.1.el9
|
||||||
|
libksba.aarch64 1.5.1-6.el9_1
|
||||||
|
libmount.aarch64 2.37.4-15.el9
|
||||||
|
libnghttp2.aarch64 1.43.0-5.el9_3.1
|
||||||
|
librepo.aarch64 1.14.5-1.el9
|
||||||
|
libselinux.aarch64 3.5-1.el9
|
||||||
|
libsemanage.aarch64 3.5-2.el9
|
||||||
|
libsepol.aarch64 3.5-1.el9
|
||||||
|
libsmartcols.aarch64 2.37.4-15.el9
|
||||||
|
libsolv.aarch64 0.7.24-2.el9
|
||||||
|
libstdc++.aarch64 11.4.1-2.1.el9
|
||||||
|
libtasn1.aarch64 4.16.0-8.el9_1
|
||||||
|
libusbx.aarch64 1.0.26-1.el9
|
||||||
|
libuser.aarch64 0.63-13.el9
|
||||||
|
libuuid.aarch64 2.37.4-15.el9
|
||||||
|
libxml2.aarch64 2.9.13-4.el9
|
||||||
|
libzstd.aarch64 1.5.1-2.el9
|
||||||
|
mpfr.aarch64 4.1.0-7.el9
|
||||||
|
ncurses-base.noarch 6.2-10.20210508.el9
|
||||||
|
ncurses-libs.aarch64 6.2-10.20210508.el9
|
||||||
|
nettle.aarch64 3.8-3.el9_0
|
||||||
|
openldap.aarch64 2.6.3-1.el9
|
||||||
|
openssl.aarch64 1:3.0.7-24.el9
|
||||||
|
openssl-libs.aarch64 1:3.0.7-24.el9
|
||||||
|
p11-kit.aarch64 0.24.1-2.el9
|
||||||
|
p11-kit-trust.aarch64 0.24.1-2.el9
|
||||||
|
pam.aarch64 1.5.1-15.el9
|
||||||
|
pcre.aarch64 8.44-3.el9.3
|
||||||
|
pcre2.aarch64 10.40-2.el9
|
||||||
|
pcre2-syntax.noarch 10.40-2.el9
|
||||||
|
python3.aarch64 3.9.18-1.el9_3
|
||||||
|
python3-dnf.noarch 4.14.0-8.el9
|
||||||
|
python3-hawkey.aarch64 0.69.0-6.el9_3
|
||||||
|
python3-libdnf.aarch64 0.69.0-6.el9_3
|
||||||
|
python3-libs.aarch64 3.9.18-1.el9_3
|
||||||
|
python3-pip-wheel.noarch 21.2.3-7.el9
|
||||||
|
python3-rpm.aarch64 4.16.1.3-25.el9
|
||||||
|
python3-setuptools-wheel.noarch 53.0.0-12.el9
|
||||||
|
rocky-gpg-keys.noarch 9.3-1.1.el9
|
||||||
|
rocky-release.noarch 9.3-1.1.el9
|
||||||
|
rocky-repos.noarch 9.3-1.1.el9
|
||||||
|
rootfiles.noarch 8.1-31.el9
|
||||||
|
rpm.aarch64 4.16.1.3-25.el9
|
||||||
|
rpm-build-libs.aarch64 4.16.1.3-25.el9
|
||||||
|
rpm-libs.aarch64 4.16.1.3-25.el9
|
||||||
|
rpm-sign-libs.aarch64 4.16.1.3-25.el9
|
||||||
|
sed.aarch64 4.8-9.el9
|
||||||
|
setup.noarch 2.13.7-9.el9
|
||||||
|
shadow-utils.aarch64 2:4.9-8.el9
|
||||||
|
sqlite-libs.aarch64 3.34.1-6.el9_1
|
||||||
|
systemd-libs.aarch64 252-18.el9
|
||||||
|
tar.aarch64 2:1.34-6.el9_1
|
||||||
|
tpm2-tss.aarch64 3.2.2-2.el9
|
||||||
|
tzdata.noarch 2023c-1.el9
|
||||||
|
usermode.aarch64 1.114-4.el9
|
||||||
|
util-linux.aarch64 2.37.4-15.el9
|
||||||
|
util-linux-core.aarch64 2.37.4-15.el9
|
||||||
|
vim-minimal.aarch64 2:8.2.2637-20.el9_1
|
||||||
|
yum.noarch 4.14.0-8.el9
|
||||||
|
package nonexistent-pkg.x86_64 is not installed
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
dnf5.aarch64 5.2.18.0-2.fc42
|
||||||
|
dnf5-plugins.aarch64 5.2.18.0-2.fc42
|
||||||
|
elfutils-default-yama-scope.noarch 0.194-1.fc42
|
||||||
|
elfutils-libelf.aarch64 0.194-1.fc42
|
||||||
|
elfutils-libs.aarch64 0.194-1.fc42
|
||||||
|
fedora-release-common.noarch 42-30
|
||||||
|
fedora-release-container.noarch 42-30
|
||||||
|
fedora-release-identity-container.noarch 42-30
|
||||||
|
glibc.aarch64 2.41-16.fc42
|
||||||
|
glibc-common.aarch64 2.41-16.fc42
|
||||||
|
glibc-minimal-langpack.aarch64 2.41-16.fc42
|
||||||
|
krb5-libs.aarch64 1.21.3-6.fc42
|
||||||
|
libdnf5.aarch64 5.2.18.0-2.fc42
|
||||||
|
libdnf5-cli.aarch64 5.2.18.0-2.fc42
|
||||||
|
libsolv.aarch64 0.7.36-2.fc42
|
||||||
|
openssl-libs.aarch64 1:3.2.6-3.fc42
|
||||||
|
rpm-sequoia.aarch64 1.10.1-1.fc42
|
||||||
|
tzdata.noarch 2025c-1.fc42
|
||||||
|
vim-data.noarch 2:9.2.280-1.fc42
|
||||||
|
vim-minimal.aarch64 2:9.2.280-1.fc42
|
||||||
|
package nonexistent-pkg.x86_64 is not installed
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
//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
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//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 }
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
//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,11 +6,14 @@ require (
|
|||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
github.com/coreos/go-systemd/v22 v22.7.0
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/ebitengine/purego v0.11.0
|
github.com/ebitengine/purego v0.11.1
|
||||||
github.com/fxamacker/cbor/v2 v2.9.4
|
github.com/fxamacker/cbor/v2 v2.9.4
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/lxzan/gws v1.10.2
|
github.com/lxzan/gws v1.10.2
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0
|
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/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.40.4
|
github.com/pocketbase/pocketbase v0.40.4
|
||||||
@@ -43,14 +46,16 @@ require (
|
|||||||
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/klauspost/compress v1.20.0 // indirect
|
github.com/klauspost/compress v1.20.1 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.15 // indirect
|
github.com/mattn/go-colorable v0.1.15 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/mdlayher/socket v0.7.0 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.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/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/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 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||||
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
||||||
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
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/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
|
||||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
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 h1:dKwiP/9zITCPfBLsDn3kchbSOu16JrnxtVEmL0fPRcI=
|
||||||
github.com/jarcoal/httpmock v1.4.2/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
||||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -83,14 +83,22 @@ 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-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 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0 h1:as/mEwdaZMijCVu0FkTUEXashhvC3Y7C5g9dsXMcmQc=
|
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.21.0/go.mod h1:dgg4kJv9K0tLXBH/1TXiSibNbM2hcd4SK6xb0sglyU4=
|
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.2 h1:2o6vyFvR6snrJWgRVztC+OwuqqPEMI1UzYl2s2iU7Cg=
|
github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ=
|
||||||
github.com/onsi/ginkgo/v2 v2.32.2/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4=
|
||||||
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
github.com/mdlayher/wifi v0.9.0 h1:d5mmqw9S2U4f95dcW5wnnUagpI6GJh328/AchVSP4ko=
|
||||||
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
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/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ func (am *AlertManager) IsNotificationSilenced(userID, systemID string) bool {
|
|||||||
|
|
||||||
// SendAlert sends an alert to the user
|
// SendAlert sends an alert to the user
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
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
|
// Check if alert is silenced
|
||||||
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
if am.IsNotificationSilenced(data.UserID, data.SystemID) {
|
||||||
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
am.hub.Logger().Info("Notification silenced", "user", data.UserID, "system", data.SystemID, "title", data.Title)
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
//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,6 +20,7 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
@@ -184,7 +185,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"min": 10,
|
"min": 10,
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
},
|
},
|
||||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
alerts, _ := app.CountRecords("alerts")
|
alerts, _ := app.CountRecords("alerts")
|
||||||
@@ -209,7 +210,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"overwrite": false,
|
"overwrite": false,
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU",
|
"name": "CPU",
|
||||||
"system": system1.Id,
|
"system": system1.Id,
|
||||||
@@ -243,7 +244,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"overwrite": true,
|
"overwrite": true,
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU",
|
"name": "CPU",
|
||||||
"system": system2.Id,
|
"system": system2.Id,
|
||||||
@@ -271,7 +272,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"systems": []string{system1.Id},
|
"systems": []string{system1.Id},
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU",
|
"name": "CPU",
|
||||||
"system": system1.Id,
|
"system": system1.Id,
|
||||||
@@ -300,7 +301,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"systems": []string{system1.Id},
|
"systems": []string{system1.Id},
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU",
|
"name": "CPU",
|
||||||
"system": system1.Id,
|
"system": system1.Id,
|
||||||
@@ -329,7 +330,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"systems": []string{system1.Id, system2.Id},
|
"systems": []string{system1.Id, system2.Id},
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "Memory",
|
"name": "Memory",
|
||||||
@@ -363,7 +364,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"systems": []string{system1.Id},
|
"systems": []string{system1.Id},
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU", "system": system1.Id, "user": user2.Id, "value": 80,
|
"name": "CPU", "system": system1.Id, "user": user2.Id, "value": 80,
|
||||||
})
|
})
|
||||||
@@ -388,7 +389,7 @@ func TestUserAlertsApi(t *testing.T) {
|
|||||||
"systems": []string{system2.Id},
|
"systems": []string{system2.Id},
|
||||||
}),
|
}),
|
||||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
beszelTests.ClearCollection(t, app, "alerts")
|
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
||||||
for _, user := range []string{user1.Id, user2.Id} {
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
"name": "CPU",
|
"name": "CPU",
|
||||||
@@ -430,19 +431,21 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
localURL := "generic+" + server.URL
|
localURL := "generic+" + server.URL
|
||||||
|
|
||||||
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
readonlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
readonlyToken, err := readonlyUser.NewAuthToken()
|
readonlyToken, err := readonlyUser.NewAuthToken()
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
userToken, err := user.NewAuthToken()
|
userToken, err := user.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create user auth token")
|
||||||
|
|
||||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
assert.NoError(t, err, "Failed to create admin user")
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
adminUserToken, err := adminUser.NewAuthToken()
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create admin auth token")
|
||||||
|
|
||||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||||
assert.NoError(t, err, "Failed to create superuser")
|
require.NoError(t, err, "Failed to create superuser")
|
||||||
superuserToken, err := superuser.NewAuthToken()
|
superuserToken, err := superuser.NewAuthToken()
|
||||||
assert.NoError(t, err, "Failed to create superuser auth token")
|
require.NoError(t, err, "Failed to create superuser auth token")
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
return hub.TestApp
|
return hub.TestApp
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package alerts_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -23,7 +24,7 @@ func TestBatteryAlertLogic(t *testing.T) {
|
|||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -68,13 +69,12 @@ func TestBatteryAlertLogic(t *testing.T) {
|
|||||||
|
|
||||||
// Simulate system update time
|
// Simulate system update time
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with high battery
|
// Handle system alerts with high battery
|
||||||
am := hub.GetAlertManager()
|
am := hub.GetAlertManager()
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
||||||
|
})
|
||||||
|
|
||||||
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
|
// Verify alert is still NOT triggered (battery 50% is above threshold 20%)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
@@ -108,15 +108,11 @@ func TestBatteryAlertLogic(t *testing.T) {
|
|||||||
|
|
||||||
// Update system timestamp
|
// Update system timestamp
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with low battery
|
// Handle system alerts with low battery
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
||||||
|
})
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert IS triggered (battery 15% is below threshold 20%)
|
// Verify alert IS triggered (battery 15% is below threshold 20%)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
@@ -150,15 +146,11 @@ func TestBatteryAlertLogic(t *testing.T) {
|
|||||||
|
|
||||||
// Update system timestamp
|
// Update system timestamp
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts with recovered battery
|
// Handle system alerts with recovered battery
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataRecovered))
|
||||||
|
})
|
||||||
// Wait for the alert to be processed
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is now resolved (battery 25% is above threshold 20%)
|
// Verify alert is now resolved (battery 25% is above threshold 20%)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
@@ -172,7 +164,7 @@ func TestBatteryAlertNoBattery(t *testing.T) {
|
|||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -206,16 +198,12 @@ func TestBatteryAlertNoBattery(t *testing.T) {
|
|||||||
|
|
||||||
// Simulate system update time
|
// Simulate system update time
|
||||||
systemRecord.Set("updated", time.Now().UTC())
|
systemRecord.Set("updated", time.Now().UTC())
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts
|
// Handle system alerts
|
||||||
am := hub.GetAlertManager()
|
am := hub.GetAlertManager()
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedData)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedData))
|
||||||
|
})
|
||||||
// Wait a moment for processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is NOT triggered (no battery data should skip the alert)
|
// Verify alert is NOT triggered (no battery data should skip the alert)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
@@ -230,7 +218,7 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
|
|||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -302,15 +290,11 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
|
|||||||
|
|
||||||
// Update system timestamp
|
// Update system timestamp
|
||||||
systemRecord.Set("updated", now)
|
systemRecord.Set("updated", now)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts - should trigger because average battery is below threshold
|
// Handle system alerts - should trigger because average battery is below threshold
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
||||||
|
})
|
||||||
// Wait for alert processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert IS triggered (average battery 15% is below threshold 25%)
|
// Verify alert IS triggered (average battery 15% is below threshold 25%)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
@@ -368,15 +352,11 @@ func TestBatteryAlertAveragedSamples(t *testing.T) {
|
|||||||
|
|
||||||
// Update system timestamp to the new time window
|
// Update system timestamp to the new time window
|
||||||
systemRecord.Set("updated", newNow)
|
systemRecord.Set("updated", newNow)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Handle system alerts - should resolve because average battery is now above threshold
|
// Handle system alerts - should resolve because average battery is now above threshold
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
||||||
|
})
|
||||||
// Wait for alert processing
|
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
// Verify alert is resolved (average battery 50% is above threshold 25%)
|
// Verify alert is resolved (average battery 50% is above threshold 25%)
|
||||||
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
batteryAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": batteryAlert.Id})
|
||||||
|
|||||||
@@ -63,7 +63,10 @@ func NewAlertsCache(app core.App) *AlertsCache {
|
|||||||
// bindEvents sets up event listeners to keep the cache in sync with database changes.
|
// bindEvents sets up event listeners to keep the cache in sync with database changes.
|
||||||
func (c *AlertsCache) bindEvents() *AlertsCache {
|
func (c *AlertsCache) bindEvents() *AlertsCache {
|
||||||
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
c.app.OnRecordAfterUpdateSuccess("alerts").BindFunc(func(e *core.RecordEvent) error {
|
||||||
// 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
|
// 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.Update(e.Record)
|
c.Update(e.Record)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package alerts_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -22,7 +23,7 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -83,13 +84,10 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", now)
|
systemRecord.Set("updated", now)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
||||||
|
})
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -140,13 +138,10 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", newNow)
|
systemRecord.Set("updated", newNow)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
||||||
|
})
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
|||||||
return e.Next()
|
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 new state is triggered, create new alert history record
|
||||||
if newTriggered {
|
if newTriggered {
|
||||||
_, _ = createAlertHistoryRecord(e.App, new)
|
_, _ = createAlertHistoryRecord(e.App, new)
|
||||||
|
|||||||
@@ -32,9 +32,6 @@ func (am *AlertManager) bindNetworkMonitorAlertEvents() {
|
|||||||
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
|
return e.BadRequestError("Delete and recreate the alert to change its type or system", nil)
|
||||||
}
|
}
|
||||||
if e.Record.GetString("name") == alertNameNetworkMonitorLoss {
|
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"))
|
e.Record.Set("triggered", e.Record.Original().GetBool("triggered"))
|
||||||
value := e.Record.GetFloat("value")
|
value := e.Record.GetFloat("value")
|
||||||
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
|
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value >= 100 {
|
||||||
@@ -136,6 +133,9 @@ func (am *AlertManager) evaluateNetworkMonitorAlerts(app core.App, systemID stri
|
|||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, alert := range alerts {
|
for _, alert := range alerts {
|
||||||
|
if !userHasSystem(tx, alert.GetString("user"), systemID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var state networkMonitorAlertState
|
var state networkMonitorAlertState
|
||||||
if err := alert.UnmarshalJSONField("state", &state); err != nil {
|
if err := alert.UnmarshalJSONField("state", &state); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -248,7 +248,7 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
|
|||||||
{name: "negative threshold", value: -1, status: 400},
|
{name: "negative threshold", value: -1, status: 400},
|
||||||
{name: "unreachable threshold", value: 100, status: 400},
|
{name: "unreachable threshold", value: 100, status: 400},
|
||||||
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
|
{name: "bulk inaccessible system", value: 5, denied: true, status: 200},
|
||||||
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 403},
|
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 400},
|
||||||
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
|
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
|
||||||
{name: "direct private state", value: 5, direct: true, status: 200},
|
{name: "direct private state", value: 5, direct: true, status: 200},
|
||||||
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
|
{name: "patch preserves state", value: 10, direct: true, patch: true, status: 200},
|
||||||
@@ -287,9 +287,6 @@ func TestNetworkMonitorAlertAPI(t *testing.T) {
|
|||||||
if tc.status == 400 {
|
if tc.status == 400 {
|
||||||
content = `"status":400`
|
content = `"status":400`
|
||||||
}
|
}
|
||||||
if tc.status == 403 {
|
|
||||||
content = `"status":403`
|
|
||||||
}
|
|
||||||
scenario := beszelTests.ApiScenario{
|
scenario := beszelTests.ApiScenario{
|
||||||
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
|
Name: tc.name, Method: method, URL: url, Body: jsonReader(body),
|
||||||
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
|
Headers: map[string]string{"Authorization": token}, ExpectedStatus: tc.status, ExpectedContent: []string{content},
|
||||||
|
|||||||
@@ -323,9 +323,14 @@ func TestAlertSilencedMultiUser(t *testing.T) {
|
|||||||
|
|
||||||
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
func TestAlertSilencedWithActualAlert(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
//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,9 +30,14 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
|
|||||||
|
|
||||||
func TestStatusAlerts(t *testing.T) {
|
func TestStatusAlerts(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 4, user.Id, "paused")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
@@ -236,9 +241,14 @@ func TestHandleStatusAlertsDoesNotSendRecoveryWhileDownIsOnlyPending(t *testing.
|
|||||||
|
|
||||||
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
|
func TestStatusAlertTimerCancellationPreventsBoundaryDelivery(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -339,9 +349,14 @@ func TestStatusAlertDownFiresAfterDelayExpires(t *testing.T) {
|
|||||||
|
|
||||||
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
||||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||||
|
|
||||||
@@ -427,9 +442,14 @@ func TestStatusAlertMultipleUsersRespectDifferentMinutes(t *testing.T) {
|
|||||||
|
|
||||||
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
|
func TestStatusAlertMultipleUsersRecoveryBetweenMinutesOnlyAlertsEarlierUser(t *testing.T) {
|
||||||
hub, user1 := beszelTests.GetHubWithUser(t)
|
hub, user1 := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
setStatusAlertEmail(t, hub, user1.Id, "user1@example.com")
|
||||||
|
|
||||||
@@ -821,9 +841,14 @@ func TestResolveStatusAlerts(t *testing.T) {
|
|||||||
|
|
||||||
func TestAlertsHistoryStatus(t *testing.T) {
|
func TestAlertsHistoryStatus(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
@@ -888,9 +913,14 @@ func TestAlertsHistoryStatus(t *testing.T) {
|
|||||||
|
|
||||||
func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
// Create a system
|
// Create a system
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ import (
|
|||||||
|
|
||||||
func TestAlertsHistory(t *testing.T) {
|
func TestAlertsHistory(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
defer hub.Cleanup()
|
t.Cleanup(func() {
|
||||||
|
synctest.Wait()
|
||||||
|
hub.GetAlertManager().Stop()
|
||||||
|
})
|
||||||
|
hub.GetSystemManager().ResetContextForTesting(t)
|
||||||
|
|
||||||
// Create systems and alerts
|
// Create systems and alerts
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
|
|||||||
@@ -10,11 +10,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
|
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
|
||||||
return &AlertManager{
|
am := &AlertManager{
|
||||||
hub: app,
|
hub: app,
|
||||||
alertsCache: NewAlertsCache(app),
|
alertsCache: NewAlertsCache(app),
|
||||||
networkMonitors: newNetworkMonitorCache(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.
|
// GetSystemAlertsCache returns the internal system alerts cache.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package alerts_test
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
@@ -23,7 +24,7 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
defer hub.Cleanup()
|
||||||
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -80,13 +81,10 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", now)
|
systemRecord.Set("updated", now)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
||||||
|
})
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -130,13 +128,10 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", newNow)
|
systemRecord.Set("updated", newNow)
|
||||||
err = hub.SaveNoValidate(systemRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
synctest.Test(t, func(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
||||||
|
})
|
||||||
time.Sleep(20 * time.Millisecond)
|
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -147,7 +142,7 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
func TestDiskAlertIgnoresRawPool(t *testing.T) {
|
func TestDiskAlertIgnoresRawPool(t *testing.T) {
|
||||||
for _, minutes := range []int{0, 2} {
|
for _, minutes := range []int{0, 2} {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
||||||
require.NoError(t, err)
|
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})
|
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)
|
require.NoError(t, err)
|
||||||
@@ -160,16 +155,18 @@ func TestDiskAlertIgnoresRawPool(t *testing.T) {
|
|||||||
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
|
record.SetRaw("created", time.Now().UTC().Add(offset*time.Second).Format(types.DefaultDateLayout))
|
||||||
require.NoError(t, hub.SaveNoValidate(record))
|
require.NoError(t, hub.SaveNoValidate(record))
|
||||||
}
|
}
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
synctest.Test(t, func(t *testing.T) {
|
||||||
time.Sleep(20 * time.Millisecond)
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
})
|
||||||
record, err := hub.FindRecordById("alerts", alert.Id)
|
record, err := hub.FindRecordById("alerts", alert.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, record.GetBool("triggered"))
|
assert.False(t, record.GetBool("triggered"))
|
||||||
if minutes > 0 {
|
if minutes > 0 {
|
||||||
// A current usable sample must not make raw historical values eligible.
|
// A current usable sample must not make raw historical values eligible.
|
||||||
pools["btrfs"].Raw = false
|
pools["btrfs"].Raw = false
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
synctest.Test(t, func(t *testing.T) {
|
||||||
time.Sleep(20 * time.Millisecond)
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
|
})
|
||||||
record, err = hub.FindRecordById("alerts", alert.Id)
|
record, err = hub.FindRecordById("alerts", alert.Id)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, record.GetBool("triggered"))
|
assert.False(t, record.GetBool("triggered"))
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
@@ -14,6 +16,12 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"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
|
// cli options
|
||||||
type cmdOptions struct {
|
type cmdOptions struct {
|
||||||
key string // key is the public key(s) for SSH authentication.
|
key string // key is the public key(s) for SSH authentication.
|
||||||
@@ -124,7 +132,7 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
|
|||||||
// Try key file
|
// Try key file
|
||||||
keyFile, ok := utils.GetEnv("KEY_FILE")
|
keyFile, ok := utils.GetEnv("KEY_FILE")
|
||||||
if !ok {
|
if !ok {
|
||||||
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")
|
return nil, noKeyProvidedError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, err := os.ReadFile(keyFile)
|
pubKey, err := os.ReadFile(keyFile)
|
||||||
@@ -138,6 +146,14 @@ func (opts *cmdOptions) getAddress() string {
|
|||||||
return agent.GetAddress(opts.listen)
|
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".
|
// handleFingerprint handles the "fingerprint" command with subcommands "view" and "reset".
|
||||||
func handleFingerprint() {
|
func handleFingerprint() {
|
||||||
subCmd := ""
|
subCmd := ""
|
||||||
@@ -182,6 +198,12 @@ func main() {
|
|||||||
var err error
|
var err error
|
||||||
serverConfig.Keys, err = opts.loadPublicKeys()
|
serverConfig.Keys, err = opts.loadPublicKeys()
|
||||||
if err != nil {
|
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)
|
log.Fatal("Failed to load public keys:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -187,6 +188,26 @@ 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) {
|
func TestGetNetwork(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ const (
|
|||||||
GetZfsData
|
GetZfsData
|
||||||
// Sync network monitor configuration to agent
|
// Sync network monitor configuration to agent
|
||||||
SyncNetworkMonitors
|
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...
|
// Add new actions here...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -83,3 +87,7 @@ type ContainerInfoRequest struct {
|
|||||||
type SystemdInfoRequest struct {
|
type SystemdInfoRequest struct {
|
||||||
ServiceName string `cbor:"0,keyasint"`
|
ServiceName string `cbor:"0,keyasint"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SystemdLogsRequest struct {
|
||||||
|
ServiceName string `cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ RUN set -eux; \
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
# 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 \
|
||||||
zfsutils-linux \
|
zfsutils-linux \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ type Config struct {
|
|||||||
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
|
Protocol string `cbor:"2,keyasint"` // "icmp", "tcp", "http", or "dns"
|
||||||
Port uint16 `cbor:"3,keyasint,omitempty"`
|
Port uint16 `cbor:"3,keyasint,omitempty"`
|
||||||
Interval uint16 `cbor:"4,keyasint"` // seconds
|
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.
|
// SyncRequest defines an incremental or full monitor sync request sent to the agent.
|
||||||
@@ -76,6 +86,8 @@ type Result struct {
|
|||||||
TotalCount int64 `cbor:"10,keyasint"`
|
TotalCount int64 `cbor:"10,keyasint"`
|
||||||
SuccessCount int64 `cbor:"11,keyasint"`
|
SuccessCount int64 `cbor:"11,keyasint"`
|
||||||
ResponseSum int64 `cbor:"12,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).
|
// Stats holds response times in microseconds and packet loss percentage (0-100).
|
||||||
|
|||||||
@@ -503,11 +503,11 @@ type SmartInfoForNvme struct {
|
|||||||
UserCapacity UserCapacity `json:"user_capacity"`
|
UserCapacity UserCapacity `json:"user_capacity"`
|
||||||
// LogicalBlockSize int `json:"logical_block_size"`
|
// LogicalBlockSize int `json:"logical_block_size"`
|
||||||
// LocalTime LocalTime `json:"local_time"`
|
// LocalTime LocalTime `json:"local_time"`
|
||||||
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
|
SmartStatus SmartStatusInfoNvme `json:"smart_status"`
|
||||||
NVMeSmartHealthInformationLog NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
|
NVMeSmartHealthInformationLog *NVMeSmartHealthInformationLog `json:"nvme_smart_health_information_log"`
|
||||||
Temperature TemperatureInfoNvme `json:"temperature"`
|
Temperature TemperatureInfoNvme `json:"temperature"`
|
||||||
PowerCycleCount uint16 `json:"power_cycle_count"`
|
PowerCycleCount uint16 `json:"power_cycle_count"`
|
||||||
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
|
PowerOnTime PowerOnTimeInfoNvme `json:"power_on_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TemperatureInfoNvme struct {
|
type TemperatureInfoNvme struct {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package system
|
||||||
|
|
||||||
|
// PackageUpdate is one pending package update on the host.
|
||||||
|
type PackageUpdate struct {
|
||||||
|
Name string `json:"name" cbor:"0,keyasint"`
|
||||||
|
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
|
||||||
|
Available string `json:"available" cbor:"2,keyasint"`
|
||||||
|
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackageUpdates is the detail payload returned by the agent for the
|
||||||
|
// GetPackageUpdates action. The counts in Info.PackageUpdates come from the same check.
|
||||||
|
type PackageUpdates struct {
|
||||||
|
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
|
||||||
|
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
|
||||||
|
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
|
||||||
|
// SecurityKnown is true if the package manager flags security updates per package.
|
||||||
|
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
|
||||||
|
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
|
||||||
|
}
|
||||||
@@ -11,6 +11,14 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"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 {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
Cpu float64 `json:"cpu" cbor:"0,keyasint"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
MaxCpu float64 `json:"cpum,omitempty" cbor:"-"`
|
||||||
@@ -55,6 +63,7 @@ type Stats struct {
|
|||||||
Batteries map[string]uint8 `json:"bats,omitempty" cbor:"37,keyasint,omitempty"`
|
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
|
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
|
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
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,18 +119,17 @@ type GPUData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type FsStats struct {
|
type FsStats struct {
|
||||||
Time time.Time `json:"-"`
|
Root bool `json:"-"`
|
||||||
Root bool `json:"-"`
|
Mountpoint string `json:"-"`
|
||||||
Mountpoint string `json:"-"`
|
Name string `json:"-"`
|
||||||
Name string `json:"-"`
|
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
||||||
DiskTotal float64 `json:"d" cbor:"0,keyasint"`
|
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
||||||
DiskUsed float64 `json:"du" cbor:"1,keyasint"`
|
TotalRead uint64 `json:"tr,omitzero" cbor:"9,keyasint,omitzero"` // cumulative device read bytes
|
||||||
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
|
||||||
TotalWrite uint64 `json:"tw,omitzero" cbor:"10,keyasint,omitzero"` // cumulative device write bytes
|
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
||||||
DiskReadPs float64 `json:"r" cbor:"2,keyasint"`
|
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
||||||
DiskWritePs float64 `json:"w" cbor:"3,keyasint"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
|
||||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||||
@@ -184,6 +192,9 @@ type Info struct {
|
|||||||
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
Services []uint16 `json:"sv,omitempty" cbor:"22,keyasint,omitempty"` // [totalServices, numFailedServices]
|
||||||
Battery Battery `json:"bat,omitzero" cbor:"23,keyasint,omitzero"` // [percent, charge state]
|
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)
|
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
|
// Data that does not change during process lifetime and is not needed in All Systems table
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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,7 +3,9 @@
|
|||||||
package systemd_test
|
package systemd_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||||
@@ -65,30 +67,36 @@ func TestServiceUpdateCPUPercent(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
|
t.Run("subsequent call calculates CPU percentage", func(t *testing.T) {
|
||||||
service := &systemd.Service{}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
service.PrevCpuUsage = 1000
|
service := &systemd.Service{}
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
service.PrevCpuUsage = 1000
|
||||||
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
|
|
||||||
service.UpdateCPUPercent(8000000000) // 8 seconds of CPU time
|
// Half of one second's CPU capacity across all cores.
|
||||||
|
cpuUsage := service.PrevCpuUsage + uint64(time.Second/2)*uint64(runtime.NumCPU())
|
||||||
|
service.UpdateCPUPercent(cpuUsage)
|
||||||
|
|
||||||
// CPU usage should be positive and reasonable
|
assert.Equal(t, 50.0, service.Cpu)
|
||||||
assert.Greater(t, service.Cpu, 0.0, "CPU usage should be positive")
|
assert.Equal(t, cpuUsage, service.PrevCpuUsage)
|
||||||
assert.LessOrEqual(t, service.Cpu, 100.0, "CPU usage should not exceed 100%")
|
assert.Equal(t, time.Now(), service.PrevReadTime)
|
||||||
assert.Equal(t, uint64(8000000000), service.PrevCpuUsage)
|
assert.Equal(t, 50.0, service.CpuPeak)
|
||||||
assert.Greater(t, service.CpuPeak, 0.0, "CPU peak should be set")
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("CPU peak updates only when higher", func(t *testing.T) {
|
t.Run("CPU peak updates only when higher", func(t *testing.T) {
|
||||||
service := &systemd.Service{}
|
synctest.Test(t, func(t *testing.T) {
|
||||||
service.PrevCpuUsage = 1000
|
service := &systemd.Service{}
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
service.PrevCpuUsage = 1000
|
||||||
service.UpdateCPUPercent(8000000000) // Set initial peak to ~50%
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
initialPeak := service.CpuPeak
|
service.UpdateCPUPercent(service.PrevCpuUsage + uint64(time.Second/2)*uint64(runtime.NumCPU()))
|
||||||
|
assert.Equal(t, 50.0, service.CpuPeak)
|
||||||
|
|
||||||
// Now try with much lower CPU usage - should not update peak
|
// A smaller increase in the cumulative counter gives 25% usage.
|
||||||
service.PrevReadTime = time.Now().Add(-time.Second)
|
service.PrevReadTime = time.Now().Add(-time.Second)
|
||||||
service.UpdateCPUPercent(1000000) // Much lower usage
|
service.UpdateCPUPercent(service.PrevCpuUsage + uint64(time.Second/4)*uint64(runtime.NumCPU()))
|
||||||
assert.Equal(t, initialPeak, service.CpuPeak, "Peak should not update for lower CPU usage")
|
assert.Equal(t, 25.0, service.Cpu)
|
||||||
|
assert.Equal(t, 50.0, service.CpuPeak, "Peak should not update for lower CPU usage")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles zero duration", func(t *testing.T) {
|
t.Run("handles zero duration", func(t *testing.T) {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,23 +31,18 @@ func createTestHub(t testing.TB) (*Hub, *pbtests.TestApp, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
return NewHub(testApp), testApp, err
|
hub := NewHub(testApp)
|
||||||
|
if err := hub.sm.InitializeSSHConfigForTesting(); err != nil {
|
||||||
|
cleanupTestHub(hub, testApp)
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
return hub, testApp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanupTestHub stops background system goroutines before tearing down the app.
|
// cleanupTestHub stops background system goroutines before tearing down the app.
|
||||||
func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) {
|
func cleanupTestHub(hub *Hub, testApp *pbtests.TestApp) {
|
||||||
if hub != nil {
|
if hub != nil {
|
||||||
sm := hub.GetSystemManager()
|
hub.GetSystemManager().RemoveAllSystems()
|
||||||
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 {
|
if testApp != nil {
|
||||||
testApp.Cleanup()
|
testApp.Cleanup()
|
||||||
|
|||||||
+62
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
"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/ghupdate"
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
@@ -202,6 +203,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
apiAuth.POST("/zfs/refresh", h.refreshZfsData).BindFunc(excludeReadOnlyRole)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
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
|
// /containers routes
|
||||||
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
// get container logs
|
// get container logs
|
||||||
@@ -441,10 +446,66 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return e.InternalServerError("", err)
|
return e.InternalServerError("", err)
|
||||||
}
|
}
|
||||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
e.Response.Header().Set("Cache-Control", "private, max-age=60")
|
||||||
|
e.Response.Header().Add("Vary", "Authorization")
|
||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
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
|
// refreshSmartData handles POST /api/beszel/smart/refresh requests
|
||||||
// Fetches fresh SMART data from the agent and updates the collection
|
// Fetches fresh SMART data from the agent and updates the collection
|
||||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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,6 +99,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
require.NoError(t, err, "Failed to create admin user")
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
adminUserToken, err := adminUser.NewAuthToken()
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create admin auth token")
|
||||||
|
|
||||||
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||||
require.NoError(t, err, "Failed to create readonly user")
|
require.NoError(t, err, "Failed to create readonly user")
|
||||||
@@ -548,6 +549,59 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||||
TestAppFactory: testAppFactory,
|
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
|
// /systemd routes
|
||||||
{
|
{
|
||||||
Name: "GET /systemd/info - no auth should fail",
|
Name: "GET /systemd/info - no auth should fail",
|
||||||
@@ -631,6 +685,25 @@ 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
|
// Auth Optional Routes - Should work without authentication
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
return err
|
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{
|
if err := applyCollectionRules(app, []string{"system_details"}, collectionRules{
|
||||||
list: &systemScopedReadRule,
|
list: &systemScopedReadRule,
|
||||||
view: &systemScopedReadRule,
|
view: &systemScopedReadRule,
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ func TestCollectionRulesDefault(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to find alerts collection")
|
require.NoError(t, err, "Failed to find alerts collection")
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
||||||
assert.Nil(t, alertsCollection.ViewRule)
|
assert.Nil(t, alertsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
assert.Equal(t, isUserMatchesUser+` && system.users.id ?= @request.auth.id`, *alertsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
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.DeleteRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
||||||
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
|
alertNames := alertsCollection.Fields.GetByName("name").(*core.SelectField).Values
|
||||||
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
|
for _, name := range []string{"CPUIOWait", "CPUSteal"} {
|
||||||
@@ -183,7 +183,7 @@ func TestCollectionRulesShareAllSystems(t *testing.T) {
|
|||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.ListRule)
|
||||||
assert.Nil(t, alertsCollection.ViewRule)
|
assert.Nil(t, alertsCollection.ViewRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.CreateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.UpdateRule)
|
assert.Equal(t, isUserMatchesUser+` && @request.body.user:changed = false && @request.body.system:changed = false`, *alertsCollection.UpdateRule)
|
||||||
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
assert.Equal(t, isUserMatchesUser, *alertsCollection.DeleteRule)
|
||||||
|
|
||||||
// alerts_history collection
|
// 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