Compare commits

...

5 Commits

Author SHA1 Message Date
Alec Rubin
68a3f8962a fix(agent): read /proc/uptime on linux instead of sysinfo(2) (#2180)
gopsutil's host.Uptime() calls the sysinfo(2) syscall. Inside an LXC
container lxcfs virtualizes /proc/uptime but cannot intercept a
syscall, so every container reported the host's uptime.

Reads /proc/uptime on linux and falls back to host.Uptime() if the file
is missing or unparseable, so other platforms are unchanged.
2026-08-18 15:18:33 -04:00
Ilya Muratov
0eb3426619 fix(agent): discover fans on legacy hwmon parent devices (#2238) 2026-08-18 11:32:52 -04:00
Jan Dziąsło
96beadc8c9 fix(agent): add fallback for CPU model detection on MIPS architectures (#2138)
gopsutil's cpu.Info() does not parse the 'cpu model' field from
/proc/cpuinfo, which is the only source of CPU model names on MIPS.
Add a fallback that reads /proc/cpuinfo directly and combines
'cpu model' (e.g. 'MIPS 1004Kc V2.15') with 'system type'
(e.g. 'MediaTek MT7621 ver:1 eco:3') for a complete identifier.

The fallback only triggers when gopsutil returns an empty ModelName,
so x86/ARM/other architectures are unaffected.
2026-08-18 10:34:41 -04:00
Sven van Ginkel
65a6f60304 fix(agent): fix QNAP MD RAID arrays incorrectly reported as FAILED (#2065) 2026-08-18 10:10:12 -04:00
hank
54dae08631 chore(helm): update app version to 0.18.8 (#2235) 2026-08-17 17:36:01 -04:00
14 changed files with 483 additions and 26 deletions

View File

@@ -72,14 +72,30 @@ func discoverHwmonFans(root string) ([]fanSensor, error) {
var sensors []fanSensor var sensors []fanSensor
for _, entry := range entries { for _, entry := range entries {
chipDir := filepath.Join(root, entry.Name()) chipDir := filepath.Join(root, entry.Name())
chipName := utils.ReadStringFile(filepath.Join(chipDir, "name")) sensorDir := chipDir
inputs, _ := filepath.Glob(filepath.Join(sensorDir, "fan*_input"))
// Some legacy hwmon drivers (notably applesmc) register a hwmon class
// device but create fan attributes on the parent platform device. In
// sysfs that parent is exposed through hwmonN/device.
if len(inputs) == 0 {
deviceDir := filepath.Join(chipDir, "device")
if deviceInputs, _ := filepath.Glob(filepath.Join(deviceDir, "fan*_input")); len(deviceInputs) > 0 {
sensorDir = deviceDir
inputs = deviceInputs
}
}
chipName := utils.ReadStringFile(filepath.Join(sensorDir, "name"))
if chipName == "" {
chipName = utils.ReadStringFile(filepath.Join(chipDir, "name"))
}
if chipName == "" { if chipName == "" {
chipName = entry.Name() chipName = entry.Name()
} }
inputs, _ := filepath.Glob(filepath.Join(chipDir, "fan*_input"))
for _, inputPath := range inputs { for _, inputPath := range inputs {
base := strings.TrimSuffix(filepath.Base(inputPath), "_input") base := strings.TrimSuffix(filepath.Base(inputPath), "_input")
label := utils.ReadStringFile(filepath.Join(chipDir, base+"_label")) label := utils.ReadStringFile(filepath.Join(sensorDir, base+"_label"))
key := chipName + "_" + base key := chipName + "_" + base
if label != "" { if label != "" {
key = chipName + "_" + label key = chipName + "_" + label

View File

@@ -50,6 +50,24 @@ func TestReadHwmonFans(t *testing.T) {
}, fans) }, fans)
} }
// TestReadHwmonFansLegacyParent verifies legacy hwmon layouts such as applesmc,
// where the hwmon class node exists but fan attributes live on hwmonN/device.
func TestReadHwmonFansLegacyParent(t *testing.T) {
root := t.TempDir()
deviceDir := filepath.Join(root, "devices", "applesmc.768")
writeFile(t, filepath.Join(deviceDir, "name"), "applesmc\n")
writeFile(t, filepath.Join(deviceDir, "fan1_input"), "1202\n")
writeFile(t, filepath.Join(deviceDir, "fan1_label"), "Exhaust\n")
chipDir := filepath.Join(root, "hwmon1")
require.NoError(t, os.MkdirAll(chipDir, 0o755))
require.NoError(t, os.Symlink(deviceDir, filepath.Join(chipDir, "device")))
fans, err := readHwmonFans(root)
require.NoError(t, err)
assert.Equal(t, map[string]uint16{"applesmc_Exhaust": 1202}, fans)
}
// TestReadHwmonFansMissingRoot returns an error rather than panicking when the // TestReadHwmonFansMissingRoot returns an error rather than panicking when the
// hwmon root doesn't exist (e.g. running on a kernel without hwmon support). // hwmon root doesn't exist (e.g. running on a kernel without hwmon support).
func TestReadHwmonFansMissingRoot(t *testing.T) { func TestReadHwmonFansMissingRoot(t *testing.T) {

View File

@@ -50,6 +50,9 @@ func generateFingerprint(hostname, cpuModel string) string {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
cpuModel = info[0].ModelName cpuModel = info[0].ModelName
} }
if cpuModel == "" {
cpuModel = getCpuModelFromCpuinfo()
}
} }
fingerprint = hostname + cpuModel fingerprint = hostname + cpuModel
} }

View File

@@ -20,6 +20,8 @@ type mdraidHealth struct {
level string level string
arrayState string arrayState string
degraded uint64 degraded uint64
faultyDisks uint64
populatedDisks uint64
raidDisks uint64 raidDisks uint64
syncAction string syncAction string
syncCompleted string syncCompleted string
@@ -92,6 +94,9 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
if health.degraded > 0 { if health.degraded > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded}) attrs = append(attrs, &smart.SmartAttribute{Name: "Degraded", RawValue: health.degraded})
} }
if health.faultyDisks > 0 {
attrs = append(attrs, &smart.SmartAttribute{Name: "FaultyDisks", RawValue: health.faultyDisks})
}
if health.syncAction != "" { if health.syncAction != "" {
attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction}) attrs = append(attrs, &smart.SmartAttribute{Name: "SyncAction", RawString: health.syncAction})
} }
@@ -152,6 +157,7 @@ func readMdraidHealth(blockName string) (mdraidHealth, bool) {
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok { if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "degraded")); ok {
out.degraded = val out.degraded = val
} }
out.faultyDisks, out.populatedDisks = countMdraidMemberStates(blockName, mdraidSysfsRoot)
if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok { if val, ok := utils.ReadUintFile(filepath.Join(mdDir, "mismatch_cnt")); ok {
out.mismatchCnt = val out.mismatchCnt = val
} }
@@ -177,7 +183,19 @@ func mdraidSmartStatus(health mdraidHealth) string {
case "resync", "recover", "reshape": case "resync", "recover", "reshape":
return "WARNING" return "WARNING"
} }
// Use actual faulty member count rather than the degraded counter, which
// equals raid_disks minus active_disks. On QNAP systems raid_disks may be
// set to a large value (e.g. 32) while only a few slots are ever used,
// making degraded misleadingly large despite zero failed disks.
if health.faultyDisks > 0 {
return "FAILED"
}
if health.degraded > 0 { if health.degraded > 0 {
if isSparseSlotDegraded(health) {
// A sysfs snapshot cannot distinguish reserved slots from a removed
// member on sparse arrays, so report the ambiguity as a warning.
return "WARNING"
}
return "FAILED" return "FAILED"
} }
if health.mismatchCnt > 0 { if health.mismatchCnt > 0 {
@@ -196,6 +214,43 @@ func mdraidSmartStatus(health mdraidHealth) string {
return "UNKNOWN" return "UNKNOWN"
} }
// countMdraidMemberStates reads member device directories under
// block/<name>/md and returns how many are explicitly marked "faulty", plus
// how many are populated at all (regardless of state). populatedDisks lets
// callers distinguish RAID slots that were never used (QNAP reserves far
// more raid_disks than it ever populates) from members that went missing.
func countMdraidMemberStates(blockName, root string) (faultyDisks, populatedDisks uint64) {
devDir := filepath.Join(root, "block", blockName, "md")
entries, err := os.ReadDir(devDir)
if err != nil {
return 0, 0
}
for _, ent := range entries {
if !strings.HasPrefix(ent.Name(), "dev-") {
continue
}
populatedDisks++
statePath := filepath.Join(devDir, ent.Name(), "state")
state := utils.ReadStringFile(statePath)
if strings.Contains(state, "faulty") {
faultyDisks++
}
}
return faultyDisks, populatedDisks
}
// isSparseSlotDegraded reports whether a non-zero "degraded" count may be
// explained by RAID slots that were never populated. QNAP configures system
// arrays with raid_disks set to a large fixed maximum (e.g. 32) far beyond the
// handful of slots it ever populates, so sparse slots outnumber populated ones.
func isSparseSlotDegraded(health mdraidHealth) bool {
if health.populatedDisks == 0 || health.raidDisks <= health.populatedDisks {
return false
}
sparseSlots := health.raidDisks - health.populatedDisks
return sparseSlots > health.populatedDisks
}
// isMdraidBlockName matches /dev/mdN-style block device names. // isMdraidBlockName matches /dev/mdN-style block device names.
func isMdraidBlockName(name string) bool { func isMdraidBlockName(name string) bool {
if !strings.HasPrefix(name, "md") { if !strings.HasPrefix(name, "md") {

View File

@@ -40,6 +40,15 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
write(filepath.Join(mdDir, "sync_completed"), "10%\n") write(filepath.Join(mdDir, "sync_completed"), "10%\n")
write(filepath.Join(mdDir, "sync_speed"), "100M\n") write(filepath.Join(mdDir, "sync_speed"), "100M\n")
write(filepath.Join(mdDir, "mismatch_cnt"), "0\n") write(filepath.Join(mdDir, "mismatch_cnt"), "0\n")
// Simulate two healthy member devices (no faulty state).
for _, dev := range []string{"dev-sda", "dev-sdb"} {
devPath := filepath.Join(mdDir, dev)
if err := os.MkdirAll(devPath, 0o755); err != nil {
t.Fatal(err)
}
write(filepath.Join(devPath, "state"), "in_sync\n")
}
write(filepath.Join(queueDir, "logical_block_size"), "512\n") write(filepath.Join(queueDir, "logical_block_size"), "512\n")
write(filepath.Join(tmp, "block", "md0", "size"), "2048\n") write(filepath.Join(tmp, "block", "md0", "size"), "2048\n")
@@ -81,15 +90,77 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
} }
} }
func TestCountMdraidMemberStates(t *testing.T) {
tmp := t.TempDir()
write := func(path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
mdDir := filepath.Join(tmp, "block", "md0", "md")
// No dev-* entries: zero faulty, zero populated.
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 0 {
t.Fatalf("no members: got (faulty=%d populated=%d), want (0,0)", faulty, populated)
}
// Two healthy members.
write(filepath.Join(mdDir, "dev-sda", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 2 {
t.Fatalf("all in_sync: got (faulty=%d populated=%d), want (0,2)", faulty, populated)
}
// One faulty member.
write(filepath.Join(mdDir, "dev-sdb", "state"), "faulty\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 1 || populated != 2 {
t.Fatalf("one faulty: got (faulty=%d populated=%d), want (1,2)", faulty, populated)
}
// QNAP-style: 28 degraded slots but no dev-* entries for them, 4 in_sync.
write(filepath.Join(mdDir, "dev-sdb", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdc", "state"), "in_sync\n")
write(filepath.Join(mdDir, "dev-sdd", "state"), "in_sync\n")
if faulty, populated := countMdraidMemberStates("md0", tmp); faulty != 0 || populated != 4 {
t.Fatalf("qnap sparse: got (faulty=%d populated=%d), want (0,4)", faulty, populated)
}
}
func TestMdraidSmartStatus(t *testing.T) { func TestMdraidSmartStatus(t *testing.T) {
if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "inactive"}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got) t.Fatalf("mdraidSmartStatus(inactive) = %q, want FAILED", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1, syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(degraded+recover) = %q, want WARNING", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1}); got != "FAILED" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", degraded: 1, faultyDisks: 1}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded) = %q, want FAILED", got) t.Fatalf("mdraidSmartStatus(degraded+faulty) = %q, want FAILED", got)
}
// QNAP-style: raid_disks=32 but only 4 populated; degraded=28 but no faulty devices.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 28, faultyDisks: 0, raidDisks: 32, populatedDisks: 4}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(qnap sparse) = %q, want WARNING", got)
}
// A member disappearing from the same sparse array is indistinguishable
// from another reserved slot, so it must not be reported as healthy.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 29, faultyDisks: 0, raidDisks: 32, populatedDisks: 3}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(qnap sparse missing member) = %q, want WARNING", got)
}
// A genuinely missing member (removed dev-* entry, not just an unpopulated
// QNAP reserve slot) must still fail: raid_disks=4, only 3 populated, all
// of them in_sync, so faultyDisks==0 but degraded==1.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 3}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(missing member) = %q, want FAILED", got)
}
// Degraded with no member-state info at all (e.g. sysfs read failed) must
// still fail rather than being silently treated as a sparse QNAP array.
if got := mdraidSmartStatus(mdraidHealth{arrayState: "clean", degraded: 1, faultyDisks: 0, raidDisks: 4, populatedDisks: 0}); got != "FAILED" {
t.Fatalf("mdraidSmartStatus(degraded, no member info) = %q, want FAILED", got)
} }
if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" { if got := mdraidSmartStatus(mdraidHealth{arrayState: "active", syncAction: "recover"}); got != "WARNING" {
t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got) t.Fatalf("mdraidSmartStatus(recover) = %q, want WARNING", got)

View File

@@ -4,6 +4,7 @@ import (
"bufio" "bufio"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"runtime" "runtime"
@@ -78,6 +79,12 @@ func (a *Agent) refreshSystemDetails() {
if info, err := cpu.Info(); err == nil && len(info) > 0 { if info, err := cpu.Info(); err == nil && len(info) > 0 {
a.systemDetails.CpuModel = info[0].ModelName a.systemDetails.CpuModel = info[0].ModelName
} }
// gopsutil doesn't parse the "cpu model" field from /proc/cpuinfo, which
// is the only source of the CPU model name on MIPS. Fall back to reading
// it directly when ModelName is empty.
if a.systemDetails.CpuModel == "" {
a.systemDetails.CpuModel = getCpuModelFromCpuinfo()
}
// cores / threads // cores / threads
cores, _ := cpu.Counts(false) cores, _ := cpu.Counts(false)
threads := hostInfo.NCPU threads := hostInfo.NCPU
@@ -258,13 +265,66 @@ func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
a.systemInfo.MemPct = systemStats.MemPct a.systemInfo.MemPct = systemStats.MemPct
a.systemInfo.DiskPct = systemStats.DiskPct a.systemInfo.DiskPct = systemStats.DiskPct
a.systemInfo.Battery = systemStats.Battery a.systemInfo.Battery = systemStats.Battery
a.systemInfo.Uptime, _ = host.Uptime() a.systemInfo.Uptime, _ = getUptime()
a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1] a.systemInfo.BandwidthBytes = systemStats.Bandwidth[0] + systemStats.Bandwidth[1]
a.systemInfo.Threads = a.systemDetails.Threads a.systemInfo.Threads = a.systemDetails.Threads
return systemStats return systemStats
} }
// cpuModelFallbackKeys are the field names to look for in /proc/cpuinfo when
// gopsutil fails to return a ModelName. The "cpu model" key is used on MIPS
// (e.g. "MIPS 1004Kc V2.15"), while "system type" provides SoC information
// on various embedded architectures.
var cpuModelFallbackKeys = []string{"cpu model", "system type"}
// getCpuModelFromCpuinfo reads /proc/cpuinfo and returns a CPU model string.
// This is a fallback for architectures where gopsutil's cpu.Info() does not
// populate ModelName, most notably MIPS.
func getCpuModelFromCpuinfo() string {
file, err := os.Open("/proc/cpuinfo")
if err != nil {
return ""
}
defer file.Close()
return parseCpuModel(file)
}
// parseCpuModel scans r (expected to be /proc/cpuinfo content) and returns
// a combined CPU model string. It collects values from all matching keys
// and joins them with " / " when multiple are found.
func parseCpuModel(r io.Reader) string {
lines := readLines(r)
var parts []string
for _, key := range cpuModelFallbackKeys {
for _, line := range lines {
after, found := strings.CutPrefix(line, key)
if !found {
continue
}
after = strings.TrimSpace(after)
if len(after) < 2 || after[0] != ':' {
continue
}
if value := strings.TrimSpace(after[1:]); value != "" {
parts = append(parts, value)
break
}
}
}
return strings.Join(parts, " / ")
}
// readLines reads all lines from r into a slice.
func readLines(r io.Reader) []string {
scanner := bufio.NewScanner(r)
var lines []string
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines
}
// calculateHostMemoryUsage derives counters defensively because /proc/meminfo may // calculateHostMemoryUsage derives counters defensively because /proc/meminfo may
// change while gopsutil reads it. Invalid unsigned subtractions saturate at zero. // change while gopsutil reads it. Invalid unsigned subtractions saturate at zero.
func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) { func calculateHostMemoryUsage(v *mem.VirtualMemoryStat, htop bool) (used, cacheBuff, swapUsed uint64) {

View File

@@ -1,6 +1,7 @@
package agent package agent
import ( import (
"strings"
"testing" "testing"
"github.com/henrygd/beszel/internal/common" "github.com/henrygd/beszel/internal/common"
@@ -113,3 +114,81 @@ func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
assert.False(t, agent.detailsDirty) assert.False(t, agent.detailsDirty)
assert.Nil(t, original.Details) assert.Nil(t, original.Details)
} }
func TestParseCpuModel(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "MIPS with both cpu model and system type",
input: `system type : MediaTek MT7621 ver:1 eco:3
machine : ASUS RT-AX53U
processor : 0
cpu model : MIPS 1004Kc V2.15
BogoMIPS : 586.13
wait instruction : yes`,
expected: "MIPS 1004Kc V2.15 / MediaTek MT7621 ver:1 eco:3",
},
{
name: "MIPS with different SoC",
input: `system type : Atheros AR7161 rev 2
machine : NETGEAR WNDR3700
processor : 0
cpu model : MIPS 24Kc V7.4
BogoMIPS : 452.19`,
expected: "MIPS 24Kc V7.4 / Atheros AR7161 rev 2",
},
{
name: "only system type when cpu model missing",
input: `system type : Broadcom BCM47xx
processor : 0
BogoMIPS : 296.11`,
expected: "Broadcom BCM47xx",
},
{
name: "only cpu model when system type missing",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "x86 cpuinfo returns empty",
input: `processor : 0
vendor_id : GenuineIntel
cpu family : 6
model : 142
model name : Intel(R) Core(TM) i5-8250U CPU @ 1.60GHz
stepping : 10`,
expected: "",
},
{
name: "empty input",
input: "",
expected: "",
},
{
name: "cpu model with extra whitespace",
input: `processor : 0
cpu model : MIPS 34Kc V2.15
BogoMIPS : 300.00`,
expected: "MIPS 34Kc V2.15",
},
{
name: "cpu model without value",
input: `processor : 0
cpu model :
BogoMIPS : 300.00`,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseCpuModel(strings.NewReader(tt.input))
assert.Equal(t, tt.expected, result)
})
}
}

44
agent/uptime_linux.go Normal file
View File

@@ -0,0 +1,44 @@
//go:build linux
package agent
import (
"math"
"os"
"strconv"
"strings"
"github.com/shirou/gopsutil/v4/host"
)
// uptimeFilePath is a variable so tests can point it at a fixture.
var uptimeFilePath = "/proc/uptime"
// getUptime returns the system uptime in seconds.
//
// This reads /proc/uptime instead of using host.Uptime(), which calls the
// sysinfo(2) syscall. Inside an LXC container lxcfs virtualizes /proc/uptime
// but cannot intercept a syscall, so sysinfo(2) reports the host's uptime
// rather than the container's.
//
// Falls back to host.Uptime() if /proc/uptime is missing or unparseable, so
// behavior is unchanged anywhere the file isn't available.
func getUptime() (uint64, error) {
data, err := os.ReadFile(uptimeFilePath)
if err != nil {
return host.Uptime()
}
fields := strings.Fields(string(data))
if len(fields) == 0 {
return host.Uptime()
}
seconds, err := strconv.ParseFloat(fields[0], 64)
if err != nil ||
math.IsNaN(seconds) ||
math.IsInf(seconds, 0) ||
seconds < 0 ||
seconds >= 1<<64 {
return host.Uptime()
}
return uint64(seconds), nil
}

101
agent/uptime_linux_test.go Normal file
View File

@@ -0,0 +1,101 @@
//go:build linux
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestGetUptimeFromProc(t *testing.T) {
tests := []struct {
name string
contents string
want uint64
}{
{"typical", "12345.67 98765.43\n", 12345},
{"zero", "0.00 0.00\n", 0},
{"no trailing newline", "42.99 7.00", 42},
{"single field", "600.5", 600},
{"large value", "266030.12 1000000.00\n", 266030},
}
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(tt.contents), 0o644); err != nil {
t.Fatal(err)
}
uptimeFilePath = path
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got != tt.want {
t.Errorf("getUptime() = %d, want %d", got, tt.want)
}
})
}
}
func writeUptime(contents string) func(t *testing.T) string {
return func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte(contents), 0o644); err != nil {
t.Fatal(err)
}
return path
}
}
// Malformed, missing, or out-of-range input must fall back to host.Uptime()
// rather than returning a bogus value, so the agent still reports something sane.
func TestGetUptimeFallsBack(t *testing.T) {
prev := uptimeFilePath
t.Cleanup(func() { uptimeFilePath = prev })
for _, tt := range []struct {
name string
prepare func(t *testing.T) string
}{
{"missing file", func(t *testing.T) string {
return filepath.Join(t.TempDir(), "does-not-exist")
}},
{"empty file", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, nil, 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"unparseable", func(t *testing.T) string {
path := filepath.Join(t.TempDir(), "uptime")
if err := os.WriteFile(path, []byte("not-a-number 1.0\n"), 0o644); err != nil {
t.Fatal(err)
}
return path
}},
{"NaN", writeUptime("NaN 1.0\n")},
{"positive infinity", writeUptime("+Inf 1.0\n")},
{"negative infinity", writeUptime("-Inf 1.0\n")},
{"negative", writeUptime("-42.5 1.0\n")},
{"exceeds uint64 range", writeUptime("1e20 1.0\n")},
} {
t.Run(tt.name, func(t *testing.T) {
uptimeFilePath = tt.prepare(t)
got, err := getUptime()
if err != nil {
t.Fatalf("getUptime() returned error: %v", err)
}
if got == 0 {
t.Error("getUptime() = 0, expected fallback to host.Uptime()")
}
})
}
}

10
agent/uptime_stub.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build !linux
package agent
import "github.com/shirou/gopsutil/v4/host"
// getUptime returns the system uptime in seconds.
func getUptime() (uint64, error) {
return host.Uptime()
}

View File

@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-agent in kubernetes description: Installs beszel-agent in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
name: beszel-agent name: beszel-agent
appVersion: "0.18.7" appVersion: "0.18.8"
# Bump this version when publishing chart changes. # Bump this version when publishing chart changes.
version: 0.1.4 version: 0.1.5
sources: sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-agent
- https://www.beszel.dev/ - https://www.beszel.dev/

View File

@@ -80,7 +80,7 @@ Essential parameters to configure:
| `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key | | `secret.sshKey` | `ssh-key` | Key name in the secret for the SSH public key |
| `secret.tokenKey` | `token` | Key name in the secret for the authentication token | | `secret.tokenKey` | `token` | Key name in the secret for the authentication token |
| `image.repository` | `henrygd/beszel-agent` | Container image | | `image.repository` | `henrygd/beszel-agent` | Container image |
| `image.tag` | Chart AppVersion (0.18.7) | Image version | | `image.tag` | Chart AppVersion (0.18.8) | Image version |
| `hostNetwork` | `false` | Use host network for network monitoring | | `hostNetwork` | `false` | Use host network for network monitoring |
| `tolerations` | Allows all taints | Tolerations for running on tainted nodes | | `tolerations` | Allows all taints | Tolerations for running on tainted nodes |
@@ -385,7 +385,7 @@ helm upgrade beszel-agent ./beszel-agent \
# Change image version # Change image version
helm upgrade beszel-agent ./beszel-agent \ helm upgrade beszel-agent ./beszel-agent \
--set image.tag="0.18.7" --set image.tag="0.18.8"
``` ```
### Restart All Agents ### Restart All Agents
@@ -522,7 +522,7 @@ kubectl get secret beszel-agent -o jsonpath='{.data.ssh-key}' | base64 -d
## Chart Information ## Chart Information
- **Chart Version**: 0.1.0 - **Chart Version**: 0.1.0
- **App Version**: 0.18.7 - **App Version**: 0.18.8
- **Kubernetes Version**: 1.19+ - **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me) - **Maintainer**: cloudwithdan (nikoloskid@pm.me)

View File

@@ -2,9 +2,9 @@ apiVersion: v1
description: Installs beszel-hub in kubernetes description: Installs beszel-hub in kubernetes
home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub home: https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
name: beszel-hub name: beszel-hub
appVersion: "0.18.7" appVersion: "0.18.8"
# Bump this version when publishing chart changes. # Bump this version when publishing chart changes.
version: 0.1.4 version: 0.1.5
sources: sources:
- https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub - https://github.com/henrygd/beszel/tree/main/supplemental/helm/beszel-hub
- https://www.beszel.dev/ - https://www.beszel.dev/

View File

@@ -47,7 +47,7 @@ Key configuration options in `values.yaml`:
|-----------|---------|-------------| |-----------|---------|-------------|
| `replicaCount` | `1` | Number of Beszel Hub replicas | | `replicaCount` | `1` | Number of Beszel Hub replicas |
| `image.repository` | `henrygd/beszel` | Container image repository | | `image.repository` | `henrygd/beszel` | Container image repository |
| `image.tag` | Chart AppVersion (0.18.7) | Container image tag | | `image.tag` | Chart AppVersion (0.18.8) | Container image tag |
| `image.pullPolicy` | `IfNotPresent` | Image pull policy | | `image.pullPolicy` | `IfNotPresent` | Image pull policy |
| `service.port` | `8090` | Service port | | `service.port` | `8090` | Service port |
| `persistentVolumeClaim.enabled` | `true` | Enable persistent volume | | `persistentVolumeClaim.enabled` | `true` | Enable persistent volume |
@@ -169,7 +169,7 @@ tolerations:
```yaml ```yaml
replicaCount: 3 replicaCount: 3
image: image:
tag: "0.18.7" tag: "0.18.8"
service: service:
type: LoadBalancer type: LoadBalancer
ingress: ingress:
@@ -330,7 +330,7 @@ By default, Beszel Hub uses a PersistentVolumeClaim for data storage. Ensure you
## Chart Information ## Chart Information
- **Chart Version**: 0.1.0 - **Chart Version**: 0.1.0
- **App Version**: 0.18.7 - **App Version**: 0.18.8
- **Kubernetes Version**: 1.19+ - **Kubernetes Version**: 1.19+
- **Maintainer**: cloudwithdan (nikoloskid@pm.me) - **Maintainer**: cloudwithdan (nikoloskid@pm.me)