mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-01 12:16:00 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ed9a504d |
@@ -29,7 +29,6 @@ jobs:
|
|||||||
# henrygd/beszel-agent:alpine
|
# henrygd/beszel-agent:alpine
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
flavor: latest=false
|
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
@@ -56,7 +55,6 @@ jobs:
|
|||||||
# henrygd/beszel-agent-nvidia:slim
|
# henrygd/beszel-agent-nvidia:slim
|
||||||
- image: henrygd/beszel-agent-nvidia
|
- image: henrygd/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||||
flavor: latest=false
|
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username_secret: DOCKERHUB_USERNAME
|
username_secret: DOCKERHUB_USERNAME
|
||||||
@@ -125,7 +123,6 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
# ghcr.io/henrygd/beszel-agent-nvidia:slim
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
- image: ghcr.io/${{ github.repository }}/beszel-agent-nvidia
|
||||||
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
dockerfile: ./internal/dockerfile_agent_nvidia_slim
|
||||||
flavor: latest=false
|
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -153,7 +150,6 @@ jobs:
|
|||||||
# ghcr.io/henrygd/beszel-agent:alpine
|
# ghcr.io/henrygd/beszel-agent:alpine
|
||||||
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
- image: ghcr.io/${{ github.repository }}/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent_alpine
|
dockerfile: ./internal/dockerfile_agent_alpine
|
||||||
flavor: latest=false
|
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
@@ -163,7 +159,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}-alpine
|
type=semver,pattern={{major}}.{{minor}}-alpine
|
||||||
type=semver,pattern={{major}}-alpine
|
type=semver,pattern={{major}}-alpine
|
||||||
|
|
||||||
# henrygd/beszel-agent
|
# henrygd/beszel-agent (keep at bottom so it gets built after :alpine and gets the latest tag)
|
||||||
- image: henrygd/beszel-agent
|
- image: henrygd/beszel-agent
|
||||||
dockerfile: ./internal/dockerfile_agent
|
dockerfile: ./internal/dockerfile_agent
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
@@ -204,8 +200,6 @@ jobs:
|
|||||||
uses: docker/metadata-action@v6
|
uses: docker/metadata-action@v6
|
||||||
with:
|
with:
|
||||||
images: ${{ matrix.image }}
|
images: ${{ matrix.image }}
|
||||||
# Variant images must not overwrite the standard image's latest tag.
|
|
||||||
flavor: ${{ matrix.flavor || 'latest=auto' }}
|
|
||||||
tags: ${{ matrix.tags }}
|
tags: ${{ matrix.tags }}
|
||||||
|
|
||||||
# https://github.com/docker/login-action
|
# https://github.com/docker/login-action
|
||||||
|
|||||||
+1
-16
@@ -29,7 +29,6 @@ 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
|
||||||
@@ -51,7 +50,6 @@ 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.
|
||||||
@@ -151,17 +149,12 @@ 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 {
|
||||||
@@ -226,10 +219,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if a.packageUpdates != nil {
|
|
||||||
data.Info.PackageUpdates = a.packageUpdates.get(time.Now())
|
|
||||||
}
|
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.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 {
|
||||||
@@ -263,11 +252,7 @@ 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
|
||||||
err := a.connectionManager.Start(serverOptions)
|
return a.connectionManager.Start(serverOptions)
|
||||||
if err != nil {
|
|
||||||
a.cleanupSensorShadow()
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) getFingerprint() string {
|
func (a *Agent) getFingerprint() string {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -236,11 +235,8 @@ 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)
|
||||||
tlsConfig := client.getOptions().TlsConfig
|
assert.Greater(t, len(client.getOptions().TlsConfig.RootCAs.Subjects()), len(systemRoots.Subjects()))
|
||||||
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())
|
||||||
@@ -320,7 +316,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(), strconv.Quote(tc.path))
|
assert.Contains(t, err.Error(), tc.path)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -685,12 +681,12 @@ func TestGetToken(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("error when TOKEN_FILE points to non-existent file", func(t *testing.T) {
|
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", filepath.Join(t.TempDir(), "missing.txt"))
|
t.Setenv("TOKEN_FILE", "/non/existent/file.txt")
|
||||||
|
|
||||||
token, err := getToken()
|
token, err := getToken()
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Equal(t, "", token)
|
assert.Equal(t, "", token)
|
||||||
assert.ErrorIs(t, err, os.ErrNotExist)
|
assert.Contains(t, err.Error(), "no such file or directory")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("handles empty token file", func(t *testing.T) {
|
t.Run("handles empty token file", func(t *testing.T) {
|
||||||
|
|||||||
+16
-55
@@ -8,7 +8,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,10 +20,7 @@ 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
|
||||||
@@ -82,29 +78,6 @@ func (c *ConnectionManager) stopWsTicker() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getState returns the current connection state.
|
|
||||||
func (c *ConnectionManager) getState() ConnectionState {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.State
|
|
||||||
}
|
|
||||||
|
|
||||||
// setConnecting sets the isConnecting flag and reports its previous value.
|
|
||||||
func (c *ConnectionManager) setConnecting(v bool) (previous bool) {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
previous = c.isConnecting
|
|
||||||
c.isConnecting = v
|
|
||||||
return previous
|
|
||||||
}
|
|
||||||
|
|
||||||
// isConnectingNow reports whether a reconnection attempt is currently in flight.
|
|
||||||
func (c *ConnectionManager) isConnectingNow() bool {
|
|
||||||
c.mu.Lock()
|
|
||||||
defer c.mu.Unlock()
|
|
||||||
return c.isConnecting
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins connection attempts and enters the main event loop.
|
// 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 {
|
||||||
@@ -149,10 +122,7 @@ 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:
|
||||||
// skip if connect() is still running its own attempt
|
_ = c.startWebSocketConnection()
|
||||||
if !c.isConnectingNow() {
|
|
||||||
_ = c.startWebSocketConnection()
|
|
||||||
}
|
|
||||||
case <-healthTicker:
|
case <-healthTicker:
|
||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigCtx.Done():
|
case <-sigCtx.Done():
|
||||||
@@ -185,7 +155,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,15 +164,15 @@ func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
|||||||
case WebSocketConnect:
|
case WebSocketConnect:
|
||||||
c.handleStateChange(WebSocketConnected)
|
c.handleStateChange(WebSocketConnected)
|
||||||
case SSHConnect:
|
case SSHConnect:
|
||||||
if c.getState() == Disconnected {
|
if c.State == Disconnected {
|
||||||
c.handleStateChange(SSHConnected)
|
c.handleStateChange(SSHConnected)
|
||||||
}
|
}
|
||||||
case WebSocketDisconnect:
|
case WebSocketDisconnect:
|
||||||
if c.getState() == WebSocketConnected {
|
if c.State == WebSocketConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
}
|
}
|
||||||
case SSHDisconnect:
|
case SSHDisconnect:
|
||||||
if c.getState() == SSHConnected {
|
if c.State == SSHConnected {
|
||||||
c.handleStateChange(Disconnected)
|
c.handleStateChange(Disconnected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -212,40 +181,30 @@ 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.setConnecting(false)
|
c.isConnecting = 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.setConnecting(false)
|
c.isConnecting = false
|
||||||
case Disconnected:
|
case Disconnected:
|
||||||
c.ConnectionType = system.ConnectionTypeNone
|
c.ConnectionType = system.ConnectionTypeNone
|
||||||
// Always keep the ticker running while disconnected. A pending WebSocket
|
if c.isConnecting {
|
||||||
// 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()
|
||||||
@@ -257,8 +216,10 @@ 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.setConnecting(true)
|
c.isConnecting = true
|
||||||
defer c.setConnecting(false)
|
defer func() {
|
||||||
|
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)
|
||||||
@@ -272,7 +233,7 @@ func (c *ConnectionManager) connect() {
|
|||||||
_ = c.stop()
|
_ = c.stop()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if c.getState() == Disconnected {
|
if c.State == Disconnected {
|
||||||
c.startSSHServer()
|
c.startSSHServer()
|
||||||
c.startWsTicker()
|
c.startWsTicker()
|
||||||
}
|
}
|
||||||
@@ -281,7 +242,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.getState() != Disconnected {
|
if c.State != Disconnected {
|
||||||
return errors.New("already connected")
|
return errors.New("already connected")
|
||||||
}
|
}
|
||||||
if c.wsClient == nil {
|
if c.wsClient == nil {
|
||||||
@@ -301,7 +262,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.getState() == Disconnected {
|
if c.State == Disconnected {
|
||||||
go c.agent.StartServer(c.serverOptions)
|
go c.agent.StartServer(c.serverOptions)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -78,10 +77,6 @@ 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")
|
||||||
|
|
||||||
@@ -100,6 +95,7 @@ func TestConnectionManager_EventHandling(t *testing.T) {
|
|||||||
Host: "localhost:8080",
|
Host: "localhost:8080",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
testCases := []struct {
|
testCases := []struct {
|
||||||
name string
|
name string
|
||||||
initialState ConnectionState
|
initialState ConnectionState
|
||||||
@@ -152,11 +148,6 @@ 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")
|
||||||
@@ -230,56 +221,12 @@ 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.setConnecting(false)
|
cm.isConnecting = 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.isConnectingNow(), "Should set isConnecting flag")
|
assert.True(t, cm.isConnecting, "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
|
||||||
|
|||||||
+1
-8
@@ -35,12 +35,6 @@ 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
|
||||||
@@ -125,8 +119,7 @@ func calculateBusy(t1, t2 cpu.TimesStat) float64 {
|
|||||||
// On Linux, it excludes guest and guest_nice time from the total to match kernel behavior.
|
// 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.User + t.System + t.Idle + t.Nice + t.Iowait + t.Irq +
|
tot := t.Total()
|
||||||
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+
|
||||||
|
|||||||
@@ -1,417 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Container-aware CPU accounting (issue #2332).
|
|
||||||
//
|
|
||||||
// Inside a container /proc/stat does not describe the container's own usage:
|
|
||||||
// lxcfs serves LXC guests the raw counters of the host cores in their cpuset,
|
|
||||||
// and plain runtimes (Docker, k8s) expose the host's /proc outright. An idle
|
|
||||||
// container sharing a host core with a busy neighbor then reports near-100%
|
|
||||||
// CPU while doing nothing. The cgroup's own accounting (cpu.stat /
|
|
||||||
// cpuacct.usage) reflects only the container's processes, so when the agent
|
|
||||||
// runs inside a container we derive CPU% from that instead.
|
|
||||||
|
|
||||||
// File paths and hooks are variables so tests can point them at fixtures.
|
|
||||||
var (
|
|
||||||
cpuCgroupRoot = "/sys/fs/cgroup" // default cgroup v2 mount point
|
|
||||||
cpuCgroupMountinfo = "/proc/self/mountinfo"
|
|
||||||
cpuProcSelfCgroup = "/proc/self/cgroup"
|
|
||||||
cpuProcOneEnviron = "/proc/1/environ"
|
|
||||||
cpuDockerenvPath = "/.dockerenv"
|
|
||||||
cpuContainerenv = "/run/.containerenv"
|
|
||||||
cpuSystemdContPath = "/run/systemd/container"
|
|
||||||
cpuNumCPU = runtime.NumCPU
|
|
||||||
cpuNow = time.Now
|
|
||||||
)
|
|
||||||
|
|
||||||
// cpuUserHZ is the USER_HZ jiffies-per-second rate cpuacct.stat reports in.
|
|
||||||
const cpuUserHZ = 100
|
|
||||||
|
|
||||||
var (
|
|
||||||
containerOnce sync.Once
|
|
||||||
containerDetected bool
|
|
||||||
)
|
|
||||||
|
|
||||||
// inContainer reports whether the agent itself runs inside a container.
|
|
||||||
// The result is cached because it cannot change during the process lifetime.
|
|
||||||
func inContainer() bool {
|
|
||||||
containerOnce.Do(func() { containerDetected = detectContainer() })
|
|
||||||
return containerDetected
|
|
||||||
}
|
|
||||||
|
|
||||||
// detectContainer looks for the usual container markers.
|
|
||||||
func detectContainer() bool {
|
|
||||||
// set by systemd-nspawn, LXC, Podman and others
|
|
||||||
if os.Getenv("container") != "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, p := range []string{cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath} {
|
|
||||||
if utils.FileExists(p) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// liblxc always puts container=lxc in the container init's environment,
|
|
||||||
// which survives on non-systemd guests such as Alpine LXC.
|
|
||||||
if data, err := os.ReadFile(cpuProcOneEnviron); err == nil {
|
|
||||||
if bytes.HasPrefix(data, []byte("container=")) ||
|
|
||||||
bytes.Contains(data, []byte("\x00container=")) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// an lxcfs mount means /proc/stat is virtualized with host core counters
|
|
||||||
if data, err := os.ReadFile(cpuCgroupMountinfo); err == nil &&
|
|
||||||
bytes.Contains(data, []byte(" - fuse.lxcfs ")) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// cgroupCpuSample is one read of the container's cumulative CPU accounting.
|
|
||||||
type cgroupCpuSample struct {
|
|
||||||
usageUsec uint64
|
|
||||||
userUsec uint64
|
|
||||||
systemUsec uint64
|
|
||||||
cores float64 // usable CPU cores: affinity ∩ cpuset ∩ quota
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
|
|
||||||
|
|
||||||
// init seeds the container CPU baseline so the first reported value is a real
|
|
||||||
// delta since startup rather than zero.
|
|
||||||
func init() {
|
|
||||||
if !inContainer() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s, ok := readContainerCpuSample(); ok {
|
|
||||||
s.at = cpuNow()
|
|
||||||
lastCgroupCpuSamples[60000] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// containerCpuMetrics derives CPU metrics from the agent's own cgroup
|
|
||||||
// accounting when running inside a container. It returns ok=false on plain
|
|
||||||
// hosts and whenever cgroup accounting is unreadable, so callers keep the
|
|
||||||
// /proc/stat fallback.
|
|
||||||
func containerCpuMetrics(cacheTimeMs uint16) (CpuMetrics, bool) {
|
|
||||||
if !inContainer() {
|
|
||||||
return CpuMetrics{}, false
|
|
||||||
}
|
|
||||||
cur, ok := readContainerCpuSample()
|
|
||||||
if !ok {
|
|
||||||
return CpuMetrics{}, false
|
|
||||||
}
|
|
||||||
cur.at = cpuNow()
|
|
||||||
|
|
||||||
prev, ok := lastCgroupCpuSamples[cacheTimeMs]
|
|
||||||
if !ok {
|
|
||||||
prev = lastCgroupCpuSamples[60000]
|
|
||||||
}
|
|
||||||
lastCgroupCpuSamples[cacheTimeMs] = cur
|
|
||||||
|
|
||||||
// No baseline yet, a backwards counter (cgroup recreated), or a
|
|
||||||
// non-positive clock delta: report zero this tick instead of guessing.
|
|
||||||
elapsedUsec := cur.at.Sub(prev.at).Microseconds()
|
|
||||||
if prev.at.IsZero() || elapsedUsec <= 0 || cur.usageUsec < prev.usageUsec {
|
|
||||||
return CpuMetrics{}, true
|
|
||||||
}
|
|
||||||
|
|
||||||
cores := cur.cores
|
|
||||||
if cores <= 0 {
|
|
||||||
cores = 1
|
|
||||||
}
|
|
||||||
window := float64(elapsedUsec) * cores
|
|
||||||
|
|
||||||
metrics := CpuMetrics{
|
|
||||||
Total: clampPercent(float64(cur.usageUsec-prev.usageUsec) / window * 100),
|
|
||||||
User: clampPercent(float64(cur.userUsec-prev.userUsec) / window * 100),
|
|
||||||
System: clampPercent(float64(cur.systemUsec-prev.systemUsec) / window * 100),
|
|
||||||
}
|
|
||||||
// cgroup accounting has no iowait/steal; everything not busy is idle.
|
|
||||||
metrics.Idle = clampPercent(100 - metrics.Total)
|
|
||||||
return metrics, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// readContainerCpuSample reads the container's cumulative CPU usage, preferring
|
|
||||||
// the cgroup v2 unified hierarchy and falling back to the v1 cpuacct
|
|
||||||
// controller.
|
|
||||||
func readContainerCpuSample() (cgroupCpuSample, bool) {
|
|
||||||
if s, ok := readCgroupV2CpuSample(); ok {
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
return readCgroupV1CpuSample()
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCgroupV2CpuSample reads usage from the unified hierarchy's cpu.stat.
|
|
||||||
//
|
|
||||||
// The mount root is usually the right cgroup to read: inside a private cgroup
|
|
||||||
// namespace (LXC, default Docker) /sys/fs/cgroup already is the container's
|
|
||||||
// root cgroup, and its cpu.stat accounts for every process in the container,
|
|
||||||
// including siblings of the agent's own service cgroup. When the hierarchy is
|
|
||||||
// not namespaced (e.g. docker run --cgroupns=host) /proc/self/cgroup instead
|
|
||||||
// holds the container's real host-side path, which is joined onto the mount.
|
|
||||||
func readCgroupV2CpuSample() (cgroupCpuSample, bool) {
|
|
||||||
rel := selfCgroupPath("0::")
|
|
||||||
if rel == "" {
|
|
||||||
return cgroupCpuSample{}, false // no v2 membership; try v1
|
|
||||||
}
|
|
||||||
dir := cpuCgroupRoot
|
|
||||||
if mount := cgroupMountPoint("cgroup2", ""); mount != "" {
|
|
||||||
dir = mount
|
|
||||||
}
|
|
||||||
if rel != "/" && hasContainerRuntimeMarker(rel) {
|
|
||||||
if cand := filepath.Join(dir, rel); directoryExistsOK(cand) {
|
|
||||||
dir = cand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
stat := filepath.Join(dir, "cpu.stat")
|
|
||||||
usage, ok := cgroupStatValue(stat, "usage_usec")
|
|
||||||
if !ok {
|
|
||||||
return cgroupCpuSample{}, false
|
|
||||||
}
|
|
||||||
s := cgroupCpuSample{usageUsec: usage, cores: cpuCgroupCores(dir)}
|
|
||||||
s.userUsec, _ = cgroupStatValue(stat, "user_usec")
|
|
||||||
s.systemUsec, _ = cgroupStatValue(stat, "system_usec")
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCgroupV1CpuSample reads usage from the legacy cpuacct controller.
|
|
||||||
// Runtimes bind-mount the container's own cpuacct directory at the hierarchy
|
|
||||||
// mount, so the mount root is normally already the container's cgroup; if the
|
|
||||||
// process's cgroup path still resolves below the mount (shared host view),
|
|
||||||
// that subdirectory is used instead.
|
|
||||||
func readCgroupV1CpuSample() (cgroupCpuSample, bool) {
|
|
||||||
mount := cgroupMountPoint("cgroup", "cpuacct")
|
|
||||||
if mount == "" {
|
|
||||||
return cgroupCpuSample{}, false
|
|
||||||
}
|
|
||||||
dir := mount
|
|
||||||
if rel := selfCgroupPath("cpuacct"); rel != "" && rel != "/" {
|
|
||||||
if cand := filepath.Join(mount, rel); utils.FileExists(filepath.Join(cand, "cpuacct.usage")) {
|
|
||||||
dir = cand
|
|
||||||
}
|
|
||||||
}
|
|
||||||
usageNs, ok := utils.ReadUintFile(filepath.Join(dir, "cpuacct.usage"))
|
|
||||||
if !ok {
|
|
||||||
return cgroupCpuSample{}, false
|
|
||||||
}
|
|
||||||
s := cgroupCpuSample{usageUsec: usageNs / 1000, cores: cpuCgroupCores(dir)}
|
|
||||||
// cpuacct.stat reports user/system in USER_HZ jiffies.
|
|
||||||
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "user"); ok {
|
|
||||||
s.userUsec = v * 1e6 / cpuUserHZ
|
|
||||||
}
|
|
||||||
if v, ok := cgroupStatValue(filepath.Join(dir, "cpuacct.stat"), "system"); ok {
|
|
||||||
s.systemUsec = v * 1e6 / cpuUserHZ
|
|
||||||
}
|
|
||||||
return s, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// selfCgroupPath returns the agent's cgroup path from /proc/self/cgroup: the
|
|
||||||
// path after "0::" for the v2 unified hierarchy, or the path of the entry
|
|
||||||
// whose controller list contains the given v1 controller (e.g. "cpuacct").
|
|
||||||
func selfCgroupPath(selector string) string {
|
|
||||||
data, err := os.ReadFile(cpuProcSelfCgroup)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
parts := strings.SplitN(line, ":", 3)
|
|
||||||
if len(parts) != 3 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if selector == "0::" {
|
|
||||||
if parts[0] == "0" && parts[1] == "" {
|
|
||||||
return parts[2]
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ctrl := range strings.Split(parts[1], ",") {
|
|
||||||
if ctrl == selector {
|
|
||||||
return parts[2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// cgroupMountPoint returns the mount point of a cgroup hierarchy from
|
|
||||||
// /proc/self/mountinfo: the cgroup2 mount for v2, or the cgroup mount whose
|
|
||||||
// super options list the wanted v1 controller.
|
|
||||||
func cgroupMountPoint(fstype, v1ctrl string) string {
|
|
||||||
data, err := os.ReadFile(cpuCgroupMountinfo)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
left, right, found := strings.Cut(line, " - ")
|
|
||||||
if !found {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
post := strings.Fields(right)
|
|
||||||
if len(post) == 0 || post[0] != fstype {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if v1ctrl != "" && !mountOptHas(post, v1ctrl) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fields := strings.Fields(left)
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
return unescapeMountPoint(fields[4])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// mountOptHas reports whether the comma-separated super options (field 3 after
|
|
||||||
// the " - " separator) contain opt.
|
|
||||||
func mountOptHas(post []string, opt string) bool {
|
|
||||||
if len(post) < 3 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, o := range strings.Split(post[2], ",") {
|
|
||||||
if o == opt {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// unescapeMountPoint decodes octal escapes (e.g. \040 for space) used in
|
|
||||||
// mountinfo paths.
|
|
||||||
func unescapeMountPoint(s string) string {
|
|
||||||
return strings.NewReplacer(`\040`, " ", `\011`, "\t", `\012`, "\n", `\134`, `\`).Replace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasContainerRuntimeMarker reports whether a cgroup path looks like a real
|
|
||||||
// host-side container cgroup path (docker/k8s/lxc/podman), meaning the visible
|
|
||||||
// hierarchy is not namespaced and the path can be resolved under the mount.
|
|
||||||
func hasContainerRuntimeMarker(path string) bool {
|
|
||||||
for _, m := range []string{"docker", "kubepods", "lxc", "crio", "libpod", "containerd", "podman"} {
|
|
||||||
if strings.Contains(path, m) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpuCgroupCores returns how many CPU cores the cgroup at dir may use: the
|
|
||||||
// smallest of the process affinity mask, the cgroup cpuset, and the CPU quota.
|
|
||||||
func cpuCgroupCores(dir string) float64 {
|
|
||||||
cores := float64(cpuNumCPU())
|
|
||||||
if n := cpusetCount(dir); n > 0 && n < cores {
|
|
||||||
cores = n
|
|
||||||
}
|
|
||||||
if q, ok := cpuQuotaCores(dir); ok && q < cores {
|
|
||||||
cores = q
|
|
||||||
}
|
|
||||||
if cores <= 0 {
|
|
||||||
cores = 1
|
|
||||||
}
|
|
||||||
return cores
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpusetCount returns the number of CPUs in the cgroup's cpuset, e.g. "0-3" or
|
|
||||||
// "2,5-7". An empty or missing file means unconstrained.
|
|
||||||
func cpusetCount(dir string) float64 {
|
|
||||||
for _, name := range []string{"cpuset.cpus.effective", "cpuset.cpus"} {
|
|
||||||
raw, err := os.ReadFile(filepath.Join(dir, name))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if n := countCpuList(strings.TrimSpace(string(raw))); n > 0 {
|
|
||||||
return float64(n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// countCpuList counts the CPUs in a Linux CPU list like "0-3,5,8-9".
|
|
||||||
func countCpuList(list string) int {
|
|
||||||
total := 0
|
|
||||||
for _, part := range strings.Split(list, ",") {
|
|
||||||
lo, hi, ranged := strings.Cut(part, "-")
|
|
||||||
a, err := strconv.Atoi(lo)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
b := a
|
|
||||||
if ranged {
|
|
||||||
if v, err := strconv.Atoi(hi); err == nil {
|
|
||||||
b = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if b >= a {
|
|
||||||
total += b - a + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// cpuQuotaCores returns the cgroup's CPU quota in cores. v2 uses cpu.max
|
|
||||||
// ("<quota|max> <period>"), v1 uses cpu.cfs_quota_us / cpu.cfs_period_us.
|
|
||||||
func cpuQuotaCores(dir string) (float64, bool) {
|
|
||||||
if raw, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil {
|
|
||||||
fields := strings.Fields(string(raw))
|
|
||||||
if len(fields) == 2 && fields[0] != "max" {
|
|
||||||
if quota, err := strconv.ParseFloat(fields[0], 64); err == nil && quota > 0 {
|
|
||||||
if period, err := strconv.ParseFloat(fields[1], 64); err == nil && period > 0 {
|
|
||||||
return quota / period, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if quota, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_quota_us")); ok && quota > 0 {
|
|
||||||
if period, ok := readCgroupInt(filepath.Join(dir, "cpu.cfs_period_us")); ok && period > 0 {
|
|
||||||
return float64(quota) / float64(period), true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// cgroupStatValue returns the value of key in a cgroup "key value" stat file.
|
|
||||||
func cgroupStatValue(path, key string) (uint64, bool) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
for _, line := range strings.Split(string(data), "\n") {
|
|
||||||
name, value, found := strings.Cut(line, " ")
|
|
||||||
if !found || name != key {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseUint(strings.TrimSpace(value), 10, 64)
|
|
||||||
return v, err == nil
|
|
||||||
}
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// readCgroupInt reads a file containing a single signed integer
|
|
||||||
// (cpu.cfs_quota_us is -1 when no quota is set).
|
|
||||||
func readCgroupInt(path string) (int64, bool) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
v, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
|
||||||
return v, err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// directoryExistsOK reports whether path is a directory.
|
|
||||||
func directoryExistsOK(path string) bool {
|
|
||||||
ok, _ := directoryExists(path)
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
@@ -1,337 +0,0 @@
|
|||||||
//go:build testing && linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// swapCpuContainerSeams points every container-detection and cgroup path at
|
|
||||||
// empty fixtures under a temp dir, then restores them on cleanup.
|
|
||||||
func swapCpuContainerSeams(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
backup := struct {
|
|
||||||
root, mountinfo, selfCgroup, oneEnviron, dockerenv, containerenv, systemdCont string
|
|
||||||
numCPU func() int
|
|
||||||
now func() time.Time
|
|
||||||
}{
|
|
||||||
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron,
|
|
||||||
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath, cpuNumCPU, cpuNow,
|
|
||||||
}
|
|
||||||
detected := containerDetected
|
|
||||||
samples := lastCgroupCpuSamples
|
|
||||||
env, hadEnv := os.LookupEnv("container")
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cpuCgroupRoot, cpuCgroupMountinfo, cpuProcSelfCgroup, cpuProcOneEnviron = backup.root, backup.mountinfo, backup.selfCgroup, backup.oneEnviron
|
|
||||||
cpuDockerenvPath, cpuContainerenv, cpuSystemdContPath = backup.dockerenv, backup.containerenv, backup.systemdCont
|
|
||||||
cpuNumCPU, cpuNow = backup.numCPU, backup.now
|
|
||||||
containerOnce = sync.Once{}
|
|
||||||
containerDetected = detected
|
|
||||||
lastCgroupCpuSamples = samples
|
|
||||||
if hadEnv {
|
|
||||||
os.Setenv("container", env)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
containerOnce = sync.Once{}
|
|
||||||
containerDetected = false
|
|
||||||
lastCgroupCpuSamples = make(map[uint16]cgroupCpuSample)
|
|
||||||
os.Unsetenv("container")
|
|
||||||
|
|
||||||
tmp := t.TempDir()
|
|
||||||
cpuCgroupRoot = filepath.Join(tmp, "cgroup")
|
|
||||||
cpuCgroupMountinfo = filepath.Join(tmp, "mountinfo")
|
|
||||||
cpuProcSelfCgroup = filepath.Join(tmp, "self-cgroup")
|
|
||||||
cpuProcOneEnviron = filepath.Join(tmp, "one-environ")
|
|
||||||
cpuDockerenvPath = filepath.Join(tmp, "dockerenv")
|
|
||||||
cpuContainerenv = filepath.Join(tmp, "containerenv")
|
|
||||||
cpuSystemdContPath = filepath.Join(tmp, "systemd-container")
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeCpuFixture(t *testing.T, path, contents string) {
|
|
||||||
t.Helper()
|
|
||||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
|
||||||
require.NoError(t, os.WriteFile(path, []byte(contents), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeNow installs a controllable clock and returns a function to advance it.
|
|
||||||
func fakeNow(t *testing.T) func(time.Duration) {
|
|
||||||
t.Helper()
|
|
||||||
cur := time.Unix(1_700_000_000, 0)
|
|
||||||
cpuNow = func() time.Time { return cur }
|
|
||||||
return func(d time.Duration) { cur = cur.Add(d) }
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDetectContainer(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
setup func(t *testing.T)
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"plain host", func(t *testing.T) {}, false},
|
|
||||||
{"container env", func(t *testing.T) { t.Setenv("container", "lxc") }, true},
|
|
||||||
{".dockerenv", func(t *testing.T) { writeCpuFixture(t, cpuDockerenvPath, "") }, true},
|
|
||||||
{".containerenv", func(t *testing.T) { writeCpuFixture(t, cpuContainerenv, "") }, true},
|
|
||||||
{"systemd container", func(t *testing.T) { writeCpuFixture(t, cpuSystemdContPath, "lxc\n") }, true},
|
|
||||||
{"init environ container=lxc", func(t *testing.T) {
|
|
||||||
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00container=lxc\x00HOME=/root\x00")
|
|
||||||
}, true},
|
|
||||||
{"init environ without marker", func(t *testing.T) {
|
|
||||||
writeCpuFixture(t, cpuProcOneEnviron, "PATH=/sbin\x00HOME=/root\x00")
|
|
||||||
}, false},
|
|
||||||
{"lxcfs serving /proc", func(t *testing.T) {
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
|
||||||
"31 25 0:28 / /proc/stat rw,nosuid,nodev,relatime - fuse.lxcfs lxcfs rw,user_id=0,group_id=0\n")
|
|
||||||
}, true},
|
|
||||||
{"cgroup-only mountinfo", func(t *testing.T) {
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
|
||||||
"36 25 0:32 / /sys/fs/cgroup rw - cgroup2 cgroup2 rw,nsdelegate\n")
|
|
||||||
}, false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
tt.setup(t)
|
|
||||||
assert.Equal(t, tt.want, detectContainer())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCgroupV2CpuSample(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
|
||||||
"usage_usec 3000000\nuser_usec 2000000\nsystem_usec 1000000\nnr_throttled 7\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "2,5-7\n")
|
|
||||||
cpuNumCPU = func() int { return 8 }
|
|
||||||
|
|
||||||
s, ok := readCgroupV2CpuSample()
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 3000000, s.usageUsec)
|
|
||||||
assert.EqualValues(t, 2000000, s.userUsec)
|
|
||||||
assert.EqualValues(t, 1000000, s.systemUsec)
|
|
||||||
assert.InDelta(t, 4, s.cores, 0.001) // cpuset 2,5-7 = 4 cores
|
|
||||||
}
|
|
||||||
|
|
||||||
// In a namespaced container the agent may sit in a sub-cgroup (e.g. a systemd
|
|
||||||
// service); the mount root still accounts for the whole container and must win.
|
|
||||||
func TestReadCgroupV2PrefersContainerRoot(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/beszel-agent.service\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "system.slice/beszel-agent.service/cpu.stat"), "usage_usec 5\n")
|
|
||||||
|
|
||||||
s, ok := readCgroupV2CpuSample()
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 9000, s.usageUsec)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Without a cgroup namespace the mount shows the real host hierarchy and the
|
|
||||||
// container's own path (docker/kubepods/lxc markers) resolves under it.
|
|
||||||
func TestReadCgroupV2ResolvesRuntimePath(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/system.slice/docker-deadbeef.scope\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 9000\n")
|
|
||||||
sub := filepath.Join(cpuCgroupRoot, "system.slice/docker-deadbeef.scope")
|
|
||||||
writeCpuFixture(t, filepath.Join(sub, "cpu.stat"), "usage_usec 5\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(sub, "cpu.max"), "100000 100000\n")
|
|
||||||
|
|
||||||
s, ok := readCgroupV2CpuSample()
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 5, s.usageUsec)
|
|
||||||
assert.InDelta(t, 1, s.cores, 0.001) // cpu.max quota of 1 core
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReadCgroupV1CpuSample(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "3:cpuacct:/\n2:memory:/\n")
|
|
||||||
v1 := filepath.Join(t.TempDir(), "cpuacct")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo,
|
|
||||||
"30 25 0:26 / "+v1+" rw,nosuid,nodev,noexec,relatime - cgroup cgroup rw,cpuacct\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(v1, "cpuacct.usage"), "2000000000\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(v1, "cpuacct.stat"), "user 100\nsystem 50\n")
|
|
||||||
cpuNumCPU = func() int { return 4 }
|
|
||||||
|
|
||||||
s, ok := readContainerCpuSample() // no 0:: line -> falls through to v1
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.EqualValues(t, 2000000, s.usageUsec) // ns -> usec
|
|
||||||
assert.EqualValues(t, 1000000, s.userUsec) // 100 jiffies * 1e6/100
|
|
||||||
assert.EqualValues(t, 500000, s.systemUsec) // 50 jiffies
|
|
||||||
assert.InDelta(t, 4, s.cores, 0.001)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainerCpuMetricsMath(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuDockerenvPath, "")
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpuset.cpus.effective"), "0-3\n")
|
|
||||||
cpuNumCPU = func() int { return 8 }
|
|
||||||
advance := fakeNow(t)
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
|
||||||
"usage_usec 1000000\nuser_usec 600000\nsystem_usec 400000\n")
|
|
||||||
m, ok := containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Zero(t, m.Total) // first call only seeds the baseline
|
|
||||||
|
|
||||||
// 1s elapsed, container burned 2 core-seconds on 4 usable cores
|
|
||||||
advance(time.Second)
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"),
|
|
||||||
"usage_usec 3000000\nuser_usec 1600000\nsystem_usec 900000\n")
|
|
||||||
m, ok = containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.InDelta(t, 50, m.Total, 0.01)
|
|
||||||
assert.InDelta(t, 25, m.User, 0.01)
|
|
||||||
assert.InDelta(t, 12.5, m.System, 0.01)
|
|
||||||
assert.Zero(t, m.Iowait)
|
|
||||||
assert.Zero(t, m.Steal)
|
|
||||||
assert.InDelta(t, 50, m.Idle, 0.01)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainerCpuMetricsHonorsQuota(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuDockerenvPath, "")
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.max"), "200000 100000\n") // 2 cores
|
|
||||||
cpuNumCPU = func() int { return 8 }
|
|
||||||
advance := fakeNow(t)
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1000000\n")
|
|
||||||
containerCpuMetrics(60000)
|
|
||||||
advance(time.Second)
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
|
||||||
m, ok := containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.InDelta(t, 50, m.Total, 0.01) // 1 core-second against a 2-core quota
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainerCpuMetricsZeroAndBackwardDelta(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuDockerenvPath, "")
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 5000000\n")
|
|
||||||
cpuNumCPU = func() int { return 4 }
|
|
||||||
advance := fakeNow(t)
|
|
||||||
|
|
||||||
// seed the baseline, then do not advance the clock: elapsed <= 0
|
|
||||||
containerCpuMetrics(60000)
|
|
||||||
m, ok := containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Zero(t, m.Total)
|
|
||||||
|
|
||||||
// counter goes backwards (cgroup recreated): report zero and re-baseline
|
|
||||||
advance(time.Second)
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 100000\n")
|
|
||||||
m, ok = containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Zero(t, m.Total)
|
|
||||||
|
|
||||||
// next tick measures from the new baseline, not the stale one
|
|
||||||
advance(time.Second)
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 1100000\n")
|
|
||||||
m, ok = containerCpuMetrics(60000)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.InDelta(t, 25, m.Total, 0.01) // 1e6 usec / (1s * 4 cores)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestContainerCpuMetricsFallbacks(t *testing.T) {
|
|
||||||
t.Run("not in container", func(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
_, ok := containerCpuMetrics(60000)
|
|
||||||
assert.False(t, ok)
|
|
||||||
})
|
|
||||||
t.Run("in container without cgroup accounting", func(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuDockerenvPath, "")
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
// cpuCgroupRoot has no cpu.stat
|
|
||||||
_, ok := containerCpuMetrics(60000)
|
|
||||||
assert.False(t, ok)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// The host path must keep reporting through gopsutil untouched.
|
|
||||||
func TestGetCpuMetricsHostFallback(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
m, err := getCpuMetrics(60000)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.GreaterOrEqual(t, m.Total, 0.0)
|
|
||||||
assert.LessOrEqual(t, m.Total, 100.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inside a container getCpuMetrics must report the cgroup-derived value, not
|
|
||||||
// the host core counters from /proc/stat.
|
|
||||||
func TestGetCpuMetricsPrefersCgroup(t *testing.T) {
|
|
||||||
swapCpuContainerSeams(t)
|
|
||||||
writeCpuFixture(t, cpuDockerenvPath, "")
|
|
||||||
writeCpuFixture(t, cpuProcSelfCgroup, "0::/\n")
|
|
||||||
writeCpuFixture(t, cpuCgroupMountinfo, "")
|
|
||||||
require.NoError(t, os.MkdirAll(cpuCgroupRoot, 0o755))
|
|
||||||
cpuNumCPU = func() int { return 4 }
|
|
||||||
advance := fakeNow(t)
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 0\n")
|
|
||||||
m, err := getCpuMetrics(60000)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Zero(t, m.Total)
|
|
||||||
|
|
||||||
advance(time.Second)
|
|
||||||
writeCpuFixture(t, filepath.Join(cpuCgroupRoot, "cpu.stat"), "usage_usec 2000000\n")
|
|
||||||
m, err = getCpuMetrics(60000)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.InDelta(t, 50, m.Total, 0.01)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCountCpuList(t *testing.T) {
|
|
||||||
assert.Equal(t, 4, countCpuList("0-3"))
|
|
||||||
assert.Equal(t, 4, countCpuList("2,5-7"))
|
|
||||||
assert.Equal(t, 1, countCpuList("2"))
|
|
||||||
assert.Equal(t, 0, countCpuList(""))
|
|
||||||
assert.Equal(t, 0, countCpuList("max"))
|
|
||||||
assert.Equal(t, 6, countCpuList("0-3,8-9"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCpuQuotaCores(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
_, ok := cpuQuotaCores(dir)
|
|
||||||
assert.False(t, ok) // no quota files
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "max 100000\n")
|
|
||||||
_, ok = cpuQuotaCores(dir)
|
|
||||||
assert.False(t, ok) // unlimited
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(dir, "cpu.max"), "150000 100000\n")
|
|
||||||
q, ok := cpuQuotaCores(dir)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.InDelta(t, 1.5, q, 0.001)
|
|
||||||
|
|
||||||
// v1 files
|
|
||||||
v1 := t.TempDir()
|
|
||||||
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "-1\n")
|
|
||||||
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_period_us"), "100000\n")
|
|
||||||
_, ok = cpuQuotaCores(v1)
|
|
||||||
assert.False(t, ok)
|
|
||||||
|
|
||||||
writeCpuFixture(t, filepath.Join(v1, "cpu.cfs_quota_us"), "50000\n")
|
|
||||||
q, ok = cpuQuotaCores(v1)
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.InDelta(t, 0.5, q, 0.001)
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/cpu"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestGetAllBusy(t *testing.T) {
|
|
||||||
times := cpu.TimesStat{
|
|
||||||
User: 1, System: 2, Idle: 3, Nice: 4, Iowait: 5,
|
|
||||||
Irq: 6, Softirq: 7, Steal: 8, Guest: 9, GuestNice: 10,
|
|
||||||
}
|
|
||||||
wantTotal, wantBusy := 55.0, 47.0
|
|
||||||
if runtime.GOOS == "linux" {
|
|
||||||
wantTotal, wantBusy = 36, 28
|
|
||||||
}
|
|
||||||
total, busy := getAllBusy(times)
|
|
||||||
assert.Equal(t, wantTotal, total)
|
|
||||||
assert.Equal(t, wantBusy, busy)
|
|
||||||
assert.InDelta(t, wantBusy/wantTotal*100, calculateBusy(cpu.TimesStat{}, times), 1e-10)
|
|
||||||
assert.Zero(t, calculateBusy(times, times))
|
|
||||||
assert.Zero(t, calculateBusy(times, cpu.TimesStat{}))
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
//go:build !linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
// containerCpuMetrics is Linux-only (cgroup accounting). Other platforms keep
|
|
||||||
// the gopsutil /proc path.
|
|
||||||
func containerCpuMetrics(uint16) (CpuMetrics, bool) {
|
|
||||||
return CpuMetrics{}, false
|
|
||||||
}
|
|
||||||
+32
-90
@@ -3,7 +3,6 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -154,12 +153,12 @@ func registerFilesystemStats(existing map[string]*system.FsStats, device, mountp
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
// addFsStat inserts a discovered filesystem if it resolves to a new tracking
|
||||||
// key and reports whether it was added. The key selection itself lives in
|
// key. The key selection itself lives in buildFsStatRegistration so that logic
|
||||||
// registerFilesystemStats so that logic can stay directly unit-tested.
|
// can stay directly unit-tested.
|
||||||
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) bool {
|
func (d *diskDiscovery) addFsStat(device, mountpoint string, root bool, customName string) {
|
||||||
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 false
|
return
|
||||||
}
|
}
|
||||||
d.agent.fsStats[key] = fsStats
|
d.agent.fsStats[key] = fsStats
|
||||||
name := key
|
name := key
|
||||||
@@ -167,7 +166,6 @@ 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
|
||||||
@@ -205,24 +203,14 @@ func isRootFallbackPartition(p disk.PartitionStat, rootMountPoint string) bool {
|
|||||||
// partition looks like the active root mount but still needs translating to an
|
// 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 {
|
||||||
// device is passed through as-is: findIoDevice normalizes it, and
|
fs, match := findIoDevice(filepath.Base(device), d.ctx.diskIoCounters)
|
||||||
// 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 root device is already resolved, so if it was registered earlier as an
|
// The resolved I/O device is already known here, so use it directly to avoid
|
||||||
// extra filesystem (e.g. root drive listed in EXTRA_FILESYSTEMS), promote that
|
// a second fallback search inside buildFsStatRegistration.
|
||||||
// entry rather than letting addLastResortRootFs guess a different device.
|
d.addFsStat(fs, mountpoint, true, "")
|
||||||
if stats, exists := d.agent.fsStats[fs]; exists {
|
return true
|
||||||
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
|
||||||
@@ -538,43 +526,13 @@ 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 := strings.TrimSpace(value)
|
name := filepath.Base(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]
|
||||||
@@ -596,9 +554,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)
|
||||||
}
|
}
|
||||||
@@ -681,9 +639,19 @@ 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 the latest counters of any interval, else seed from current
|
// Seed from agent-level fsStats if present, else seed from current
|
||||||
prev, hasPrev = a.diskBaseline[name]
|
prev = prevDisk{
|
||||||
if !hasPrev {
|
readBytes: stats.TotalRead,
|
||||||
|
writeBytes: stats.TotalWrite,
|
||||||
|
readTime: d.ReadTime,
|
||||||
|
writeTime: d.WriteTime,
|
||||||
|
ioTime: d.IoTime,
|
||||||
|
weightedIO: d.WeightedIO,
|
||||||
|
readCount: d.ReadCount,
|
||||||
|
writeCount: d.WriteCount,
|
||||||
|
at: stats.Time,
|
||||||
|
}
|
||||||
|
if prev.at.IsZero() {
|
||||||
prev = prevDiskFromCounter(d, now)
|
prev = prevDiskFromCounter(d, now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -718,31 +686,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// This is the total number of milliseconds spent by all reads (as
|
// 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)
|
||||||
deltaReadTime := ioTimeDelta(d.ReadTime, prev.readTime)
|
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
||||||
deltaWriteTime := ioTimeDelta(d.WriteTime, prev.writeTime)
|
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
||||||
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(ioTimeDelta(d.IoTime, prev.ioTime)) / float64(msElapsed) * 100)
|
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
// 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(ioTimeDelta(d.WeightedIO, prev.weightedIO)) / float64(msElapsed) * 100)
|
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
||||||
|
|
||||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
// 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(deltaReadTime) / float64(deltaReadCount))
|
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
||||||
}
|
}
|
||||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||||
wAwait = utils.TwoDecimals(float64(deltaWriteTime) / float64(deltaWriteCount))
|
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the baseline that seeds new intervals
|
// Update global fsStats baseline for cross-interval correctness
|
||||||
a.setDiskBaseline(name, prevDiskFromCounter(d, now))
|
stats.Time = now
|
||||||
stats.TotalRead = d.ReadBytes
|
stats.TotalRead = d.ReadBytes
|
||||||
stats.TotalWrite = d.WriteBytes
|
stats.TotalWrite = d.WriteBytes
|
||||||
stats.DiskReadPs = readMbPerSecond
|
stats.DiskReadPs = readMbPerSecond
|
||||||
@@ -774,30 +740,6 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDiskBaseline stores the latest counters of a device. A cache interval
|
|
||||||
// without its own snapshot measures its first sample from them.
|
|
||||||
func (a *Agent) setDiskBaseline(name string, d prevDisk) {
|
|
||||||
if a.diskBaseline == nil {
|
|
||||||
a.diskBaseline = make(map[string]prevDisk)
|
|
||||||
}
|
|
||||||
a.diskBaseline[name] = d
|
|
||||||
}
|
|
||||||
|
|
||||||
// ioTimeDelta returns the increase of a cumulative millisecond counter from
|
|
||||||
// the disk I/O stats. Linux prints these fields of /proc/diskstats as 32-bit
|
|
||||||
// unsigned ints, so they wrap to zero at 2^32. A busy disk reaches that in
|
|
||||||
// days for the weighted I/O time. Other platforms report 64-bit counters,
|
|
||||||
// so a lower value there is a reset.
|
|
||||||
func ioTimeDelta(current, previous uint64) uint64 {
|
|
||||||
if current >= previous {
|
|
||||||
return current - previous
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "linux" && previous <= math.MaxUint32 {
|
|
||||||
return current + (math.MaxUint32 + 1 - previous)
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// getRootMountPoint returns the appropriate root mount point for the system.
|
// 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 /
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/shirou/gopsutil/v4/disk"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Linux prints four millisecond fields of /proc/diskstats as 32-bit unsigned ints:
|
|
||||||
// read time, write time, io time and weighted io time. They wrap to zero at 2^32.
|
|
||||||
func TestUpdateDiskIoTimeCounterWrap(t *testing.T) {
|
|
||||||
const wrap = uint64(1) << 32
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
base uint64 // added to every previous time counter
|
|
||||||
}{
|
|
||||||
{"no wrap", 0},
|
|
||||||
{"32-bit wrap", wrap - 1000},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
// Deltas over 60s: read 300ms / 10 ops, write 400ms / 20 ops,
|
|
||||||
// io time 1200ms, weighted io 3000ms.
|
|
||||||
prev := prevDisk{
|
|
||||||
readBytes: 20000 * 512,
|
|
||||||
writeBytes: 10000 * 512,
|
|
||||||
readTime: tt.base + 900,
|
|
||||||
writeTime: tt.base + 700,
|
|
||||||
ioTime: tt.base + 400,
|
|
||||||
weightedIO: tt.base,
|
|
||||||
readCount: 1000,
|
|
||||||
writeCount: 500,
|
|
||||||
at: time.Now().Add(-60 * time.Second),
|
|
||||||
}
|
|
||||||
cur := func(v uint64) uint64 { return v % wrap }
|
|
||||||
line := fmt.Sprintf(" 8 0 sda %d 0 %d %d %d 0 %d %d 0 %d %d\n",
|
|
||||||
1010, 21200, cur(prev.readTime+300),
|
|
||||||
520, 10400, cur(prev.writeTime+400),
|
|
||||||
cur(prev.ioTime+1200), cur(prev.weightedIO+3000))
|
|
||||||
|
|
||||||
dir := t.TempDir()
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
|
|
||||||
t.Setenv("HOST_PROC", dir)
|
|
||||||
t.Setenv("HOST_SYS", dir)
|
|
||||||
t.Setenv("HOST_DEV", dir)
|
|
||||||
t.Setenv("HOST_RUN", dir)
|
|
||||||
|
|
||||||
fs := &system.FsStats{Root: true}
|
|
||||||
a := &Agent{
|
|
||||||
fsNames: []string{"sda"},
|
|
||||||
fsStats: map[string]*system.FsStats{"sda": fs},
|
|
||||||
diskPrev: map[uint16]map[string]prevDisk{60000: {"sda": prev}},
|
|
||||||
}
|
|
||||||
var stats system.Stats
|
|
||||||
a.updateDiskIo(60000, &stats)
|
|
||||||
|
|
||||||
// Same order as DiskIoStats in system.FsStats.
|
|
||||||
want := [6]float64{0.5, 0.67, 2, 30, 20, 5}
|
|
||||||
for i := range want {
|
|
||||||
assert.InDelta(t, want[i], fs.DiskIoStats[i], 0.01, "DiskIoStats[%d]", i)
|
|
||||||
assert.InDelta(t, want[i], stats.DiskIoStats[i], 0.01, "system DiskIoStats[%d]", i)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The first sample of a cache interval has no snapshot of its own. It must
|
|
||||||
// measure the time counters from the same baseline as the byte counters.
|
|
||||||
func TestUpdateDiskIoFirstSampleOfInterval(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("HOST_PROC", dir)
|
|
||||||
t.Setenv("HOST_SYS", dir)
|
|
||||||
t.Setenv("HOST_DEV", dir)
|
|
||||||
t.Setenv("HOST_RUN", dir)
|
|
||||||
writeDiskstats := func(line string) {
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "diskstats"), []byte(line), 0o644))
|
|
||||||
}
|
|
||||||
|
|
||||||
writeDiskstats(" 8 0 sda 1000 0 20000 900 500 0 10000 700 0 400 0\n")
|
|
||||||
counters, err := disk.IOCounters("sda")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fs := &system.FsStats{Root: true}
|
|
||||||
a := &Agent{
|
|
||||||
fsStats: map[string]*system.FsStats{"sda": fs},
|
|
||||||
diskPrev: map[uint16]map[string]prevDisk{},
|
|
||||||
}
|
|
||||||
a.initializeDiskIoStats(counters)
|
|
||||||
|
|
||||||
// updateDiskIo skips samples less than 100ms apart.
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
|
|
||||||
// Deltas: read 300ms / 10 ops, write 400ms / 20 ops, io time 1200ms, weighted io 3000ms.
|
|
||||||
writeDiskstats(" 8 0 sda 1010 0 21200 1200 520 0 10400 1100 0 1600 3000\n")
|
|
||||||
var stats system.Stats
|
|
||||||
a.updateDiskIo(60000, &stats)
|
|
||||||
|
|
||||||
require.NotZero(t, fs.DiskReadBytes, "bytes are measured from the baseline")
|
|
||||||
for i := range 3 {
|
|
||||||
assert.NotZero(t, fs.DiskIoStats[i], "DiskIoStats[%d]", i)
|
|
||||||
}
|
|
||||||
assert.InDelta(t, 30, fs.DiskIoStats[3], 0.01, "r_await")
|
|
||||||
assert.InDelta(t, 20, fs.DiskIoStats[4], 0.01, "w_await")
|
|
||||||
assert.NotZero(t, fs.DiskIoStats[5], "weighted io")
|
|
||||||
|
|
||||||
// A second interval starts from the latest counters, not from the ones at start.
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
// Deltas: read 100ms / 10 ops, write 100ms / 20 ops.
|
|
||||||
writeDiskstats(" 8 0 sda 1020 0 22400 1300 540 0 10800 1200 0 1800 3500\n")
|
|
||||||
a.updateDiskIo(1000, &stats)
|
|
||||||
|
|
||||||
assert.InDelta(t, 10, fs.DiskIoStats[3], 0.01, "r_await")
|
|
||||||
assert.InDelta(t, 5, fs.DiskIoStats[4], 0.01, "w_await")
|
|
||||||
}
|
|
||||||
+98
-140
@@ -3,17 +3,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"strings"
|
||||||
"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) {
|
||||||
@@ -507,13 +504,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: filepath.FromSlash("/extra-filesystems/existing")},
|
"existing": {Mountpoint: "/extra-filesystems/existing"},
|
||||||
}}
|
}}
|
||||||
discovery := diskDiscovery{
|
discovery := diskDiscovery{
|
||||||
agent: agent,
|
agent: agent,
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
isWindows: false,
|
isWindows: false,
|
||||||
efPath: filepath.FromSlash("/extra-filesystems"),
|
efPath: "/extra-filesystems",
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"newdisk": {Name: "newdisk"},
|
"newdisk": {Name: "newdisk"},
|
||||||
},
|
},
|
||||||
@@ -522,10 +519,10 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
|
|||||||
|
|
||||||
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
discovery.addExtraFilesystemFolders([]string{"existing", "newdisk__Archive"})
|
||||||
|
|
||||||
require.Len(t, agent.fsStats, 2)
|
assert.Len(t, agent.fsStats, 2)
|
||||||
stats, exists := agent.fsStats["newdisk"]
|
stats, exists := agent.fsStats["newdisk"]
|
||||||
require.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.Equal(t, filepath.FromSlash("/extra-filesystems/newdisk__Archive"), stats.Mountpoint)
|
assert.Equal(t, "/extra-filesystems/newdisk__Archive", stats.Mountpoint)
|
||||||
assert.Equal(t, "Archive", stats.Name)
|
assert.Equal(t, "Archive", stats.Name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -536,7 +533,7 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
agent: agent,
|
agent: agent,
|
||||||
ctx: fsRegistrationContext{
|
ctx: fsRegistrationContext{
|
||||||
isWindows: false,
|
isWindows: false,
|
||||||
efPath: filepath.FromSlash("/extra-filesystems"),
|
efPath: "/extra-filesystems",
|
||||||
diskIoCounters: map[string]disk.IOCountersStat{
|
diskIoCounters: map[string]disk.IOCountersStat{
|
||||||
"nvme0n1p1": {Name: "nvme0n1p1"},
|
"nvme0n1p1": {Name: "nvme0n1p1"},
|
||||||
"nvme1n1": {Name: "nvme1n1"},
|
"nvme1n1": {Name: "nvme1n1"},
|
||||||
@@ -551,12 +548,12 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
|
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{
|
d.addPartitionExtraFs(disk.PartitionStat{
|
||||||
Device: "/dev/nvme0n1p1",
|
Device: "/dev/nvme0n1p1",
|
||||||
Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"),
|
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
||||||
})
|
})
|
||||||
|
|
||||||
stats, exists := agent.fsStats["nvme0n1p1"]
|
stats, exists := agent.fsStats["nvme0n1p1"]
|
||||||
require.True(t, exists)
|
assert.True(t, exists)
|
||||||
assert.Equal(t, filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root"), stats.Mountpoint)
|
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
||||||
assert.Equal(t, "caddy1-root", stats.Name)
|
assert.Equal(t, "caddy1-root", stats.Name)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -567,10 +564,10 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
// These simulate the virtual mounts that appear when host / is bind-mounted
|
// 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{
|
||||||
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc"),
|
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
||||||
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys"),
|
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
||||||
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/dev"),
|
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
||||||
filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/run"),
|
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
||||||
} {
|
} {
|
||||||
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
||||||
}
|
}
|
||||||
@@ -583,20 +580,18 @@ func TestAddPartitionExtraFs(t *testing.T) {
|
|||||||
d := makeDiscovery(agent)
|
d := makeDiscovery(agent)
|
||||||
|
|
||||||
partitions := []disk.PartitionStat{
|
partitions := []disk.PartitionStat{
|
||||||
{Device: "/dev/nvme0n1p1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root")},
|
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
||||||
{Device: "/dev/nvme1n1", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme1n1__caddy1-docker")},
|
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
||||||
{Device: "proc", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/proc")},
|
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
||||||
{Device: "sysfs", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/sys")},
|
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
||||||
{Device: "overlay", Mountpoint: filepath.FromSlash("/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker")},
|
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
||||||
}
|
}
|
||||||
for _, p := range partitions {
|
for _, p := range partitions {
|
||||||
d.addPartitionExtraFs(p)
|
d.addPartitionExtraFs(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
require.Len(t, agent.fsStats, 2)
|
assert.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)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -769,6 +764,82 @@ func TestIsDockerSpecialMountpoint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitializeDiskInfoWithCustomNames(t *testing.T) {
|
||||||
|
// Test with custom names
|
||||||
|
t.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2")
|
||||||
|
|
||||||
|
// Mock disk partitions (we'll just test the parsing logic)
|
||||||
|
// Since the actual disk operations are system-dependent, we'll focus on the parsing
|
||||||
|
testCases := []struct {
|
||||||
|
envValue string
|
||||||
|
expectedFs []string
|
||||||
|
expectedNames map[string]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
envValue: "sda1__my-storage,sdb1__backup-drive",
|
||||||
|
expectedFs: []string{"sda1", "sdb1"},
|
||||||
|
expectedNames: map[string]string{
|
||||||
|
"sda1": "my-storage",
|
||||||
|
"sdb1": "backup-drive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
envValue: "sda1,nvme0n1p2__fast-ssd",
|
||||||
|
expectedFs: []string{"sda1", "nvme0n1p2"},
|
||||||
|
expectedNames: map[string]string{
|
||||||
|
"nvme0n1p2": "fast-ssd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run("env_"+tc.envValue, func(t *testing.T) {
|
||||||
|
t.Setenv("EXTRA_FILESYSTEMS", tc.envValue)
|
||||||
|
|
||||||
|
// Create mock partitions that would match our test cases
|
||||||
|
partitions := []disk.PartitionStat{}
|
||||||
|
for _, fs := range tc.expectedFs {
|
||||||
|
if strings.HasPrefix(fs, "/dev/") {
|
||||||
|
partitions = append(partitions, disk.PartitionStat{
|
||||||
|
Device: fs,
|
||||||
|
Mountpoint: fs,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
partitions = append(partitions, disk.PartitionStat{
|
||||||
|
Device: "/dev/" + fs,
|
||||||
|
Mountpoint: "/" + fs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the parsing logic by calling the relevant part
|
||||||
|
// We'll create a simplified version to test just the parsing
|
||||||
|
extraFilesystems := tc.envValue
|
||||||
|
for fsEntry := range strings.SplitSeq(extraFilesystems, ",") {
|
||||||
|
// Parse the entry
|
||||||
|
fsEntry = strings.TrimSpace(fsEntry)
|
||||||
|
var fs, customName string
|
||||||
|
if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 {
|
||||||
|
fs = strings.TrimSpace(parts[0])
|
||||||
|
customName = strings.TrimSpace(parts[1])
|
||||||
|
} else {
|
||||||
|
fs = fsEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the device is in our expected list
|
||||||
|
assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list")
|
||||||
|
|
||||||
|
// Check if custom name should exist
|
||||||
|
if expectedName, exists := tc.expectedNames[fs]; exists {
|
||||||
|
assert.Equal(t, expectedName, customName, "custom name should match expected")
|
||||||
|
} else {
|
||||||
|
assert.Empty(t, customName, "custom name should be empty when not expected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFsStatsWithCustomNames(t *testing.T) {
|
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{
|
||||||
@@ -959,10 +1030,8 @@ 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.Equal(t, uint64(10), agent.diskBaseline["sda"].readBytes)
|
assert.False(t, agent.fsStats["sda"].Time.IsZero())
|
||||||
assert.Equal(t, uint64(40), agent.diskBaseline["sdb"].writeBytes)
|
assert.False(t, agent.fsStats["sdb"].Time.IsZero())
|
||||||
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},
|
||||||
@@ -972,114 +1041,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
+8
-12
@@ -68,11 +68,10 @@ 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
|
||||||
imageUpdatesDisabled bool // Whether image update checks are disabled by configuration
|
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
||||||
imageUpdatesMutex sync.RWMutex // Protects imageUpdates, its entries, and imageUpdatesRunning
|
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
||||||
imageUpdates map[string]*imageUpdateStatus // Shared update status keyed by normalized image reference
|
imageUpdatesRunning bool // Whether a background image-update batch is in progress
|
||||||
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
|
||||||
@@ -689,8 +688,6 @@ 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 != "" {
|
||||||
@@ -710,11 +707,10 @@ 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,9 +31,6 @@ 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,29 +27,6 @@ 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)
|
||||||
|
|||||||
+19
-21
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,7 +37,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()
|
||||||
|
|
||||||
localDigests, err := dm.inspectImageDigests(image, registry, repository)
|
localDigest, err := dm.inspectImageDigest(image, registry, repository)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -48,49 +47,48 @@ func (dm *dockerManager) checkImageUpdate(image string) (bool, error) {
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return !slices.Contains(localDigests, remoteDigest), nil
|
return remoteDigest != localDigest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// inspectImageDigests reads Docker's image metadata without using dm.decode.
|
// inspectImageDigest reads Docker's image metadata without using dm.decode.
|
||||||
// The checker runs in the image-discovery goroutine, so it must not hold any
|
// 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) inspectImageDigests(image, registry, repository string) ([]string, error) {
|
func (dm *dockerManager) inspectImageDigest(image, registry, repository string) (string, error) {
|
||||||
if dm.client == nil {
|
if dm.client == nil {
|
||||||
return nil, fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
return "", fmt.Errorf("inspect image %q: Docker client is unavailable", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint := "http://localhost/images/" + url.PathEscape(image) + "/json"
|
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 nil, fmt.Errorf("inspect image %q: %w", image, err)
|
return "", 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 nil, fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
return "", fmt.Errorf("inspect image %q failed: %s", image, responseStatus(resp))
|
||||||
}
|
}
|
||||||
|
|
||||||
var inspect struct {
|
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 nil, fmt.Errorf("decode image inspect %q: %w", image, err)
|
return "", fmt.Errorf("decode image inspect %q: %w", image, err)
|
||||||
}
|
}
|
||||||
if len(inspect.RepoDigests) == 0 {
|
if len(inspect.RepoDigests) == 0 {
|
||||||
return nil, fmt.Errorf("inspect image %q returned no repository digests", image)
|
return "", fmt.Errorf("inspect image %q returned no repository digests", image)
|
||||||
}
|
}
|
||||||
|
|
||||||
localDigests := matchingRepositoryDigests(inspect.RepoDigests, registry, repository)
|
localDigest, ok := matchingRepositoryDigest(inspect.RepoDigests, registry, repository)
|
||||||
if len(localDigests) == 0 {
|
if !ok {
|
||||||
return nil, fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
return "", fmt.Errorf("inspect image %q returned no valid digest for %s/%s", image, registry, repository)
|
||||||
}
|
}
|
||||||
return localDigests, nil
|
return localDigest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchingRepositoryDigests returns all valid digests belonging to the requested
|
// matchingRepositoryDigest returns a valid digest belonging to the requested
|
||||||
// repository. Container engines can return both index and platform manifest digests for one
|
// repository. Docker can return multiple RepoDigests for one local image; an
|
||||||
// local image, in either order.
|
// unrelated first entry must never be used for the comparison.
|
||||||
func matchingRepositoryDigests(repoDigests []string, registry, repository string) []string {
|
func matchingRepositoryDigest(repoDigests []string, registry, repository string) (string, bool) {
|
||||||
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, '@')
|
||||||
@@ -110,9 +108,9 @@ func matchingRepositoryDigests(repoDigests []string, registry, repository string
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
digests = append(digests, d.String())
|
return d.String(), true
|
||||||
}
|
}
|
||||||
return digests
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameRegistry(left, right string) bool {
|
func sameRegistry(left, right string) bool {
|
||||||
|
|||||||
@@ -80,45 +80,6 @@ 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
|
||||||
|
|||||||
+2
-18
@@ -12,7 +12,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fanSensor struct {
|
type fanSensor struct {
|
||||||
key, path, chip string
|
key, path string
|
||||||
}
|
}
|
||||||
|
|
||||||
var getFanSensors = newFanSensorCache(hwmonRoot)
|
var getFanSensors = newFanSensorCache(hwmonRoot)
|
||||||
@@ -34,10 +34,6 @@ 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
|
||||||
@@ -104,7 +100,7 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
|
|||||||
if label != "" {
|
if label != "" {
|
||||||
key = chipName + "_" + label
|
key = chipName + "_" + label
|
||||||
}
|
}
|
||||||
sensors = append(sensors, fanSensor{key, inputPath, chipName})
|
sensors = append(sensors, fanSensor{key, inputPath})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sensors, nil
|
return sensors, nil
|
||||||
@@ -119,15 +115,3 @@ 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,20 +103,3 @@ 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)
|
|
||||||
}
|
|
||||||
|
|||||||
+2
-29
@@ -454,8 +454,8 @@ func (gm *GPUManager) storeSnapshot(id string, gpu *system.GPUData, cacheKey uin
|
|||||||
// It only reports capability presence and does not apply policy decisions.
|
// 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,36 +750,9 @@ 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
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Run a copy of the test binary as a GPU command so fixtures do not need a shell.
|
|
||||||
func TestMain(m *testing.M) {
|
|
||||||
executable, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
switch strings.TrimSuffix(filepath.Base(executable), ".exe") {
|
|
||||||
case nvidiaSmiCmd, rocmSmiCmd, tegraStatsCmd, nvtopCmd, intelGpuStatsCmd:
|
|
||||||
output, err := os.ReadFile(executable + ".stdout")
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
// Only the parent creates files; a late collector must not undo cleanup.
|
|
||||||
args, err := os.OpenFile(executable+".args", os.O_WRONLY|os.O_TRUNC, 0)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
_, err = io.WriteString(args, strings.Join(os.Args[1:], " "))
|
|
||||||
closeErr := args.Close()
|
|
||||||
if err == nil {
|
|
||||||
err = closeErr
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Print(string(output))
|
|
||||||
os.Exit(0)
|
|
||||||
}
|
|
||||||
os.Exit(m.Run())
|
|
||||||
}
|
|
||||||
|
|
||||||
func gpuCommandFixture(t *testing.T, dir, name, output string) string {
|
|
||||||
t.Helper()
|
|
||||||
executable, err := os.Executable()
|
|
||||||
require.NoError(t, err)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
name += ".exe"
|
|
||||||
}
|
|
||||||
path := filepath.Join(dir, name)
|
|
||||||
if err := os.Link(executable, path); err != nil {
|
|
||||||
src, err := os.Open(executable)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer src.Close()
|
|
||||||
dst, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = io.Copy(dst, src)
|
|
||||||
closeErr := dst.Close()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, closeErr)
|
|
||||||
}
|
|
||||||
require.NoError(t, os.WriteFile(path+".stdout", []byte(output), 0600))
|
|
||||||
require.NoError(t, os.WriteFile(path+".args", nil, 0600))
|
|
||||||
return path + ".args"
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGPUFixtureDoesNotRecreateRemovedArgs(t *testing.T) {
|
|
||||||
argsFile := gpuCommandFixture(t, t.TempDir(), nvidiaSmiCmd, "fixture output\n")
|
|
||||||
require.NoError(t, os.WriteFile(argsFile, nil, 0600))
|
|
||||||
require.NoError(t, os.Remove(argsFile))
|
|
||||||
|
|
||||||
cmd := exec.Command(strings.TrimSuffix(argsFile, ".args"))
|
|
||||||
err := cmd.Run()
|
|
||||||
require.NoFileExists(t, argsFile, "a late fixture process must not recreate files removed by cleanup")
|
|
||||||
require.Error(t, err, "the fixture must report a missing argument-capture file")
|
|
||||||
}
|
|
||||||
+100
-120
@@ -2,9 +2,7 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -51,10 +49,10 @@ func (gm *GPUManager) updateIntelFromStats(sample *intelGpuStats) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// collectIntelStats executes intel_gpu_top in JSON mode (-J) and parses the output.
|
// collectIntelStats executes intel_gpu_top in text mode (-l) and parses the output
|
||||||
func (gm *GPUManager) collectIntelStats() (err error) {
|
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, "-J"}
|
args := []string{"-s", intelGpuStatsInterval, "-l"}
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -82,64 +80,48 @@ func (gm *GPUManager) collectIntelStats() (err error) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := gm.parseIntelJSONStream(stdout); err != nil {
|
scanner := bufio.NewScanner(stdout)
|
||||||
return err
|
var header1 string
|
||||||
}
|
var engineNames []string
|
||||||
// The closing "]" is printed as the process exits, so read to EOF to let
|
var friendlyNames []string
|
||||||
// it finish instead of killing it.
|
var preEngineCols int
|
||||||
_, _ = io.Copy(io.Discard, stdout)
|
var powerIndex int
|
||||||
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
|
|
||||||
// usually never closed, so output ending mid-array or mid-sample (the
|
for scanner.Scan() {
|
||||||
// process was killed) is the normal end of the stream rather than an error.
|
line := strings.TrimSpace(scanner.Text())
|
||||||
for dec.More() {
|
if line == "" {
|
||||||
var sample intelGpuJSONSample
|
continue
|
||||||
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
|
||||||
}
|
}
|
||||||
stats := parseIntelJSONSample(sample)
|
sample, err := gm.parseIntelData(line, engineNames, friendlyNames, powerIndex, preEngineCols)
|
||||||
if !validIntelPower(stats.PowerGPU) || !validIntelPower(stats.PowerPkg) {
|
if err != nil {
|
||||||
slog.Debug("Skipping intel_gpu_top sample with invalid power", "gpu", stats.PowerGPU, "pkg", stats.PowerPkg)
|
return err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
hadDataRow = true
|
hadDataRow = true
|
||||||
gm.updateIntelFromStats(&stats)
|
gm.updateIntelFromStats(&sample)
|
||||||
|
}
|
||||||
|
if scanErr := scanner.Err(); scanErr != nil {
|
||||||
|
return scanErr
|
||||||
}
|
}
|
||||||
if !hadDataRow {
|
if !hadDataRow {
|
||||||
return errNoValidData
|
return errNoValidData
|
||||||
@@ -147,82 +129,80 @@ func (gm *GPUManager) parseIntelJSONStream(r io.Reader) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// eofReader records whether the underlying reader has returned io.EOF. The
|
func (gm *GPUManager) parseIntelHeaders(header1 string, header2 string) (engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) {
|
||||||
// json decoder reports a stream ending mid-value as a syntax error, so this
|
// Build indexes
|
||||||
// is how a truncated final sample is told apart from invalid output.
|
h1 := strings.Fields(header1)
|
||||||
type eofReader struct {
|
h2 := strings.Fields(header2)
|
||||||
r io.Reader
|
powerIndex = -1 // Initialize to -1, will be set to actual index if found
|
||||||
eof bool
|
// Collect engine names from header1
|
||||||
}
|
for _, col := range h1 {
|
||||||
|
key := strings.TrimRightFunc(col, func(r rune) bool {
|
||||||
func (e *eofReader) Read(p []byte) (int, error) {
|
return (r >= '0' && r <= '9') || r == '/'
|
||||||
n, err := e.r.Read(p)
|
})
|
||||||
if err == io.EOF {
|
var friendly string
|
||||||
e.eof = true
|
switch key {
|
||||||
}
|
case "RCS":
|
||||||
return n, err
|
friendly = "Render/3D"
|
||||||
}
|
case "BCS":
|
||||||
|
friendly = "Blitter"
|
||||||
// peekNonSpace discards leading JSON whitespace and returns the next byte without consuming it.
|
case "VCS":
|
||||||
func peekNonSpace(br *bufio.Reader) (byte, error) {
|
friendly = "Video"
|
||||||
for {
|
case "VECS":
|
||||||
b, err := br.Peek(1)
|
friendly = "VideoEnhance"
|
||||||
if err != nil {
|
case "CCS":
|
||||||
return 0, err
|
friendly = "Compute"
|
||||||
}
|
|
||||||
switch b[0] {
|
|
||||||
case ' ', '\t', '\n', '\r':
|
|
||||||
_, _ = br.ReadByte()
|
|
||||||
default:
|
default:
|
||||||
return b[0], nil
|
continue
|
||||||
|
}
|
||||||
|
engineNames = append(engineNames, key)
|
||||||
|
friendlyNames = append(friendlyNames, friendly)
|
||||||
|
}
|
||||||
|
// find power gpu index among pre-engine columns
|
||||||
|
if n := len(engineNames); n > 0 {
|
||||||
|
preEngineCols = max(len(h2)-3*n, 0)
|
||||||
|
limit := min(len(h2), preEngineCols)
|
||||||
|
for i := range limit {
|
||||||
|
if strings.EqualFold(h2[i], "gpu") {
|
||||||
|
powerIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return engineNames, friendlyNames, powerIndex, preEngineCols
|
||||||
}
|
}
|
||||||
|
|
||||||
// intelGpuJSONSample is a single sample from intel_gpu_top -J output. Only the
|
func (gm *GPUManager) parseIntelData(line string, engineNames []string, friendlyNames []string, powerIndex int, preEngineCols int) (sample intelGpuStats, err error) {
|
||||||
// needed fields are mapped.
|
fields := strings.Fields(line)
|
||||||
type intelGpuJSONSample struct {
|
if len(fields) == 0 {
|
||||||
Power *struct {
|
return sample, errNoValidData
|
||||||
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 {
|
// Make sure row has enough columns for engines
|
||||||
stats.Engines = make(map[string]float64, len(sample.Engines))
|
if need := preEngineCols + 3*len(engineNames); len(fields) < need {
|
||||||
for key, engine := range sample.Engines {
|
return sample, errNoValidData
|
||||||
stats.Engines[intelEngineClass(key)] += engine.Busy
|
}
|
||||||
|
if powerIndex >= 0 && powerIndex < len(fields) {
|
||||||
|
if v, perr := strconv.ParseFloat(fields[powerIndex], 64); perr == nil {
|
||||||
|
sample.PowerGPU = v
|
||||||
|
}
|
||||||
|
if v, perr := strconv.ParseFloat(fields[powerIndex+1], 64); perr == nil {
|
||||||
|
sample.PowerPkg = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return stats
|
if len(engineNames) > 0 {
|
||||||
}
|
sample.Engines = make(map[string]float64, len(engineNames))
|
||||||
|
for k := range engineNames {
|
||||||
// intelEngineClass returns the engine class name for an engine key. Keys are
|
base := preEngineCols + 3*k
|
||||||
// class names ("Render/3D", "Video") in class view, which JSON output uses by
|
if base < len(fields) {
|
||||||
// default since v1.28, and instance names ("Render/3D/0", "Video/1") in
|
busy := 0.0
|
||||||
// physical view, which older versions use.
|
if v, e := strconv.ParseFloat(fields[base], 64); e == nil {
|
||||||
func intelEngineClass(key string) string {
|
busy = v
|
||||||
if i := strings.LastIndexByte(key, '/'); i >= 0 {
|
}
|
||||||
if _, err := strconv.ParseUint(key[i+1:], 10, 32); err == nil {
|
cur := sample.Engines[friendlyNames[k]]
|
||||||
return key[:i]
|
sample.Engines[friendlyNames[k]] = cur + busy
|
||||||
|
} else {
|
||||||
|
sample.Engines[friendlyNames[k]] = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return key
|
return sample, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
//go:build testing && !(amd64 && (windows || (linux && glibc)))
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This fallback requires NVML initialisation to fail, as guaranteed by the unsupported implementation.
|
|
||||||
func TestNewGPUManagerPriorityNvmlFallbackToNvidiaSmi(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
t.Setenv("PATH", dir)
|
|
||||||
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml,nvidia-smi")
|
|
||||||
|
|
||||||
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Fallback GPU, 41, 256, 1024, 8, 14`+"\n")
|
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, gm)
|
|
||||||
|
|
||||||
waitGPUs(t, gm, "0")
|
|
||||||
gm.Lock()
|
|
||||||
defer gm.Unlock()
|
|
||||||
gpu, ok := gm.GpuDataMap["0"]
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, "Fallback GPU", gpu.Name)
|
|
||||||
}
|
|
||||||
+350
-258
@@ -3,10 +3,9 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -1124,7 +1123,7 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
setupCommands func(*testing.T, string)
|
setupCommands func(string) error
|
||||||
wantNvidiaSmi bool
|
wantNvidiaSmi bool
|
||||||
wantRocmSmi bool
|
wantRocmSmi bool
|
||||||
wantTegrastats bool
|
wantTegrastats bool
|
||||||
@@ -1132,8 +1131,10 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "nvidia-smi not available",
|
name: "nvidia-smi not available",
|
||||||
setupCommands: func(*testing.T, string) {},
|
setupCommands: func(_ string) error {
|
||||||
|
return nil
|
||||||
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
@@ -1142,8 +1143,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nvidia-smi available",
|
name: "nvidia-smi available",
|
||||||
setupCommands: func(t *testing.T, tempDir string) {
|
setupCommands: func(tempDir string) error {
|
||||||
gpuCommandFixture(t, tempDir, "nvidia-smi", "test\n")
|
path := filepath.Join(tempDir, "nvidia-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: true,
|
wantNvidiaSmi: true,
|
||||||
wantTegrastats: false,
|
wantTegrastats: false,
|
||||||
@@ -1153,8 +1160,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "rocm-smi available",
|
name: "rocm-smi available",
|
||||||
setupCommands: func(t *testing.T, tempDir string) {
|
setupCommands: func(tempDir string) error {
|
||||||
gpuCommandFixture(t, tempDir, "rocm-smi", "test\n")
|
path := filepath.Join(tempDir, "rocm-smi")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: true,
|
wantRocmSmi: true,
|
||||||
@@ -1164,8 +1177,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "tegrastats available",
|
name: "tegrastats available",
|
||||||
setupCommands: func(t *testing.T, tempDir string) {
|
setupCommands: func(tempDir string) error {
|
||||||
gpuCommandFixture(t, tempDir, "tegrastats", "test\n")
|
path := filepath.Join(tempDir, "tegrastats")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "test"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
@@ -1175,8 +1194,14 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "nvtop available",
|
name: "nvtop available",
|
||||||
setupCommands: func(t *testing.T, tempDir string) {
|
setupCommands: func(tempDir string) error {
|
||||||
gpuCommandFixture(t, tempDir, "nvtop", "test\n")
|
path := filepath.Join(tempDir, "nvtop")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "[]"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: false,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: false,
|
wantRocmSmi: false,
|
||||||
@@ -1185,9 +1210,12 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "no gpu tools available",
|
name: "no gpu tools available",
|
||||||
setupCommands: func(*testing.T, string) {},
|
setupCommands: func(_ string) error {
|
||||||
wantErr: true,
|
// The subtest already restricts PATH to its empty temporary directory.
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1195,7 +1223,9 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
t.Setenv("PATH", tempDir)
|
t.Setenv("PATH", tempDir)
|
||||||
tt.setupCommands(t, tempDir)
|
if err := tt.setupCommands(tempDir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
gm := &GPUManager{}
|
gm := &GPUManager{}
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
@@ -1237,21 +1267,6 @@ func TestGPUCapabilitiesAndLegacyPriority(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitGPUs(t *testing.T, gm *GPUManager, ids ...string) {
|
|
||||||
t.Helper()
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
gm.Lock()
|
|
||||||
defer gm.Unlock()
|
|
||||||
for _, id := range ids {
|
|
||||||
gpu := gm.GpuDataMap[id]
|
|
||||||
if gpu == nil || gpu.Count == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}, 5*time.Second, 10*time.Millisecond, "GPU collectors did not produce data for %v", ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCollectorStartHelpers(t *testing.T) {
|
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()
|
||||||
@@ -1260,17 +1275,21 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
command string
|
command string
|
||||||
gpuID string
|
setup func(t *testing.T) error
|
||||||
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",
|
||||||
gpuID: "0",
|
setup: func(t *testing.T) error {
|
||||||
setup: func(t *testing.T) {
|
path := filepath.Join(dir, "nvidia-smi")
|
||||||
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100`+"\n")
|
script := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Test GPU, 50, 1024, 4096, 25, 100"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
validate: func(t *testing.T, gm *GPUManager) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["0"]
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
@@ -1285,9 +1304,14 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "rocm-smi collector",
|
name: "rocm-smi collector",
|
||||||
command: "rocm-smi",
|
command: "rocm-smi",
|
||||||
gpuID: "34756",
|
setup: func(t *testing.T) error {
|
||||||
setup: func(t *testing.T) {
|
path := 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]", "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")
|
script := `#!/bin/sh
|
||||||
|
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "Card Model": "0x1681", "Card Vendor": "Advanced Micro Devices, Inc. [AMD/ATI]", "Card SKU": "REMBRANDT", "Subsystem ID": "0x8a22", "Device Rev": "0xc8", "Node ID": "1", "GUID": "34756", "GFX Version": "gfx1035"}}'`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
validate: func(t *testing.T, gm *GPUManager) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["34756"]
|
gpu, exists := gm.GpuDataMap["34756"]
|
||||||
@@ -1302,9 +1326,14 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "tegrastats collector",
|
name: "tegrastats collector",
|
||||||
command: "tegrastats",
|
command: "tegrastats",
|
||||||
gpuID: "0",
|
setup: func(t *testing.T) error {
|
||||||
setup: func(t *testing.T) {
|
path := 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")
|
script := `#!/bin/sh
|
||||||
|
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
validate: func(t *testing.T, gm *GPUManager) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["0"]
|
gpu, exists := gm.GpuDataMap["0"]
|
||||||
@@ -1322,9 +1351,14 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "nvtop collector",
|
name: "nvtop collector",
|
||||||
command: "nvtop",
|
command: "nvtop",
|
||||||
gpuID: "n0",
|
setup: func(t *testing.T) error {
|
||||||
setup: func(t *testing.T) {
|
path := filepath.Join(dir, "nvtop")
|
||||||
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")
|
script := `#!/bin/sh
|
||||||
|
echo '[{"device_name":"NVIDIA Test GPU","temp":"52C","power_draw":"31W","gpu_util":"37%","mem_total":"4294967296","mem_used":"536870912","processes":[]}]'`
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
},
|
},
|
||||||
validate: func(t *testing.T, gm *GPUManager) {
|
validate: func(t *testing.T, gm *GPUManager) {
|
||||||
gpu, exists := gm.GpuDataMap["n0"]
|
gpu, exists := gm.GpuDataMap["n0"]
|
||||||
@@ -1339,7 +1373,9 @@ func TestCollectorStartHelpers(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) {
|
||||||
tt.setup(t)
|
if err := tt.setup(t); err != nil {
|
||||||
|
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),
|
||||||
@@ -1357,9 +1393,7 @@ func TestCollectorStartHelpers(t *testing.T) {
|
|||||||
default:
|
default:
|
||||||
t.Fatalf("unknown test command %q", tt.command)
|
t.Fatalf("unknown test command %q", tt.command)
|
||||||
}
|
}
|
||||||
waitGPUs(t, tt.gm, tt.gpuID)
|
time.Sleep(50 * time.Millisecond) // Give collector time to run
|
||||||
tt.gm.Lock()
|
|
||||||
defer tt.gm.Unlock()
|
|
||||||
tt.validate(t, tt.gm)
|
tt.validate(t, tt.gm)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1370,17 +1404,21 @@ 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")
|
||||||
|
|
||||||
gpuCommandFixture(t, dir, "nvtop", `not-json`+"\n")
|
nvtopPath := filepath.Join(dir, "nvtop")
|
||||||
|
nvtopScript := `#!/bin/sh
|
||||||
|
echo 'not-json'`
|
||||||
|
require.NoError(t, os.WriteFile(nvtopPath, []byte(nvtopScript), 0755))
|
||||||
|
|
||||||
gpuCommandFixture(t, dir, "nvidia-smi", `0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25`+"\n")
|
nvidiaPath := filepath.Join(dir, "nvidia-smi")
|
||||||
|
nvidiaScript := `#!/bin/sh
|
||||||
|
echo "0, NVIDIA Priority GPU, 45, 512, 2048, 12, 25"`
|
||||||
|
require.NoError(t, os.WriteFile(nvidiaPath, []byte(nvidiaScript), 0755))
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
waitGPUs(t, gm, "0")
|
time.Sleep(150 * time.Millisecond)
|
||||||
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)
|
||||||
@@ -1392,27 +1430,52 @@ 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")
|
||||||
|
|
||||||
intelOutput := intelJSONStream(true,
|
intelPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
intelScript := `#!/bin/sh
|
||||||
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"
|
||||||
gpuCommandFixture(t, dir, intelGpuStatsCmd, intelOutput+"\n")
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(intelPath, []byte(intelScript), 0755))
|
||||||
|
|
||||||
gpuCommandFixture(t, dir, "rocm-smi", `{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}`+"\n")
|
rocmPath := filepath.Join(dir, "rocm-smi")
|
||||||
|
rocmScript := `#!/bin/sh
|
||||||
|
echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphics Package Power (W)": "28.159", "GPU use (%)": "0", "VRAM Total Memory (B)": "536870912", "VRAM Total Used Memory (B)": "445550592", "Card Series": "Rembrandt [Radeon 680M]", "GUID": "34756"}}'
|
||||||
|
`
|
||||||
|
require.NoError(t, os.WriteFile(rocmPath, []byte(rocmScript), 0755))
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
waitGPUs(t, gm, "i0", "34756")
|
time.Sleep(150 * time.Millisecond)
|
||||||
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)
|
||||||
@@ -1447,12 +1510,8 @@ 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)
|
||||||
}
|
}
|
||||||
@@ -1462,15 +1521,16 @@ 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")
|
||||||
|
|
||||||
gpuCommandFixture(t, dir, "tegrastats", `11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW`+"\n")
|
tegraPath := filepath.Join(dir, "tegrastats")
|
||||||
|
tegraScript := `#!/bin/sh
|
||||||
|
echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
|
||||||
|
require.NoError(t, os.WriteFile(tegraPath, []byte(tegraScript), 0755))
|
||||||
|
|
||||||
gm, err := NewGPUManager()
|
gm, err := NewGPUManager()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NotNil(t, gm)
|
require.NotNil(t, gm)
|
||||||
|
|
||||||
waitGPUs(t, gm, "0")
|
time.Sleep(100 * time.Millisecond)
|
||||||
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)
|
||||||
@@ -1692,60 +1752,22 @@ 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)
|
||||||
|
|
||||||
engines := func(render, blitter, video float64) map[string]float64 {
|
// Create a fake intel_gpu_top that prints -l format with four samples (first will be skipped) and exits
|
||||||
return map[string]float64{"Render/3D": render, "Blitter": blitter, "Video": video}
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
|
script := `#!/bin/sh
|
||||||
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS"
|
||||||
|
echo " req act /s % gpu pkg rd wr % se wa % se wa % se wa"
|
||||||
|
echo "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0"
|
||||||
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0 22.00 0 1"
|
||||||
|
echo "298 295 278 51 2.20 3.12 1675 942 5.75 1 2 9.50 3 1 12.00 1 0"`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
output := intelJSONStream(true,
|
|
||||||
intelJSONSample(1.5, 4.13, engines(12.34, 0, 5)),
|
|
||||||
intelJSONSample(2.0, 2.69, engines(0, 0, 0)),
|
|
||||||
intelJSONSample(1.8, 2.45, engines(8.5, 15, 22)),
|
|
||||||
intelJSONSample(2.2, 3.12, engines(5.75, 9.5, 12)),
|
|
||||||
) + "\n]"
|
|
||||||
|
|
||||||
// Create a fake intel_gpu_top that prints -J output with four samples (first will be skipped) and exits
|
|
||||||
gpuCommandFixture(t, dir, intelGpuStatsCmd, output+"\n")
|
|
||||||
|
|
||||||
gm := &GPUManager{
|
gm := &GPUManager{
|
||||||
GpuDataMap: make(map[string]*system.GPUData),
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
@@ -1759,168 +1781,229 @@ func TestIntelCollectorStreaming(t *testing.T) {
|
|||||||
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.InDelta(t, 6.0, gpu.Power, 0.001)
|
assert.EqualValues(t, 6.0, gpu.Power)
|
||||||
assert.InDelta(t, 8.26, gpu.PowerPkg, 0.01) // Allow small floating point differences
|
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.InDelta(t, 14.25, gpu.Engines["Render/3D"], 0.001) // 0.00 + 8.50 + 5.75
|
assert.EqualValues(t, 14.25, gpu.Engines["Render/3D"]) // 0.00 + 8.50 + 5.75
|
||||||
assert.InDelta(t, 34.0, gpu.Engines["Video"], 0.001) // 0.00 + 22.00 + 12.00
|
assert.EqualValues(t, 34.0, gpu.Engines["Video"]) // 0.00 + 22.00 + 12.00
|
||||||
assert.InDelta(t, 24.5, gpu.Engines["Blitter"], 0.001) // 0.00 + 15.00 + 9.50
|
assert.EqualValues(t, 24.5, gpu.Engines["Blitter"]) // 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 TestParseIntelJSONStream(t *testing.T) {
|
func TestParseIntelHeaders(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
|
||||||
input string
|
header1 string
|
||||||
wantErr error
|
header2 string
|
||||||
wantAnyErr bool
|
wantEngineNames []string
|
||||||
wantCount float64
|
wantFriendlyNames []string
|
||||||
wantPower float64
|
wantPowerIndex int
|
||||||
wantPkg float64
|
wantPreEngineCols int
|
||||||
wantEngines map[string]float64
|
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "array still open while process runs",
|
name: "basic headers with RCS BCS VCS",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1]),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS BCS VCS",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "closed array",
|
name: "basic headers with RCS BCS VCS using index in name",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1]) + "\n]\n",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS/0 BCS/1 VCS/2",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4, // "gpu" is at index 4
|
||||||
|
wantPreEngineCols: 8, // 17 total cols - 3*3 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "truncated final sample",
|
name: "headers with only RCS",
|
||||||
input: intelJSONStream(true, first, classView[0], classView[1], `{"period": {"duration": 33`),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
wantCount: 2,
|
header2: " req act /s % gpu pkg rd wr % se wa",
|
||||||
wantPower: 3,
|
wantEngineNames: []string{"RCS"},
|
||||||
wantPkg: 5,
|
wantFriendlyNames: []string{"Render/3D"},
|
||||||
wantEngines: classViewWant,
|
wantPowerIndex: 4,
|
||||||
|
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// intel_gpu_top < 1.28 omits the opening "[" and uses physical engine names
|
name: "headers with VECS and CCS",
|
||||||
name: "legacy output without array and with engine instances",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s VECS CCS",
|
||||||
input: intelJSONStream(false,
|
header2: " req act /s % gpu pkg rd wr % se wa % se wa",
|
||||||
intelJSONSample(9, 9, map[string]float64{"Render/3D/0": 99}),
|
wantEngineNames: []string{"VECS", "CCS"},
|
||||||
intelJSONSample(1.5, 2.5, map[string]float64{"Render/3D/0": 12, "Blitter/0": 1, "Video/0": 4, "Video/1": 6, "VideoEnhance/0": 2}),
|
wantFriendlyNames: []string{"VideoEnhance", "Compute"},
|
||||||
),
|
wantPowerIndex: 4,
|
||||||
wantCount: 1,
|
wantPreEngineCols: 8, // 14 total - 3*2 = 8
|
||||||
wantPower: 1.5,
|
|
||||||
wantPkg: 2.5,
|
|
||||||
wantEngines: map[string]float64{"Render/3D": 12, "Blitter": 1, "Video": 10, "VideoEnhance": 2},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// energy counter read lower than the previous sample in intel_gpu_top
|
name: "no engines",
|
||||||
name: "sample with invalid power is skipped",
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s",
|
||||||
input: intelJSONStream(true, first, classView[0],
|
header2: " req act /s % gpu pkg rd wr",
|
||||||
intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50}),
|
wantEngineNames: nil, // no engines found, slices remain nil
|
||||||
intelJSONSample(2, 90_000_000, map[string]float64{"Render/3D": 50}),
|
wantFriendlyNames: nil,
|
||||||
classView[1],
|
wantPowerIndex: -1, // no engines, so no search
|
||||||
),
|
wantPreEngineCols: 0,
|
||||||
wantCount: 2,
|
|
||||||
wantPower: 3,
|
|
||||||
wantPkg: 5,
|
|
||||||
wantEngines: classViewWant,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "only samples with invalid power",
|
name: "power index not found",
|
||||||
input: intelJSONStream(true, first, intelJSONSample(86_000_000, 3, map[string]float64{"Render/3D": 50})),
|
header1: "Freq MHz IRQ RC6 Power W IMC MiB/s RCS",
|
||||||
wantErr: errNoValidData,
|
header2: " req act /s % pkg cpu rd wr % se wa", // no "gpu"
|
||||||
|
wantEngineNames: []string{"RCS"},
|
||||||
|
wantFriendlyNames: []string{"Render/3D"},
|
||||||
|
wantPowerIndex: -1, // "gpu" not found
|
||||||
|
wantPreEngineCols: 8, // 11 total - 3*1 = 8
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty output",
|
name: "empty headers",
|
||||||
input: "",
|
header1: "",
|
||||||
wantErr: errNoValidData,
|
header2: "",
|
||||||
},
|
wantEngineNames: nil, // empty input, slices remain nil
|
||||||
{
|
wantFriendlyNames: nil,
|
||||||
name: "only first sample, which is skipped",
|
wantPowerIndex: -1,
|
||||||
input: intelJSONStream(true, first),
|
wantPreEngineCols: 0,
|
||||||
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{GpuDataMap: make(map[string]*system.GPUData)}
|
gm := &GPUManager{}
|
||||||
err := gm.parseIntelJSONStream(strings.NewReader(tt.input))
|
engineNames, friendlyNames, powerIndex, preEngineCols := gm.parseIntelHeaders(tt.header1, tt.header2)
|
||||||
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)
|
|
||||||
|
|
||||||
gpu := gm.GpuDataMap["i0"]
|
assert.Equal(t, tt.wantEngineNames, engineNames)
|
||||||
require.NotNil(t, gpu)
|
assert.Equal(t, tt.wantFriendlyNames, friendlyNames)
|
||||||
assert.Equal(t, tt.wantCount, gpu.Count)
|
assert.Equal(t, tt.wantPowerIndex, powerIndex)
|
||||||
assert.InDelta(t, tt.wantPower, gpu.Power, 0.001)
|
assert.Equal(t, tt.wantPreEngineCols, preEngineCols)
|
||||||
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 TestParseIntelJSONSample(t *testing.T) {
|
func TestParseIntelData(t *testing.T) {
|
||||||
t.Run("without power", func(t *testing.T) {
|
tests := []struct {
|
||||||
var sample intelGpuJSONSample
|
name string
|
||||||
require.NoError(t, json.Unmarshal([]byte(`{"engines": {"Render/3D": {"busy": 7.5, "unit": "%"}}}`), &sample))
|
line string
|
||||||
stats := parseIntelJSONSample(sample)
|
engineNames []string
|
||||||
assert.Zero(t, stats.PowerGPU)
|
friendlyNames []string
|
||||||
assert.Zero(t, stats.PowerPkg)
|
powerIndex int
|
||||||
assert.Equal(t, map[string]float64{"Render/3D": 7.5}, stats.Engines)
|
preEngineCols int
|
||||||
})
|
wantPowerGPU float64
|
||||||
|
wantEngines map[string]float64
|
||||||
t.Run("without engines", func(t *testing.T) {
|
wantErr error
|
||||||
var sample intelGpuJSONSample
|
}{
|
||||||
require.NoError(t, json.Unmarshal([]byte(`{"power": {"GPU": 1.25, "Package": 4.5, "unit": "W"}}`), &sample))
|
{
|
||||||
stats := parseIntelJSONSample(sample)
|
name: "basic data with power and engines",
|
||||||
assert.Equal(t, 1.25, stats.PowerGPU)
|
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, 4.5, stats.PowerPkg)
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
assert.Nil(t, stats.Engines)
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
})
|
powerIndex: 4,
|
||||||
}
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
func TestIntelEngineClass(t *testing.T) {
|
wantEngines: map[string]float64{
|
||||||
tests := map[string]string{
|
"Render/3D": 12.34,
|
||||||
"Render/3D": "Render/3D",
|
"Blitter": 0.00,
|
||||||
"Render/3D/0": "Render/3D",
|
"Video": 5.00,
|
||||||
"Blitter": "Blitter",
|
},
|
||||||
"Blitter/0": "Blitter",
|
},
|
||||||
"Video/1": "Video",
|
{
|
||||||
"VideoEnhance/0": "VideoEnhance",
|
name: "data with zero power",
|
||||||
"Compute/3": "Compute",
|
line: "226 223 338 58 0.00 2.69 1820 965 0.00 0 0 0.00 0 0 0.00 0 0",
|
||||||
"[unknown]": "[unknown]",
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
"[unknown]/0": "[unknown]",
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
"Video/": "Video/",
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.00,
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 0.00,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 0.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with no power index",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: -1,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0, // no power parsed
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 12.34,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with insufficient columns",
|
||||||
|
line: "373 373 224 45 1.50", // too few columns
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0,
|
||||||
|
wantEngines: nil, // empty sample returned
|
||||||
|
wantErr: errNoValidData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty line",
|
||||||
|
line: "",
|
||||||
|
engineNames: []string{"RCS"},
|
||||||
|
friendlyNames: []string{"Render/3D"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0,
|
||||||
|
wantEngines: nil,
|
||||||
|
wantErr: errNoValidData,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with invalid power value",
|
||||||
|
line: "373 373 224 45 N/A 4.13 2554 714 12.34 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 0.0, // N/A can't be parsed
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 12.34,
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with invalid engine value",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714 N/A 0 0 0.00 0 0 5.00 0 0",
|
||||||
|
engineNames: []string{"RCS", "BCS", "VCS"},
|
||||||
|
friendlyNames: []string{"Render/3D", "Blitter", "Video"},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
|
wantEngines: map[string]float64{
|
||||||
|
"Render/3D": 0.0, // N/A becomes 0
|
||||||
|
"Blitter": 0.00,
|
||||||
|
"Video": 5.00,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "data with no engines",
|
||||||
|
line: "373 373 224 45 1.50 4.13 2554 714",
|
||||||
|
engineNames: []string{},
|
||||||
|
friendlyNames: []string{},
|
||||||
|
powerIndex: 4,
|
||||||
|
preEngineCols: 8,
|
||||||
|
wantPowerGPU: 1.50,
|
||||||
|
wantEngines: nil,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for key, want := range tests {
|
|
||||||
assert.Equal(t, want, intelEngineClass(key), key)
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
sample, err := gm.parseIntelData(tt.line, tt.engineNames, tt.friendlyNames, tt.powerIndex, tt.preEngineCols)
|
||||||
|
assert.Equal(t, tt.wantErr, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantPowerGPU, sample.PowerGPU)
|
||||||
|
assert.Equal(t, tt.wantEngines, sample.Engines)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1928,12 +2011,21 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|||||||
dir := t.TempDir()
|
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
|
||||||
output := intelJSONStream(true,
|
scriptPath := filepath.Join(dir, "intel_gpu_top")
|
||||||
intelJSONSample(2, 2.69, map[string]float64{"Render/3D": 0, "Video": 0}),
|
script := fmt.Sprintf(`#!/bin/sh
|
||||||
intelJSONSample(1.8, 2.45, map[string]float64{"Render/3D": 8.5, "Video": 15}),
|
echo "$@" > %s
|
||||||
)
|
echo "Freq MHz IRQ RC6 Power W IMC MiB/s RCS VCS"
|
||||||
argsFile := gpuCommandFixture(t, dir, intelGpuStatsCmd, output)
|
echo " req act /s %% gpu pkg rd wr %% se wa %% se wa"
|
||||||
|
echo "226 223 338 58 2.00 2.69 1820 965 0.00 0 0 0.00 0 0"
|
||||||
|
echo "189 187 412 67 1.80 2.45 1950 823 8.50 2 1 15.00 1 0"
|
||||||
|
`, argsFile)
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
// Set device selector via prefixed env var
|
// Set device selector via prefixed env var
|
||||||
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
t.Setenv("BESZEL_AGENT_INTEL_GPU_DEVICE", "sriov")
|
||||||
@@ -1951,5 +2043,5 @@ func TestIntelCollectorDeviceEnv(t *testing.T) {
|
|||||||
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, "-J")
|
require.Contains(t, argsStr, "-l")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -53,10 +52,8 @@ 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
|
||||||
}
|
}
|
||||||
@@ -201,20 +198,6 @@ 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
@@ -246,33 +229,6 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
// GetSystemdLogsHandler handles recent systemd service log requests.
|
|
||||||
type GetSystemdLogsHandler struct{}
|
|
||||||
|
|
||||||
func (h *GetSystemdLogsHandler) Handle(hctx *HandlerContext) error {
|
|
||||||
if hctx.Agent.systemdManager == nil {
|
|
||||||
return errors.ErrUnsupported
|
|
||||||
}
|
|
||||||
|
|
||||||
var req common.SystemdLogsRequest
|
|
||||||
if err := cbor.Unmarshal(hctx.Request.Data, &req); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if req.ServiceName == "" {
|
|
||||||
return errors.New("service name is required")
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, err := hctx.Agent.systemdManager.getServiceLogs(req.ServiceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return hctx.SendResponse(logs, hctx.RequestID)
|
|
||||||
}
|
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
|
|
||||||
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
|
// SyncNetworkMonitorsHandler handles monitor configuration sync from hub
|
||||||
type SyncNetworkMonitorsHandler struct{}
|
type SyncNetworkMonitorsHandler struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -201,9 +201,12 @@ func mdraidSmartStatus(health mdraidHealth) string {
|
|||||||
if health.mismatchCnt > 0 {
|
if health.mismatchCnt > 0 {
|
||||||
return "WARNING"
|
return "WARNING"
|
||||||
}
|
}
|
||||||
// "check" and "repair" are requested consistency scans, not evidence of
|
// "check" scans for consistency problems without repairing mismatches.
|
||||||
// array failure. With no health issues above, keep scrubbing green while
|
// With no mismatches, keep it green while reporting progress attributes.
|
||||||
// reporting the sync action and progress attributes.
|
switch syncAction {
|
||||||
|
case "repair":
|
||||||
|
return "WARNING"
|
||||||
|
}
|
||||||
switch state {
|
switch state {
|
||||||
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
case "clean", "active", "active-idle", "write-pending", "read-auto", "readonly":
|
||||||
return "PASSED"
|
return "PASSED"
|
||||||
|
|||||||
@@ -174,25 +174,8 @@ func TestMdraidSmartStatus(t *testing.T) {
|
|||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", mismatchCnt: 1}); got != "WARNING" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
t.Fatalf("mdraidSmartStatus(clean+mismatch) = %q, want WARNING", got)
|
||||||
}
|
}
|
||||||
for _, tc := range []struct {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", syncAction: "repair"}); got != "WARNING" {
|
||||||
name string
|
t.Fatalf("mdraidSmartStatus(repair) = %q, want WARNING", got)
|
||||||
health mdraidHealth
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"clean", mdraidHealth{arrayState: "clean"}, "PASSED"},
|
|
||||||
{"active", mdraidHealth{arrayState: "active"}, "PASSED"},
|
|
||||||
{"mismatch", mdraidHealth{arrayState: "active", mismatchCnt: 1}, "WARNING"},
|
|
||||||
{"degraded", mdraidHealth{arrayState: "active", degraded: 1}, "FAILED"},
|
|
||||||
{"faulty member", mdraidHealth{arrayState: "active", faultyDisks: 1}, "FAILED"},
|
|
||||||
{"inactive", mdraidHealth{arrayState: "inactive"}, "FAILED"},
|
|
||||||
{"unknown", mdraidHealth{arrayState: "unknown"}, "UNKNOWN"},
|
|
||||||
} {
|
|
||||||
t.Run("repair/"+tc.name, func(t *testing.T) {
|
|
||||||
tc.health.syncAction = "repair"
|
|
||||||
if got := mdraidSmartStatus(tc.health); got != tc.want {
|
|
||||||
t.Fatalf("mdraidSmartStatus(%+v) = %q, want %s", tc.health, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean"}); got != "PASSED" {
|
||||||
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
t.Fatalf("mdraidSmartStatus(clean) = %q, want PASSED", got)
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +23,7 @@ func newMonitorManager() *MonitorManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
func newMonitorManagerWithProbe(probe monitorProbe) *MonitorManager {
|
||||||
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe, certCheck: checkCert}
|
return &MonitorManager{monitors: make(map[string]*monitorTask), probe: probe}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SyncMonitors replaces all monitor tasks with the given configs.
|
// SyncMonitors replaces all monitor tasks with the given configs.
|
||||||
@@ -108,7 +107,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
if !runNow {
|
if !runNow {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
return pm.runNow(task), nil
|
return task.runProbe(pm.probe), nil
|
||||||
}
|
}
|
||||||
if exists {
|
if exists {
|
||||||
task.cancel()
|
task.cancel()
|
||||||
@@ -120,7 +119,7 @@ func (pm *MonitorManager) UpsertMonitor(config monitor.Config, runNow bool) (*mo
|
|||||||
pm.mu.Unlock()
|
pm.mu.Unlock()
|
||||||
|
|
||||||
if runNow {
|
if runNow {
|
||||||
result := pm.runNow(task)
|
result := task.runProbe(pm.probe)
|
||||||
pm.startMonitor(task)
|
pm.startMonitor(task)
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -128,19 +127,6 @@ 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 == "" {
|
||||||
@@ -172,11 +158,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
certCheckInterval = 24 * time.Hour
|
|
||||||
certCheckRetryInterval = time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// certChecker fetches the leaf certificate for an HTTPS target.
|
|
||||||
type certChecker func(context.Context, string) (monitor.CertInfo, error)
|
|
||||||
|
|
||||||
// certCheckEnabled reports whether a monitor's certificate is checked, which is
|
|
||||||
// the case for every HTTP monitor with an https target.
|
|
||||||
func certCheckEnabled(config monitor.Config) bool {
|
|
||||||
return config.Protocol == "http" && len(config.Target) > 8 && strings.EqualFold(config.Target[:8], "https://")
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkCert reads the leaf certificate presented by an HTTPS target. The chain is
|
|
||||||
// not verified, so expired or self-signed certificates are still reported.
|
|
||||||
func checkCert(ctx context.Context, target string) (monitor.CertInfo, error) {
|
|
||||||
address, host, err := certAddress(target)
|
|
||||||
if err != nil {
|
|
||||||
return monitor.CertInfo{}, err
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(ctx, monitor.MaxProbeTimeout)
|
|
||||||
defer cancel()
|
|
||||||
dialer := tls.Dialer{Config: &tls.Config{ServerName: host, InsecureSkipVerify: true}}
|
|
||||||
conn, err := dialer.DialContext(ctx, "tcp", address)
|
|
||||||
if err != nil {
|
|
||||||
return monitor.CertInfo{}, err
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
certs := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
|
||||||
if len(certs) == 0 {
|
|
||||||
return monitor.CertInfo{}, errors.New("no peer certificates")
|
|
||||||
}
|
|
||||||
leaf := certs[0]
|
|
||||||
return monitor.CertInfo{
|
|
||||||
Expires: leaf.NotAfter.UnixMilli(),
|
|
||||||
Issuer: leaf.Issuer.CommonName,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// certAddress returns the dial address and server name for an HTTPS URL.
|
|
||||||
func certAddress(target string) (address, host string, err error) {
|
|
||||||
u, err := url.Parse(target)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(u.Scheme, "https") {
|
|
||||||
return "", "", fmt.Errorf("certificate check requires an https target: %s", target)
|
|
||||||
}
|
|
||||||
host = u.Hostname()
|
|
||||||
if host == "" {
|
|
||||||
return "", "", fmt.Errorf("missing host in target: %s", target)
|
|
||||||
}
|
|
||||||
port := u.Port()
|
|
||||||
if port == "" {
|
|
||||||
port = "443"
|
|
||||||
}
|
|
||||||
return net.JoinHostPort(host, port), host, nil
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCheckCertReadsUnverifiedLeaf(t *testing.T) {
|
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
// httptest uses a self-signed certificate, which must still be reported.
|
|
||||||
info, err := checkCert(context.Background(), server.URL)
|
|
||||||
require.NoError(t, err)
|
|
||||||
leaf := server.Certificate()
|
|
||||||
assert.Equal(t, leaf.NotAfter.UnixMilli(), info.Expires)
|
|
||||||
assert.Equal(t, leaf.Issuer.CommonName, info.Issuer)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCertAddress(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
target, address, host string
|
|
||||||
wantErr bool
|
|
||||||
}{
|
|
||||||
{target: "https://example.com", address: "example.com:443", host: "example.com"},
|
|
||||||
{target: "https://example.com:8443/path?q=1", address: "example.com:8443", host: "example.com"},
|
|
||||||
{target: "HTTPS://[::1]:9443", address: "[::1]:9443", host: "::1"},
|
|
||||||
{target: "http://example.com", wantErr: true},
|
|
||||||
{target: "https://", wantErr: true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
address, host, err := certAddress(tt.target)
|
|
||||||
if tt.wantErr {
|
|
||||||
assert.Error(t, err, tt.target)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
require.NoError(t, err, tt.target)
|
|
||||||
assert.Equal(t, tt.address, address)
|
|
||||||
assert.Equal(t, tt.host, host)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshCertCadence(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
|
|
||||||
defer task.cancel()
|
|
||||||
var calls int
|
|
||||||
var fail error
|
|
||||||
// Far enough out that the regular interval applies for the whole test.
|
|
||||||
expires := time.Now().Add(365 * 24 * time.Hour).UnixMilli()
|
|
||||||
check := func(context.Context, string) (monitor.CertInfo, error) {
|
|
||||||
calls++
|
|
||||||
if fail != nil {
|
|
||||||
return monitor.CertInfo{}, fail
|
|
||||||
}
|
|
||||||
return monitor.CertInfo{Expires: expires + int64(calls)}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
task.refreshCert(check)
|
|
||||||
require.NotNil(t, task.certInfo())
|
|
||||||
assert.Equal(t, expires+1, task.certInfo().Expires)
|
|
||||||
|
|
||||||
// Not due again until the check interval passes.
|
|
||||||
time.Sleep(certCheckInterval - time.Second)
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 2, calls)
|
|
||||||
|
|
||||||
// Failures keep the last known certificate and retry sooner.
|
|
||||||
fail = errors.New("connection refused")
|
|
||||||
time.Sleep(certCheckInterval)
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 3, calls)
|
|
||||||
assert.Equal(t, expires+2, task.certInfo().Expires)
|
|
||||||
time.Sleep(certCheckRetryInterval)
|
|
||||||
fail = nil
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 4, calls)
|
|
||||||
assert.Equal(t, expires+4, task.certInfo().Expires)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshCertRetriesSoonerNearExpiry(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
expires time.Duration // relative to the check
|
|
||||||
interval time.Duration
|
|
||||||
}{
|
|
||||||
{"expired", -time.Hour, certCheckRetryInterval},
|
|
||||||
{"expires before next regular check", certCheckInterval - time.Minute, certCheckRetryInterval},
|
|
||||||
{"expires after next regular check", certCheckInterval + time.Minute, certCheckInterval},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "test", Target: "https://example.test", Protocol: "http"})
|
|
||||||
defer task.cancel()
|
|
||||||
var calls int
|
|
||||||
check := func(context.Context, string) (monitor.CertInfo, error) {
|
|
||||||
calls++
|
|
||||||
return monitor.CertInfo{Expires: time.Now().Add(tc.expires).UnixMilli()}, nil
|
|
||||||
}
|
|
||||||
task.refreshCert(check)
|
|
||||||
time.Sleep(tc.interval - time.Second)
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 1, calls)
|
|
||||||
time.Sleep(time.Second)
|
|
||||||
task.refreshCert(check)
|
|
||||||
assert.Equal(t, 2, calls)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCertCheckEnabled(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
protocol, target string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"http", "https://example.com", true},
|
|
||||||
{"http", "HTTPS://example.com:8443/path", true},
|
|
||||||
{"http", "http://example.com", false},
|
|
||||||
{"http", "https://", false},
|
|
||||||
{"tcp", "https://example.com", false},
|
|
||||||
{"icmp", "example.com", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
assert.Equal(t, tt.want, certCheckEnabled(monitor.Config{Protocol: tt.protocol, Target: tt.target}), tt.protocol+" "+tt.target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRefreshCertSkipsNonHTTPS(t *testing.T) {
|
|
||||||
task := newMonitorTask(monitor.Config{ID: "test", Target: "http://example.test", Protocol: "http"})
|
|
||||||
defer task.cancel()
|
|
||||||
task.refreshCert(func(context.Context, string) (monitor.CertInfo, error) {
|
|
||||||
t.Fatal("certificate check must not run for non-https targets")
|
|
||||||
return monitor.CertInfo{}, nil
|
|
||||||
})
|
|
||||||
assert.Nil(t, task.certInfo())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpsertMonitorRunNowIncludesCert(t *testing.T) {
|
|
||||||
server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
|
||||||
defer server.Close()
|
|
||||||
|
|
||||||
pm := newMonitorManagerWithProbe(func(context.Context, monitor.Config) (int64, error) { return 100, nil })
|
|
||||||
defer pm.Stop()
|
|
||||||
config := monitor.Config{ID: "cert", Target: server.URL, Protocol: "http", Interval: 60}
|
|
||||||
result, err := pm.UpsertMonitor(config, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, result)
|
|
||||||
require.NotNil(t, result.Cert)
|
|
||||||
assert.Equal(t, server.Certificate().NotAfter.UnixMilli(), result.Cert.Expires)
|
|
||||||
|
|
||||||
// Realtime results never carry the certificate, and the default interval
|
|
||||||
// sends it only once per check.
|
|
||||||
assert.Nil(t, pm.GetResults(1000)["cert"].Cert)
|
|
||||||
results := pm.GetResults(defaultDataCacheTimeMs)
|
|
||||||
require.NotNil(t, results["cert"].Cert)
|
|
||||||
assert.Equal(t, result.Cert.Expires, results["cert"].Cert.Expires)
|
|
||||||
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
|
|
||||||
|
|
||||||
// Changing the interval keeps the known certificate without resending it.
|
|
||||||
config.Interval = 30
|
|
||||||
_, err = pm.UpsertMonitor(config, false)
|
|
||||||
require.NoError(t, err)
|
|
||||||
pm.mu.RLock()
|
|
||||||
task := pm.monitors["cert"]
|
|
||||||
pm.mu.RUnlock()
|
|
||||||
assert.NotNil(t, task.certInfo())
|
|
||||||
assert.Nil(t, pm.GetResults(defaultDataCacheTimeMs)["cert"].Cert)
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,9 @@ import (
|
|||||||
"net/http"
|
"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)
|
||||||
@@ -28,7 +25,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, config.Server)
|
return monitorDNS(ctx, config.Target)
|
||||||
default:
|
default:
|
||||||
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
return -1, fmt.Errorf("unknown monitor protocol: %s", config.Protocol)
|
||||||
}
|
}
|
||||||
@@ -73,43 +70,19 @@ func monitorTCP(ctx context.Context, target string, port uint16) (int64, error)
|
|||||||
return -1, err
|
return -1, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// monitorDNS measures DNS resolution response time in microseconds. If server is
|
// monitorDNS measures DNS resolution response time in microseconds. Returns -1 and an error on failure.
|
||||||
// non-empty, the lookup is sent to that DNS server (host or host:port, default
|
func monitorDNS(ctx context.Context, target string) (int64, error) {
|
||||||
// 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 := resolver.LookupHost(ctx, target)
|
ips, err := net.DefaultResolver.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 {
|
||||||
@@ -120,7 +93,6 @@ 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,12 +14,9 @@ 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,12 +21,6 @@ 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 {
|
||||||
@@ -51,11 +45,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -118,70 +107,6 @@ 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,7 +10,6 @@ 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"
|
||||||
@@ -241,7 +240,6 @@ 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()
|
||||||
@@ -376,79 +374,15 @@ func tcpMonitorTestResolver(ips []string) *net.Resolver {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// udpDNSTestServer starts a UDP server on loopback that answers A queries with the
|
|
||||||
// given IPs, and returns its listen address (host:port).
|
|
||||||
func udpDNSTestServer(t *testing.T, ips []string) string {
|
|
||||||
t.Helper()
|
|
||||||
conn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")})
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { conn.Close() })
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
buf := make([]byte, 512)
|
|
||||||
for {
|
|
||||||
n, addr, err := conn.ReadFromUDP(buf)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var msg dnsmessage.Message
|
|
||||||
if err := msg.Unpack(buf[:n]); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
msg.Header.Response = true
|
|
||||||
msg.Header.RecursionAvailable = true
|
|
||||||
for _, question := range msg.Questions {
|
|
||||||
if question.Type != dnsmessage.TypeA {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ip := range ips {
|
|
||||||
msg.Answers = append(msg.Answers, dnsmessage.Resource{
|
|
||||||
Header: dnsmessage.ResourceHeader{Name: question.Name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET},
|
|
||||||
Body: &dnsmessage.AResource{A: [4]byte(net.ParseIP(ip).To4())},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
packet, err := msg.Pack()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
_, _ = conn.WriteToUDP(packet, addr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return conn.LocalAddr().String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMonitorDNS(t *testing.T) {
|
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)
|
||||||
})
|
})
|
||||||
@@ -543,7 +477,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.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,508 +0,0 @@
|
|||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultPackageUpdatesInterval = time.Hour
|
|
||||||
packageUpdatesTimeout = 5 * time.Minute
|
|
||||||
// pacmanSyncInterval limits how often checkupdates downloads fresh sync
|
|
||||||
// databases. Checks in between reuse the last synced copy.
|
|
||||||
pacmanSyncInterval = 12 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// packageUpdatesResult is the outcome of one package manager check.
|
|
||||||
type packageUpdatesResult struct {
|
|
||||||
// counts is [total] or [total, security] pending package updates.
|
|
||||||
counts []uint16
|
|
||||||
packages []system.PackageUpdate
|
|
||||||
// securityKnown is true if packages carry per-package security flags.
|
|
||||||
securityKnown bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type packageUpdatesCheck func(ctx context.Context) (packageUpdatesResult, error)
|
|
||||||
|
|
||||||
// packageUpdatesManager periodically checks the host package manager for pending
|
|
||||||
// updates in the background and caches the result, so checks never delay metrics.
|
|
||||||
type packageUpdatesManager struct {
|
|
||||||
sync.Mutex
|
|
||||||
name string
|
|
||||||
check packageUpdatesCheck
|
|
||||||
interval time.Duration
|
|
||||||
result packageUpdatesResult
|
|
||||||
checkedAt time.Time
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// newPackageUpdatesManager returns nil if disabled or no supported package manager
|
|
||||||
// is found. Agents running in a container are skipped because the container's
|
|
||||||
// package database is not the host's. dataDir holds pacman's private sync databases.
|
|
||||||
func newPackageUpdatesManager(dataDir string) *packageUpdatesManager {
|
|
||||||
if runtime.GOOS != "linux" || runningInContainer() {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
interval, enabled := packageUpdatesInterval()
|
|
||||||
if !enabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
name, check := detectPackageManager(dataDir)
|
|
||||||
if check == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
slog.Debug("Package updates", "manager", name, "interval", interval)
|
|
||||||
return &packageUpdatesManager{name: name, check: check, interval: interval}
|
|
||||||
}
|
|
||||||
|
|
||||||
// packageUpdatesInterval reads PACKAGE_UPDATES_INTERVAL as a Go duration such as
|
|
||||||
// "30m" or "6h". "0" disables checks. Invalid or negative values keep the default.
|
|
||||||
func packageUpdatesInterval() (interval time.Duration, enabled bool) {
|
|
||||||
env, exists := utils.GetEnv("PACKAGE_UPDATES_INTERVAL")
|
|
||||||
if !exists {
|
|
||||||
return defaultPackageUpdatesInterval, true
|
|
||||||
}
|
|
||||||
duration, err := time.ParseDuration(env)
|
|
||||||
switch {
|
|
||||||
case err == nil && duration == 0:
|
|
||||||
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", "disabled")
|
|
||||||
return 0, false
|
|
||||||
case err == nil && duration > 0:
|
|
||||||
slog.Info("PACKAGE_UPDATES_INTERVAL", "duration", duration)
|
|
||||||
return duration, true
|
|
||||||
default:
|
|
||||||
slog.Warn("Invalid PACKAGE_UPDATES_INTERVAL", "value", env)
|
|
||||||
return defaultPackageUpdatesInterval, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// get returns the last cached counts and starts a background check if they are stale.
|
|
||||||
func (pm *packageUpdatesManager) get(now time.Time) []uint16 {
|
|
||||||
pm.Lock()
|
|
||||||
defer pm.Unlock()
|
|
||||||
if !pm.running && (pm.checkedAt.IsZero() || now.Sub(pm.checkedAt) >= pm.interval) {
|
|
||||||
pm.running = true
|
|
||||||
go pm.refresh()
|
|
||||||
}
|
|
||||||
return pm.result.counts
|
|
||||||
}
|
|
||||||
|
|
||||||
// list returns the per-package details of the last check. It never starts a check.
|
|
||||||
func (pm *packageUpdatesManager) list() system.PackageUpdates {
|
|
||||||
pm.Lock()
|
|
||||||
defer pm.Unlock()
|
|
||||||
data := system.PackageUpdates{
|
|
||||||
Manager: pm.name,
|
|
||||||
SecurityKnown: pm.result.securityKnown,
|
|
||||||
Packages: pm.result.packages,
|
|
||||||
}
|
|
||||||
if !pm.checkedAt.IsZero() {
|
|
||||||
data.CheckedAt = pm.checkedAt.Unix()
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
func (pm *packageUpdatesManager) refresh() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), packageUpdatesTimeout)
|
|
||||||
defer cancel()
|
|
||||||
result, err := pm.check(ctx)
|
|
||||||
if err != nil {
|
|
||||||
slog.Debug("Package updates check failed", "err", err)
|
|
||||||
result = packageUpdatesResult{}
|
|
||||||
}
|
|
||||||
pm.Lock()
|
|
||||||
pm.result = result
|
|
||||||
pm.checkedAt = time.Now()
|
|
||||||
pm.running = false
|
|
||||||
pm.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func runningInContainer() bool {
|
|
||||||
for _, path := range []string{"/.dockerenv", "/run/.containerenv"} {
|
|
||||||
if _, err := os.Stat(path); err == nil {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectPackageManager(dataDir string) (string, packageUpdatesCheck) {
|
|
||||||
switch {
|
|
||||||
case commandExists("apt-get"):
|
|
||||||
return "apt", checkApt
|
|
||||||
case commandExists("dnf"):
|
|
||||||
return "dnf", checkDnf
|
|
||||||
case commandExists("zypper"):
|
|
||||||
return "zypper", checkZypper
|
|
||||||
case commandExists("checkupdates"):
|
|
||||||
return "pacman", newPacmanCheck(dataDir)
|
|
||||||
case commandExists("apk"):
|
|
||||||
return "apk", checkApk
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func commandExists(name string) bool {
|
|
||||||
_, err := exec.LookPath(name)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPackageCommand runs a read-only package manager command and returns stdout.
|
|
||||||
// okCodes lists non-zero exit codes that still mean success.
|
|
||||||
func runPackageCommand(ctx context.Context, okCodes []int, name string, args ...string) (string, error) {
|
|
||||||
return runPackageCommandEnv(ctx, nil, okCodes, name, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// runPackageCommandEnv is runPackageCommand with extra environment variables.
|
|
||||||
func runPackageCommandEnv(ctx context.Context, env []string, okCodes []int, name string, args ...string) (string, error) {
|
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
|
||||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
|
||||||
cmd.Env = append(cmd.Env, env...)
|
|
||||||
// checkupdates is a shell script, so a timeout kills only the script and its
|
|
||||||
// children can keep stdout open. WaitDelay stops Output from waiting on them.
|
|
||||||
cmd.WaitDelay = 10 * time.Second
|
|
||||||
out, err := cmd.Output()
|
|
||||||
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok && slices.Contains(okCodes, exitErr.ExitCode()) {
|
|
||||||
return string(out), nil
|
|
||||||
}
|
|
||||||
return string(out), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// countSecurity returns the number of packages flagged as security updates.
|
|
||||||
func countSecurity(packages []system.PackageUpdate) (count uint16) {
|
|
||||||
for _, pkg := range packages {
|
|
||||||
if pkg.Security {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkApt simulates a full upgrade against the current package lists.
|
|
||||||
// It never refreshes the lists; apt-daily or the user does that.
|
|
||||||
func checkApt(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, nil, "apt-get", "-s", "dist-upgrade")
|
|
||||||
if err != nil {
|
|
||||||
return packageUpdatesResult{}, err
|
|
||||||
}
|
|
||||||
packages := parseAptSimulate(out)
|
|
||||||
return packageUpdatesResult{
|
|
||||||
counts: []uint16{uint16(len(packages)), countSecurity(packages)},
|
|
||||||
packages: packages,
|
|
||||||
securityKnown: true,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkDnf uses the system metadata cache only (-C), so it never downloads metadata.
|
|
||||||
// check-update lists only available versions, so installed versions come from rpm.
|
|
||||||
func checkDnf(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update")
|
|
||||||
if err != nil {
|
|
||||||
return packageUpdatesResult{}, err
|
|
||||||
}
|
|
||||||
packages := parseDnfCheckUpdate(out)
|
|
||||||
result := packageUpdatesResult{packages: packages}
|
|
||||||
|
|
||||||
if len(packages) > 0 {
|
|
||||||
args := []string{"-q", "--qf", rpmInstalledQueryFormat}
|
|
||||||
for _, pkg := range packages {
|
|
||||||
args = append(args, pkg.Name)
|
|
||||||
}
|
|
||||||
// rpm exits non-zero if any package is not installed; keep what it printed
|
|
||||||
out, _ = runPackageCommand(ctx, nil, "rpm", args...)
|
|
||||||
installed := parseRpmInstalled(out)
|
|
||||||
for i := range packages {
|
|
||||||
packages[i].Current = installed[packages[i].Name]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err = runPackageCommand(ctx, []int{100}, "dnf", "-q", "-C", "check-update", "--security")
|
|
||||||
if err == nil {
|
|
||||||
// --security lists the lowest version that fixes an advisory, which may be
|
|
||||||
// older than the version check-update offers, so match on name.arch only
|
|
||||||
security := make(map[string]struct{})
|
|
||||||
for _, pkg := range parseDnfCheckUpdate(out) {
|
|
||||||
security[pkg.Name] = struct{}{}
|
|
||||||
}
|
|
||||||
for i := range packages {
|
|
||||||
_, packages[i].Security = security[packages[i].Name]
|
|
||||||
}
|
|
||||||
result.securityKnown = true
|
|
||||||
}
|
|
||||||
for i := range packages {
|
|
||||||
packages[i].Name = trimRpmArch(packages[i].Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.counts = []uint16{uint16(len(packages))}
|
|
||||||
if result.securityKnown {
|
|
||||||
result.counts = append(result.counts, countSecurity(packages))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkZypper lists package updates. Security updates come from patches, which
|
|
||||||
// zypper does not map to packages here, so only the security count is known.
|
|
||||||
func checkZypper(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-updates")
|
|
||||||
if err != nil {
|
|
||||||
return packageUpdatesResult{}, err
|
|
||||||
}
|
|
||||||
packages := parseZypperListUpdates(out)
|
|
||||||
result := packageUpdatesResult{packages: packages, counts: []uint16{uint16(len(packages))}}
|
|
||||||
out, err = runPackageCommand(ctx, nil, "zypper", "--no-refresh", "-q", "list-patches", "--category", "security")
|
|
||||||
if err == nil {
|
|
||||||
result.counts = append(result.counts, parseZypperTable(out))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// newPacmanCheck uses checkupdates (pacman-contrib), which syncs a private copy of
|
|
||||||
// the databases and never touches pacman's own. The copy lives in dataDir because
|
|
||||||
// the systemd unit's ProtectSystem=strict makes the default /tmp location read-only.
|
|
||||||
// It syncs every pacmanSyncInterval and uses the existing copy (-n) in between.
|
|
||||||
// Local upgrades show up right away since checkupdates links the live local DB.
|
|
||||||
// Exit code 2 means no updates.
|
|
||||||
func newPacmanCheck(dataDir string) packageUpdatesCheck {
|
|
||||||
var env []string
|
|
||||||
var syncDir string
|
|
||||||
if dataDir != "" {
|
|
||||||
dbPath := filepath.Join(dataDir, "checkup-db")
|
|
||||||
env = []string{"CHECKUPDATES_DB=" + dbPath}
|
|
||||||
syncDir = filepath.Join(dbPath, "sync")
|
|
||||||
}
|
|
||||||
// checks never overlap (packageUpdatesManager.running), so no lock is needed
|
|
||||||
var lastSync time.Time
|
|
||||||
return func(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
// -n with a missing database reports no updates rather than failing,
|
|
||||||
// so always sync first and whenever the private copy is missing
|
|
||||||
sync := lastSync.IsZero() || time.Since(lastSync) >= pacmanSyncInterval
|
|
||||||
if !sync && syncDir != "" {
|
|
||||||
if _, err := os.Stat(syncDir); err != nil {
|
|
||||||
sync = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var args []string
|
|
||||||
if !sync {
|
|
||||||
args = append(args, "-n")
|
|
||||||
}
|
|
||||||
out, err := runPackageCommandEnv(ctx, env, []int{2}, "checkupdates", args...)
|
|
||||||
if err != nil {
|
|
||||||
return packageUpdatesResult{}, err
|
|
||||||
}
|
|
||||||
if sync {
|
|
||||||
lastSync = time.Now()
|
|
||||||
}
|
|
||||||
packages := parsePacmanCheckUpdates(out)
|
|
||||||
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkApk(ctx context.Context) (packageUpdatesResult, error) {
|
|
||||||
out, err := runPackageCommand(ctx, nil, "apk", "--no-network", "-u", "list")
|
|
||||||
if err != nil {
|
|
||||||
return packageUpdatesResult{}, err
|
|
||||||
}
|
|
||||||
packages := parseApkUpgradable(out)
|
|
||||||
return packageUpdatesResult{counts: []uint16{uint16(len(packages))}, packages: packages}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAptSimulate parses upgrades in `apt-get -s` output. Upgrade lines look like
|
|
||||||
// "Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])".
|
|
||||||
// New dependencies have no "[old version]" and are skipped.
|
|
||||||
func parseAptSimulate(out string) (packages []system.PackageUpdate) {
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if len(fields) < 4 || fields[0] != "Inst" || !strings.HasPrefix(fields[2], "[") || !strings.HasPrefix(fields[3], "(") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pkg := system.PackageUpdate{
|
|
||||||
Name: fields[1],
|
|
||||||
Current: strings.Trim(fields[2], "[]"),
|
|
||||||
Available: strings.TrimPrefix(fields[3], "("),
|
|
||||||
}
|
|
||||||
start := strings.IndexByte(line, '(')
|
|
||||||
end := strings.IndexByte(line, ')')
|
|
||||||
pkg.Security = start >= 0 && end > start && strings.Contains(line[start:end], "-security")
|
|
||||||
packages = append(packages, pkg)
|
|
||||||
}
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseDnfCheckUpdate parses "name.arch version repo" lines, stopping at the
|
|
||||||
// obsoletes section so obsoleted packages are not listed twice. Names keep the
|
|
||||||
// arch so they can be matched with rpm output. dnf4 wraps a long name.arch onto
|
|
||||||
// its own line, with the version and repo on the next line.
|
|
||||||
func parseDnfCheckUpdate(out string) (packages []system.PackageUpdate) {
|
|
||||||
var wrappedName string
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
if strings.HasPrefix(line, "Obsoleting") {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if wrappedName != "" && len(fields) == 2 {
|
|
||||||
fields = []string{wrappedName, fields[0], fields[1]}
|
|
||||||
}
|
|
||||||
wrappedName = ""
|
|
||||||
switch {
|
|
||||||
case len(fields) == 3 && strings.Contains(fields[0], "."):
|
|
||||||
packages = append(packages, system.PackageUpdate{Name: fields[0], Available: fields[1]})
|
|
||||||
case len(fields) == 1 && strings.Contains(fields[0], ".") && !strings.HasPrefix(line, " "):
|
|
||||||
wrappedName = fields[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
// rpmInstalledQueryFormat prints "name.arch [epoch:]version-release", matching
|
|
||||||
// the version format of dnf check-update.
|
|
||||||
const rpmInstalledQueryFormat = `%{NAME}.%{ARCH} %|EPOCH?{%{EPOCH}:}:{}|%{VERSION}-%{RELEASE}\n`
|
|
||||||
|
|
||||||
// parseRpmInstalled maps name.arch to its installed version. For packages with
|
|
||||||
// several installed versions, such as kernels, the last one listed wins.
|
|
||||||
func parseRpmInstalled(out string) map[string]string {
|
|
||||||
installed := make(map[string]string)
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
// "package foo.x86_64 is not installed" has more than two fields
|
|
||||||
if fields := strings.Fields(scanner.Text()); len(fields) == 2 {
|
|
||||||
installed[fields[0]] = fields[1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return installed
|
|
||||||
}
|
|
||||||
|
|
||||||
// trimRpmArch removes the ".arch" suffix from a dnf package name.
|
|
||||||
func trimRpmArch(name string) string {
|
|
||||||
if i := strings.LastIndexByte(name, '.'); i > 0 {
|
|
||||||
return name[:i]
|
|
||||||
}
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseZypperTable counts the data rows of a zypper table (the lines after the
|
|
||||||
// "---+---" separator).
|
|
||||||
func parseZypperTable(out string) (count uint16) {
|
|
||||||
inTable := false
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
switch {
|
|
||||||
case !inTable:
|
|
||||||
inTable = strings.HasPrefix(line, "--") && strings.Contains(line, "-+-")
|
|
||||||
case strings.Contains(line, "|"):
|
|
||||||
count++
|
|
||||||
default:
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseZypperListUpdates parses the `zypper list-updates` table, locating the
|
|
||||||
// columns by their header names.
|
|
||||||
func parseZypperListUpdates(out string) (packages []system.PackageUpdate) {
|
|
||||||
nameCol, currentCol, availableCol := -1, -1, -1
|
|
||||||
var header []string
|
|
||||||
inTable := false
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
switch {
|
|
||||||
case !inTable && strings.HasPrefix(line, "--") && strings.Contains(line, "-+-"):
|
|
||||||
for i, col := range header {
|
|
||||||
switch strings.TrimSpace(col) {
|
|
||||||
case "Name":
|
|
||||||
nameCol = i
|
|
||||||
case "Current Version":
|
|
||||||
currentCol = i
|
|
||||||
case "Available Version":
|
|
||||||
availableCol = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if nameCol < 0 || availableCol < 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
inTable = true
|
|
||||||
case !inTable:
|
|
||||||
header = strings.Split(line, "|")
|
|
||||||
case strings.Contains(line, "|"):
|
|
||||||
cols := strings.Split(line, "|")
|
|
||||||
if len(cols) != len(header) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
pkg := system.PackageUpdate{
|
|
||||||
Name: strings.TrimSpace(cols[nameCol]),
|
|
||||||
Available: strings.TrimSpace(cols[availableCol]),
|
|
||||||
}
|
|
||||||
if currentCol >= 0 {
|
|
||||||
pkg.Current = strings.TrimSpace(cols[currentCol])
|
|
||||||
}
|
|
||||||
packages = append(packages, pkg)
|
|
||||||
default:
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
// parsePacmanCheckUpdates parses "name old -> new" lines.
|
|
||||||
func parsePacmanCheckUpdates(out string) (packages []system.PackageUpdate) {
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
fields := strings.Fields(scanner.Text())
|
|
||||||
if len(fields) >= 4 && fields[2] == "->" {
|
|
||||||
packages = append(packages, system.PackageUpdate{Name: fields[0], Current: fields[1], Available: fields[3]})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseApkUpgradable parses lines of `apk -u list`, which look like
|
|
||||||
// "musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]".
|
|
||||||
func parseApkUpgradable(out string) (packages []system.PackageUpdate) {
|
|
||||||
const marker = "[upgradable from:"
|
|
||||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
i := strings.Index(line, marker)
|
|
||||||
fields := strings.Fields(line)
|
|
||||||
if i < 0 || len(fields) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name, available := splitApkNameVersion(fields[0])
|
|
||||||
_, current := splitApkNameVersion(strings.TrimSuffix(strings.TrimSpace(line[i+len(marker):]), "]"))
|
|
||||||
packages = append(packages, system.PackageUpdate{Name: name, Current: current, Available: available})
|
|
||||||
}
|
|
||||||
return packages
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitApkNameVersion splits "name-version-rN" into name and "version-rN".
|
|
||||||
// Names may contain dashes, but versions do not.
|
|
||||||
func splitApkNameVersion(s string) (name, version string) {
|
|
||||||
rel := strings.LastIndexByte(s, '-')
|
|
||||||
if rel <= 0 || !strings.HasPrefix(s[rel+1:], "r") {
|
|
||||||
return s, ""
|
|
||||||
}
|
|
||||||
ver := strings.LastIndexByte(s[:rel], '-')
|
|
||||||
if ver <= 0 {
|
|
||||||
return s, ""
|
|
||||||
}
|
|
||||||
return s[:ver], s[ver+1:]
|
|
||||||
}
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readPackageUpdatesTestData(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
data, err := os.ReadFile(filepath.Join("test-data", "package_updates", name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test data files are real command outputs captured in containers.
|
|
||||||
|
|
||||||
// findPackage returns the named package from a parsed list.
|
|
||||||
func findPackage(t *testing.T, packages []system.PackageUpdate, name string) system.PackageUpdate {
|
|
||||||
t.Helper()
|
|
||||||
for _, pkg := range packages {
|
|
||||||
if pkg.Name == name {
|
|
||||||
return pkg
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("package %q not found", name)
|
|
||||||
return system.PackageUpdate{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeCommands puts shell scripts named after package manager commands first on PATH.
|
|
||||||
func fakeCommands(t *testing.T, scripts map[string]string) {
|
|
||||||
t.Helper()
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("requires shell scripts on PATH")
|
|
||||||
}
|
|
||||||
binDir := t.TempDir()
|
|
||||||
for name, script := range scripts {
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, name), []byte("#!/bin/sh\n"+script), 0o755))
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
}
|
|
||||||
|
|
||||||
func testDataPath(t *testing.T, name string) string {
|
|
||||||
t.Helper()
|
|
||||||
path, err := filepath.Abs(filepath.Join("test-data", "package_updates", name))
|
|
||||||
require.NoError(t, err)
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPackageUpdatesInterval(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
value *string
|
|
||||||
interval time.Duration
|
|
||||||
enabled bool
|
|
||||||
}{
|
|
||||||
{"unset", nil, time.Hour, true},
|
|
||||||
{"duration", new("30m"), 30 * time.Minute, true},
|
|
||||||
{"compound duration", new("1h30m"), 90 * time.Minute, true},
|
|
||||||
{"zero disables", new("0"), 0, false},
|
|
||||||
{"zero with unit disables", new("0s"), 0, false},
|
|
||||||
{"negative keeps default", new("-5m"), time.Hour, true},
|
|
||||||
{"no unit keeps default", new("60"), time.Hour, true},
|
|
||||||
{"invalid keeps default", new("hourly"), time.Hour, true},
|
|
||||||
{"empty keeps default", new(""), time.Hour, true},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "")
|
|
||||||
require.NoError(t, os.Unsetenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL"))
|
|
||||||
t.Setenv("PACKAGE_UPDATES_INTERVAL", "")
|
|
||||||
require.NoError(t, os.Unsetenv("PACKAGE_UPDATES_INTERVAL"))
|
|
||||||
if tt.value != nil {
|
|
||||||
t.Setenv("PACKAGE_UPDATES_INTERVAL", *tt.value)
|
|
||||||
}
|
|
||||||
interval, enabled := packageUpdatesInterval()
|
|
||||||
assert.Equal(t, tt.interval, interval)
|
|
||||||
assert.Equal(t, tt.enabled, enabled)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("prefixed variable takes precedence", func(t *testing.T) {
|
|
||||||
t.Setenv("PACKAGE_UPDATES_INTERVAL", "0")
|
|
||||||
t.Setenv("BESZEL_AGENT_PACKAGE_UPDATES_INTERVAL", "6h")
|
|
||||||
interval, enabled := packageUpdatesInterval()
|
|
||||||
assert.Equal(t, 6*time.Hour, interval)
|
|
||||||
assert.True(t, enabled)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseAptSimulate(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
file string
|
|
||||||
total, security int
|
|
||||||
}{
|
|
||||||
{"apt_debian12.txt", 44, 5},
|
|
||||||
{"apt_ubuntu2204.txt", 58, 45},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
|
||||||
packages := parseAptSimulate(readPackageUpdatesTestData(t, tt.file))
|
|
||||||
assert.Len(t, packages, tt.total)
|
|
||||||
assert.EqualValues(t, tt.security, countSecurity(packages))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("versions", func(t *testing.T) {
|
|
||||||
packages := parseAptSimulate(readPackageUpdatesTestData(t, "apt_ubuntu2204.txt"))
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "libc6", Current: "2.35-0ubuntu3.4", Available: "2.35-0ubuntu3.15", Security: true}, findPackage(t, packages, "libc6"))
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "base-files", Current: "12ubuntu4.4", Available: "12ubuntu4.7"}, findPackage(t, packages, "base-files"))
|
|
||||||
|
|
||||||
packages = parseAptSimulate(readPackageUpdatesTestData(t, "apt_debian12.txt"))
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "tzdata", Current: "2023c-5+deb12u1", Available: "2026b-0+deb12u1"}, findPackage(t, packages, "tzdata"))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("new dependencies and trailing brackets", func(t *testing.T) {
|
|
||||||
out := `Inst linux-image-6.8.0-50-generic (6.8.0-50.51 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
|
||||||
Inst linux-image-generic [6.8.0-49.49] (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
|
||||||
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
|
||||||
Conf linux-image-generic (6.8.0-50.50 Ubuntu:24.04/noble-updates, Ubuntu:24.04/noble-security [amd64])
|
|
||||||
Remv oldpkg [1.0]`
|
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
|
||||||
{Name: "linux-image-generic", Current: "6.8.0-49.49", Available: "6.8.0-50.50", Security: true},
|
|
||||||
{Name: "gcc-12-base", Current: "12.3.0-1ubuntu1~22.04", Available: "12.3.0-1ubuntu1~22.04.3"},
|
|
||||||
}, parseAptSimulate(out))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("no updates", func(t *testing.T) {
|
|
||||||
assert.Empty(t, parseAptSimulate("Reading package lists...\n0 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.\n"))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDnfCheckUpdate(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
file string
|
|
||||||
count int
|
|
||||||
}{
|
|
||||||
{"dnf4_rocky9_check_update.txt", 110},
|
|
||||||
{"dnf4_rocky9_check_update_security.txt", 53},
|
|
||||||
{"dnf5_fedora42_check_update.txt", 20},
|
|
||||||
{"dnf5_fedora42_check_update_security.txt", 5},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
|
||||||
assert.Len(t, parseDnfCheckUpdate(readPackageUpdatesTestData(t, tt.file)), tt.count)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("versions keep epoch and arch", func(t *testing.T) {
|
|
||||||
packages := parseDnfCheckUpdate(readPackageUpdatesTestData(t, "dnf5_fedora42_check_update.txt"))
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "openssl-libs.aarch64", Available: "1:3.2.6-4.fc42"}, findPackage(t, packages, "openssl-libs.aarch64"))
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("obsoletes section, notices and wrapped names", func(t *testing.T) {
|
|
||||||
out := `
|
|
||||||
kernel.x86_64 5.14.0-503.el9 baseos
|
|
||||||
Security: kernel-core-5.14.0-427.el9.x86_64 is an installed security update
|
|
||||||
python3-some-very-long-package-name-that-wraps.noarch
|
|
||||||
1.2.3-4.el9 appstream
|
|
||||||
Obsoleting Packages
|
|
||||||
grub2-tools.x86_64 1:2.06-80.el9 baseos
|
|
||||||
grub2-tools.x86_64 1:2.06-77.el9 @baseos
|
|
||||||
`
|
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
|
||||||
{Name: "kernel.x86_64", Available: "5.14.0-503.el9"},
|
|
||||||
{Name: "python3-some-very-long-package-name-that-wraps.noarch", Available: "1.2.3-4.el9"},
|
|
||||||
}, parseDnfCheckUpdate(out))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseRpmInstalled(t *testing.T) {
|
|
||||||
installed := parseRpmInstalled(readPackageUpdatesTestData(t, "dnf4_rocky9_rpm_installed.txt"))
|
|
||||||
assert.Len(t, installed, 110)
|
|
||||||
assert.Equal(t, "2.34-83.el9.7", installed["glibc.aarch64"])
|
|
||||||
assert.Equal(t, "1:3.0.7-24.el9", installed["openssl-libs.aarch64"])
|
|
||||||
assert.NotContains(t, installed, "package")
|
|
||||||
|
|
||||||
// several installed kernels: the last one wins
|
|
||||||
installed = parseRpmInstalled("kernel.x86_64 5.14.0-427.el9\nkernel.x86_64 5.14.0-503.el9\n")
|
|
||||||
assert.Equal(t, "5.14.0-503.el9", installed["kernel.x86_64"])
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckDnf(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name, updates, security, installed string
|
|
||||||
total, securityCount int
|
|
||||||
pkg system.PackageUpdate
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "dnf4",
|
|
||||||
updates: "dnf4_rocky9_check_update.txt", security: "dnf4_rocky9_check_update_security.txt", installed: "dnf4_rocky9_rpm_installed.txt",
|
|
||||||
total: 110, securityCount: 53,
|
|
||||||
pkg: system.PackageUpdate{Name: "vim-minimal", Current: "2:8.2.2637-20.el9_1", Available: "2:8.2.2637-26.el9_8.21", Security: true},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "dnf5",
|
|
||||||
updates: "dnf5_fedora42_check_update.txt", security: "dnf5_fedora42_check_update_security.txt", installed: "dnf5_fedora42_rpm_installed.txt",
|
|
||||||
total: 20, securityCount: 5,
|
|
||||||
pkg: system.PackageUpdate{Name: "openssl-libs", Current: "1:3.2.6-3.fc42", Available: "1:3.2.6-4.fc42", Security: true},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
fakeCommands(t, map[string]string{
|
|
||||||
"dnf": `case "$*" in *--security*) cat "` + testDataPath(t, tt.security) + `" ;; *) cat "` + testDataPath(t, tt.updates) + `" ;; esac
|
|
||||||
exit 100`,
|
|
||||||
"rpm": `cat "` + testDataPath(t, tt.installed) + `"
|
|
||||||
exit 1`,
|
|
||||||
})
|
|
||||||
result, err := checkDnf(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []uint16{uint16(tt.total), uint16(tt.securityCount)}, result.counts)
|
|
||||||
assert.True(t, result.securityKnown)
|
|
||||||
assert.Len(t, result.packages, tt.total)
|
|
||||||
assert.Equal(t, tt.pkg, findPackage(t, result.packages, tt.pkg.Name))
|
|
||||||
for _, pkg := range result.packages {
|
|
||||||
assert.NotEmpty(t, pkg.Current, pkg.Name)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("security query fails", func(t *testing.T) {
|
|
||||||
fakeCommands(t, map[string]string{
|
|
||||||
"dnf": `case "$*" in *--security*) exit 1 ;; esac
|
|
||||||
echo "bash.x86_64 5.1.8-9.el9 baseos"
|
|
||||||
exit 100`,
|
|
||||||
"rpm": `echo "bash.x86_64 5.1.8-6.el9_1"`,
|
|
||||||
})
|
|
||||||
result, err := checkDnf(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []uint16{1}, result.counts)
|
|
||||||
assert.False(t, result.securityKnown)
|
|
||||||
assert.Equal(t, []system.PackageUpdate{{Name: "bash", Current: "5.1.8-6.el9_1", Available: "5.1.8-9.el9"}}, result.packages)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseZypperTable(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
file string
|
|
||||||
count uint16
|
|
||||||
}{
|
|
||||||
{"zypper_leap155_list_updates.txt", 22},
|
|
||||||
{"zypper_leap155_list_patches_security.txt", 4},
|
|
||||||
{"zypper_leap156_list_updates_none.txt", 0},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.file, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.count, parseZypperTable(readPackageUpdatesTestData(t, tt.file)))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseZypperListUpdates(t *testing.T) {
|
|
||||||
packages := parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_updates.txt"))
|
|
||||||
assert.Len(t, packages, 22)
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "zypper", Current: "1.14.76-150500.6.6.15", Available: "1.14.78-150500.6.14.1"}, findPackage(t, packages, "zypper"))
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "aaa_base", Current: "84.87+git20180409.04c9dae-150300.10.20.1", Available: "84.87+git20180409.04c9dae-150300.10.23.1"}, findPackage(t, packages, "aaa_base"))
|
|
||||||
|
|
||||||
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap156_list_updates_none.txt")))
|
|
||||||
// patch tables have no version columns
|
|
||||||
assert.Empty(t, parseZypperListUpdates(readPackageUpdatesTestData(t, "zypper_leap155_list_patches_security.txt")))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckZypper(t *testing.T) {
|
|
||||||
fakeCommands(t, map[string]string{
|
|
||||||
"zypper": `case "$*" in *list-patches*) cat "` + testDataPath(t, "zypper_leap155_list_patches_security.txt") + `" ;; *) cat "` + testDataPath(t, "zypper_leap155_list_updates.txt") + `" ;; esac`,
|
|
||||||
})
|
|
||||||
result, err := checkZypper(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
// security patches don't map to packages, so only the count is known
|
|
||||||
assert.Equal(t, []uint16{22, 4}, result.counts)
|
|
||||||
assert.False(t, result.securityKnown)
|
|
||||||
assert.Len(t, result.packages, 22)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParsePacmanCheckUpdates(t *testing.T) {
|
|
||||||
assert.Equal(t, []system.PackageUpdate{
|
|
||||||
{Name: "libpcap", Current: "1.10.7-1", Available: "1.11.0-1"},
|
|
||||||
{Name: "libsecret", Current: "0.21.7-1", Available: "0.21.8.2-1"},
|
|
||||||
{Name: "libtirpc", Current: "1.3.7-1", Available: "1.3.8-1"},
|
|
||||||
{Name: "tzdata", Current: "2026c-1", Available: "2026d-1"},
|
|
||||||
}, parsePacmanCheckUpdates(readPackageUpdatesTestData(t, "pacman_checkupdates.txt")))
|
|
||||||
assert.Empty(t, parsePacmanCheckUpdates(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseApkUpgradable(t *testing.T) {
|
|
||||||
packages := parseApkUpgradable(readPackageUpdatesTestData(t, "apk_alpine320_list_upgradable.txt"))
|
|
||||||
assert.Len(t, packages, 10)
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}, packages[6])
|
|
||||||
// names with dashes and digits
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "busybox-binsh", Current: "1.36.1-r28", Available: "1.36.1-r31"}, packages[2])
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "ca-certificates-bundle", Current: "20240226-r0", Available: "20260413-r0"}, packages[3])
|
|
||||||
assert.Equal(t, system.PackageUpdate{Name: "libcrypto3", Current: "3.3.0-r2", Available: "3.3.7-r0"}, packages[4])
|
|
||||||
assert.Empty(t, parseApkUpgradable(""))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSplitApkNameVersion(t *testing.T) {
|
|
||||||
tests := []struct{ in, name, version string }{
|
|
||||||
{"musl-1.2.5-r3", "musl", "1.2.5-r3"},
|
|
||||||
{"py3-foo-bar-2.0_rc1-r0", "py3-foo-bar", "2.0_rc1-r0"},
|
|
||||||
{"apk-tools-2.14.4-r1", "apk-tools", "2.14.4-r1"},
|
|
||||||
// unexpected formats keep the whole string as the name
|
|
||||||
{"noversion", "noversion", ""},
|
|
||||||
{"name-1.0", "name-1.0", ""},
|
|
||||||
{"-1.0-r0", "-1.0-r0", ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
name, version := splitApkNameVersion(tt.in)
|
|
||||||
assert.Equal(t, tt.name, name, tt.in)
|
|
||||||
assert.Equal(t, tt.version, version, tt.in)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPackageUpdatesManagerCaching(t *testing.T) {
|
|
||||||
calls := make(chan struct{}, 10)
|
|
||||||
packages := []system.PackageUpdate{{Name: "libc6", Current: "1", Available: "2", Security: true}}
|
|
||||||
result := packageUpdatesResult{counts: []uint16{3, 1}, packages: packages, securityKnown: true}
|
|
||||||
var resultErr error
|
|
||||||
pm := &packageUpdatesManager{
|
|
||||||
name: "apt",
|
|
||||||
interval: time.Hour,
|
|
||||||
check: func(context.Context) (packageUpdatesResult, error) {
|
|
||||||
calls <- struct{}{}
|
|
||||||
return result, resultErr
|
|
||||||
},
|
|
||||||
}
|
|
||||||
waitIdle := func() {
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
pm.Lock()
|
|
||||||
defer pm.Unlock()
|
|
||||||
return !pm.running
|
|
||||||
}, time.Second, time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
// no check has finished yet
|
|
||||||
assert.Equal(t, system.PackageUpdates{Manager: "apt"}, pm.list())
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
// first call starts a background check and returns nothing yet
|
|
||||||
assert.Nil(t, pm.get(now))
|
|
||||||
waitIdle()
|
|
||||||
assert.Len(t, calls, 1)
|
|
||||||
|
|
||||||
// cached result within interval, no new check
|
|
||||||
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(time.Minute)))
|
|
||||||
assert.Len(t, calls, 1)
|
|
||||||
list := pm.list()
|
|
||||||
assert.Equal(t, "apt", list.Manager)
|
|
||||||
assert.True(t, list.SecurityKnown)
|
|
||||||
assert.Equal(t, packages, list.Packages)
|
|
||||||
assert.NotZero(t, list.CheckedAt)
|
|
||||||
// list never starts a check
|
|
||||||
assert.Len(t, calls, 1)
|
|
||||||
|
|
||||||
// stale after interval: returns cached value and refreshes in background
|
|
||||||
result, resultErr = packageUpdatesResult{}, errors.New("boom")
|
|
||||||
assert.Equal(t, []uint16{3, 1}, pm.get(now.Add(2*time.Hour)))
|
|
||||||
waitIdle()
|
|
||||||
assert.Len(t, calls, 2)
|
|
||||||
|
|
||||||
// failed check clears the counts and the list
|
|
||||||
assert.Nil(t, pm.get(time.Now()))
|
|
||||||
assert.Nil(t, pm.list().Packages)
|
|
||||||
assert.False(t, pm.list().SecurityKnown)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetPackageUpdatesHandler(t *testing.T) {
|
|
||||||
var sent any
|
|
||||||
hctx := &HandlerContext{
|
|
||||||
Agent: &Agent{},
|
|
||||||
SendResponse: func(data any, _ *uint32) error {
|
|
||||||
sent = data
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
handler := &GetPackageUpdatesHandler{}
|
|
||||||
|
|
||||||
// no supported package manager
|
|
||||||
require.NoError(t, handler.Handle(hctx))
|
|
||||||
assert.Equal(t, system.PackageUpdates{}, sent)
|
|
||||||
|
|
||||||
packages := []system.PackageUpdate{{Name: "musl", Current: "1.2.5-r0", Available: "1.2.5-r3"}}
|
|
||||||
hctx.Agent.packageUpdates = &packageUpdatesManager{
|
|
||||||
name: "apk",
|
|
||||||
result: packageUpdatesResult{counts: []uint16{1}, packages: packages},
|
|
||||||
checkedAt: time.Unix(1700000000, 0),
|
|
||||||
}
|
|
||||||
require.NoError(t, handler.Handle(hctx))
|
|
||||||
assert.Equal(t, system.PackageUpdates{Manager: "apk", CheckedAt: 1700000000, Packages: packages}, sent)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPacmanCheckSync(t *testing.T) {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
t.Skip("requires a shell script on PATH")
|
|
||||||
}
|
|
||||||
binDir := t.TempDir()
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
logFile := filepath.Join(binDir, "calls.log")
|
|
||||||
// fake checkupdates logs its args and db path, and creates the sync dir when syncing
|
|
||||||
script := `#!/bin/sh
|
|
||||||
echo "args=[$*] db=$CHECKUPDATES_DB" >> ` + logFile + `
|
|
||||||
[ "$1" = "-n" ] || mkdir -p "$CHECKUPDATES_DB/sync"
|
|
||||||
echo "linux 6.1-1 -> 6.2-1"
|
|
||||||
`
|
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(binDir, "checkupdates"), []byte(script), 0o755))
|
|
||||||
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
check := newPacmanCheck(dataDir)
|
|
||||||
dbPath := filepath.Join(dataDir, "checkup-db")
|
|
||||||
readCalls := func() []string {
|
|
||||||
data, err := os.ReadFile(logFile)
|
|
||||||
require.NoError(t, err)
|
|
||||||
return strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// first check syncs
|
|
||||||
result, err := check(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []uint16{1}, result.counts)
|
|
||||||
assert.Equal(t, []system.PackageUpdate{{Name: "linux", Current: "6.1-1", Available: "6.2-1"}}, result.packages)
|
|
||||||
// later checks reuse the synced copy
|
|
||||||
_, err = check(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
// a missing private copy forces a sync
|
|
||||||
require.NoError(t, os.RemoveAll(dbPath))
|
|
||||||
_, err = check(context.Background())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, []string{
|
|
||||||
"args=[] db=" + dbPath,
|
|
||||||
"args=[-n] db=" + dbPath,
|
|
||||||
"args=[] db=" + dbPath,
|
|
||||||
}, readCalls())
|
|
||||||
}
|
|
||||||
+2
-123
@@ -5,9 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,8 +32,6 @@ type SensorConfig struct {
|
|||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
skipGPU bool
|
|
||||||
sensorShadow string
|
|
||||||
firstRun bool
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,14 +41,13 @@ 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, skipGPU == "true")
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, skipGPU bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection 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 {
|
||||||
@@ -67,7 +62,6 @@ 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{}),
|
||||||
}
|
}
|
||||||
@@ -79,19 +73,6 @@ 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, "-") {
|
||||||
@@ -168,9 +149,6 @@ 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 "":
|
||||||
@@ -267,102 +245,3 @@ 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
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-154
@@ -5,9 +5,6 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -331,7 +328,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, false)
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
||||||
|
|
||||||
// Check primary sensor
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -623,153 +620,3 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
|
assert.Equal(t, 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)
|
|
||||||
}
|
|
||||||
|
|||||||
+11
-14
@@ -57,11 +57,21 @@ 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: newSSHServerConfig,
|
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||||
|
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()
|
||||||
@@ -86,19 +96,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/ed25519"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
|
||||||
|
|
||||||
"github.com/gliderlabs/ssh"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
gossh "golang.org/x/crypto/ssh"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSSHServerConfigConcurrentConnections(t *testing.T) {
|
|
||||||
_, key, err := ed25519.GenerateKey(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
signer, err := gossh.NewSignerFromKey(key)
|
|
||||||
require.NoError(t, err)
|
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
const connections = 2
|
|
||||||
configs := make(chan *gossh.ServerConfig, connections)
|
|
||||||
release := make(chan struct{})
|
|
||||||
var releaseOnce sync.Once
|
|
||||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
|
||||||
server := &ssh.Server{
|
|
||||||
HostSigners: []ssh.Signer{signer},
|
|
||||||
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
|
||||||
config := newSSHServerConfig(ctx)
|
|
||||||
configs <- config
|
|
||||||
// Both connections must obtain their configuration before either
|
|
||||||
// lets gliderlabs add host keys and connection-specific callbacks.
|
|
||||||
<-release
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
PublicKeyHandler: func(_ ssh.Context, key ssh.PublicKey) bool {
|
|
||||||
return ssh.KeysEqual(key, signer.PublicKey())
|
|
||||||
},
|
|
||||||
Handler: func(session ssh.Session) { _ = session.Exit(0) },
|
|
||||||
}
|
|
||||||
served := make(chan error, 1)
|
|
||||||
go func() { served <- server.Serve(listener) }()
|
|
||||||
t.Cleanup(func() {
|
|
||||||
unblock()
|
|
||||||
_ = listener.Close()
|
|
||||||
_ = server.Close()
|
|
||||||
select {
|
|
||||||
case <-served:
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Error("SSH test server did not stop")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
results := make(chan error, connections)
|
|
||||||
for range connections {
|
|
||||||
go func() {
|
|
||||||
conn, err := net.DialTimeout("tcp", listener.Addr().String(), 5*time.Second)
|
|
||||||
if err != nil {
|
|
||||||
results <- err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
|
||||||
client, _, _, err := gossh.NewClientConn(conn, listener.Addr().String(), &gossh.ClientConfig{
|
|
||||||
User: "test",
|
|
||||||
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
|
|
||||||
HostKeyCallback: gossh.FixedHostKey(signer.PublicKey()),
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
err = client.Close()
|
|
||||||
}
|
|
||||||
results <- err
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
var first *gossh.ServerConfig
|
|
||||||
for range connections {
|
|
||||||
select {
|
|
||||||
case config := <-configs:
|
|
||||||
assert.Equal(t, fmt.Sprintf("SSH-2.0-%s_%s", beszel.AppName, beszel.Version), config.ServerVersion)
|
|
||||||
assert.Equal(t, common.DefaultKeyExchanges, config.KeyExchanges)
|
|
||||||
assert.Equal(t, common.DefaultMACs, config.MACs)
|
|
||||||
assert.Equal(t, common.DefaultCiphers, config.Ciphers)
|
|
||||||
if first == nil {
|
|
||||||
first = config
|
|
||||||
} else {
|
|
||||||
assert.NotSame(t, first, config, "SSH connections must not share mutable configuration")
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("SSH connections did not reach their config callbacks")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unblock()
|
|
||||||
for range connections {
|
|
||||||
select {
|
|
||||||
case err := <-results:
|
|
||||||
require.NoError(t, err)
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("SSH handshake did not finish")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-25
@@ -311,13 +311,11 @@ func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceIn
|
|||||||
return filtered
|
return filtered
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectSmartOutputType inspects protocol-specific sections and the reported
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
// device type to choose a parser, including when the NVMe health log is missing.
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
|
// when the reported device type is ambiguous or missing.
|
||||||
func detectSmartOutputType(output []byte) string {
|
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"`
|
||||||
@@ -328,7 +326,7 @@ func detectSmartOutputType(output []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case hasJSONValue(hints.NVMeSmartHealthInformationLog), normalizeParserType(hints.Device.Type) == "nvme":
|
case hasJSONValue(hints.NVMeSmartHealthInformationLog):
|
||||||
return "nvme"
|
return "nvme"
|
||||||
case hasJSONValue(hints.AtaSmartAttributes):
|
case hasJSONValue(hints.AtaSmartAttributes):
|
||||||
return "sat"
|
return "sat"
|
||||||
@@ -399,11 +397,11 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inspect every response so a failed NVMe query cannot reach other parsers.
|
// Only run the type detection when we do not yet know which parser works
|
||||||
structureType := detectSmartOutputType(output)
|
// or the previous attempt failed.
|
||||||
// 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
|
||||||
@@ -444,11 +442,6 @@ 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
|
||||||
@@ -1152,16 +1145,6 @@ 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()
|
||||||
|
|
||||||
@@ -1184,7 +1167,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 = log.Temperature
|
smartData.Temperature = data.NVMeSmartHealthInformationLog.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
|
||||||
@@ -1194,6 +1177,7 @@ 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,27 +837,6 @@ 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)
|
||||||
|
|||||||
+21
-71
@@ -54,18 +54,12 @@ 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
|
||||||
|
|
||||||
// Utility-backed caches below are refreshed in the background after the
|
poolData []zfs.PoolStat // cached pool inventory (TTL below)
|
||||||
// first collection, so cacheMu guards them against those goroutines.
|
lastPoolStats time.Time
|
||||||
cacheMu sync.Mutex
|
kernelSamples map[string]poolKernelSample
|
||||||
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.
|
||||||
@@ -183,40 +177,19 @@ 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. Only the first collection blocks; later
|
// every poolStatsRefreshInterval. On failure the previous inventory is
|
||||||
// refreshes run in the background because utilities like `zpool list` can hang
|
// retained and the refresh is retried on the next cadence.
|
||||||
// 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 {
|
||||||
b.cacheMu.Lock()
|
if b.lastPoolStats.IsZero() || time.Since(b.lastPoolStats) >= poolStatsRefreshInterval {
|
||||||
defer b.cacheMu.Unlock()
|
|
||||||
if b.poolRefreshing || (!b.lastPoolStats.IsZero() && time.Since(b.lastPoolStats) < poolStatsRefreshInterval) {
|
|
||||||
return b.poolData
|
|
||||||
}
|
|
||||||
if b.lastPoolStats.IsZero() {
|
|
||||||
b.storePoolStats(b.poolStatsFn())
|
|
||||||
return b.poolData
|
|
||||||
}
|
|
||||||
b.poolRefreshing = true
|
|
||||||
go func() {
|
|
||||||
pools, err := b.poolStatsFn()
|
pools, err := b.poolStatsFn()
|
||||||
b.cacheMu.Lock()
|
if err != nil {
|
||||||
defer b.cacheMu.Unlock()
|
slog.Debug("Storage pool stats unavailable", "backend", b.name, "err", err)
|
||||||
b.poolRefreshing = false
|
} else {
|
||||||
b.storePoolStats(pools, err)
|
b.poolData = pools
|
||||||
}()
|
}
|
||||||
return b.poolData
|
b.lastPoolStats = time.Now()
|
||||||
}
|
|
||||||
|
|
||||||
// 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()
|
return b.poolData
|
||||||
}
|
}
|
||||||
|
|
||||||
// kernelStats reads cumulative pool counters and converts them to per-second
|
// kernelStats reads cumulative pool counters and converts them to per-second
|
||||||
@@ -252,33 +225,12 @@ func (b *poolBackend) kernelStats() (map[string]zfs.PoolKernelStat, map[string]z
|
|||||||
}
|
}
|
||||||
|
|
||||||
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
// refreshDatasetUsage re-runs `zfs list` when the refresh window has elapsed
|
||||||
// and returns the mountpoint-keyed usage map. Like poolStats, only the first
|
// and rebuilds the mountpoint-keyed usage map.
|
||||||
// collection blocks and later refreshes run in the background.
|
func (b *poolBackend) refreshDatasetUsage() {
|
||||||
func (b *poolBackend) refreshDatasetUsage() map[string]zfsDatasetUsage {
|
if !b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval {
|
||||||
b.cacheMu.Lock()
|
return
|
||||||
defer b.cacheMu.Unlock()
|
|
||||||
if b.usageRefreshing || (!b.lastUsageRefresh.IsZero() && time.Since(b.lastUsageRefresh) < datasetUsageRefreshInterval) {
|
|
||||||
return b.datasetUsage
|
|
||||||
}
|
}
|
||||||
if b.lastUsageRefresh.IsZero() {
|
datasets, err := b.datasets()
|
||||||
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 {
|
||||||
@@ -299,7 +251,8 @@ func (b *poolBackend) storeDatasetUsage(datasets []zfs.Dataset, err error) {
|
|||||||
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" {
|
||||||
return backend.refreshDatasetUsage()
|
backend.refreshDatasetUsage()
|
||||||
|
return backend.datasetUsage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -489,10 +442,7 @@ func (m *StoragePoolManager) markDuplicateCharts(stats *system.Stats, filesystem
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, backend := range m.backends {
|
for _, backend := range m.backends {
|
||||||
backend.cacheMu.Lock()
|
for _, pool := range backend.poolData {
|
||||||
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,31 +518,3 @@ 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,7 +14,6 @@ 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"
|
||||||
@@ -268,14 +267,6 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wi-Fi collection spawns a process on macOS and dumps the BSS cache on
|
|
||||||
// Linux, so only refresh on the default interval. Real-time requests reuse
|
|
||||||
// the last snapshot.
|
|
||||||
if cacheTimeMs == defaultDataCacheTimeMs {
|
|
||||||
a.systemInfo.WiFi = wifi.Collect()
|
|
||||||
}
|
|
||||||
systemStats.WiFi = wifi.Signals(a.systemInfo.WiFi)
|
|
||||||
|
|
||||||
// update system info
|
// 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
|
||||||
|
|||||||
+4
-115
@@ -3,15 +3,12 @@
|
|||||||
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"
|
||||||
@@ -23,50 +20,6 @@ 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 {
|
||||||
@@ -74,7 +27,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,7 +68,6 @@ 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(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,14 +232,6 @@ 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())
|
||||||
@@ -297,7 +240,10 @@ func (sm *systemdManager) getServiceDetails(serviceName string) (systemd.Service
|
|||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
unitName := serviceUnitName(serviceName)
|
unitName := 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)
|
||||||
@@ -332,63 +278,6 @@ 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,7 +11,6 @@ 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.
|
||||||
@@ -37,7 +36,3 @@ 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,14 +3,10 @@
|
|||||||
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,127 +32,6 @@ func TestUnescapeServiceName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLimitedBuffer(t *testing.T) {
|
|
||||||
buffer := limitedBuffer{limit: 5}
|
|
||||||
|
|
||||||
n, err := buffer.Write([]byte("abcdef"))
|
|
||||||
assert.Equal(t, 5, n)
|
|
||||||
assert.ErrorIs(t, err, errSystemdLogLimitReached)
|
|
||||||
assert.Equal(t, "abcde", buffer.String())
|
|
||||||
|
|
||||||
n, err = buffer.Write([]byte("g"))
|
|
||||||
assert.Zero(t, n)
|
|
||||||
assert.True(t, errors.Is(err, errSystemdLogLimitReached))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLimitedBufferCapsExecOutput(t *testing.T) {
|
|
||||||
buffer := limitedBuffer{limit: 5}
|
|
||||||
cmd := exec.Command("sh", "-c", "printf 'abcdef'")
|
|
||||||
cmd.Stdout = &buffer
|
|
||||||
|
|
||||||
err := cmd.Run()
|
|
||||||
assert.ErrorIs(t, err, errSystemdLogLimitReached)
|
|
||||||
assert.Equal(t, "abcde", buffer.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServiceUnitName(t *testing.T) {
|
|
||||||
tests := map[string]string{
|
|
||||||
"nginx": "nginx.service",
|
|
||||||
"nginx.service": "nginx.service",
|
|
||||||
"backup.timer": "backup.timer",
|
|
||||||
}
|
|
||||||
for input, want := range tests {
|
|
||||||
t.Run(input, func(t *testing.T) {
|
|
||||||
assert.Equal(t, want, serviceUnitName(input))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCanReadSystemJournal(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
script string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"readable", "#!/bin/sh\nprintf 'system log\\n'\n", true},
|
|
||||||
{"empty", "#!/bin/sh\nexit 0\n", true},
|
|
||||||
{"denied", "#!/bin/sh\nexit 1\n", false},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(test.script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
|
||||||
assert.Equal(t, test.want, canReadSystemJournal())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetServiceLogsOnlyMonitoredUnits(t *testing.T) {
|
|
||||||
// Fake journalctl prints the unit it was asked for
|
|
||||||
dir := t.TempDir()
|
|
||||||
script := "#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = --unit ] && printf '%s' \"$2\"; shift; done\n"
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte(script), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
sm := &systemdManager{logsEnabled: true, serviceStatsMap: map[string]*systemd.Service{
|
|
||||||
"nginx.service": {Name: "nginx"},
|
|
||||||
"backup.timer": {Name: "backup.timer"},
|
|
||||||
"foo\\x2dbar.service": {Name: "foo-bar"},
|
|
||||||
"getty@tty1.service": {Name: "getty@tty1"},
|
|
||||||
}}
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"nginx", "nginx.service"},
|
|
||||||
{"nginx.service", "nginx.service"},
|
|
||||||
{"backup.timer", "backup.timer"},
|
|
||||||
{"foo-bar", "foo\\x2dbar.service"},
|
|
||||||
{"getty@tty1", "getty@tty1.service"},
|
|
||||||
}
|
|
||||||
for _, test := range tests {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
logs, err := sm.getServiceLogs(test.name)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.Equal(t, test.want, logs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range []string{"sshd", "*", "*.service", "nginx*"} {
|
|
||||||
t.Run("rejects "+name, func(t *testing.T) {
|
|
||||||
logs, err := sm.getServiceLogs(name)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Empty(t, logs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Run("disabled", func(t *testing.T) {
|
|
||||||
sm.logsEnabled = false
|
|
||||||
logs, err := sm.getServiceLogs("nginx")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Empty(t, logs)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemdLogsEnabled(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
if err := os.WriteFile(filepath.Join(dir, "journalctl"), []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
|
|
||||||
|
|
||||||
assert.True(t, systemdLogsEnabled())
|
|
||||||
|
|
||||||
t.Setenv("SKIP_SYSTEMD_LOGS", "true")
|
|
||||||
assert.False(t, systemdLogsEnabled())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUnescapeServiceNameInvalid(t *testing.T) {
|
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{
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
apk-tools-2.14.4-r1 aarch64 {apk-tools} (GPL-2.0-only) [upgradable from: apk-tools-2.14.4-r0]
|
|
||||||
busybox-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-1.36.1-r28]
|
|
||||||
busybox-binsh-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: busybox-binsh-1.36.1-r28]
|
|
||||||
ca-certificates-bundle-20260413-r0 aarch64 {ca-certificates} (MPL-2.0 AND MIT) [upgradable from: ca-certificates-bundle-20240226-r0]
|
|
||||||
libcrypto3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libcrypto3-3.3.0-r2]
|
|
||||||
libssl3-3.3.7-r0 aarch64 {openssl} (Apache-2.0) [upgradable from: libssl3-3.3.0-r2]
|
|
||||||
musl-1.2.5-r3 aarch64 {musl} (MIT) [upgradable from: musl-1.2.5-r0]
|
|
||||||
musl-utils-1.2.5-r3 aarch64 {musl} (MIT AND BSD-2-Clause AND GPL-2.0-or-later) [upgradable from: musl-utils-1.2.5-r0]
|
|
||||||
ssl_client-1.36.1-r31 aarch64 {busybox} (GPL-2.0-only) [upgradable from: ssl_client-1.36.1-r28]
|
|
||||||
zlib-1.3.2-r0 aarch64 {zlib} (Zlib) [upgradable from: zlib-1.3.1-r1]
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
Reading package lists...
|
|
||||||
Building dependency tree...
|
|
||||||
Reading state information...
|
|
||||||
Calculating upgrade...
|
|
||||||
The following packages will be upgraded:
|
|
||||||
base-files bash bsdutils debian-archive-keyring dpkg e2fsprogs gcc-12-base
|
|
||||||
gpgv init-system-helpers libblkid1 libc-bin libc6 libcap2 libcom-err2
|
|
||||||
libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 liblzma5 libmount1
|
|
||||||
libpam-modules libpam-modules-bin libpam-runtime libpam0g libpcre2-8-0
|
|
||||||
libseccomp2 libsmartcols1 libss2 libstdc++6 libsystemd0 libtasn1-6 libudev1
|
|
||||||
libuuid1 login logsave mount passwd perl-base sed tar tzdata usr-is-merged
|
|
||||||
util-linux util-linux-extra
|
|
||||||
44 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
|
||||||
Inst base-files [12.4+deb12u4] (12.4+deb12u15 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf base-files (12.4+deb12u15 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst bash [5.2.15-2+b2] (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf bash (5.2.15-2+b13 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst bsdutils [1:2.38.1-5+b1] (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf bsdutils (1:2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst tar [1.34+dfsg-1.2] (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf tar (1.34+dfsg-1.2+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst dpkg [1.21.22] (1.21.23 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf dpkg (1.21.23 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst login [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf login (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst perl-base [5.36.0-7+deb12u1] (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf perl-base (5.36.0-7+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst sed [4.9-1] (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf sed (4.9-1+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst gcc-12-base [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
|
||||||
Conf gcc-12-base (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
|
||||||
Inst libgcc-s1 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
|
|
||||||
Conf libgcc-s1 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64]) [libstdc++6:arm64 ]
|
|
||||||
Inst libstdc++6 [12.2.0-14] (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libstdc++6 (12.2.0-14+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libc6 [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libc6 (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libsmartcols1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libsmartcols1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst util-linux-extra [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf util-linux-extra (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst util-linux [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf util-linux (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst usr-is-merged [35] (37~deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Conf usr-is-merged (37~deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Inst init-system-helpers [1.65.2] (1.65.2+deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Conf init-system-helpers (1.65.2+deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Inst libc-bin [2.36-9+deb12u3] (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libc-bin (2.36-9+deb12u14 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libpam0g [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libpam0g (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libpam-modules-bin [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
|
|
||||||
Conf libpam-modules-bin (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64]) [libpam-modules:arm64 ]
|
|
||||||
Inst libpam-modules [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libpam-modules (1.5.2-6+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst logsave [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libext2fs2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
|
|
||||||
Conf libext2fs2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64]) [e2fsprogs:arm64 ]
|
|
||||||
Inst e2fsprogs [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst mount [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libpam-runtime [1.5.2-6+deb12u1] (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
|
|
||||||
Conf libpam-runtime (1.5.2-6+deb12u2 Debian:12.15/oldstable [all])
|
|
||||||
Inst passwd [1:4.13+dfsg1-1+b1] (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf passwd (1:4.13+dfsg1-1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst debian-archive-keyring [2023.3+deb12u1] (2023.3+deb12u2 Debian:12.15/oldstable [all])
|
|
||||||
Conf debian-archive-keyring (2023.3+deb12u2 Debian:12.15/oldstable [all])
|
|
||||||
Inst libgcrypt20 [1.10.1-3] (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Conf libgcrypt20 (1.10.1-3+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Inst gpgv [2.2.40-1.1] (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf gpgv (2.2.40-1.1+deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libblkid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libblkid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libcap2 [1:2.66-4] (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libcap2 (1:2.66-4+deb12u3+b1 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libtasn1-6 [4.19.0-2] (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Conf libtasn1-6 (4.19.0-2+deb12u1 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Inst libgnutls30 [3.7.9-2+deb12u1] (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Conf libgnutls30 (3.7.9-2+deb12u7 Debian:12.15/oldstable, Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Inst liblzma5 [5.4.1-0.2] (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Conf liblzma5 (5.4.1-1+deb12u2 Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Inst libmount1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libmount1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libpcre2-8-0 [10.42-1] (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Conf libpcre2-8-0 (10.42-1+deb12u1 Debian-Security:12/oldstable-security [arm64])
|
|
||||||
Inst libseccomp2 [2.5.4-1+b3] (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libseccomp2 (2.5.4-1+deb12u1 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libsystemd0 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libsystemd0 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libudev1 [252.19-1~deb12u1] (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libudev1 (252.39-1~deb12u2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libuuid1 [2.38.1-5+b1] (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libuuid1 (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst tzdata [2023c-5+deb12u1] (2026b-0+deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Inst libcom-err2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Inst libss2 [1.47.0-2] (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf logsave (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf e2fsprogs (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf mount (2.38.1-5+deb12u3 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf tzdata (2026b-0+deb12u1 Debian:12.15/oldstable [all])
|
|
||||||
Conf libcom-err2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
Conf libss2 (1.47.0-2+b2 Debian:12.15/oldstable [arm64])
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
Reading package lists...
|
|
||||||
Building dependency tree...
|
|
||||||
Reading state information...
|
|
||||||
Calculating upgrade...
|
|
||||||
The following packages will be upgraded:
|
|
||||||
apt base-files bash bsdutils coreutils diffutils dpkg e2fsprogs gcc-12-base
|
|
||||||
gpgv gzip libapt-pkg6.0 libattr1 libblkid1 libbz2-1.0 libc-bin libc6 libcap2
|
|
||||||
libcom-err2 libext2fs2 libgcc-s1 libgcrypt20 libgnutls30 libgssapi-krb5-2
|
|
||||||
libk5crypto3 libkrb5-3 libkrb5support0 liblzma5 libmount1 libncurses6
|
|
||||||
libncursesw6 libp11-kit0 libpam-modules libpam-modules-bin libpam-runtime
|
|
||||||
libpam0g libprocps8 libseccomp2 libsmartcols1 libss2 libssl3 libstdc++6
|
|
||||||
libsystemd0 libtasn1-6 libtinfo6 libudev1 libuuid1 login logsave mount
|
|
||||||
ncurses-base ncurses-bin passwd perl-base procps sed tar util-linux
|
|
||||||
58 upgraded, 0 newly installed, 0 to remove and 0 not upgraded.
|
|
||||||
Inst gcc-12-base [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
|
||||||
Conf gcc-12-base (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 libgcc-s1:arm64 ]
|
|
||||||
Inst libgcc-s1 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
|
|
||||||
Conf libgcc-s1 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libstdc++6:arm64 ]
|
|
||||||
Inst libstdc++6 [12.3.0-1ubuntu1~22.04] (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libstdc++6 (12.3.0-1ubuntu1~22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libc6 [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libc6 (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst base-files [12ubuntu4.4] (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf base-files (12ubuntu4.7 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst bash [5.1-6ubuntu1] (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf bash (5.1-6ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst bsdutils [1:2.37.2-4ubuntu3] (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf bsdutils (1:2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst coreutils [8.32-4.1ubuntu1] (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf coreutils (8.32-4.1ubuntu1.4 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst diffutils [1:3.8-0ubuntu2] (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf diffutils (1:3.8-0ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libbz2-1.0 [1.0.8-5build1] (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libbz2-1.0 (1.0.8-5ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libgcrypt20 [1.9.4-3ubuntu3] (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libgcrypt20 (1.9.4-3ubuntu3.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst liblzma5 [5.2.5-2ubuntu1] (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf liblzma5 (5.2.5-2ubuntu1.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libsystemd0 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libsystemd0 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libudev1 [249.11-0ubuntu3.10] (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libudev1 (249.11-0ubuntu3.22 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libapt-pkg6.0 [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf libapt-pkg6.0 (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst tar [1.34+dfsg-1ubuntu0.1.22.04.1] (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf tar (1.34+dfsg-1ubuntu0.1.22.04.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst dpkg [1.21.1ubuntu2.2] (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf dpkg (1.21.1ubuntu2.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst gzip [1.10-4ubuntu4.1] (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf gzip (1.10-4ubuntu4.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst login [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf login (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst ncurses-bin [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf ncurses-bin (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst perl-base [5.34.0-3ubuntu1.2] (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf perl-base (5.34.0-3ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst sed [4.8-1ubuntu2] (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf sed (4.8-1ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst util-linux [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf util-linux (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libc-bin [2.35-0ubuntu3.4] (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libc-bin (2.35-0ubuntu3.15 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst ncurses-base [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
|
||||||
Conf ncurses-base (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
|
||||||
Inst gpgv [2.2.27-3ubuntu2.1] (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf gpgv (2.2.27-3ubuntu2.5 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libp11-kit0 [0.24.0-6build1] (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libp11-kit0 (0.24.0-6ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libtasn1-6 [4.18.0-4build1] (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libtasn1-6 (4.18.0-4ubuntu0.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libgnutls30 [3.7.3-4ubuntu1.2] (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libgnutls30 (3.7.3-4ubuntu1.9 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libseccomp2 [2.5.3-2ubuntu2] (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf libseccomp2 (2.5.3-2ubuntu3~22.04.1 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst apt [2.4.10] (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf apt (2.4.14 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst libpam0g [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libpam0g (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libpam-modules-bin [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 on libpam-modules-bin:arm64] [libpam-modules:arm64 ]
|
|
||||||
Conf libpam-modules-bin (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) [libpam-modules:arm64 ]
|
|
||||||
Inst libpam-modules [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libpam-modules (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst logsave [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst libext2fs2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 on libext2fs2:arm64] [e2fsprogs:arm64 ]
|
|
||||||
Conf libext2fs2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64]) [e2fsprogs:arm64 ]
|
|
||||||
Inst e2fsprogs [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst mount [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libattr1 [1:2.5.1-1build1] (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libattr1 (1:2.5.1-1ubuntu0.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libblkid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libblkid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libcap2 [1:2.44-1ubuntu0.22.04.1] (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libcap2 (1:2.44-1ubuntu0.22.04.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libcom-err2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf libcom-err2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst libk5crypto3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf libk5crypto3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst libkrb5support0 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
|
|
||||||
Conf libkrb5support0 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libkrb5-3:arm64 ]
|
|
||||||
Inst libkrb5-3 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
|
|
||||||
Conf libkrb5-3 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64]) [libgssapi-krb5-2:arm64 ]
|
|
||||||
Inst libgssapi-krb5-2 [1.19.2-2ubuntu0.2] (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf libgssapi-krb5-2 (1.19.2-2ubuntu0.10 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst libssl3 [3.0.2-0ubuntu1.10] (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libssl3 (3.0.2-0ubuntu1.29 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libmount1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libmount1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libpam-runtime [1.4.0-11ubuntu2.3] (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
|
||||||
Conf libpam-runtime (1.4.0-11ubuntu2.8 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [all])
|
|
||||||
Inst libsmartcols1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libsmartcols1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libncurses6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
|
|
||||||
Inst libncursesw6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64]) []
|
|
||||||
Inst libtinfo6 [6.3-2ubuntu0.1] (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libtinfo6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libuuid1 [2.37.2-4ubuntu3] (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libuuid1 (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst passwd [1:4.8.1-2ubuntu2.1] (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf passwd (1:4.8.1-2ubuntu2.2 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libprocps8 [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Inst libss2 [1.46.5-2ubuntu1.1] (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Inst procps [2:3.3.17-6ubuntu2] (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf logsave (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf e2fsprogs (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf mount (2.37.2-4ubuntu3.6 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libncurses6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libncursesw6 (6.3-2ubuntu0.3 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libprocps8 (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
Conf libss2 (1.46.5-2ubuntu1.2 Ubuntu:22.04/jammy-updates [arm64])
|
|
||||||
Conf procps (2:3.3.17-6ubuntu2.1 Ubuntu:22.04/jammy-updates, Ubuntu:22.04/jammy-security [arm64])
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
|
|
||||||
alternatives.aarch64 1.24-2.el9 baseos
|
|
||||||
audit-libs.aarch64 3.1.5-8.el9 baseos
|
|
||||||
basesystem.noarch 11-13.el9.0.1 baseos
|
|
||||||
bash.aarch64 5.1.8-9.el9 baseos
|
|
||||||
binutils.aarch64 2.35.2-72.el9 baseos
|
|
||||||
binutils-gold.aarch64 2.35.2-72.el9 baseos
|
|
||||||
bzip2-libs.aarch64 1.0.8-11.el9 baseos
|
|
||||||
ca-certificates.noarch 2025.2.80_v9.0.305-91.el9 baseos
|
|
||||||
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
|
|
||||||
cracklib.aarch64 2.9.6-28.el9 baseos
|
|
||||||
cracklib-dicts.aarch64 2.9.6-28.el9 baseos
|
|
||||||
crypto-policies.noarch 20260224-1.gitea0f072.el9 baseos
|
|
||||||
crypto-policies-scripts.noarch 20260224-1.gitea0f072.el9 baseos
|
|
||||||
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
|
||||||
cyrus-sasl-lib.aarch64 2.1.27-22.el9_7 baseos
|
|
||||||
dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
|
||||||
dnf-data.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
|
||||||
elfutils-debuginfod-client.aarch64 0.194-1.el9.rocky.0.1 baseos
|
|
||||||
elfutils-default-yama-scope.noarch 0.194-1.el9.rocky.0.1 baseos
|
|
||||||
elfutils-libelf.aarch64 0.194-1.el9.rocky.0.1 baseos
|
|
||||||
elfutils-libs.aarch64 0.194-1.el9.rocky.0.1 baseos
|
|
||||||
expat.aarch64 2.5.0-6.el9_8.3 baseos
|
|
||||||
file-libs.aarch64 5.39-17.el9 baseos
|
|
||||||
filesystem.aarch64 3.16-5.el9 baseos
|
|
||||||
findutils.aarch64 1:4.8.0-7.el9 baseos
|
|
||||||
gdbm-libs.aarch64 1:1.23-1.el9 baseos
|
|
||||||
glib2.aarch64 2.68.4-19.el9_8.10 baseos
|
|
||||||
glibc.aarch64 2.34-275.el9_8 baseos
|
|
||||||
glibc-common.aarch64 2.34-275.el9_8 baseos
|
|
||||||
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
|
|
||||||
gnupg2.aarch64 2.3.3-5.el9_7 baseos
|
|
||||||
gnutls.aarch64 3.8.10-8.el9_8 baseos
|
|
||||||
gzip.aarch64 1.12-2.el9_8 baseos
|
|
||||||
ima-evm-utils.aarch64 1.6.2-2.el9.rocky.0.2 baseos
|
|
||||||
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
|
|
||||||
less.aarch64 590-6.el9 baseos
|
|
||||||
libacl.aarch64 2.4.0-1.el9_8 baseos
|
|
||||||
libarchive.aarch64 3.5.3-11.el9_8 baseos
|
|
||||||
libatomic.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libattr.aarch64 2.6.0-1.el9_8 baseos
|
|
||||||
libblkid.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libcap.aarch64 2.48-10.el9_7.1 baseos
|
|
||||||
libcom_err.aarch64 1.46.5-8.el9 baseos
|
|
||||||
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
|
||||||
libdb.aarch64 5.3.28-57.el9_6 baseos
|
|
||||||
libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
|
||||||
libeconf.aarch64 0.4.1-7.el9_8 baseos
|
|
||||||
libevent.aarch64 2.1.13-1.el9_8 baseos
|
|
||||||
libfdisk.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libgcc.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
|
|
||||||
libgomp.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libksba.aarch64 1.5.1-7.el9 baseos
|
|
||||||
libmount.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
|
|
||||||
librepo.aarch64 1.19.0-1.el9 baseos
|
|
||||||
libselinux.aarch64 3.6-3.el9 baseos
|
|
||||||
libsemanage.aarch64 3.6-5.el9_6 baseos
|
|
||||||
libsepol.aarch64 3.6-3.el9 baseos
|
|
||||||
libsmartcols.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libsolv.aarch64 0.7.24-6.el9_8 baseos
|
|
||||||
libstdc++.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libtasn1.aarch64 4.16.0-10.el9_8 baseos
|
|
||||||
libusbx.aarch64 1.0.30-1.el9_8 baseos
|
|
||||||
libuser.aarch64 0.63-17.el9 baseos
|
|
||||||
libuuid.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
|
|
||||||
libzstd.aarch64 1.5.5-1.el9 baseos
|
|
||||||
mpfr.aarch64 4.1.0-10.el9 baseos
|
|
||||||
ncurses-base.noarch 6.2-12.20210508.el9 baseos
|
|
||||||
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
|
|
||||||
nettle.aarch64 3.10.1-1.el9 baseos
|
|
||||||
openldap.aarch64 2.6.8-4.el9.0.1 baseos
|
|
||||||
openssl.aarch64 1:3.5.8-1.el9_8 baseos
|
|
||||||
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
|
|
||||||
p11-kit.aarch64 0.26.4-1.el9_8 baseos
|
|
||||||
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
|
|
||||||
pam.aarch64 1.5.1-28.el9_8.1 baseos
|
|
||||||
pcre.aarch64 8.44-4.el9 baseos
|
|
||||||
pcre2.aarch64 10.40-6.el9 baseos
|
|
||||||
pcre2-syntax.noarch 10.40-6.el9 baseos
|
|
||||||
python3.aarch64 3.9.25-7.el9_8.3 baseos
|
|
||||||
python3-dnf.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
|
||||||
python3-hawkey.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
|
||||||
python3-libdnf.aarch64 0.69.0-18.el9.rocky.0.1 baseos
|
|
||||||
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
|
|
||||||
python3-pip-wheel.noarch 21.3.1-2.el9_8.rocky.0.1 baseos
|
|
||||||
python3-rpm.aarch64 4.16.1.3-40.el9 baseos
|
|
||||||
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
|
|
||||||
rocky-gpg-keys.noarch 9.8-1.2.el9 baseos
|
|
||||||
rocky-release.noarch 9.8-1.2.el9 baseos
|
|
||||||
rocky-repos.noarch 9.8-1.2.el9 baseos
|
|
||||||
rootfiles.noarch 8.1-35.el9 baseos
|
|
||||||
rpm.aarch64 4.16.1.3-40.el9 baseos
|
|
||||||
rpm-build-libs.aarch64 4.16.1.3-40.el9 baseos
|
|
||||||
rpm-libs.aarch64 4.16.1.3-40.el9 baseos
|
|
||||||
rpm-sign-libs.aarch64 4.16.1.3-40.el9 baseos
|
|
||||||
sed.aarch64 4.8-10.el9_8 baseos
|
|
||||||
setup.noarch 2.13.7-10.el9 baseos
|
|
||||||
shadow-utils.aarch64 2:4.9-16.el9 baseos
|
|
||||||
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
|
|
||||||
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
|
|
||||||
tar.aarch64 2:1.34-13.el9_8 baseos
|
|
||||||
tpm2-tss.aarch64 3.2.3-1.el9 baseos
|
|
||||||
tzdata.noarch 2026c-1.el9_8 baseos
|
|
||||||
usermode.aarch64 1.114-7.el9 baseos
|
|
||||||
util-linux.aarch64 2.37.4-25.el9 baseos
|
|
||||||
util-linux-core.aarch64 2.37.4-25.el9 baseos
|
|
||||||
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
|
|
||||||
yum.noarch 4.14.0-34.el9_8.rocky.0.1 baseos
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
|
|
||||||
binutils.aarch64 2.35.2-72.el9 baseos
|
|
||||||
binutils-gold.aarch64 2.35.2-72.el9 baseos
|
|
||||||
bzip2-libs.aarch64 1.0.8-11.el9 baseos
|
|
||||||
coreutils-single.aarch64 8.32-41.el9_8.1 baseos
|
|
||||||
curl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
|
||||||
expat.aarch64 2.5.0-6.el9_8.3 baseos
|
|
||||||
file-libs.aarch64 5.39-17.el9 baseos
|
|
||||||
glib2.aarch64 2.68.4-19.el9_8.10 baseos
|
|
||||||
glibc.aarch64 2.34-275.el9_8 baseos
|
|
||||||
glibc-common.aarch64 2.34-275.el9_8 baseos
|
|
||||||
glibc-minimal-langpack.aarch64 2.34-275.el9_8 baseos
|
|
||||||
gnupg2.aarch64 2.3.3-5.el9_7 baseos
|
|
||||||
gnutls.aarch64 3.8.10-8.el9_8 baseos
|
|
||||||
gzip.aarch64 1.12-2.el9_8 baseos
|
|
||||||
krb5-libs.aarch64 1.21.1-10.el9_8 baseos
|
|
||||||
less.aarch64 590-6.el9 baseos
|
|
||||||
libacl.aarch64 2.4.0-1.el9_8 baseos
|
|
||||||
libarchive.aarch64 3.5.3-11.el9_8 baseos
|
|
||||||
libatomic.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libattr.aarch64 2.6.0-1.el9_8 baseos
|
|
||||||
libblkid.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libcap.aarch64 2.48-10.el9_7.1 baseos
|
|
||||||
libcurl-minimal.aarch64 7.76.1-40.el9_8.5 baseos
|
|
||||||
libevent.aarch64 2.1.13-1.el9_8 baseos
|
|
||||||
libfdisk.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libgcc.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libgcrypt.aarch64 1.10.0-13.el9_8 baseos
|
|
||||||
libgomp.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libmount.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libnghttp2.aarch64 1.43.0-6.el9_8.2 baseos
|
|
||||||
libsmartcols.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libsolv.aarch64 0.7.24-6.el9_8 baseos
|
|
||||||
libstdc++.aarch64 11.5.0-14.el9 baseos
|
|
||||||
libtasn1.aarch64 4.16.0-10.el9_8 baseos
|
|
||||||
libuuid.aarch64 2.37.4-25.el9 baseos
|
|
||||||
libxml2.aarch64 2.9.13-14.el9_8.4 baseos
|
|
||||||
ncurses-base.noarch 6.2-12.20210508.el9 baseos
|
|
||||||
ncurses-libs.aarch64 6.2-12.20210508.el9 baseos
|
|
||||||
openssl.aarch64 1:3.5.8-1.el9_8 baseos
|
|
||||||
openssl-libs.aarch64 1:3.5.8-1.el9_8 baseos
|
|
||||||
p11-kit.aarch64 0.26.4-1.el9_8 baseos
|
|
||||||
p11-kit-trust.aarch64 0.26.4-1.el9_8 baseos
|
|
||||||
pam.aarch64 1.5.1-28.el9_8.1 baseos
|
|
||||||
python3.aarch64 3.9.25-7.el9_8.3 baseos
|
|
||||||
python3-libs.aarch64 3.9.25-7.el9_8.3 baseos
|
|
||||||
python3-setuptools-wheel.noarch 53.0.0-15.el9 baseos
|
|
||||||
shadow-utils.aarch64 2:4.9-16.el9 baseos
|
|
||||||
sqlite-libs.aarch64 3.34.1-11.el9_8 baseos
|
|
||||||
systemd-libs.aarch64 252-67.el9_8.6.rocky.0.1 baseos
|
|
||||||
tar.aarch64 2:1.34-13.el9_8 baseos
|
|
||||||
util-linux.aarch64 2.37.4-25.el9 baseos
|
|
||||||
util-linux-core.aarch64 2.37.4-25.el9 baseos
|
|
||||||
vim-minimal.aarch64 2:8.2.2637-26.el9_8.21 baseos
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
alternatives.aarch64 1.24-1.el9
|
|
||||||
audit-libs.aarch64 3.0.7-104.el9
|
|
||||||
basesystem.noarch 11-13.el9
|
|
||||||
bash.aarch64 5.1.8-6.el9_1
|
|
||||||
binutils.aarch64 2.35.2-42.el9
|
|
||||||
binutils-gold.aarch64 2.35.2-42.el9
|
|
||||||
bzip2-libs.aarch64 1.0.8-8.el9
|
|
||||||
ca-certificates.noarch 2023.2.60_v7.0.306-90.1.el9_2
|
|
||||||
coreutils-single.aarch64 8.32-34.el9
|
|
||||||
cracklib.aarch64 2.9.6-27.el9
|
|
||||||
cracklib-dicts.aarch64 2.9.6-27.el9
|
|
||||||
crypto-policies.noarch 20230731-1.git94f0e2c.el9_3.1
|
|
||||||
crypto-policies-scripts.noarch 20230731-1.git94f0e2c.el9_3.1
|
|
||||||
curl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
|
|
||||||
cyrus-sasl-lib.aarch64 2.1.27-21.el9
|
|
||||||
dnf.noarch 4.14.0-8.el9
|
|
||||||
dnf-data.noarch 4.14.0-8.el9
|
|
||||||
elfutils-debuginfod-client.aarch64 0.189-3.el9
|
|
||||||
elfutils-default-yama-scope.noarch 0.189-3.el9
|
|
||||||
elfutils-libelf.aarch64 0.189-3.el9
|
|
||||||
elfutils-libs.aarch64 0.189-3.el9
|
|
||||||
expat.aarch64 2.5.0-1.el9
|
|
||||||
file-libs.aarch64 5.39-14.el9
|
|
||||||
filesystem.aarch64 3.16-2.el9
|
|
||||||
findutils.aarch64 1:4.8.0-6.el9
|
|
||||||
gdbm-libs.aarch64 1:1.19-4.el9
|
|
||||||
glib2.aarch64 2.68.4-11.el9
|
|
||||||
glibc.aarch64 2.34-83.el9.7
|
|
||||||
glibc-common.aarch64 2.34-83.el9.7
|
|
||||||
glibc-minimal-langpack.aarch64 2.34-83.el9.7
|
|
||||||
gnupg2.aarch64 2.3.3-4.el9
|
|
||||||
gnutls.aarch64 3.7.6-23.el9
|
|
||||||
gzip.aarch64 1.12-1.el9
|
|
||||||
ima-evm-utils.aarch64 1.4-4.el9
|
|
||||||
krb5-libs.aarch64 1.21.1-1.el9
|
|
||||||
less.aarch64 590-2.el9_2
|
|
||||||
libacl.aarch64 2.3.1-3.el9
|
|
||||||
libarchive.aarch64 3.5.3-4.el9
|
|
||||||
libatomic.aarch64 11.4.1-2.1.el9
|
|
||||||
libattr.aarch64 2.5.1-3.el9
|
|
||||||
libblkid.aarch64 2.37.4-15.el9
|
|
||||||
libcap.aarch64 2.48-9.el9_2
|
|
||||||
libcom_err.aarch64 1.46.5-3.el9
|
|
||||||
libcurl-minimal.aarch64 7.76.1-26.el9_3.2.0.1
|
|
||||||
libdb.aarch64 5.3.28-53.el9
|
|
||||||
libdnf.aarch64 0.69.0-6.el9_3
|
|
||||||
libeconf.aarch64 0.4.1-3.el9_2
|
|
||||||
libevent.aarch64 2.1.12-6.el9
|
|
||||||
libfdisk.aarch64 2.37.4-15.el9
|
|
||||||
libgcc.aarch64 11.4.1-2.1.el9
|
|
||||||
libgcrypt.aarch64 1.10.0-10.el9_2
|
|
||||||
libgomp.aarch64 11.4.1-2.1.el9
|
|
||||||
libksba.aarch64 1.5.1-6.el9_1
|
|
||||||
libmount.aarch64 2.37.4-15.el9
|
|
||||||
libnghttp2.aarch64 1.43.0-5.el9_3.1
|
|
||||||
librepo.aarch64 1.14.5-1.el9
|
|
||||||
libselinux.aarch64 3.5-1.el9
|
|
||||||
libsemanage.aarch64 3.5-2.el9
|
|
||||||
libsepol.aarch64 3.5-1.el9
|
|
||||||
libsmartcols.aarch64 2.37.4-15.el9
|
|
||||||
libsolv.aarch64 0.7.24-2.el9
|
|
||||||
libstdc++.aarch64 11.4.1-2.1.el9
|
|
||||||
libtasn1.aarch64 4.16.0-8.el9_1
|
|
||||||
libusbx.aarch64 1.0.26-1.el9
|
|
||||||
libuser.aarch64 0.63-13.el9
|
|
||||||
libuuid.aarch64 2.37.4-15.el9
|
|
||||||
libxml2.aarch64 2.9.13-4.el9
|
|
||||||
libzstd.aarch64 1.5.1-2.el9
|
|
||||||
mpfr.aarch64 4.1.0-7.el9
|
|
||||||
ncurses-base.noarch 6.2-10.20210508.el9
|
|
||||||
ncurses-libs.aarch64 6.2-10.20210508.el9
|
|
||||||
nettle.aarch64 3.8-3.el9_0
|
|
||||||
openldap.aarch64 2.6.3-1.el9
|
|
||||||
openssl.aarch64 1:3.0.7-24.el9
|
|
||||||
openssl-libs.aarch64 1:3.0.7-24.el9
|
|
||||||
p11-kit.aarch64 0.24.1-2.el9
|
|
||||||
p11-kit-trust.aarch64 0.24.1-2.el9
|
|
||||||
pam.aarch64 1.5.1-15.el9
|
|
||||||
pcre.aarch64 8.44-3.el9.3
|
|
||||||
pcre2.aarch64 10.40-2.el9
|
|
||||||
pcre2-syntax.noarch 10.40-2.el9
|
|
||||||
python3.aarch64 3.9.18-1.el9_3
|
|
||||||
python3-dnf.noarch 4.14.0-8.el9
|
|
||||||
python3-hawkey.aarch64 0.69.0-6.el9_3
|
|
||||||
python3-libdnf.aarch64 0.69.0-6.el9_3
|
|
||||||
python3-libs.aarch64 3.9.18-1.el9_3
|
|
||||||
python3-pip-wheel.noarch 21.2.3-7.el9
|
|
||||||
python3-rpm.aarch64 4.16.1.3-25.el9
|
|
||||||
python3-setuptools-wheel.noarch 53.0.0-12.el9
|
|
||||||
rocky-gpg-keys.noarch 9.3-1.1.el9
|
|
||||||
rocky-release.noarch 9.3-1.1.el9
|
|
||||||
rocky-repos.noarch 9.3-1.1.el9
|
|
||||||
rootfiles.noarch 8.1-31.el9
|
|
||||||
rpm.aarch64 4.16.1.3-25.el9
|
|
||||||
rpm-build-libs.aarch64 4.16.1.3-25.el9
|
|
||||||
rpm-libs.aarch64 4.16.1.3-25.el9
|
|
||||||
rpm-sign-libs.aarch64 4.16.1.3-25.el9
|
|
||||||
sed.aarch64 4.8-9.el9
|
|
||||||
setup.noarch 2.13.7-9.el9
|
|
||||||
shadow-utils.aarch64 2:4.9-8.el9
|
|
||||||
sqlite-libs.aarch64 3.34.1-6.el9_1
|
|
||||||
systemd-libs.aarch64 252-18.el9
|
|
||||||
tar.aarch64 2:1.34-6.el9_1
|
|
||||||
tpm2-tss.aarch64 3.2.2-2.el9
|
|
||||||
tzdata.noarch 2023c-1.el9
|
|
||||||
usermode.aarch64 1.114-4.el9
|
|
||||||
util-linux.aarch64 2.37.4-15.el9
|
|
||||||
util-linux-core.aarch64 2.37.4-15.el9
|
|
||||||
vim-minimal.aarch64 2:8.2.2637-20.el9_1
|
|
||||||
yum.noarch 4.14.0-8.el9
|
|
||||||
package nonexistent-pkg.x86_64 is not installed
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
dnf5.aarch64 5.2.18.0-3.fc42 updates
|
|
||||||
dnf5-plugins.aarch64 5.2.18.0-3.fc42 updates
|
|
||||||
elfutils-default-yama-scope.noarch 0.195-1.fc42 updates
|
|
||||||
elfutils-libelf.aarch64 0.195-1.fc42 updates
|
|
||||||
elfutils-libs.aarch64 0.195-1.fc42 updates
|
|
||||||
fedora-release-common.noarch 42-31 updates
|
|
||||||
fedora-release-container.noarch 42-31 updates
|
|
||||||
fedora-release-identity-container.noarch 42-31 updates
|
|
||||||
glibc.aarch64 2.41-18.fc42 updates
|
|
||||||
glibc-common.aarch64 2.41-18.fc42 updates
|
|
||||||
glibc-minimal-langpack.aarch64 2.41-18.fc42 updates
|
|
||||||
krb5-libs.aarch64 1.21.3-7.fc42 updates
|
|
||||||
libdnf5.aarch64 5.2.18.0-3.fc42 updates
|
|
||||||
libdnf5-cli.aarch64 5.2.18.0-3.fc42 updates
|
|
||||||
libsolv.aarch64 0.7.37-2.fc42 updates
|
|
||||||
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
|
|
||||||
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
|
|
||||||
tzdata.noarch 2026b-1.fc42 updates
|
|
||||||
vim-data.noarch 2:9.2.390-1.fc42 updates
|
|
||||||
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
krb5-libs.aarch64 1.21.3-7.fc42 updates
|
|
||||||
openssl-libs.aarch64 1:3.2.6-4.fc42 updates
|
|
||||||
rpm-sequoia.aarch64 1.10.2-2.fc42 updates
|
|
||||||
vim-data.noarch 2:9.2.390-1.fc42 updates
|
|
||||||
vim-minimal.aarch64 2:9.2.390-1.fc42 updates
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
dnf5.aarch64 5.2.18.0-2.fc42
|
|
||||||
dnf5-plugins.aarch64 5.2.18.0-2.fc42
|
|
||||||
elfutils-default-yama-scope.noarch 0.194-1.fc42
|
|
||||||
elfutils-libelf.aarch64 0.194-1.fc42
|
|
||||||
elfutils-libs.aarch64 0.194-1.fc42
|
|
||||||
fedora-release-common.noarch 42-30
|
|
||||||
fedora-release-container.noarch 42-30
|
|
||||||
fedora-release-identity-container.noarch 42-30
|
|
||||||
glibc.aarch64 2.41-16.fc42
|
|
||||||
glibc-common.aarch64 2.41-16.fc42
|
|
||||||
glibc-minimal-langpack.aarch64 2.41-16.fc42
|
|
||||||
krb5-libs.aarch64 1.21.3-6.fc42
|
|
||||||
libdnf5.aarch64 5.2.18.0-2.fc42
|
|
||||||
libdnf5-cli.aarch64 5.2.18.0-2.fc42
|
|
||||||
libsolv.aarch64 0.7.36-2.fc42
|
|
||||||
openssl-libs.aarch64 1:3.2.6-3.fc42
|
|
||||||
rpm-sequoia.aarch64 1.10.1-1.fc42
|
|
||||||
tzdata.noarch 2025c-1.fc42
|
|
||||||
vim-data.noarch 2:9.2.280-1.fc42
|
|
||||||
vim-minimal.aarch64 2:9.2.280-1.fc42
|
|
||||||
package nonexistent-pkg.x86_64 is not installed
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
libpcap 1.10.7-1 -> 1.11.0-1
|
|
||||||
libsecret 0.21.7-1 -> 0.21.8.2-1
|
|
||||||
libtirpc 1.3.7-1 -> 1.3.8-1
|
|
||||||
tzdata 2026c-1 -> 2026d-1
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
|
|
||||||
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
|
|
||||||
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Repository | Name | Category | Severity | Interactive | Status | Summary
|
|
||||||
-------------------------------------------------------------+-----------------------------+----------+-----------+-------------+--------+--------------------------------
|
|
||||||
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3765 | security | moderate | --- | needed | Security update for openssl-1_1
|
|
||||||
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-3926 | security | moderate | --- | needed | Security update for curl
|
|
||||||
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4078 | security | important | --- | needed | Security update for glib2
|
|
||||||
Update repository with updates from SUSE Linux Enterprise 15 | openSUSE-SLE-15.5-2024-4359 | security | moderate | --- | needed | Security update for curl
|
|
||||||
|
|
||||||
4 patches needed (4 security patches)
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2025-03-02 19:18:12 UTC.
|
|
||||||
Warning: Repository 'Main Update Repository' metadata expired since 2025-08-30 08:17:31 UTC.
|
|
||||||
Warning: Repository 'Update Repository (Non-Oss)' metadata expired since 2025-04-10 11:03:28 UTC.
|
|
||||||
|
|
||||||
|
|
||||||
S | Repository | Name | Current Version | Available Version | Arch
|
|
||||||
---+--------------------------------------------------------------+--------------------+------------------------------------------+------------------------------------------+--------
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | aaa_base | 84.87+git20180409.04c9dae-150300.10.20.1 | 84.87+git20180409.04c9dae-150300.10.23.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | bash | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | bash-sh | 4.4-150400.25.22 | 4.4-150400.27.3.2 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | crypto-policies | 20210917.c9d86d1-150400.3.6.1 | 20210917.c9d86d1-150400.3.8.1 | noarch
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | curl | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | glibc | 2.31-150300.86.3 | 2.31-150300.89.2 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libcom_err2 | 1.46.4-150400.3.6.2 | 1.46.4-150400.3.9.2 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libcurl4 | 8.0.1-150400.5.50.1 | 8.0.1-150400.5.59.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libgcc_s1 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libglib-2_0-0 | 2.70.5-150400.3.14.1 | 2.70.5-150400.3.17.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libopenssl1_1-hmac | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libreadline7 | 7.0-150400.25.22 | 7.0-150400.27.3.2 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libsolv-tools-base | 0.7.30-150400.3.27.2 | 0.7.31-150500.6.5.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libstdc++6 | 13.3.0+git8781-150000.1.12.1 | 14.2.0+git10526-150000.1.6.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libudev1 | 249.17-150400.8.43.1 | 249.17-150400.8.46.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | libzypp | 17.35.8-150500.6.13.1 | 17.35.16-150500.6.31.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | login_defs | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | noarch
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | openssl-1_1 | 1.1.1l-150500.17.34.1 | 1.1.1l-150500.17.37.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | shadow | 4.8.1-150400.10.21.1 | 4.8.1-150400.10.24.1 | aarch64
|
|
||||||
v | Update repository with updates from SUSE Linux Enterprise 15 | zypper | 1.14.76-150500.6.6.15 | 1.14.78-150500.6.14.1 | aarch64
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Warning: Repository 'Update repository of openSUSE Backports' metadata expired since 2026-07-10 11:19:15 UTC.
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Connected Wi-Fi signal
|
|
||||||
|
|
||||||
Reports connected station interfaces only (no scans). `info.wf` holds the current
|
|
||||||
snapshot keyed by interface (`s` SSID, `r` RSSI in dBm when available);
|
|
||||||
`stats.wf` stores available RSSI as integer dBm. Collected on the default
|
|
||||||
interval only; real-time requests reuse the last snapshot.
|
|
||||||
|
|
||||||
- Linux: nl80211 via `github.com/mdlayher/wifi`. Docker needs `network_mode: host`.
|
|
||||||
- macOS: CoreWLAN via `osascript` (JXA). SSID may be redacted by privacy settings.
|
|
||||||
- Windows: native WLAN API, keyed by interface GUID.
|
|
||||||
- Other platforms: unsupported.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// Package wifi collects only currently associated station interfaces. Collection
|
|
||||||
// failures are empty snapshots, never cached connected state.
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math"
|
|
||||||
"time"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// validSSID omits non-UTF-8 SSIDs: 802.11 permits arbitrary octets, but CBOR
|
|
||||||
// text strings require UTF-8. Metadata must never invalidate the whole response.
|
|
||||||
func validSSID(ssid string) string {
|
|
||||||
if !utf8.ValidString(ssid) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return ssid
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect uses a single deadline across interface queries where supported.
|
|
||||||
// Unsupported platforms and denied association access produce no readings;
|
|
||||||
// later polls retry.
|
|
||||||
func Collect() map[string]system.WiFi {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
return collect(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Signals reduces a snapshot to the RSSI values stored in stats history.
|
|
||||||
// Interfaces without an available reading are omitted.
|
|
||||||
func Signals(snapshot map[string]system.WiFi) map[string]int8 {
|
|
||||||
var signals map[string]int8
|
|
||||||
for id, reading := range snapshot {
|
|
||||||
if reading.Signal == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if signals == nil {
|
|
||||||
signals = make(map[string]int8, len(snapshot))
|
|
||||||
}
|
|
||||||
signals[id] = int8(max(math.Round(*reading.Signal), math.MinInt8))
|
|
||||||
}
|
|
||||||
return signals
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
//go:build darwin
|
|
||||||
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
// JXA exposes the system CoreWLAN framework without cgo, private airport tools,
|
|
||||||
// sudo, or scanning nearby networks. SSID can be redacted by macOS privacy rules.
|
|
||||||
const coreWLANScript = `ObjC.import('CoreWLAN');
|
|
||||||
var result = {};
|
|
||||||
var interfaces = $.CWWiFiClient.sharedWiFiClient.interfaces;
|
|
||||||
if (interfaces) {
|
|
||||||
for (var i = 0; i < interfaces.count; i++) {
|
|
||||||
var iface = interfaces.objectAtIndex(i);
|
|
||||||
if (!iface.powerOn || Number(iface.interfaceMode) !== 1) continue;
|
|
||||||
var name = ObjC.unwrap(iface.interfaceName);
|
|
||||||
if (!name) continue;
|
|
||||||
var reading = {};
|
|
||||||
var ssid = ObjC.unwrap(iface.ssid);
|
|
||||||
if (ssid) reading.s = ssid;
|
|
||||||
var signal = Number(iface.rssiValue);
|
|
||||||
if (signal >= -150 && signal < 0) reading.r = signal;
|
|
||||||
result[name] = reading;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
JSON.stringify(result);`
|
|
||||||
|
|
||||||
func collect(ctx context.Context) map[string]system.WiFi {
|
|
||||||
cmd := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", coreWLANScript)
|
|
||||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
|
||||||
cmd.WaitDelay = 100 * time.Millisecond
|
|
||||||
output, err := cmd.Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var result map[string]system.WiFi
|
|
||||||
if json.Unmarshal(output, &result) != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"github.com/mdlayher/genetlink"
|
|
||||||
"github.com/mdlayher/netlink"
|
|
||||||
native "github.com/mdlayher/wifi"
|
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
)
|
|
||||||
|
|
||||||
type linuxClient interface {
|
|
||||||
Interfaces() ([]*native.Interface, error)
|
|
||||||
BSS(*native.Interface) (*native.BSS, error)
|
|
||||||
Station(*native.Interface, net.HardwareAddr) (*native.StationInfo, error)
|
|
||||||
SetDeadline(time.Time) error
|
|
||||||
Close() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// nl80211Client adds a targeted GET_STATION request, as used by `iw link`.
|
|
||||||
// mdlayher/wifi only dumps stations, which some full-MAC drivers (e.g.
|
|
||||||
// out-of-tree Realtek USB) answer with an empty list.
|
|
||||||
type nl80211Client struct {
|
|
||||||
*native.Client
|
|
||||||
conn *genetlink.Conn
|
|
||||||
family genetlink.Family
|
|
||||||
}
|
|
||||||
|
|
||||||
func newNL80211Client() (*nl80211Client, error) {
|
|
||||||
client, err := native.New()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
conn, err := genetlink.Dial(nil)
|
|
||||||
if err != nil {
|
|
||||||
client.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
family, err := conn.GetFamily(unix.NL80211_GENL_NAME)
|
|
||||||
if err != nil {
|
|
||||||
conn.Close()
|
|
||||||
client.Close()
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &nl80211Client{Client: client, conn: conn, family: family}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nl80211Client) Station(ifi *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
|
|
||||||
ae := netlink.NewAttributeEncoder()
|
|
||||||
ae.Uint32(unix.NL80211_ATTR_IFINDEX, uint32(ifi.Index))
|
|
||||||
ae.Bytes(unix.NL80211_ATTR_MAC, mac)
|
|
||||||
data, err := ae.Encode()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
msgs, err := c.conn.Execute(genetlink.Message{
|
|
||||||
Header: genetlink.Header{Command: unix.NL80211_CMD_GET_STATION, Version: c.family.Version},
|
|
||||||
Data: data,
|
|
||||||
}, c.family.ID, netlink.Request)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if len(msgs) == 0 {
|
|
||||||
return nil, errors.New("no station info")
|
|
||||||
}
|
|
||||||
return native.ParseStationInfo(msgs[0].Data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nl80211Client) SetDeadline(t time.Time) error {
|
|
||||||
return errors.Join(c.Client.SetDeadline(t), c.conn.SetDeadline(t))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nl80211Client) Close() error {
|
|
||||||
return errors.Join(c.conn.Close(), c.Client.Close())
|
|
||||||
}
|
|
||||||
|
|
||||||
func collect(ctx context.Context) map[string]system.WiFi {
|
|
||||||
client, err := newNL80211Client()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer client.Close()
|
|
||||||
return collectLinux(ctx, client)
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectLinux(ctx context.Context, client linuxClient) map[string]system.WiFi {
|
|
||||||
result := make(map[string]system.WiFi)
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
if deadline, ok := ctx.Deadline(); ok {
|
|
||||||
if client.SetDeadline(deadline) != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
interfaces, err := client.Interfaces()
|
|
||||||
if err != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
for _, iface := range interfaces {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if iface == nil || iface.Type != native.InterfaceTypeStation || iface.Name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// GET_SCAN reads the kernel's BSS cache, without triggering a scan.
|
|
||||||
// Only the explicit associated status proves a current connection.
|
|
||||||
bss, err := client.BSS(iface)
|
|
||||||
if err != nil || bss == nil || bss.Status != native.BSSStatusAssociated {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
reading := system.WiFi{SSID: validSSID(bss.SSID)}
|
|
||||||
// Station statistics may require permissions unavailable in default
|
|
||||||
// containers. Keep association even when RSSI cannot be read. Do not
|
|
||||||
// substitute cached scan signal, which may be arbitrarily old.
|
|
||||||
if len(bss.BSSID) > 0 {
|
|
||||||
if station, err := client.Station(iface, bss.BSSID); err == nil && station != nil {
|
|
||||||
signal := float64(station.Signal)
|
|
||||||
if signal >= -150 && signal < 0 {
|
|
||||||
reading.Signal = &signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result[iface.Name] = reading
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
native "github.com/mdlayher/wifi"
|
|
||||||
)
|
|
||||||
|
|
||||||
type fakeLinuxClient struct {
|
|
||||||
interfaces []*native.Interface
|
|
||||||
bss map[string]*native.BSS
|
|
||||||
stations map[string][]*native.StationInfo
|
|
||||||
interfacesErr, bssErr, stationErr, deadlineErr error
|
|
||||||
deadline time.Time
|
|
||||||
stationCalls int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeLinuxClient) Interfaces() ([]*native.Interface, error) {
|
|
||||||
return f.interfaces, f.interfacesErr
|
|
||||||
}
|
|
||||||
func (f *fakeLinuxClient) BSS(i *native.Interface) (*native.BSS, error) {
|
|
||||||
return f.bss[i.Name], f.bssErr
|
|
||||||
}
|
|
||||||
func (f *fakeLinuxClient) Station(i *native.Interface, mac net.HardwareAddr) (*native.StationInfo, error) {
|
|
||||||
f.stationCalls++
|
|
||||||
if f.stationErr != nil {
|
|
||||||
return nil, f.stationErr
|
|
||||||
}
|
|
||||||
for _, station := range f.stations[i.Name] {
|
|
||||||
if bytes.Equal(station.HardwareAddr, mac) {
|
|
||||||
return station, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, errors.New("no such station")
|
|
||||||
}
|
|
||||||
func (f *fakeLinuxClient) SetDeadline(d time.Time) error { f.deadline = d; return f.deadlineErr }
|
|
||||||
func (f *fakeLinuxClient) Close() error { return nil }
|
|
||||||
|
|
||||||
func connectedClient() *fakeLinuxClient {
|
|
||||||
mac := net.HardwareAddr{1, 2, 3, 4, 5, 6}
|
|
||||||
return &fakeLinuxClient{
|
|
||||||
interfaces: []*native.Interface{{Name: "wlan0", Type: native.InterfaceTypeStation}},
|
|
||||||
bss: map[string]*native.BSS{"wlan0": {Status: native.BSSStatusAssociated, SSID: "home", BSSID: mac}},
|
|
||||||
stations: map[string][]*native.StationInfo{"wlan0": {{HardwareAddr: mac, Signal: -52}}},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLinuxSnapshots(t *testing.T) {
|
|
||||||
for _, tc := range []struct {
|
|
||||||
name string
|
|
||||||
modify func(*fakeLinuxClient)
|
|
||||||
want int
|
|
||||||
wantSignal bool
|
|
||||||
}{
|
|
||||||
{"connected", func(f *fakeLinuxClient) {}, 1, true},
|
|
||||||
{"multiple", func(f *fakeLinuxClient) {
|
|
||||||
f.interfaces = append(f.interfaces, &native.Interface{Name: "wlan1", Type: native.InterfaceTypeStation})
|
|
||||||
f.bss["wlan1"] = f.bss["wlan0"]
|
|
||||||
}, 2, true},
|
|
||||||
{"unsupported", func(f *fakeLinuxClient) { f.interfacesErr = errors.New("unsupported") }, 0, false},
|
|
||||||
{"association denied", func(f *fakeLinuxClient) { f.bssErr = errors.New("denied") }, 0, false},
|
|
||||||
{"disconnected", func(f *fakeLinuxClient) { f.bss["wlan0"] = nil }, 0, false},
|
|
||||||
{"authenticated only", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusAuthenticated }, 0, false},
|
|
||||||
{"cached nearby BSS", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusNotAssociated }, 0, false},
|
|
||||||
{"access point", func(f *fakeLinuxClient) { f.interfaces[0].Type = native.InterfaceTypeAP }, 0, false},
|
|
||||||
{"ad hoc", func(f *fakeLinuxClient) { f.bss["wlan0"].Status = native.BSSStatusIBSSJoined }, 0, false},
|
|
||||||
{"station permission denied", func(f *fakeLinuxClient) {
|
|
||||||
f.stationErr = errors.New("permission denied")
|
|
||||||
f.bss["wlan0"].Signal = -4200
|
|
||||||
}, 1, false},
|
|
||||||
{"no station data", func(f *fakeLinuxClient) { f.stations = nil }, 1, false},
|
|
||||||
{"different AP", func(f *fakeLinuxClient) { f.stations["wlan0"][0].HardwareAddr = net.HardwareAddr{9, 8, 7, 6, 5, 4} }, 1, false},
|
|
||||||
{"missing signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = 0 }, 1, false},
|
|
||||||
{"invalid signal", func(f *fakeLinuxClient) { f.stations["wlan0"][0].Signal = -151 }, 1, false},
|
|
||||||
{"deadline failure", func(f *fakeLinuxClient) { f.deadlineErr = errors.New("deadline") }, 0, false},
|
|
||||||
} {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
f := connectedClient()
|
|
||||||
tc.modify(f)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
|
||||||
defer cancel()
|
|
||||||
got := collectLinux(ctx, f)
|
|
||||||
if len(got) != tc.want {
|
|
||||||
t.Fatalf("got %#v", got)
|
|
||||||
}
|
|
||||||
if tc.want > 0 && (got["wlan0"].Signal != nil) != tc.wantSignal {
|
|
||||||
t.Fatalf("signal: %#v", got["wlan0"])
|
|
||||||
}
|
|
||||||
if tc.wantSignal && *got["wlan0"].Signal != -52 {
|
|
||||||
t.Fatal(got)
|
|
||||||
}
|
|
||||||
if tc.want == 0 && f.stationCalls != 0 {
|
|
||||||
t.Fatal("queried station without association")
|
|
||||||
}
|
|
||||||
deadline, _ := ctx.Deadline()
|
|
||||||
if f.deadline != deadline {
|
|
||||||
t.Fatal("deadline not shared")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconnect(t *testing.T) {
|
|
||||||
f := connectedClient()
|
|
||||||
if len(collectLinux(context.Background(), f)) != 1 {
|
|
||||||
t.Fatal("initial")
|
|
||||||
}
|
|
||||||
f.bss["wlan0"].Status = native.BSSStatusNotAssociated
|
|
||||||
if len(collectLinux(context.Background(), f)) != 0 {
|
|
||||||
t.Fatal("stale association")
|
|
||||||
}
|
|
||||||
f.bss["wlan0"].Status = native.BSSStatusAssociated
|
|
||||||
f.bss["wlan0"].SSID = "new"
|
|
||||||
if collectLinux(context.Background(), f)["wlan0"].SSID != "new" {
|
|
||||||
t.Fatal("stale SSID")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLinuxInvalidSSID(t *testing.T) {
|
|
||||||
f := connectedClient()
|
|
||||||
f.bss["wlan0"].SSID = "raw\xff"
|
|
||||||
got := collectLinux(context.Background(), f)
|
|
||||||
if len(got) != 1 || got["wlan0"].SSID != "" || got["wlan0"].Signal == nil {
|
|
||||||
t.Fatal(got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLinuxCancelled(t *testing.T) {
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
f := connectedClient()
|
|
||||||
if len(collectLinux(ctx, f)) != 0 || f.stationCalls != 0 {
|
|
||||||
t.Fatal("ignored cancellation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSSIDWireSafety(t *testing.T) {
|
|
||||||
for _, tc := range []struct{ input, want string }{
|
|
||||||
{"home", "home"}, {"网络 café", "网络 café"}, {"", ""},
|
|
||||||
{"raw\xffssid", ""}, {"truncated\xe2\x82", ""},
|
|
||||||
} {
|
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
|
||||||
ssid := validSSID(tc.input)
|
|
||||||
if ssid != tc.want {
|
|
||||||
t.Fatalf("got %q, want %q", ssid, tc.want)
|
|
||||||
}
|
|
||||||
signal := -50.0
|
|
||||||
payload := map[string]system.WiFi{"wlan0": {SSID: ssid, Signal: &signal}}
|
|
||||||
wire, err := cbor.Marshal(payload)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var decoded map[string]system.WiFi
|
|
||||||
if err := cbor.Unmarshal(wire, &decoded); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decoded["wlan0"].Signal == nil || *decoded["wlan0"].Signal != signal || decoded["wlan0"].SSID != tc.want {
|
|
||||||
t.Fatal(decoded)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSignals(t *testing.T) {
|
|
||||||
strong, weak, rounded := -40.0, -200.0, -52.6
|
|
||||||
got := Signals(map[string]system.WiFi{
|
|
||||||
"wlan0": {SSID: "home", Signal: &strong},
|
|
||||||
"wlan1": {Signal: &weak},
|
|
||||||
"wlan2": {Signal: &rounded},
|
|
||||||
"wlan3": {SSID: "no rssi"},
|
|
||||||
})
|
|
||||||
want := map[string]int8{"wlan0": -40, "wlan1": -128, "wlan2": -53}
|
|
||||||
if len(got) != len(want) {
|
|
||||||
t.Fatalf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
for id, signal := range want {
|
|
||||||
if got[id] != signal {
|
|
||||||
t.Fatalf("got %v, want %v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if Signals(map[string]system.WiFi{"wlan0": {}}) != nil || Signals(nil) != nil {
|
|
||||||
t.Fatal("expected nil without available readings")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
//go:build !linux && !windows && !darwin
|
|
||||||
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
)
|
|
||||||
|
|
||||||
func collect(context.Context) map[string]system.WiFi { return nil }
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package wifi
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
var wlan = windows.NewLazySystemDLL("wlanapi.dll")
|
|
||||||
var wlanOpen = wlan.NewProc("WlanOpenHandle")
|
|
||||||
var wlanClose = wlan.NewProc("WlanCloseHandle")
|
|
||||||
var wlanEnum = wlan.NewProc("WlanEnumInterfaces")
|
|
||||||
var wlanQuery = wlan.NewProc("WlanQueryInterface")
|
|
||||||
var wlanFree = wlan.NewProc("WlanFreeMemory")
|
|
||||||
|
|
||||||
type wlanInterface struct {
|
|
||||||
GUID windows.GUID
|
|
||||||
Description [256]uint16
|
|
||||||
State uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
type wlanConnection struct {
|
|
||||||
State uint32
|
|
||||||
Mode uint32
|
|
||||||
Profile [256]uint16
|
|
||||||
SSIDLength uint32
|
|
||||||
SSID [32]byte
|
|
||||||
// Only the prefix through DOT11_SSID is read.
|
|
||||||
}
|
|
||||||
|
|
||||||
func collect(ctx context.Context) map[string]system.WiFi {
|
|
||||||
result := make(map[string]system.WiFi)
|
|
||||||
for _, proc := range []*windows.LazyProc{wlanOpen, wlanClose, wlanEnum, wlanQuery, wlanFree} {
|
|
||||||
if proc.Find() != nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var handle windows.Handle
|
|
||||||
var version uint32
|
|
||||||
if rc, _, _ := wlanOpen.Call(2, 0, uintptr(unsafe.Pointer(&version)), uintptr(unsafe.Pointer(&handle))); rc != 0 {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer wlanClose.Call(uintptr(handle), 0)
|
|
||||||
var list unsafe.Pointer
|
|
||||||
if rc, _, _ := wlanEnum.Call(uintptr(handle), 0, uintptr(unsafe.Pointer(&list))); rc != 0 || list == nil {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
defer wlanFree.Call(uintptr(list))
|
|
||||||
count := *(*uint32)(list)
|
|
||||||
if count > 1024 {
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
interfaces := unsafe.Slice((*wlanInterface)(unsafe.Add(list, 8)), int(count))
|
|
||||||
for _, iface := range interfaces {
|
|
||||||
if ctx.Err() != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if iface.State != 1 {
|
|
||||||
continue
|
|
||||||
} // wlan_interface_state_connected
|
|
||||||
reading := system.WiFi{}
|
|
||||||
// SSID access may be denied by location privacy policy. Association comes
|
|
||||||
// from the interface state, so missing SSID does not suppress valid RSSI.
|
|
||||||
if data, size := queryWLAN(handle, &iface.GUID, 7); data != nil {
|
|
||||||
if size >= uint32(unsafe.Sizeof(wlanConnection{})) {
|
|
||||||
connection := (*wlanConnection)(data)
|
|
||||||
if connection.State == 1 && connection.SSIDLength <= 32 {
|
|
||||||
reading.SSID = validSSID(string(connection.SSID[:connection.SSIDLength]))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wlanFree.Call(uintptr(data))
|
|
||||||
}
|
|
||||||
// Native RSSI LONG, not the quality percentage in association attributes.
|
|
||||||
if data, size := queryWLAN(handle, &iface.GUID, 0x10000102); data != nil {
|
|
||||||
if size >= 4 {
|
|
||||||
signal := float64(*(*int32)(data))
|
|
||||||
if signal >= -150 && signal < 0 {
|
|
||||||
reading.Signal = &signal
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wlanFree.Call(uintptr(data))
|
|
||||||
}
|
|
||||||
result[iface.GUID.String()] = reading
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func queryWLAN(handle windows.Handle, guid *windows.GUID, opcode uintptr) (unsafe.Pointer, uint32) {
|
|
||||||
var data unsafe.Pointer
|
|
||||||
var size uint32
|
|
||||||
if rc, _, _ := wlanQuery.Call(uintptr(handle), uintptr(unsafe.Pointer(guid)), opcode, 0, uintptr(unsafe.Pointer(&size)), uintptr(unsafe.Pointer(&data)), 0); rc != 0 {
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
return data, size
|
|
||||||
}
|
|
||||||
@@ -6,27 +6,23 @@ 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.1
|
github.com/ebitengine/purego v0.11.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.4
|
github.com/fxamacker/cbor/v2 v2.9.3
|
||||||
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.1
|
||||||
github.com/mdlayher/genetlink v1.4.0
|
github.com/nicholas-fedor/shoutrrr v0.20.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.2
|
||||||
github.com/shirou/gopsutil/v4 v4.26.8
|
github.com/shirou/gopsutil/v4 v4.26.8
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
github.com/stretchr/testify v1.12.1
|
github.com/stretchr/testify v1.12.1
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.56.0
|
||||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
|
||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/sys v0.47.0
|
||||||
golang.org/x/sys v0.48.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
howett.net/plist v1.0.1
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
@@ -36,7 +32,7 @@ require (
|
|||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||||
github.com/disintegration/imaging v1.6.2 // indirect
|
github.com/disintegration/imaging v1.6.2 // indirect
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/eclipse/paho.golang v0.23.0 // indirect
|
github.com/eclipse/paho.golang v0.23.0 // indirect
|
||||||
github.com/fatih/color v1.19.0 // indirect
|
github.com/fatih/color v1.19.0 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
@@ -46,34 +42,27 @@ 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.1 // indirect
|
github.com/klauspost/compress v1.20.0 // 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/mdlayher/socket v0.7.0 // indirect
|
github.com/ncruces/go-strftime v1.0.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-20260805114148-88456608a4f6 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
github.com/tklauser/go-sysconf v0.4.0 // indirect
|
||||||
github.com/tklauser/numcpus v0.12.0 // indirect
|
github.com/tklauser/numcpus v0.12.0 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/image v0.46.0 // indirect
|
golang.org/x/image v0.45.0 // indirect
|
||||||
golang.org/x/mod v0.41.0 // indirect
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/term v0.46.0 // indirect
|
golang.org/x/term v0.45.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/tools v0.50.0 // indirect
|
|
||||||
mellium.im/reader v0.1.0 // indirect
|
|
||||||
mellium.im/sasl v0.3.2 // indirect
|
|
||||||
mellium.im/xmlstream v0.15.4 // indirect
|
|
||||||
mellium.im/xmpp v0.23.0 // indirect
|
|
||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.12.1 // indirect
|
modernc.org/memory v1.12.1 // indirect
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
|||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
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.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0=
|
github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
|
||||||
github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ=
|
||||||
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
|
github.com/eclipse/paho.golang v0.23.0 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=
|
||||||
@@ -31,8 +31,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
|||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
|
github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q=
|
||||||
github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus=
|
||||||
@@ -69,36 +69,28 @@ 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.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
|
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||||
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 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=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 h1:eveIIGn4BGM3qknO74omf6HYr30/exH+eVUTuAgwjZ0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/lxzan/gws v1.10.2 h1:htReTvcY89iMk1ScVtUbk6J96kIZWaafj6r/lasK/NA=
|
github.com/lxzan/gws v1.10.1 h1:1xG+tDOV0lgDeVPf0wNT74u3cn0K3LpcavRrTPTrMwQ=
|
||||||
github.com/lxzan/gws v1.10.2/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
github.com/lxzan/gws v1.10.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
|
||||||
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY=
|
||||||
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/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
|
github.com/nicholas-fedor/shoutrrr v0.20.0 h1:hMAxIYlfAeZ1FcTDgU0kUOvVXUsOirWo8IWlnzGLkac=
|
||||||
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
|
github.com/nicholas-fedor/shoutrrr v0.20.0/go.mod h1:hgde37yNWCXh8+N6WemyDRMNYLOFTf326GsBx8Z7CFA=
|
||||||
github.com/mdlayher/socket v0.7.0 h1:qVREPVwtUMg17pwvveQxpurq0PVisMxi3FGgpsorMYQ=
|
github.com/onsi/ginkgo/v2 v2.32.1 h1:6tlvcDm/3sE8lGJbZ4+d4mO3RLy24/tQWOFzVSQNIfw=
|
||||||
github.com/mdlayher/socket v0.7.0/go.mod h1:f7iKql2EK/rfsWYDKofKjk2Ig7I+6HQWdMIdn1tO4A4=
|
github.com/onsi/ginkgo/v2 v2.32.1/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44=
|
||||||
github.com/mdlayher/wifi v0.9.0 h1:d5mmqw9S2U4f95dcW5wnnUagpI6GJh328/AchVSP4ko=
|
github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU=
|
||||||
github.com/mdlayher/wifi v0.9.0/go.mod h1:Bfkrz+VncrVPaOLcFG/bR9tSY2PbmYNQicWvAZlUZlI=
|
github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg=
|
||||||
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=
|
||||||
@@ -106,10 +98,10 @@ github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA
|
|||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs=
|
||||||
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g=
|
||||||
github.com/pocketbase/pocketbase v0.40.4 h1:0SvSUreR3NhUMCs9LchE59oEG53efZ3cKiMGyAGBN9U=
|
github.com/pocketbase/pocketbase v0.40.2 h1:7gTqvt3bmilkphyZZ1QNhX19g3BXHqT7ynDyU81RVT4=
|
||||||
github.com/pocketbase/pocketbase v0.40.4/go.mod h1:2mU+80FLiY1fb13WZRg8Xx/lKg4nTjgKVJMigyRH6k0=
|
github.com/pocketbase/pocketbase v0.40.2/go.mod h1:jc3YuyToy+ZXM4CeO7uSCN/htgR8yv+tjSE3eJZ8eh8=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0 h1:XA01Vk/wv9YikCi1V51yRzIHPMT5of9+cMpZoZvDn/M=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20260916203055-22a1a467d9f0/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
@@ -144,37 +136,37 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
|||||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+aiyBQyDSMae6e/xmpU=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||||
golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -184,14 +176,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww=
|
|
||||||
mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI=
|
|
||||||
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
|
||||||
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
|
||||||
mellium.im/xmlstream v0.15.4 h1:gLKxcWl4rLMUpKgtzrTBvr4OexPeO/edYus+uK3F6ZI=
|
|
||||||
mellium.im/xmlstream v0.15.4/go.mod h1:yXaCW2++fmVO4L9piKVkyLDqnCmictVYF7FDQW8prb4=
|
|
||||||
mellium.im/xmpp v0.23.0 h1:rvKvOvMdIURCLaAWEJN8J0QpO3AJYCDKjxLsqtTPSjY=
|
|
||||||
mellium.im/xmpp v0.23.0/go.mod h1:GHDKlKKQe0LNmD9YqExyxnFEEBiz84KGqnfiA2VNzb8=
|
|
||||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
|||||||
@@ -212,10 +212,6 @@ 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)
|
||||||
|
|||||||
@@ -1,361 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"testing/synctest"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/monitor"
|
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func alertAPIRouter(t *testing.T, hub *beszelTests.TestHub) http.Handler {
|
|
||||||
t.Helper()
|
|
||||||
router, err := apis.NewRouter(hub)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, hub.OnServe().Trigger(&core.ServeEvent{App: hub, Router: router}))
|
|
||||||
mux, err := router.BuildMux()
|
|
||||||
require.NoError(t, err)
|
|
||||||
return mux
|
|
||||||
}
|
|
||||||
|
|
||||||
func alertAPIRequest(t *testing.T, handler http.Handler, token, method, path string, body any, status int) map[string]any {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(method, path, jsonReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Authorization", token)
|
|
||||||
res := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(res, req)
|
|
||||||
assert.Equal(t, status, res.Code, "%s %s: %s", method, path, res.Body.String())
|
|
||||||
var result map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &result))
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertRecordSystemAccess(t *testing.T) {
|
|
||||||
for _, shareAll := range []string{"false", "true"} {
|
|
||||||
t.Run("share_all="+shareAll, func(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", shareAll)
|
|
||||||
hub, err := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
hub.StartHub()
|
|
||||||
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
other, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
token, err := user.NewAuthToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
own, err := beszelTests.CreateSystems(hub, 2, user.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
foreign, err := beszelTests.CreateSystems(hub, 1, other.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
handler := alertAPIRouter(t, hub)
|
|
||||||
const records = "/api/collections/alerts/records"
|
|
||||||
// Collection rules reject inaccessible creates as 400 and hide inaccessible updates as 404.
|
|
||||||
readStatus, createStatus, updateStatus := 404, 400, 404
|
|
||||||
if shareAll == "true" {
|
|
||||||
readStatus, createStatus, updateStatus = 200, 200, 200
|
|
||||||
}
|
|
||||||
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+own[0].Id, nil, 200)
|
|
||||||
alertAPIRequest(t, handler, token, "GET", "/api/collections/systems/records/"+foreign[0].Id, nil, readStatus)
|
|
||||||
collection, err := hub.FindCollectionByNameOrId("alerts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, name := range collection.Fields.GetByName("name").(*core.SelectField).Values {
|
|
||||||
t.Run(name, func(t *testing.T) {
|
|
||||||
// Scalar and array representations both pass through PocketBase's relation binding.
|
|
||||||
for _, relation := range []any{foreign[0].Id, []string{foreign[0].Id}} {
|
|
||||||
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
|
|
||||||
"name": name, "user": user.Id, "system": relation, "value": 50,
|
|
||||||
}, createStatus)
|
|
||||||
if id, ok := result["id"].(string); ok {
|
|
||||||
record, err := hub.FindRecordById("alerts", id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, hub.Delete(record))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{
|
|
||||||
"name": name, "user": user.Id, "system": own[0].Id, "value": 50,
|
|
||||||
}, 200)
|
|
||||||
id := result["id"].(string)
|
|
||||||
defer func() {
|
|
||||||
record, err := hub.FindRecordById("alerts", id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, hub.Delete(record))
|
|
||||||
}()
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, map[string]any{"value": 51}, 200)
|
|
||||||
// The system of an existing alert is immutable through the API, even between accessible systems.
|
|
||||||
for _, body := range []map[string]any{
|
|
||||||
{"system": foreign[0].Id},
|
|
||||||
{"system+": foreign[0].Id},
|
|
||||||
{"system": []string{own[1].Id}},
|
|
||||||
} {
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+id, body, 404)
|
|
||||||
}
|
|
||||||
saved, err := hub.FindRecordById("alerts", id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, own[0].Id, saved.GetString("system"))
|
|
||||||
// Internal saves can still move an alert, which must remove its previous cache binding.
|
|
||||||
saved.Set("system", own[1].Id)
|
|
||||||
require.NoError(t, hub.Save(saved))
|
|
||||||
cache := hub.GetAlertManager().GetSystemAlertsCache()
|
|
||||||
assert.Empty(t, cache.GetSystemAlerts(own[0].Id), "moving an alert must remove its previous cache binding")
|
|
||||||
assert.Len(t, cache.GetSystemAlerts(own[1].Id), 1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": own[0].Id}, 400)
|
|
||||||
alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": "missing00000000"}, 400)
|
|
||||||
alertAPIRequest(t, handler, "", "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 400)
|
|
||||||
// An old subscription must still be checked when PATCH omits the relation.
|
|
||||||
oldAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU", "user": user.Id, "system": foreign[0].Id, "value": 50,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+oldAlert.Id, map[string]any{"value": 51}, updateStatus)
|
|
||||||
require.NoError(t, hub.Delete(oldAlert))
|
|
||||||
otherAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU", "user": other.Id, "system": foreign[0].Id,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+otherAlert.Id, map[string]any{"system": own[0].Id}, 404)
|
|
||||||
require.NoError(t, hub.Delete(otherAlert))
|
|
||||||
// Alerts cannot be handed to another user.
|
|
||||||
ownAlert, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU", "user": user.Id, "system": own[0].Id,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+ownAlert.Id, map[string]any{"user": other.Id}, 404)
|
|
||||||
ownAlert, err = hub.FindRecordById("alerts", ownAlert.Id)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, user.Id, ownAlert.GetString("user"))
|
|
||||||
require.NoError(t, hub.Delete(ownAlert))
|
|
||||||
// Readonly users retain their own alert preferences; superusers retain their bypass.
|
|
||||||
user.Set("role", "readonly")
|
|
||||||
require.NoError(t, hub.Save(user))
|
|
||||||
result := alertAPIRequest(t, handler, token, "POST", records, map[string]any{"name": "CPU", "user": user.Id, "system": own[0].Id}, 200)
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "Memory"}, 200)
|
|
||||||
alertAPIRequest(t, handler, token, "PATCH", records+"/"+result["id"].(string), map[string]any{"name": "NetworkMonitorLoss"}, 400)
|
|
||||||
record, err := hub.FindRecordById("alerts", result["id"].(string))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, hub.Delete(record))
|
|
||||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
|
||||||
require.NoError(t, err)
|
|
||||||
superToken, err := superuser.NewAuthToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
result = alertAPIRequest(t, handler, superToken, "POST", records, map[string]any{"name": "CPU", "user": other.Id, "system": foreign[0].Id}, 200)
|
|
||||||
record, err = hub.FindRecordById("alerts", result["id"].(string))
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NoError(t, hub.Delete(record))
|
|
||||||
// Bulk requests continue to skip inaccessible systems and accept accessible ones.
|
|
||||||
alertAPIRequest(t, handler, token, "POST", "/api/beszel/user-alerts", map[string]any{
|
|
||||||
"name": "CPU", "systems": []string{own[0].Id, foreign[0].Id}, "value": 50,
|
|
||||||
}, 200)
|
|
||||||
count, err := hub.CountRecords("alerts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
expectedCount := 1
|
|
||||||
if shareAll == "true" {
|
|
||||||
expectedCount = 2
|
|
||||||
}
|
|
||||||
assert.EqualValues(t, expectedCount, count)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertRecordForeignSystemDelivery(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
|
||||||
hub, err := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
hub.StartHub()
|
|
||||||
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
for _, recipient := range []*core.Record{user, owner} {
|
|
||||||
_, err := beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": recipient.Id, "settings": map[string]any{"emails": []string{recipient.GetString("email")}, "webhooks": []string{}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
foreign, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
foreign[0].Set("name", "private-system")
|
|
||||||
require.NoError(t, hub.Save(foreign[0]))
|
|
||||||
handler := alertAPIRouter(t, hub)
|
|
||||||
for _, recipient := range []*core.Record{user, owner} {
|
|
||||||
token, err := recipient.NewAuthToken()
|
|
||||||
require.NoError(t, err)
|
|
||||||
status := 200
|
|
||||||
if recipient.Id == user.Id {
|
|
||||||
status = 400
|
|
||||||
}
|
|
||||||
alertAPIRequest(t, handler, token, "POST", "/api/collections/alerts/records", map[string]any{
|
|
||||||
"name": "CPU", "user": recipient.Id, "system": foreign[0].Id, "min": 1, "value": 50,
|
|
||||||
}, status)
|
|
||||||
}
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(foreign[0], &system.CombinedData{Info: system.Info{Cpu: 91}}))
|
|
||||||
synctest.Wait()
|
|
||||||
messages := hub.TestMailer.Messages()
|
|
||||||
assert.Len(t, messages, 1, "only the authorised subscriber should receive telemetry")
|
|
||||||
for _, message := range messages {
|
|
||||||
assert.Equal(t, "owner@example.com", message.To[0].Address)
|
|
||||||
assert.Contains(t, message.Subject, "private-system CPU above threshold")
|
|
||||||
assert.Contains(t, message.Text, "91.00%")
|
|
||||||
t.Logf("captured synthetic email: recipient=%s subject=%q body=%q", message.To[0].Address, message.Subject, message.Text)
|
|
||||||
}
|
|
||||||
history, err := hub.FindAllRecords("alerts_history", dbx.HashExp{"system": foreign[0].Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, history, 1)
|
|
||||||
for _, record := range history {
|
|
||||||
assert.Equal(t, owner.Id, record.GetString("user"))
|
|
||||||
}
|
|
||||||
t.Logf("captured synthetic history entries=%d", len(history))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertStoredSubscriptionAccess(t *testing.T) {
|
|
||||||
for _, mode := range []string{"foreign", "revoked", "shared", "share_all", "revoked_active"} {
|
|
||||||
t.Run(mode, func(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
|
||||||
if mode == "share_all" {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
|
||||||
}
|
|
||||||
hub, err := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
hub.StartHub()
|
|
||||||
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
subscriber, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, owner.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
systemRecord := systems[0]
|
|
||||||
if mode == "revoked" || mode == "shared" || mode == "revoked_active" {
|
|
||||||
systemRecord.Set("users", []string{owner.Id, subscriber.Id})
|
|
||||||
require.NoError(t, hub.Save(systemRecord))
|
|
||||||
}
|
|
||||||
for _, user := range []*core.Record{owner, subscriber} {
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": user.Id, "settings": map[string]any{"emails": []string{user.GetString("email")}, "webhooks": []string{}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Direct saves model records already stored before the request-hook fix.
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "CPU", "user": user.Id, "system": systemRecord.Id, "value": 50, "min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
require.NoError(t, hub.GetAlertManager().GetSystemAlertsCache().PopulateFromDB(true))
|
|
||||||
if mode == "revoked" {
|
|
||||||
systemRecord.Set("users", []string{owner.Id})
|
|
||||||
require.NoError(t, hub.Save(systemRecord))
|
|
||||||
}
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 91}}))
|
|
||||||
synctest.Wait()
|
|
||||||
})
|
|
||||||
allowed := mode == "shared" || mode == "share_all" || mode == "revoked_active"
|
|
||||||
want := 1
|
|
||||||
if allowed {
|
|
||||||
want = 2
|
|
||||||
}
|
|
||||||
assert.Equal(t, want, hub.TestMailer.TotalSend())
|
|
||||||
for _, message := range hub.TestMailer.Messages() {
|
|
||||||
if !allowed {
|
|
||||||
assert.Equal(t, "owner@example.com", message.To[0].Address)
|
|
||||||
}
|
|
||||||
t.Logf("%s captured recipient=%s body=%q", mode, message.To[0].Address, message.Text)
|
|
||||||
}
|
|
||||||
history, err := hub.FindAllRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, history, want)
|
|
||||||
for _, record := range history {
|
|
||||||
if !allowed {
|
|
||||||
assert.Equal(t, owner.Id, record.GetString("user"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
count, err := hub.CountRecords("alerts")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.EqualValues(t, 2, count, "stored subscriptions are preserved")
|
|
||||||
if mode == "revoked_active" {
|
|
||||||
systemRecord.Set("users", []string{owner.Id})
|
|
||||||
require.NoError(t, hub.Save(systemRecord))
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systemRecord, &system.CombinedData{Info: system.Info{Cpu: 40}}))
|
|
||||||
synctest.Wait()
|
|
||||||
})
|
|
||||||
assert.Equal(t, 3, hub.TestMailer.TotalSend(), "only the owner should receive recovery after revocation")
|
|
||||||
previous, err := hub.FindFirstRecordByFilter("alerts_history", "user={:user}", dbx.Params{"user": subscriber.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.True(t, previous.GetDateTime("resolved").IsZero(), "revoked subscribers must not learn recovery timing through history")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertPendingStatusAccessRevoked(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
|
||||||
hub, err := beszelTests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
hub.StartHub()
|
|
||||||
user, err := beszelTests.CreateUser(hub, "subscriber@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
owner, err := beszelTests.CreateUser(hub, "owner@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "user_settings", map[string]any{
|
|
||||||
"user": user.Id, "settings": map[string]any{"emails": []string{"subscriber@example.com"}, "webhooks": []string{}},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status", "user": user.Id, "system": systems[0].Id, "min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
|
||||||
defer hub.GetAlertManager().Stop()
|
|
||||||
require.NoError(t, hub.GetAlertManager().HandleStatusAlerts("down", systems[0]))
|
|
||||||
require.Equal(t, 1, hub.GetAlertManager().GetPendingAlertsCount())
|
|
||||||
systems[0].Set("users", []string{owner.Id})
|
|
||||||
require.NoError(t, hub.Save(systems[0]))
|
|
||||||
time.Sleep(61 * time.Second)
|
|
||||||
synctest.Wait()
|
|
||||||
assert.Zero(t, hub.TestMailer.TotalSend())
|
|
||||||
count, err := hub.CountRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Zero(t, count)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertStoredNetworkMonitorAccess(t *testing.T) {
|
|
||||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "false")
|
|
||||||
hub, systemRecord, _, monitors := networkAlertSetup(t)
|
|
||||||
other, err := beszelTests.CreateUser(hub, "foreign@example.com", "password")
|
|
||||||
require.NoError(t, err)
|
|
||||||
foreign, err := beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "NetworkMonitorLoss", "user": other.Id, "system": systemRecord.Id, "value": 5,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
|
||||||
require.NoError(t, am.HandleNetworkMonitorAlerts(systemRecord, map[string]monitor.Result{monitors[0].Id: monitorResult(10)}))
|
|
||||||
history, err := hub.FindAllRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, history, 1, "foreign subscriptions must not block the authorised incident transaction")
|
|
||||||
assert.NotEqual(t, foreign.Id, history[0].GetString("alert_id"))
|
|
||||||
assert.Equal(t, 1, hub.TestMailer.TotalSend())
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
|
||||||
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
@@ -20,7 +19,6 @@ 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)
|
||||||
@@ -185,7 +183,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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")
|
||||||
@@ -210,7 +208,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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,
|
||||||
@@ -244,7 +242,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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,
|
||||||
@@ -272,7 +270,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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,
|
||||||
@@ -301,7 +299,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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,
|
||||||
@@ -330,7 +328,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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",
|
||||||
@@ -364,7 +362,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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,
|
||||||
})
|
})
|
||||||
@@ -389,7 +387,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) {
|
||||||
require.NoError(t, beszelTests.ClearCollection(t, app, "alerts"))
|
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",
|
||||||
@@ -431,21 +429,19 @@ 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")
|
||||||
require.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
readonlyToken, err := readonlyUser.NewAuthToken()
|
readonlyToken, err := readonlyUser.NewAuthToken()
|
||||||
require.NoError(t, err)
|
assert.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")
|
||||||
require.NoError(t, err, "Failed to create admin user")
|
assert.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")
|
||||||
require.NoError(t, err, "Failed to create superuser")
|
assert.NoError(t, err, "Failed to create superuser")
|
||||||
superuserToken, err := superuser.NewAuthToken()
|
superuserToken, err := superuser.NewAuthToken()
|
||||||
require.NoError(t, err, "Failed to create superuser auth token")
|
assert.NoError(t, err, "Failed to create superuser auth token")
|
||||||
|
|
||||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
return hub.TestApp
|
return hub.TestApp
|
||||||
@@ -537,20 +533,6 @@ func TestSendTestNotification(t *testing.T) {
|
|||||||
|
|
||||||
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
|
for _, url := range []string{localURL, "smtp://user:pass@127.0.0.1/?fromAddress=sender@example.com&toAddresses=recipient@example.com", "mqtt://127.0.0.1/topic"} {
|
||||||
scenarios = append(scenarios, beszelTests.ApiScenario{
|
scenarios = append(scenarios, beszelTests.ApiScenario{
|
||||||
BeforeTestFunc: func(tb testing.TB, _ *pbTests.TestApp, e *core.ServeEvent) {
|
|
||||||
if !strings.HasPrefix(url, "mqtt://") {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Keep the real MQTT rejection path, but advance its library's
|
|
||||||
// fixed timeout using virtual time instead of waiting 10 seconds.
|
|
||||||
e.Router.BindFunc(func(re *core.RequestEvent) error {
|
|
||||||
var err error
|
|
||||||
synctest.Test(tb.(*testing.T), func(t *testing.T) {
|
|
||||||
err = re.Next()
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
},
|
|
||||||
Name: "readonly cannot send to " + url,
|
Name: "readonly cannot send to " + url,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
URL: "/api/beszel/test-notification",
|
URL: "/api/beszel/test-notification",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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"
|
||||||
@@ -24,7 +23,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -69,12 +68,13 @@ 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()
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
|
||||||
// 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,11 +108,15 @@ 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
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
// Verify alert IS triggered (battery 15% is below threshold 20%)
|
// 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})
|
||||||
@@ -146,11 +150,15 @@ 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
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataRecovered)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataRecovered))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
// Wait for the alert to be processed
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
// Verify alert is now resolved (battery 25% is above threshold 20%)
|
// 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})
|
||||||
@@ -164,7 +172,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -198,12 +206,16 @@ 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()
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedData)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedData))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
// Wait a moment for processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
// Verify alert is NOT triggered (no battery data should skip the alert)
|
// 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})
|
||||||
@@ -218,7 +230,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -290,11 +302,15 @@ 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
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
// Wait for alert processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
// Verify alert IS triggered (average battery 15% is below threshold 25%)
|
// 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})
|
||||||
@@ -352,11 +368,15 @@ 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
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
// Wait for alert processing
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
// Verify alert is resolved (average battery 50% is above threshold 25%)
|
// 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,10 +63,7 @@ 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 {
|
||||||
// Remove the previous binding if the system changed (only possible through superuser or internal saves).
|
// 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
|
||||||
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,7 +5,6 @@ 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 +22,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -84,10 +83,13 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", now)
|
systemRecord.Set("updated", now)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -138,10 +140,13 @@ func TestDiskAlertExtraFsMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", newNow)
|
systemRecord.Set("updated", newNow)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -39,11 +39,6 @@ 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,6 +32,9 @@ 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 {
|
||||||
@@ -133,9 +136,6 @@ 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: 400},
|
{name: "direct inaccessible system", value: 5, direct: true, denied: true, status: 403},
|
||||||
{name: "direct invalid threshold", value: -1, direct: true, status: 400},
|
{name: "direct 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,6 +287,9 @@ 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,14 +323,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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")
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
//go:build testing
|
|
||||||
|
|
||||||
package alerts_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
|
||||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestStandaloneAlertManagerStopsBeforeDatabaseCleanup(t *testing.T) {
|
|
||||||
for _, state := range []string{"pending", "already stopped", "delivered"} {
|
|
||||||
t.Run(state, func(t *testing.T) {
|
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
|
||||||
cleanup := sync.OnceFunc(hub.Cleanup)
|
|
||||||
defer cleanup()
|
|
||||||
setStatusAlertEmail(t, hub, user.Id, "shutdown@example.com")
|
|
||||||
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "paused")
|
|
||||||
require.NoError(t, err)
|
|
||||||
_, err = beszelTests.CreateRecord(hub, "alerts", map[string]any{
|
|
||||||
"name": "Status", "system": systems[0].Id, "user": user.Id, "min": 1,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
|
||||||
defer am.Stop()
|
|
||||||
require.NoError(t, am.HandleStatusAlerts("down", systems[0]))
|
|
||||||
require.Equal(t, 1, am.GetPendingAlertsCount())
|
|
||||||
switch state {
|
|
||||||
case "already stopped":
|
|
||||||
am.Stop()
|
|
||||||
case "delivered":
|
|
||||||
am.ForceExpirePendingAlerts()
|
|
||||||
processed, err := am.ProcessPendingAlerts()
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, processed, 1)
|
|
||||||
require.Equal(t, 1, hub.TestMailer.TotalSend())
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
// No timer may retain the disposed app until its eventual deadline.
|
|
||||||
require.Zero(t, am.GetPendingAlertsCount())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -30,14 +30,9 @@ func setStatusAlertEmail(t *testing.T, hub core.App, userID, email string) {
|
|||||||
|
|
||||||
func TestStatusAlerts(t *testing.T) {
|
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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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)
|
||||||
@@ -241,14 +236,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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)
|
||||||
@@ -349,14 +339,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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")
|
||||||
|
|
||||||
@@ -442,14 +427,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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")
|
||||||
|
|
||||||
@@ -841,14 +821,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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")
|
||||||
@@ -913,14 +888,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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,14 +16,9 @@ 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) {
|
||||||
t.Cleanup(func() {
|
defer hub.Cleanup()
|
||||||
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,17 +10,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
|
func NewTestAlertManagerWithoutWorker(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
return &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,7 +5,6 @@ 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"
|
||||||
@@ -24,7 +23,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
systemRecord := systems[0]
|
systemRecord := systems[0]
|
||||||
|
|
||||||
@@ -81,10 +80,13 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", now)
|
systemRecord.Set("updated", now)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataHigh)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataHigh))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -128,10 +130,13 @@ func TestDiskAlertZfsPoolMultiMinute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
systemRecord.Set("updated", newNow)
|
systemRecord.Set("updated", newNow)
|
||||||
|
err = hub.SaveNoValidate(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err = am.HandleSystemAlerts(systemRecord, combinedDataLow)
|
||||||
require.NoError(t, am.HandleSystemAlerts(systemRecord, combinedDataLow))
|
require.NoError(t, err)
|
||||||
})
|
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
|
||||||
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
diskAlert, err = hub.FindFirstRecordByFilter("alerts", "id={:id}", dbx.Params{"id": diskAlert.Id})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -142,7 +147,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, "paused")
|
systems, err := beszelTests.CreateSystems(hub, 1, user.Id, "up")
|
||||||
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)
|
||||||
@@ -155,18 +160,16 @@ 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))
|
||||||
}
|
}
|
||||||
synctest.Test(t, func(t *testing.T) {
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
time.Sleep(20 * time.Millisecond)
|
||||||
})
|
|
||||||
record, err := hub.FindRecordById("alerts", alert.Id)
|
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
|
||||||
synctest.Test(t, func(t *testing.T) {
|
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
||||||
require.NoError(t, hub.GetAlertManager().HandleSystemAlerts(systems[0], &system.CombinedData{Stats: system.Stats{ZfsPools: pools}}))
|
time.Sleep(20 * time.Millisecond)
|
||||||
})
|
|
||||||
record, err = hub.FindRecordById("alerts", alert.Id)
|
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"))
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
|
||||||
|
|
||||||
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
"github.com/nicholas-fedor/shoutrrr/pkg/types"
|
||||||
"golang.org/x/net/dns/dnsmessage"
|
"golang.org/x/net/dns/dnsmessage"
|
||||||
@@ -179,45 +178,39 @@ func TestPublicNotificationTCP(t *testing.T) {
|
|||||||
} {
|
} {
|
||||||
t.Run(rawURL, func(t *testing.T) {
|
t.Run(rawURL, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
// MQTT waits for a fixed library timeout even after a dial failure.
|
|
||||||
// Virtual time preserves the full send/cleanup path without that delay.
|
|
||||||
t.Run("internal destination", func(t *testing.T) {
|
t.Run("internal destination", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
||||||
err := sendPublicNotification(strings.ReplaceAll(rawURL, "HOST", "127.0.0.1"), "test")
|
if !errors.Is(err, errInternalDestination) {
|
||||||
if !errors.Is(err, errInternalDestination) {
|
t.Fatalf("expected blocked destination, got %v", err)
|
||||||
t.Fatalf("expected blocked destination, got %v", err)
|
}
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
t.Run("public destination uses injected dialer", func(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
var calls atomic.Int32
|
||||||
var calls atomic.Int32
|
stopped := errors.New("test dial stopped")
|
||||||
stopped := errors.New("test dial stopped")
|
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
||||||
service, err := newPublicNotificationService(strings.ReplaceAll(rawURL, "HOST", "8.8.8.8"), types.SenderOptions{
|
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
calls.Add(1)
|
||||||
calls.Add(1)
|
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
||||||
if network != "tcp" || !strings.HasPrefix(address, "8.8.8.8:") {
|
t.Errorf("unexpected dial: %s %s", network, address)
|
||||||
t.Errorf("unexpected dial: %s %s", network, address)
|
}
|
||||||
}
|
if err := checkNotificationAddress(address); err != nil {
|
||||||
if err := checkNotificationAddress(address); err != nil {
|
t.Error(err)
|
||||||
t.Error(err)
|
}
|
||||||
}
|
return nil, stopped
|
||||||
return nil, stopped
|
},
|
||||||
},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if closer, ok := service.(io.Closer); ok {
|
|
||||||
defer closer.Close()
|
|
||||||
}
|
|
||||||
if err := service.Send("test", &types.Params{}); err == nil {
|
|
||||||
t.Fatal("expected dial failure")
|
|
||||||
}
|
|
||||||
if calls.Load() == 0 {
|
|
||||||
t.Fatal("custom dialer was not used")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if closer, ok := service.(io.Closer); ok {
|
||||||
|
defer closer.Close()
|
||||||
|
}
|
||||||
|
if err := service.Send("test", &types.Params{}); err == nil {
|
||||||
|
t.Fatal("expected dial failure")
|
||||||
|
}
|
||||||
|
if calls.Load() == 0 {
|
||||||
|
t.Fatal("custom dialer was not used")
|
||||||
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
@@ -16,12 +14,6 @@ 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.
|
||||||
@@ -132,7 +124,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, noKeyProvidedError{}
|
return nil, fmt.Errorf("no key provided: must set -key flag, KEY env var, or KEY_FILE env var. Use 'beszel-agent help' for usage")
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKey, err := os.ReadFile(keyFile)
|
pubKey, err := os.ReadFile(keyFile)
|
||||||
@@ -146,14 +138,6 @@ 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 := ""
|
||||||
@@ -198,12 +182,6 @@ 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,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"errors"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -188,26 +187,6 @@ func TestLoadPublicKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIsBenignStartupError(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
err error
|
|
||||||
goos string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{name: "missing key on windows", err: noKeyProvidedError{}, goos: "windows", want: true},
|
|
||||||
{name: "wrapped missing key on windows", err: errors.Join(errors.New("startup failed"), noKeyProvidedError{}), goos: "windows", want: true},
|
|
||||||
{name: "missing key on linux", err: noKeyProvidedError{}, goos: "linux", want: false},
|
|
||||||
{name: "different error on windows", err: errors.New("invalid key"), goos: "windows", want: false},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
assert.Equal(t, tt.want, isBenignStartupError(tt.err, tt.goos))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetNetwork(t *testing.T) {
|
func TestGetNetwork(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ 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...
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,7 +83,3 @@ 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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ RUN apk add --no-cache ca-certificates && update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
RUN rm -rf /tmp/*
|
RUN rm -rf /tmp/*
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image: default scratch-based agent
|
# Final image: default scratch-based agent
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:3.24
|
FROM alpine:3.23
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
# AMD GPU name lookup (used by agent on Linux when /usr/share/libdrm/amdgpu.ids is read)
|
||||||
@@ -28,4 +28,4 @@ RUN apk add --no-cache smartmontools zfs
|
|||||||
# Ensure data persistence across container recreations
|
# Ensure data persistence across container recreations
|
||||||
VOLUME ["/var/lib/beszel-agent"]
|
VOLUME ["/var/lib/beszel-agent"]
|
||||||
|
|
||||||
ENTRYPOINT ["/agent"]
|
ENTRYPOINT ["/agent"]
|
||||||
@@ -10,13 +10,13 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Final image
|
# Final image
|
||||||
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
# Note: must cap_add: [CAP_PERFMON] and mount /dev/dri/ as volume
|
||||||
# --------------------------
|
# --------------------------
|
||||||
FROM alpine:3.24
|
FROM alpine:3.23
|
||||||
|
|
||||||
COPY --from=builder /agent /agent
|
COPY --from=builder /agent /agent
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ COPY . ./
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# Smartmontools builder stage
|
# Smartmontools builder stage
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN set -eux; \
|
|||||||
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
if [ "$TARGETARCH" = "arm" ] && [ -n "$TARGETVARIANT" ]; then \
|
||||||
export GOARM="${TARGETVARIANT#v}"; \
|
export GOARM="${TARGETVARIANT#v}"; \
|
||||||
fi; \
|
fi; \
|
||||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
go build -tags glibc -ldflags "-w -s" -o /agent ./internal/cmd/agent
|
||||||
|
|
||||||
# --------------------------
|
# --------------------------
|
||||||
@@ -70,9 +70,7 @@ RUN set -eux; \
|
|||||||
# --------------------------
|
# --------------------------
|
||||||
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS zfsutils-builder
|
||||||
|
|
||||||
# zfsutils-linux is distributed in Debian's contrib component.
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
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/*
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ RUN update-ca-certificates
|
|||||||
|
|
||||||
# Build
|
# Build
|
||||||
ARG TARGETOS TARGETARCH
|
ARG TARGETOS TARGETARCH
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
RUN CGO_ENABLED=0 GOGC=75 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-w -s" -o /beszel ./internal/cmd/hub
|
||||||
|
|
||||||
# ? -------------------------
|
# ? -------------------------
|
||||||
FROM scratch
|
FROM scratch
|
||||||
@@ -31,4 +31,4 @@ VOLUME ["/beszel_data"]
|
|||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|
||||||
ENTRYPOINT [ "/beszel" ]
|
ENTRYPOINT [ "/beszel" ]
|
||||||
CMD ["serve", "--http=0.0.0.0:8090"]
|
CMD ["serve", "--http=0.0.0.0:8090"]
|
||||||
@@ -25,16 +25,6 @@ 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.
|
||||||
@@ -86,8 +76,6 @@ 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 {
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
package system
|
|
||||||
|
|
||||||
// PackageUpdate is one pending package update on the host.
|
|
||||||
type PackageUpdate struct {
|
|
||||||
Name string `json:"name" cbor:"0,keyasint"`
|
|
||||||
Current string `json:"current,omitempty" cbor:"1,keyasint,omitempty"` // installed version, empty if unknown
|
|
||||||
Available string `json:"available" cbor:"2,keyasint"`
|
|
||||||
Security bool `json:"security,omitempty" cbor:"3,keyasint,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// PackageUpdates is the detail payload returned by the agent for the
|
|
||||||
// GetPackageUpdates action. The counts in Info.PackageUpdates come from the same check.
|
|
||||||
type PackageUpdates struct {
|
|
||||||
Manager string `json:"manager,omitempty" cbor:"0,keyasint,omitempty"`
|
|
||||||
// CheckedAt is the Unix time in seconds of the last check, 0 if none has finished.
|
|
||||||
CheckedAt int64 `json:"checkedAt,omitempty" cbor:"1,keyasint,omitempty"`
|
|
||||||
// SecurityKnown is true if the package manager flags security updates per package.
|
|
||||||
SecurityKnown bool `json:"securityKnown,omitempty" cbor:"2,keyasint,omitempty"`
|
|
||||||
Packages []PackageUpdate `json:"packages" cbor:"3,keyasint"`
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user