mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 17:59:28 +08:00
feat: agent data cache to support connections to multiple hubs (#341)
This commit is contained in:
@@ -14,6 +14,7 @@ clean:
|
|||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
|
test: export GOEXPERIMENT=synctest
|
||||||
test:
|
test:
|
||||||
go test -tags=testing ./...
|
go test -tags=testing ./...
|
||||||
|
|
||||||
|
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
)
|
)
|
||||||
@@ -27,11 +28,13 @@ type Agent struct {
|
|||||||
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
func NewAgent() *Agent {
|
||||||
agent := &Agent{
|
agent := &Agent{
|
||||||
fsStats: make(map[string]*system.FsStats),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
|
|
||||||
@@ -63,7 +66,7 @@ func NewAgent() *Agent {
|
|||||||
// Set sensors whitelist
|
// Set sensors whitelist
|
||||||
if sensors, exists := GetEnv("SENSORS"); exists {
|
if sensors, exists := GetEnv("SENSORS"); exists {
|
||||||
agent.sensorsWhitelist = make(map[string]struct{})
|
agent.sensorsWhitelist = make(map[string]struct{})
|
||||||
for _, sensor := range strings.Split(sensors, ",") {
|
for sensor := range strings.SplitSeq(sensors, ",") {
|
||||||
if sensor != "" {
|
if sensor != "" {
|
||||||
agent.sensorsWhitelist[sensor] = struct{}{}
|
agent.sensorsWhitelist[sensor] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -85,7 +88,7 @@ func NewAgent() *Agent {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats())
|
slog.Debug("Stats", "data", agent.gatherStats(""))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent
|
return agent
|
||||||
@@ -100,29 +103,37 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
return os.LookupEnv(key)
|
return os.LookupEnv(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) gatherStats() system.CombinedData {
|
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
|
||||||
a.Lock()
|
a.Lock()
|
||||||
defer a.Unlock()
|
defer a.Unlock()
|
||||||
slog.Debug("Getting stats")
|
|
||||||
systemData := system.CombinedData{
|
cachedData, ok := a.cache.Get(sessionID)
|
||||||
|
if ok {
|
||||||
|
slog.Debug("Cached stats", "session", sessionID)
|
||||||
|
return cachedData
|
||||||
|
}
|
||||||
|
|
||||||
|
*cachedData = system.CombinedData{
|
||||||
Stats: a.getSystemStats(),
|
Stats: a.getSystemStats(),
|
||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
slog.Debug("System stats", "data", systemData)
|
slog.Debug("System stats", "data", cachedData)
|
||||||
// add docker stats
|
|
||||||
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
systemData.Containers = containerStats
|
cachedData.Containers = containerStats
|
||||||
slog.Debug("Docker stats", "data", systemData.Containers)
|
slog.Debug("Docker stats", "data", cachedData.Containers)
|
||||||
} else {
|
} else {
|
||||||
slog.Debug("Error getting docker stats", "err", err)
|
slog.Debug("Docker stats", "err", err)
|
||||||
}
|
}
|
||||||
// add extra filesystems
|
|
||||||
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
systemData.Stats.ExtraFs[name] = stats
|
cachedData.Stats.ExtraFs[name] = stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
|
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
||||||
return systemData
|
|
||||||
|
a.cache.Set(sessionID, cachedData)
|
||||||
|
return cachedData
|
||||||
}
|
}
|
||||||
|
36
beszel/internal/agent/agent_cache.go
Normal file
36
beszel/internal/agent/agent_cache.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Not thread safe since we only access from gatherStats which is already locked
|
||||||
|
type SessionCache struct {
|
||||||
|
data *system.CombinedData
|
||||||
|
lastUpdate time.Time
|
||||||
|
primarySession string
|
||||||
|
leaseTime time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionCache(leaseTime time.Duration) *SessionCache {
|
||||||
|
return &SessionCache{
|
||||||
|
leaseTime: leaseTime,
|
||||||
|
data: &system.CombinedData{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
|
||||||
|
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
|
||||||
|
return c.data, true
|
||||||
|
}
|
||||||
|
return c.data, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
|
||||||
|
if data != nil {
|
||||||
|
*c.data = *data
|
||||||
|
}
|
||||||
|
c.primarySession = sessionID
|
||||||
|
c.lastUpdate = time.Now()
|
||||||
|
}
|
85
beszel/internal/agent/agent_cache_test.go
Normal file
85
beszel/internal/agent/agent_cache_test.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"testing"
|
||||||
|
"testing/synctest"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSessionCache_GetSet(t *testing.T) {
|
||||||
|
synctest.Run(func() {
|
||||||
|
cache := NewSessionCache(69 * time.Second)
|
||||||
|
|
||||||
|
testData := &system.CombinedData{
|
||||||
|
Info: system.Info{
|
||||||
|
Hostname: "test-host",
|
||||||
|
Cores: 4,
|
||||||
|
},
|
||||||
|
Stats: system.Stats{
|
||||||
|
Cpu: 50.0,
|
||||||
|
MemPct: 30.0,
|
||||||
|
DiskPct: 40.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test initial state - should not be cached
|
||||||
|
data, isCached := cache.Get("session1")
|
||||||
|
assert.False(t, isCached, "Expected no cached data initially")
|
||||||
|
assert.NotNil(t, data, "Expected data to be initialized")
|
||||||
|
// Set data for session1
|
||||||
|
cache.Set("session1", testData)
|
||||||
|
|
||||||
|
time.Sleep(15 * time.Second)
|
||||||
|
|
||||||
|
// Get data for a different session - should be cached
|
||||||
|
data, isCached = cache.Get("session2")
|
||||||
|
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
||||||
|
require.NotNil(t, data, "Expected cached data to be returned")
|
||||||
|
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
||||||
|
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
|
||||||
|
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
|
||||||
|
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
|
||||||
|
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
|
||||||
|
// Get data for the primary session - should not be cached
|
||||||
|
data, isCached = cache.Get("session1")
|
||||||
|
assert.False(t, isCached, "Expected data not to be cached for primary session")
|
||||||
|
require.NotNil(t, data, "Expected data to be returned even if not cached")
|
||||||
|
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
|
||||||
|
// if not cached, agent will update the data
|
||||||
|
cache.Set("session1", testData)
|
||||||
|
|
||||||
|
time.Sleep(45 * time.Second)
|
||||||
|
|
||||||
|
// Get data for a different session - should still be cached
|
||||||
|
_, isCached = cache.Get("session2")
|
||||||
|
assert.True(t, isCached, "Expected data to be cached for non-primary session")
|
||||||
|
|
||||||
|
// Wait for the lease to expire
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
|
||||||
|
// Get data for session2 - should not be cached
|
||||||
|
_, isCached = cache.Get("session2")
|
||||||
|
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionCache_NilData(t *testing.T) {
|
||||||
|
// Create a new SessionCache
|
||||||
|
cache := NewSessionCache(30 * time.Second)
|
||||||
|
|
||||||
|
// Test setting nil data (should not panic)
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
cache.Set("session1", nil)
|
||||||
|
}, "Setting nil data should not panic")
|
||||||
|
|
||||||
|
// Get data - should not be nil even though we set nil
|
||||||
|
data, _ := cache.Get("session2")
|
||||||
|
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
|
||||||
|
}
|
@@ -61,8 +61,8 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
func (a *Agent) handleSession(s sshServer.Session) {
|
||||||
// slog.Debug("connection", "remoteaddr", s.RemoteAddr(), "user", s.User())
|
slog.Debug("New session", "client", s.RemoteAddr())
|
||||||
stats := a.gatherStats()
|
stats := a.gatherStats(s.Context().SessionID())
|
||||||
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
if err := json.NewEncoder(s).Encode(stats); err != nil {
|
||||||
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
slog.Error("Error encoding stats", "err", err, "stats", stats)
|
||||||
s.Exit(1)
|
s.Exit(1)
|
||||||
@@ -74,24 +74,18 @@ func (a *Agent) handleSession(s sshServer.Session) {
|
|||||||
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
|
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
|
||||||
func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
||||||
var parsedKeys []ssh.PublicKey
|
var parsedKeys []ssh.PublicKey
|
||||||
|
|
||||||
for line := range strings.Lines(input) {
|
for line := range strings.Lines(input) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
|
||||||
// Skip empty lines or comments
|
// Skip empty lines or comments
|
||||||
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
if len(line) == 0 || strings.HasPrefix(line, "#") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the key
|
// Parse the key
|
||||||
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
|
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the parsed key to the list
|
|
||||||
parsedKeys = append(parsedKeys, parsedKey)
|
parsedKeys = append(parsedKeys, parsedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsedKeys, nil
|
return parsedKeys, nil
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user